Compare commits

..

33 Commits

Author SHA1 Message Date
Stavros
ec67ea3807 refactor: detect if using browser or headless client for better responses 2025-03-10 17:02:23 +02:00
Stavros
3649d0d84e fix: allow oauth resource when oauth whitelist is empty 2025-03-10 16:22:32 +02:00
Stavros
c0ffe3faf4 refactor: release multiple semver tags 2025-03-10 16:02:48 +02:00
Stavros
ad718d3ef8 refactor: migrate release workflow to native runners 2025-03-09 19:24:52 +02:00
Stavros
38105d0b4e fix: exp build should wait form arm builder 2025-03-09 19:16:51 +02:00
Stavros
e13bd14eb6 fix: setup docker buildx on exp build 2025-03-09 19:13:28 +02:00
Stavros
43dc3f9aa6 chore: remove create release from experimental build 2025-03-09 19:08:33 +02:00
Stavros
00bfaa1cbe feat: add experimental docker build 2025-03-09 19:07:24 +02:00
Stavros
8cc0f8b31b chore: bump version 2025-03-09 18:44:45 +02:00
Stavros
631059be69 refactor: rename x-tinyauth-user to remote-user 2025-03-09 18:41:20 +02:00
Stavros
5188089673 Feat/totp (#45)
* wip

* feat: finalize totp gen code

* refactor: split login screen and forms

* feat: add totp logic and ui

* refactor: make totp pending expiry time fixed

* refactor: skip all checks when disable continue is enabled

* fix: fix cli not exiting on invalid input
2025-03-09 18:39:25 +02:00
Stavros
47fff12bac chore: fix typo 2025-03-09 16:52:17 +02:00
Stavros
a8c51b649f chore: update funding 2025-03-09 16:51:38 +02:00
Stavros
c2e8f1b473 chore: switch to table for sponsor section 2025-03-09 14:33:01 +02:00
Stavros
bdf327cc9a chore: remove styles are they are not applied in readme 2025-03-09 14:26:27 +02:00
Stavros
46ec623d74 chore: add sponsors section 2025-03-09 14:20:46 +02:00
Stavros
f97c4d7e78 chore: add funding 2025-03-08 23:33:40 +02:00
Stavros
d45b148725 Merge pull request #44 from WilliamB78/main
feat: add Remote-User header
2025-03-04 16:20:47 +02:00
Stavros
33904f7f86 refactor: rename remote user to x tinyauth user 2025-03-04 16:00:28 +02:00
WilliamB78
7e0bc84b0f feat: add Remote-User header 2025-03-04 09:59:42 +01:00
Stavros
fc3f8b5036 refactor: rename the run function to runCheck in the docker helper 2025-02-26 19:31:37 +02:00
Stavros
3030fc5fcf refactor: change error to 500 when there is an error 2025-02-26 19:27:43 +02:00
Stavros
e4379cf3ed feat: allowed paths label 2025-02-26 19:25:54 +02:00
Stavros
30aab17f06 feat: allow custom app and generic oauth title 2025-02-23 20:51:56 +02:00
Stavros
7ee0b645e6 chore: bump version 2025-02-19 17:41:23 +02:00
Stavros
5c34ab96a9 fix: redirect user correctly 2025-02-19 17:05:01 +02:00
Stavros
cb6f93d879 chore: update readme 2025-02-17 07:49:01 +02:00
Stavros
df0c356511 chore: update example and dev compose files 2025-02-16 22:59:04 +02:00
Stavros
d1c6ae1ba1 fix: redirect to frontend when no redirect uri is present in oauth callback 2025-02-16 22:48:04 +02:00
Stavros
0f8d2e7fde fix: make query check account for spaces 2025-02-16 21:14:21 +02:00
Stavros
0da82ae3fe chore: update readme 2025-02-15 19:53:21 +02:00
Stavros
f9ab9a6406 fix: filter oauth whitelist to remove empty strings 2025-02-15 17:23:24 +02:00
Stavros
6f35923735 refactor: use try catch instead of can parse 2025-02-12 18:43:35 +02:00
38 changed files with 1299 additions and 535 deletions

View File

@@ -1,58 +0,0 @@
name: Alpha Release
on:
workflow_dispatch:
inputs:
alpha:
description: "Alpha version (e.g. 1, 2, 3)"
required: true
jobs:
get-tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.name }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get tag
id: tag
run: echo "name=$(cat internal/assets/version)-alpha.${{ github.event.inputs.alpha }}" >> $GITHUB_OUTPUT
build-docker:
needs: get-tag
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/arm64, linux/amd64
tags: ghcr.io/${{ github.repository_owner }}/tinyauth:${{ needs.get-tag.outputs.tag }}
alpha-release:
needs: [get-tag, build-docker]
runs-on: ubuntu-latest
steps:
- name: Create alpha release
uses: softprops/action-gh-release@v2
with:
prerelease: true
tag_name: ${{ needs.get-tag.outputs.tag }}

View File

@@ -1,58 +0,0 @@
name: Beta Release
on:
workflow_dispatch:
inputs:
alpha:
description: "Beta version (e.g. 1, 2, 3)"
required: true
jobs:
get-tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.name }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get tag
id: tag
run: echo "name=$(cat internal/assets/version)-beta.${{ github.event.inputs.alpha }}" >> $GITHUB_OUTPUT
build-docker:
needs: get-tag
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/arm64, linux/amd64
tags: ghcr.io/${{ github.repository_owner }}/tinyauth:${{ needs.get-tag.outputs.tag }}
beta-release:
needs: [get-tag, build-docker]
runs-on: ubuntu-latest
steps:
- name: Create beta release
uses: softprops/action-gh-release@v2
with:
prerelease: true
tag_name: ${{ needs.get-tag.outputs.tag }}

View File

@@ -1,32 +1,22 @@
name: Release
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
get-tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.name }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get tag
id: tag
run: echo "name=$(cat internal/assets/version)" >> $GITHUB_OUTPUT
build-docker:
needs: get-tag
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -35,21 +25,112 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
id: build
with:
context: .
push: true
platforms: linux/arm64, linux/amd64
tags: ghcr.io/${{ github.repository_owner }}/tinyauth:${{ needs.get-tag.outputs.tag }}, ghcr.io/${{ github.repository_owner }}/tinyauth:latest
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
release:
needs: [get-tag, build-docker]
runs-on: ubuntu-latest
steps:
- name: Create release
uses: softprops/action-gh-release@v2
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
prerelease: false
make_latest: false
tag_name: ${{ needs.get-tag.outputs.tag }}
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-arm:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
id: build
with:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)

2
FUNDING.yml Normal file
View File

@@ -0,0 +1,2 @@
github: steveiliop56
buy_me_a_coffee: steveiliop56

25
Makefile Normal file
View File

@@ -0,0 +1,25 @@
# Build website
web:
cd site; bun run build
# Copy site assets
assets: web
rm -rf internal/assets/dist
mkdir -p internal/assets/dist
cp -r site/dist/* internal/assets/dist
# Run development binary
run: assets
go run main.go
# Test
test:
go test ./...
# Build
build: assets
go build -o tinyauth
# Build no site
build-skip-web:
go build -o tinyauth

View File

@@ -24,7 +24,7 @@ Tinyauth is a simple authentication middleware that adds simple username/passwor
## Discord
I just made a Discord server for Tinyauth! It is not only for Tinyauth but general self-hosting because I just like chatting with people! The link is [here](https://discord.gg/gWpzrksk), see you there!
I just made a Discord server for Tinyauth! It is not only for Tinyauth but general self-hosting because I just like chatting with people! The link is [here](https://discord.gg/eHzVaCzRRd), see you there!
## Getting Started
@@ -42,9 +42,17 @@ All contributions to the codebase are welcome! If you have any recommendations o
Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. For more information about the license check the [license](./LICENSE) file.
## Sponsors
Thanks a lot to the following people for providing me with more coffee:
| <img height="64" src="https://avatars.githubusercontent.com/u/47644445?v=4" alt="Nicolas"> | <img height="64" src="https://avatars.githubusercontent.com/u/4255748?v=4" alt="Erwin"> |
| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| <div align="center"><a href="https://github.com/nicotsx">Nicolas</a></div> | <div align="center"><a href="https://github.com/erwinkramer">Erwin</a></div> |
## Acknowledgements
Credits for the logo of this app go to:
- **Freepik** for providing the police hat and logo.
- **Freepik** for providing the police hat and badge.
- **Renee French** for the original gopher logo.

View File

@@ -5,7 +5,8 @@ import (
"os"
"strings"
"time"
cmd "tinyauth/cmd/user"
totpCmd "tinyauth/cmd/totp"
userCmd "tinyauth/cmd/user"
"tinyauth/internal/api"
"tinyauth/internal/assets"
"tinyauth/internal/auth"
@@ -62,7 +63,9 @@ var rootCmd = &cobra.Command{
}
// Create oauth whitelist
oauthWhitelist := strings.Split(config.OAuthWhitelist, ",")
oauthWhitelist := utils.Filter(strings.Split(config.OAuthWhitelist, ","), func(val string) bool {
return val != ""
})
log.Debug().Msg("Parsed OAuth whitelist")
// Create OAuth config
@@ -111,7 +114,9 @@ var rootCmd = &cobra.Command{
AppURL: config.AppURL,
CookieSecure: config.CookieSecure,
DisableContinue: config.DisableContinue,
CookieExpiry: config.SessionExpiry,
SessionExpiry: config.SessionExpiry,
Title: config.Title,
GenericName: config.GenericName,
}, hooks, auth, providers)
// Setup routes
@@ -137,7 +142,10 @@ func HandleError(err error, msg string) {
func init() {
// Add user command
rootCmd.AddCommand(cmd.UserCmd())
rootCmd.AddCommand(userCmd.UserCmd())
// Add totp command
rootCmd.AddCommand(totpCmd.TotpCmd())
// Read environment variables
viper.AutomaticEnv()
@@ -167,10 +175,12 @@ func init() {
rootCmd.Flags().String("generic-auth-url", "", "Generic OAuth auth URL.")
rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token URL.")
rootCmd.Flags().String("generic-user-url", "", "Generic OAuth user info URL.")
rootCmd.Flags().String("generic-name", "Generic", "Generic OAuth provider name.")
rootCmd.Flags().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.")
rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.")
rootCmd.Flags().Int("session-expiry", 86400, "Session (cookie) expiration time in seconds.")
rootCmd.Flags().Int("log-level", 1, "Log level.")
rootCmd.Flags().String("app-title", "Tinyauth", "Title of the app.")
// Bind flags to environment
viper.BindEnv("port", "PORT")
@@ -197,10 +207,12 @@ func init() {
viper.BindEnv("generic-auth-url", "GENERIC_AUTH_URL")
viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL")
viper.BindEnv("generic-user-url", "GENERIC_USER_URL")
viper.BindEnv("generic-name", "GENERIC_NAME")
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST")
viper.BindEnv("session-expiry", "SESSION_EXPIRY")
viper.BindEnv("log-level", "LOG_LEVEL")
viper.BindEnv("app-title", "APP_TITLE")
// Bind flags to viper
viper.BindPFlags(rootCmd.Flags())

View File

@@ -0,0 +1,121 @@
package generate
import (
"errors"
"fmt"
"os"
"strings"
"tinyauth/internal/utils"
"github.com/charmbracelet/huh"
"github.com/mdp/qrterminal/v3"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
// Interactive flag
var interactive bool
// i stands for input
var iUser string
var GenerateCmd = &cobra.Command{
Use: "generate",
Short: "Generate a totp secret",
Run: func(cmd *cobra.Command, args []string) {
// Setup logger
log.Logger = log.Level(zerolog.InfoLevel)
// Use simple theme
var baseTheme *huh.Theme = huh.ThemeBase()
// Interactive
if interactive {
// Create huh form
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Current username:hash").Value(&iUser).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
return nil
})),
),
)
// Run form
formErr := form.WithTheme(baseTheme).Run()
if formErr != nil {
log.Fatal().Err(formErr).Msg("Form failed")
}
}
// Parse user
user, parseErr := utils.ParseUser(iUser)
if parseErr != nil {
log.Fatal().Err(parseErr).Msg("Failed to parse user")
}
// Check if user was using docker escape
dockerEscape := false
if strings.Contains(iUser, "$$") {
dockerEscape = true
}
// Check it has totp
if user.TotpSecret != "" {
log.Fatal().Msg("User already has a totp secret")
}
// Generate totp secret
key, keyErr := totp.Generate(totp.GenerateOpts{
Issuer: "Tinyauth",
AccountName: user.Username,
})
if keyErr != nil {
log.Fatal().Err(keyErr).Msg("Failed to generate totp secret")
}
// Create secret
secret := key.Secret()
// Print secret and image
log.Info().Str("secret", secret).Msg("Generated totp secret")
// Print QR code
log.Info().Msg("Generated QR code")
config := qrterminal.Config{
Level: qrterminal.L,
Writer: os.Stdout,
BlackChar: qrterminal.BLACK,
WhiteChar: qrterminal.WHITE,
QuietZone: 2,
}
qrterminal.GenerateWithConfig(key.URL(), config)
// Add the secret to the user
user.TotpSecret = secret
// If using docker escape re-escape it
if dockerEscape {
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
}
// Print success
log.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
},
}
func init() {
// Add interactive flag
GenerateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Run in interactive mode")
GenerateCmd.Flags().StringVar(&iUser, "user", "", "Your current username:hash")
}

22
cmd/totp/totp.go Normal file
View File

@@ -0,0 +1,22 @@
package cmd
import (
"tinyauth/cmd/totp/generate"
"github.com/spf13/cobra"
)
func TotpCmd() *cobra.Command {
// Create the totp command
totpCmd := &cobra.Command{
Use: "totp",
Short: "Totp utilities",
Long: `Utilities for creating and verifying totp codes.`,
}
// Add the generate command
totpCmd.AddCommand(generate.GenerateCmd)
// Return the totp command
return totpCmd
}

View File

@@ -60,7 +60,7 @@ var CreateCmd = &cobra.Command{
// Do we have username and password?
if iUsername == "" || iPassword == "" {
log.Error().Msg("Username and password cannot be empty")
log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty")
}
log.Info().Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Creating user")

View File

@@ -2,9 +2,10 @@ package verify
import (
"errors"
"strings"
"tinyauth/internal/utils"
"github.com/charmbracelet/huh"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
@@ -17,22 +18,26 @@ var docker bool
// i stands for input
var iUsername string
var iPassword string
var iTotp string
var iUser string
var VerifyCmd = &cobra.Command{
Use: "verify",
Short: "Verify a user is set up correctly",
Long: `Verify a user is set up correctly meaning that it has a correct username and password.`,
Long: `Verify a user is set up correctly meaning that it has a correct username, password and totp code.`,
Run: func(cmd *cobra.Command, args []string) {
// Setup logger
log.Logger = log.Level(zerolog.InfoLevel)
// Use simple theme
var baseTheme *huh.Theme = huh.ThemeBase()
// Check if interactive
if interactive {
// Create huh form
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("User (username:hash)").Value(&iUser).Validate((func(s string) error {
huh.NewInput().Title("User (username:hash:totp)").Value(&iUser).Validate((func(s string) error {
if s == "" {
return errors.New("user cannot be empty")
}
@@ -50,13 +55,11 @@ var VerifyCmd = &cobra.Command{
}
return nil
})),
huh.NewSelect[bool]().Title("Is the user formatted for docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&docker),
huh.NewInput().Title("Totp Code (if setup)").Value(&iTotp),
),
)
// Use simple theme
var baseTheme *huh.Theme = huh.ThemeBase()
// Run form
formErr := form.WithTheme(baseTheme).Run()
if formErr != nil {
@@ -64,33 +67,44 @@ var VerifyCmd = &cobra.Command{
}
}
// Do we have username, password and user?
if iUsername == "" || iPassword == "" || iUser == "" {
log.Fatal().Msg("Username, password and user cannot be empty")
// Parse user
user, userErr := utils.ParseUser(iUser)
if userErr != nil {
log.Fatal().Err(userErr).Msg("Failed to parse user")
}
log.Info().Str("user", iUser).Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Verifying user")
// Split username and password hash
username, hash, ok := strings.Cut(iUser, ":")
if !ok {
log.Fatal().Msg("User is not formatted correctly")
// Compare username
if user.Username != iUsername {
log.Fatal().Msg("Username is incorrect")
}
// Replace $$ with $ if formatted for docker
if docker {
hash = strings.ReplaceAll(hash, "$$", "$")
// Compare password
verifyErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(iPassword))
if verifyErr != nil {
log.Fatal().Msg("Ppassword is incorrect")
}
// Compare username and password
verifyErr := bcrypt.CompareHashAndPassword([]byte(hash), []byte(iPassword))
if verifyErr != nil || username != iUsername {
log.Fatal().Msg("Username or password incorrect")
} else {
log.Info().Msg("Verification successful")
// Check if user has 2fa code
if user.TotpSecret == "" {
if iTotp != "" {
log.Warn().Msg("User does not have 2fa secret")
}
log.Info().Msg("User verified")
return
}
// Check totp code
totpOk := totp.Validate(iTotp, user.TotpSecret)
if !totpOk {
log.Fatal().Msg("Totp code incorrect")
}
// Done
log.Info().Msg("User verified")
},
}
@@ -100,5 +114,6 @@ func init() {
VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?")
VerifyCmd.Flags().StringVar(&iUsername, "username", "", "Username")
VerifyCmd.Flags().StringVar(&iPassword, "password", "", "Password")
VerifyCmd.Flags().StringVar(&iUser, "user", "", "Hash (username:hash combination)")
VerifyCmd.Flags().StringVar(&iTotp, "totp", "", "Totp code")
VerifyCmd.Flags().StringVar(&iUser, "user", "", "Hash (username:hash:totp combination)")
}

View File

@@ -8,12 +8,12 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
nginx:
container_name: nginx
image: nginx:latest
whoami:
container_name: whoami
image: traefik/whoami:latest
labels:
traefik.enable: true
traefik.http.routers.nginx.rule: Host(`nginx.dev.local`)
traefik.http.routers.nginx.rule: Host(`whoami.dev.local`)
traefik.http.services.nginx.loadbalancer.server.port: 80
traefik.http.routers.nginx.middlewares: tinyauth
@@ -30,4 +30,5 @@ services:
traefik.enable: true
traefik.http.routers.tinyauth.rule: Host(`tinyauth.dev.local`)
traefik.http.services.tinyauth.loadbalancer.server.port: 3000
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth/traefik
traefik.http.middlewares.tinyauth.forwardauth.authResponseHeaders: Remote-User

View File

@@ -28,4 +28,5 @@ services:
traefik.enable: true
traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`)
traefik.http.services.tinyauth.loadbalancer.server.port: 3000
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth/traefik
traefik.http.middlewares.tinyauth.forwardauth.authResponseHeaders: Remote-User

31
go.mod
View File

@@ -13,23 +13,37 @@ require (
golang.org/x/crypto v0.32.0
)
require (
github.com/containerd/log v0.1.0 // indirect
github.com/mdp/qrterminal/v3 v3.2.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect
go.opentelemetry.io/otel/sdk v1.34.0 // indirect
golang.org/x/term v0.28.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
rsc.io/qr v0.2.0 // indirect
)
require (
github.com/Microsoft/go-winio v0.4.14 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/boombuler/barcode v1.0.2 // indirect
github.com/bytedance/sonic v1.12.7 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/bubbles v0.20.0 // indirect
github.com/charmbracelet/bubbletea v1.1.0 // indirect
github.com/charmbracelet/huh v0.6.0 // indirect
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/lipgloss v0.13.0 // indirect
github.com/charmbracelet/x/ansi v0.2.3 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v27.5.1+incompatible // indirect
github.com/docker/docker v27.5.1+incompatible
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -38,7 +52,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -53,7 +67,7 @@ require (
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/magiconair/properties v1.8.7
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -70,6 +84,7 @@ require (
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pquerna/otp v1.4.0
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@@ -81,15 +96,15 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/oauth2 v0.25.0
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect

78
go.sum
View File

@@ -1,9 +1,16 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.12.7 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q=
github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -11,6 +18,8 @@ github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wio
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c=
@@ -28,6 +37,8 @@ github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4c
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -61,8 +72,8 @@ github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9S
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -79,19 +90,23 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -126,17 +141,23 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk=
github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -155,11 +176,13 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
@@ -169,6 +192,8 @@ github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgY
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
@@ -203,14 +228,24 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
@@ -250,10 +285,14 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@@ -262,14 +301,25 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y=
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA=
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A=
google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View File

@@ -19,6 +19,7 @@ import (
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog/log"
)
@@ -84,7 +85,7 @@ func (api *API) Init() {
Path: "/",
HttpOnly: true,
Secure: api.Config.CookieSecure,
MaxAge: api.Config.CookieExpiry,
MaxAge: api.Config.SessionExpiry,
})
router.Use(sessions.Sessions("tinyauth", store))
@@ -130,14 +131,53 @@ func (api *API) SetupRoutes() {
return
}
// Check if the request is coming from a browser (tools like curl/bruno use */* and they don't include the text/html)
isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html")
if isBrowser {
log.Debug().Msg("Request is most likely coming from a browser")
} else {
log.Debug().Msg("Request is most likely not coming from a browser")
}
log.Debug().Interface("proxy", proxy.Proxy).Msg("Got proxy")
// Check if auth is enabled
authEnabled, authEnabledErr := api.Auth.AuthEnabled(c)
// Handle error
if authEnabledErr != nil {
// Return 500 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
log.Error().Err(authEnabledErr).Msg("Failed to check if auth is enabled")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
// Return the internal server error page
if api.handleError(c, "Failed to check if auth is enabled", authEnabledErr) {
return
}
}
// If auth is not enabled, return 200
if !authEnabled {
// The user is allowed to access the app
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
// Stop further processing
return
}
// Get user context
userContext := api.Hooks.UseUserContext(c)
// Check if using basic auth
_, _, basicAuth := c.Request.BasicAuth()
// Get headers
uri := c.Request.Header.Get("X-Forwarded-Uri")
proto := c.Request.Header.Get("X-Forwarded-Proto")
@@ -148,15 +188,15 @@ func (api *API) SetupRoutes() {
log.Debug().Msg("Authenticated")
// Check if user is allowed to access subdomain, if request is nginx.example.com the subdomain (resource) is nginx
appAllowed, appAllowedErr := api.Auth.ResourceAllowed(userContext, host)
appAllowed, appAllowedErr := api.Auth.ResourceAllowed(c, userContext)
// Check if there was an error
if appAllowedErr != nil {
// Return 501 if nginx is the proxy or if the request is using basic auth
if proxy.Proxy == "nginx" || basicAuth {
// Return 500 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
log.Error().Err(appAllowedErr).Msg("Failed to check if app is allowed")
c.JSON(501, gin.H{
"status": 501,
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
@@ -174,9 +214,11 @@ func (api *API) SetupRoutes() {
if !appAllowed {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed")
// Return 401 if nginx is the proxy or if the request is using an Authorization header
if proxy.Proxy == "nginx" || basicAuth {
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
// Set WWW-Authenticate header
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
// Return 401 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
@@ -202,6 +244,9 @@ func (api *API) SetupRoutes() {
return
}
// Set the user header
c.Header("Remote-User", userContext.Username)
// The user is allowed to access the app
c.JSON(200, gin.H{
"status": 200,
@@ -215,9 +260,11 @@ func (api *API) SetupRoutes() {
// The user is not logged in
log.Debug().Msg("Unauthorized")
// Return 401 if nginx is the proxy or if the request is using an Authorization header
if proxy.Proxy == "nginx" || basicAuth {
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
// Set www-authenticate header
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
// Return 401 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
@@ -285,7 +332,29 @@ func (api *API) SetupRoutes() {
return
}
log.Debug().Msg("Password correct, logging in")
log.Debug().Msg("Password correct, checking totp")
// Check if user has totp enabled
if user.TotpSecret != "" {
log.Debug().Msg("Totp enabled")
// Set totp pending cookie
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: login.Username,
Provider: "username",
TotpPending: true,
})
// Return totp required
c.JSON(200, gin.H{
"status": 200,
"message": "Waiting for totp",
"totpPending": true,
})
// Stop further processing
return
}
// Create session cookie with username as provider
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
@@ -293,6 +362,80 @@ func (api *API) SetupRoutes() {
Provider: "username",
})
// Return logged in
c.JSON(200, gin.H{
"status": 200,
"message": "Logged in",
"totpPending": false,
})
})
api.Router.POST("/api/totp", func(c *gin.Context) {
// Create totp struct
var totpReq types.Totp
// Bind JSON
err := c.BindJSON(&totpReq)
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Msg("Checking totp")
// Get user context
userContext := api.Hooks.UseUserContext(c)
// Check if we have a user
if userContext.Username == "" {
log.Debug().Msg("No user context")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Get user
user := api.Auth.GetUser(userContext.Username)
// Check if user exists
if user == nil {
log.Debug().Msg("User not found")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Check if totp is correct
totpOk := totp.Validate(totpReq.Code, user.TotpSecret)
// TOTP is incorrect
if !totpOk {
log.Debug().Msg("Totp incorrect")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
log.Debug().Msg("Totp correct")
// Create session cookie with username as provider
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: user.Username,
Provider: "username",
})
// Return logged in
c.JSON(200, gin.H{
"status": 200,
@@ -332,36 +475,33 @@ func (api *API) SetupRoutes() {
configuredProviders = append(configuredProviders, "username")
}
// We are not logged in so return unauthorized
// Fill status struct with data from user context and api config
status := types.Status{
Username: userContext.Username,
IsLoggedIn: userContext.IsLoggedIn,
Oauth: userContext.OAuth,
Provider: userContext.Provider,
ConfiguredProviders: configuredProviders,
DisableContinue: api.Config.DisableContinue,
Title: api.Config.Title,
GenericName: api.Config.GenericName,
TotpPending: userContext.TotpPending,
}
// If we are not logged in we set the status to 401 and add the WWW-Authenticate header else we set it to 200
if !userContext.IsLoggedIn {
log.Debug().Msg("Unauthorized")
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
c.JSON(200, gin.H{
"status": 200,
"message": "Unauthorized",
"username": "",
"isLoggedIn": false,
"oauth": false,
"provider": "",
"configuredProviders": configuredProviders,
"disableContinue": api.Config.DisableContinue,
})
return
status.Status = 401
status.Message = "Unauthorized"
} else {
log.Debug().Interface("userContext", userContext).Strs("configuredProviders", configuredProviders).Bool("disableContinue", api.Config.DisableContinue).Msg("Authenticated")
status.Status = 200
status.Message = "Authenticated"
}
log.Debug().Interface("userContext", userContext).Strs("configuredProviders", configuredProviders).Bool("disableContinue", api.Config.DisableContinue).Msg("Authenticated")
// We are logged in so return our user context
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
"username": userContext.Username,
"isLoggedIn": userContext.IsLoggedIn,
"oauth": userContext.OAuth,
"provider": userContext.Provider,
"configuredProviders": configuredProviders,
"disableContinue": api.Config.DisableContinue,
})
// Return data
c.JSON(200, status)
})
api.Router.GET("/api/oauth/url/:provider", func(c *gin.Context) {
@@ -533,10 +673,7 @@ func (api *API) SetupRoutes() {
// If it is empty it means that no redirect_uri was provided to the login screen so we just log in
if redirectURIErr != nil {
c.JSON(200, gin.H{
"status": 200,
"message": "Logged in",
})
c.Redirect(http.StatusPermanentRedirect, api.Config.AppURL)
}
log.Debug().Str("redirectURI", redirectURI).Msg("Got redirect URI")

View File

@@ -23,7 +23,7 @@ var apiConfig = types.APIConfig{
Secret: "super-secret-api-thing-for-tests", // It is 32 chars long
AppURL: "http://tinyauth.localhost",
CookieSecure: false,
CookieExpiry: 3600,
SessionExpiry: 3600,
DisableContinue: false,
}
@@ -55,7 +55,7 @@ func getAPI(t *testing.T) *api.API {
Username: user.Username,
Password: user.Password,
},
}, nil, apiConfig.CookieExpiry)
}, nil, apiConfig.SessionExpiry)
// Create providers service
providers := providers.NewProviders(types.OAuthConfig{})

View File

@@ -1 +1 @@
v3.0.0
v3.1.0

View File

@@ -1,12 +1,12 @@
package auth
import (
"regexp"
"slices"
"strings"
"time"
"tinyauth/internal/docker"
"tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
@@ -70,10 +70,20 @@ func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie)
log.Debug().Msg("Setting session cookie")
// Calculate expiry
var sessionExpiry int
if data.TotpPending {
sessionExpiry = 3600
} else {
sessionExpiry = auth.SessionExpiry
}
// Set data
sessions.Set("username", data.Username)
sessions.Set("provider", data.Provider)
sessions.Set("expiry", time.Now().Add(time.Duration(auth.SessionExpiry)*time.Second).Unix())
sessions.Set("expiry", time.Now().Add(time.Duration(sessionExpiry)*time.Second).Unix())
sessions.Set("totpPending", data.TotpPending)
// Save session
sessions.Save()
@@ -102,14 +112,16 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie {
cookieUsername := sessions.Get("username")
cookieProvider := sessions.Get("provider")
cookieExpiry := sessions.Get("expiry")
cookieTotpPending := sessions.Get("totpPending")
// Convert interfaces to correct types
username, usernameOk := cookieUsername.(string)
provider, providerOk := cookieProvider.(string)
expiry, expiryOk := cookieExpiry.(int64)
totpPending, totpPendingOk := cookieTotpPending.(bool)
// Check if the cookie is invalid
if !usernameOk || !providerOk || !expiryOk {
if !usernameOk || !providerOk || !expiryOk || !totpPendingOk {
log.Warn().Msg("Session cookie invalid")
return types.SessionCookie{}
}
@@ -125,12 +137,13 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie {
return types.SessionCookie{}
}
log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Msg("Parsed cookie")
log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Bool("totpPending", totpPending).Msg("Parsed cookie")
// Return the cookie
return types.SessionCookie{
Username: username,
Provider: provider,
Username: username,
Provider: provider,
TotpPending: totpPending,
}
}
@@ -139,76 +152,92 @@ func (auth *Auth) UserAuthConfigured() bool {
return len(auth.Users) > 0
}
func (auth *Auth) ResourceAllowed(context types.UserContext, host string) (bool, error) {
// Check if we have access to the Docker API
isConnected := auth.Docker.DockerConnected()
func (auth *Auth) ResourceAllowed(c *gin.Context, context types.UserContext) (bool, error) {
// Get headers
host := c.Request.Header.Get("X-Forwarded-Host")
// If we don't have access, it is assumed that the user has access
if !isConnected {
log.Debug().Msg("Docker not connected, allowing access")
return true, nil
}
// Get the app ID from the host
// Get app id
appId := strings.Split(host, ".")[0]
// Get the containers
containers, containersErr := auth.Docker.GetContainers()
// Check if resource is allowed
allowed, allowedErr := auth.Docker.ContainerAction(appId, func(labels types.TinyauthLabels) (bool, error) {
// If the container has an oauth whitelist, check if the user is in it
if context.OAuth {
if len(labels.OAuthWhitelist) == 0 {
return true, nil
}
log.Debug().Msg("Checking OAuth whitelist")
if slices.Contains(labels.OAuthWhitelist, context.Username) {
return true, nil
}
return false, nil
}
// If the container has users, check if the user is in it
if len(labels.Users) != 0 {
log.Debug().Msg("Checking users")
if slices.Contains(labels.Users, context.Username) {
return true, nil
}
return false, nil
}
// Allowed
return true, nil
})
// If there is an error, return false
if containersErr != nil {
return false, containersErr
if allowedErr != nil {
log.Error().Err(allowedErr).Msg("Error checking if resource is allowed")
return false, allowedErr
}
log.Debug().Msg("Got containers")
// Return if the resource is allowed
return allowed, nil
}
// Loop through the containers
for _, container := range containers {
// Inspect the container
inspect, inspectErr := auth.Docker.InspectContainer(container.ID)
func (auth *Auth) AuthEnabled(c *gin.Context) (bool, error) {
// Get headers
uri := c.Request.Header.Get("X-Forwarded-Uri")
host := c.Request.Header.Get("X-Forwarded-Host")
// If there is an error, return false
if inspectErr != nil {
return false, inspectErr
// Get app id
appId := strings.Split(host, ".")[0]
// Check if auth is enabled
enabled, enabledErr := auth.Docker.ContainerAction(appId, func(labels types.TinyauthLabels) (bool, error) {
// Check if the allowed label is empty
if labels.Allowed == "" {
// Auth enabled
return true, nil
}
// Get the container name (for some reason it is /name)
containerName := strings.Split(inspect.Name, "/")[1]
// Compile regex
regex, regexErr := regexp.Compile(labels.Allowed)
// There is a container with the same name as the app ID
if containerName == appId {
log.Debug().Str("container", containerName).Msg("Found container")
// Get only the tinyauth labels in a struct
labels := utils.GetTinyauthLabels(inspect.Config.Labels)
log.Debug().Msg("Got labels")
// If the container has an oauth whitelist, check if the user is in it
if context.OAuth && len(labels.OAuthWhitelist) != 0 {
log.Debug().Msg("Checking OAuth whitelist")
if slices.Contains(labels.OAuthWhitelist, context.Username) {
return true, nil
}
return false, nil
}
// If the container has users, check if the user is in it
if len(labels.Users) != 0 {
log.Debug().Msg("Checking users")
if slices.Contains(labels.Users, context.Username) {
return true, nil
}
return false, nil
}
// If there is an error, invalid regex, auth enabled
if regexErr != nil {
log.Warn().Err(regexErr).Msg("Invalid regex")
return true, regexErr
}
// Check if the uri matches the regex
if regex.MatchString(uri) {
// Auth disabled
return false, nil
}
// Auth enabled
return true, nil
})
// If there is an error, auth enabled
if enabledErr != nil {
log.Error().Err(enabledErr).Msg("Error checking if auth is enabled")
return true, enabledErr
}
log.Debug().Msg("No matching container found, allowing access")
// If no matching container is found, allow access
return true, nil
return enabled, nil
}
func (auth *Auth) GetBasicAuth(c *gin.Context) *types.User {

View File

@@ -4,4 +4,5 @@ package constants
var TinyauthLabels = []string{
"tinyauth.oauth.whitelist",
"tinyauth.users",
"tinyauth.allowed",
}

View File

@@ -2,10 +2,14 @@ package docker
import (
"context"
"strings"
appTypes "tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
apiTypes "github.com/docker/docker/api/types"
containerTypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
)
func NewDocker() *Docker {
@@ -34,9 +38,9 @@ func (docker *Docker) Init() error {
return nil
}
func (docker *Docker) GetContainers() ([]types.Container, error) {
func (docker *Docker) GetContainers() ([]apiTypes.Container, error) {
// Get the list of containers
containers, err := docker.Client.ContainerList(docker.Context, container.ListOptions{})
containers, err := docker.Client.ContainerList(docker.Context, containerTypes.ListOptions{})
// Check if there was an error
if err != nil {
@@ -47,13 +51,13 @@ func (docker *Docker) GetContainers() ([]types.Container, error) {
return containers, nil
}
func (docker *Docker) InspectContainer(containerId string) (types.ContainerJSON, error) {
func (docker *Docker) InspectContainer(containerId string) (apiTypes.ContainerJSON, error) {
// Inspect the container
inspect, err := docker.Client.ContainerInspect(docker.Context, containerId)
// Check if there was an error
if err != nil {
return types.ContainerJSON{}, err
return apiTypes.ContainerJSON{}, err
}
// Return the inspect
@@ -65,3 +69,57 @@ func (docker *Docker) DockerConnected() bool {
_, err := docker.Client.Ping(docker.Context)
return err == nil
}
func (docker *Docker) ContainerAction(appId string, runCheck func(labels appTypes.TinyauthLabels) (bool, error)) (bool, error) {
// Check if we have access to the Docker API
isConnected := docker.DockerConnected()
// If we don't have access, it is assumed that the check passed
if !isConnected {
log.Debug().Msg("Docker not connected, passing check")
return true, nil
}
// Get the containers
containers, containersErr := docker.GetContainers()
// If there is an error, return false
if containersErr != nil {
return false, containersErr
}
log.Debug().Msg("Got containers")
// Loop through the containers
for _, container := range containers {
// Inspect the container
inspect, inspectErr := docker.InspectContainer(container.ID)
// If there is an error, return false
if inspectErr != nil {
return false, inspectErr
}
// Get the container name (for some reason it is /name)
containerName := strings.Split(inspect.Name, "/")[1]
// There is a container with the same name as the app ID
if containerName == appId {
log.Debug().Str("container", containerName).Msg("Found container")
// Get only the tinyauth labels in a struct
labels := utils.GetTinyauthLabels(inspect.Config.Labels)
log.Debug().Msg("Got labels")
// Run the check
return runCheck(labels)
}
}
log.Debug().Msg("No matching container found, passing check")
// If no matching container is found, pass check
return true, nil
}

View File

@@ -36,15 +36,29 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
if user != nil && hooks.Auth.CheckPassword(*user, basic.Password) {
// Return user context since we are logged in with basic auth
return types.UserContext{
Username: basic.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "basic",
Username: basic.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "basic",
TotpPending: false,
}
}
}
// Check if session cookie has totp pending
if cookie.TotpPending {
log.Debug().Msg("Totp pending")
// Return empty context since we are pending totp
return types.UserContext{
Username: cookie.Username,
IsLoggedIn: false,
OAuth: false,
Provider: cookie.Provider,
TotpPending: true,
}
}
// Check if session cookie is username/password auth
if cookie.Provider == "username" {
log.Debug().Msg("Provider is username")
@@ -55,10 +69,11 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
// It exists so we are logged in
return types.UserContext{
Username: cookie.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "username",
Username: cookie.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "username",
TotpPending: false,
}
}
}
@@ -81,10 +96,11 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
// Return empty context
return types.UserContext{
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
TotpPending: false,
}
}
@@ -92,18 +108,20 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
// Return user context since we are logged in with oauth
return types.UserContext{
Username: cookie.Username,
IsLoggedIn: true,
OAuth: true,
Provider: cookie.Provider,
Username: cookie.Username,
IsLoggedIn: true,
OAuth: true,
Provider: cookie.Provider,
TotpPending: false,
}
}
// Neither basic auth or oauth is set so we return an empty context
return types.UserContext{
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
TotpPending: false,
}
}

View File

@@ -15,8 +15,9 @@ type LoginRequest struct {
// User is the struct for a user
type User struct {
Username string
Password string
Username string
Password string
TotpSecret string
}
// Users is a list of users
@@ -48,18 +49,21 @@ type Config struct {
GenericAuthURL string `mapstructure:"generic-auth-url"`
GenericTokenURL string `mapstructure:"generic-token-url"`
GenericUserURL string `mapstructure:"generic-user-url"`
GenericName string `mapstructure:"generic-name"`
DisableContinue bool `mapstructure:"disable-continue"`
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
SessionExpiry int `mapstructure:"session-expiry"`
LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"`
Title string `mapstructure:"app-title"`
}
// UserContext is the context for the user
type UserContext struct {
Username string
IsLoggedIn bool
OAuth bool
Provider string
Username string
IsLoggedIn bool
OAuth bool
Provider string
TotpPending bool
}
// APIConfig is the configuration for the API
@@ -69,8 +73,10 @@ type APIConfig struct {
Secret string
AppURL string
CookieSecure bool
CookieExpiry int
SessionExpiry int
DisableContinue bool
GenericName string
Title string
}
// OAuthConfig is the configuration for the providers
@@ -110,14 +116,16 @@ type UnauthorizedQuery struct {
// SessionCookie is the cookie for the session (exculding the expiry)
type SessionCookie struct {
Username string
Provider string
Username string
Provider string
TotpPending bool
}
// TinyauthLabels is the labels for the tinyauth container
type TinyauthLabels struct {
OAuthWhitelist []string
Users []string
Allowed string
}
// TailscaleQuery is the query parameters for the tailscale endpoint
@@ -129,3 +137,23 @@ type TailscaleQuery struct {
type Proxy struct {
Proxy string `uri:"proxy" binding:"required"`
}
// Status response
type Status struct {
Status int `json:"status"`
Message string `json:"message"`
IsLoggedIn bool `json:"isLoggedIn"`
Username string `json:"username"`
Provider string `json:"provider"`
Oauth bool `json:"oauth"`
ConfiguredProviders []string `json:"configuredProviders"`
DisableContinue bool `json:"disableContinue"`
Title string `json:"title"`
GenericName string `json:"genericName"`
TotpPending bool `json:"totpPending"`
}
// Totp request
type Totp struct {
Code string `json:"code"`
}

View File

@@ -29,19 +29,15 @@ func ParseUsers(users string) (types.Users, error) {
// Loop through the users and split them by colon
for _, user := range userList {
// Split the user by colon
userSplit := strings.Split(user, ":")
parsed, parseErr := ParseUser(user)
// Check if the user is in the correct format
if len(userSplit) != 2 {
return types.Users{}, errors.New("invalid user format")
// Check if there was an error
if parseErr != nil {
return types.Users{}, parseErr
}
// Append the user to the users struct
usersParsed = append(usersParsed, types.User{
Username: userSplit[0],
Password: userSplit[1],
})
usersParsed = append(usersParsed, parsed)
}
log.Debug().Msg("Parsed users")
@@ -195,6 +191,8 @@ func GetTinyauthLabels(labels map[string]string) types.TinyauthLabels {
tinyauthLabels.OAuthWhitelist = strings.Split(value, ",")
case "tinyauth.users":
tinyauthLabels.Users = strings.Split(value, ",")
case "tinyauth.allowed":
tinyauthLabels.Allowed = value
}
}
}
@@ -207,3 +205,53 @@ func GetTinyauthLabels(labels map[string]string) types.TinyauthLabels {
func OAuthConfigured(config types.Config) bool {
return (config.GithubClientId != "" && config.GithubClientSecret != "") || (config.GoogleClientId != "" && config.GoogleClientSecret != "") || (config.GenericClientId != "" && config.GenericClientSecret != "") || (config.TailscaleClientId != "" && config.TailscaleClientSecret != "")
}
// Filter helper function
func Filter[T any](slice []T, test func(T) bool) (res []T) {
for _, value := range slice {
if test(value) {
res = append(res, value)
}
}
return res
}
// Parse user
func ParseUser(user string) (types.User, error) {
// Check if the user is escaped
if strings.Contains(user, "$$") {
user = strings.ReplaceAll(user, "$$", "$")
}
// Split the user by colon
userSplit := strings.Split(user, ":")
// Check if the user is in the correct format
if len(userSplit) < 2 || len(userSplit) > 3 {
return types.User{}, errors.New("invalid user format")
}
// Check if the user has a totp secret
if len(userSplit) == 2 {
// Check for empty username or password
if userSplit[1] == "" || userSplit[0] == "" {
return types.User{}, errors.New("invalid user format")
}
return types.User{
Username: userSplit[0],
Password: userSplit[1],
}, nil
}
// Check for empty username, password or totp secret
if userSplit[2] == "" || userSplit[1] == "" || userSplit[0] == "" {
return types.User{}, errors.New("invalid user format")
}
// Return the user struct
return types.User{
Username: userSplit[0],
Password: userSplit[1],
TotpSecret: userSplit[2],
}, nil
}

View File

@@ -36,18 +36,6 @@ func TestParseUsers(t *testing.T) {
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing parse users with an invalid string")
// Test the parse users function with an invalid string
users = "user1:pass1,user2"
_, err = utils.ParseUsers(users)
// There should be an error
if err == nil {
t.Fatalf("Expected error parsing users")
}
}
// Test the get root url function
@@ -298,12 +286,14 @@ func TestGetTinyauthLabels(t *testing.T) {
labels := map[string]string{
"tinyauth.users": "user1,user2",
"tinyauth.oauth.whitelist": "user1,user2",
"tinyauth.allowed": "random",
"random": "random",
}
expected := types.TinyauthLabels{
Users: []string{"user1", "user2"},
OAuthWhitelist: []string{"user1", "user2"},
Allowed: "random",
}
result := utils.GetTinyauthLabels(labels)
@@ -313,3 +303,84 @@ func TestGetTinyauthLabels(t *testing.T) {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test the filter function
func TestFilter(t *testing.T) {
t.Log("Testing filter helper")
// Create variables
data := []string{"", "val1", "", "val2", "", "val3", ""}
expected := []string{"val1", "val2", "val3"}
// Test the filter function
result := utils.Filter(data, func(val string) bool {
return val != ""
})
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test parse user
func TestParseUser(t *testing.T) {
t.Log("Testing parse user with a valid user")
// Create variables
user := "user:pass:secret"
expected := types.User{
Username: "user",
Password: "pass",
TotpSecret: "secret",
}
// Test the parse user function
result, err := utils.ParseUser(user)
// Check if there was an error
if err != nil {
t.Fatalf("Error parsing user: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing parse user with an escaped user")
// Create variables
user = "user:p$$ass$$:secret"
expected = types.User{
Username: "user",
Password: "p$ass$",
TotpSecret: "secret",
}
// Test the parse user function
result, err = utils.ParseUser(user)
// Check if there was an error
if err != nil {
t.Fatalf("Error parsing user: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing parse user with an invalid user")
// Create variables
user = "user::pass"
// Test the parse user function
_, err = utils.ParseUser(user)
// Check if there was an error
if err == nil {
t.Fatalf("Expected error parsing user")
}
}

View File

@@ -0,0 +1,46 @@
import { TextInput, PasswordInput, Button } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { LoginFormValues, loginSchema } from "../../schemas/login-schema";
interface LoginFormProps {
isLoading: boolean;
onSubmit: (values: LoginFormValues) => void;
}
export const LoginForm = (props: LoginFormProps) => {
const { isLoading, onSubmit } = props;
const form = useForm({
mode: "uncontrolled",
initialValues: {
username: "",
password: "",
},
validate: zodResolver(loginSchema),
});
return (
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
label="Username"
placeholder="user@example.com"
required
disabled={isLoading}
key={form.key("username")}
{...form.getInputProps("username")}
/>
<PasswordInput
label="Password"
placeholder="password"
required
mt="md"
disabled={isLoading}
key={form.key("password")}
{...form.getInputProps("password")}
/>
<Button fullWidth mt="xl" type="submit" loading={isLoading}>
Login
</Button>
</form>
);
};

View File

@@ -0,0 +1,72 @@
import { Grid, Button } from "@mantine/core";
import { GithubIcon } from "../../icons/github";
import { GoogleIcon } from "../../icons/google";
import { OAuthIcon } from "../../icons/oauth";
import { TailscaleIcon } from "../../icons/tailscale";
interface OAuthButtonsProps {
oauthProviders: string[];
isLoading: boolean;
mutate: (provider: string) => void;
genericName: string;
}
export const OAuthButtons = (props: OAuthButtonsProps) => {
const { oauthProviders, isLoading, genericName, mutate } = props;
return (
<Grid mb="md" mt="md" align="center" justify="center">
{oauthProviders.includes("google") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={<GoogleIcon style={{ width: 14, height: 14 }} />}
variant="default"
onClick={() => mutate("google")}
loading={isLoading}
>
Google
</Button>
</Grid.Col>
)}
{oauthProviders.includes("github") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={<GithubIcon style={{ width: 14, height: 14 }} />}
variant="default"
onClick={() => mutate("github")}
loading={isLoading}
>
Github
</Button>
</Grid.Col>
)}
{oauthProviders.includes("tailscale") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={<TailscaleIcon style={{ width: 14, height: 14 }} />}
variant="default"
onClick={() => mutate("tailscale")}
loading={isLoading}
>
Tailscale
</Button>
</Grid.Col>
)}
{oauthProviders.includes("generic") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={<OAuthIcon style={{ width: 14, height: 14 }} />}
variant="default"
onClick={() => mutate("generic")}
loading={isLoading}
>
{genericName}
</Button>
</Grid.Col>
)}
</Grid>
);
};

View File

@@ -0,0 +1,40 @@
import { Button, PinInput } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod";
const schema = z.object({
code: z.string(),
});
type FormValues = z.infer<typeof schema>;
interface TotpFormProps {
onSubmit: (values: FormValues) => void;
isLoading: boolean;
}
export const TotpForm = (props: TotpFormProps) => {
const { onSubmit, isLoading } = props;
const form = useForm({
mode: "uncontrolled",
initialValues: {
code: "",
},
validate: zodResolver(schema),
});
return (
<form onSubmit={form.onSubmit(onSubmit)}>
<PinInput
length={6}
type={"number"}
placeholder=""
{...form.getInputProps("code")}
/>
<Button type="submit" mt="xl" loading={isLoading} fullWidth>
Verify
</Button>
</form>
);
};

View File

@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import React, { createContext, useContext } from "react";
import { UserContextSchemaType } from "../schemas/user-context-schema";
import axios from "axios";
import { UserContextSchemaType } from "../schemas/user-context-schema";
const UserContext = createContext<UserContextSchemaType | null>(null);
@@ -15,7 +15,7 @@ export const UserContextProvider = ({
isLoading,
error,
} = useQuery({
queryKey: ["isLoggedIn"],
queryKey: ["userContext"],
queryFn: async () => {
const res = await axios.get("/api/status");
return res.data;

View File

@@ -15,6 +15,7 @@ import { ContinuePage } from "./pages/continue-page.tsx";
import { NotFoundPage } from "./pages/not-found-page.tsx";
import { UnauthorizedPage } from "./pages/unauthorized-page.tsx";
import { InternalServerError } from "./pages/internal-server-error.tsx";
import { TotpPage } from "./pages/totp-page.tsx";
const queryClient = new QueryClient({
defaultOptions: {
@@ -34,6 +35,7 @@ createRoot(document.getElementById("root")!).render(
<Routes>
<Route path="/" element={<App />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/totp" element={<TotpPage />} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/continue" element={<ContinuePage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />

View File

@@ -4,6 +4,7 @@ import { Navigate } from "react-router";
import { useUserContext } from "../context/user-context";
import { Layout } from "../components/layouts/layout";
import { ReactNode } from "react";
import { isQueryValid } from "../utils/utils";
export const ContinuePage = () => {
const queryString = window.location.search;
@@ -16,7 +17,7 @@ export const ContinuePage = () => {
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
}
if (redirectUri === "null" || redirectUri === "") {
if (!isQueryValid(redirectUri)) {
return <Navigate to="/" />;
}
@@ -31,7 +32,11 @@ export const ContinuePage = () => {
}, 500);
};
if (!URL.canParse(redirectUri)) {
let uri;
try {
uri = new URL(redirectUri);
} catch {
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
@@ -45,28 +50,6 @@ export const ContinuePage = () => {
);
}
const uri = new URL(redirectUri);
if (
window.location.protocol === "https:" &&
uri.protocol === "http:"
) {
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
Insecure Redirect
</Text>
<Text>
Your are logged in but trying to redirect from <Code>https</Code> to{" "}
<Code>http</Code>, please click the button to redirect.
</Text>
<Button fullWidth mt="xl" onClick={redirect}>
Continue
</Button>
</ContinuePageLayout>
);
}
if (disableContinue) {
window.location.href = redirectUri;
return (
@@ -79,6 +62,23 @@ export const ContinuePage = () => {
);
}
if (window.location.protocol === "https:" && uri.protocol === "http:") {
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
Insecure Redirect
</Text>
<Text>
Your are trying to redirect from <Code>https</Code> to{" "}
<Code>http</Code>, are you sure you want to continue?
</Text>
<Button fullWidth mt="xl" color="yellow" onClick={redirect}>
Continue
</Button>
</ContinuePageLayout>
);
}
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>

View File

@@ -1,32 +1,22 @@
import {
Button,
Paper,
PasswordInput,
TextInput,
Title,
Text,
Divider,
Grid,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { Paper, Title, Text, Divider } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { z } from "zod";
import { useUserContext } from "../context/user-context";
import { Navigate } from "react-router";
import { Layout } from "../components/layouts/layout";
import { GoogleIcon } from "../icons/google";
import { GithubIcon } from "../icons/github";
import { OAuthIcon } from "../icons/oauth";
import { TailscaleIcon } from "../icons/tailscale";
import { OAuthButtons } from "../components/auth/oauth-buttons";
import { LoginFormValues } from "../schemas/login-schema";
import { LoginForm } from "../components/auth/login-forn";
import { isQueryValid } from "../utils/utils";
export const LoginPage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri") ?? "";
const { isLoggedIn, configuredProviders } = useUserContext();
const { isLoggedIn, configuredProviders, title, genericName } =
useUserContext();
const oauthProviders = configuredProviders.filter(
(value) => value !== "username",
@@ -36,24 +26,8 @@ export const LoginPage = () => {
return <Navigate to="/logout" />;
}
const schema = z.object({
username: z.string(),
password: z.string(),
});
type FormValues = z.infer<typeof schema>;
const form = useForm({
mode: "uncontrolled",
initialValues: {
username: "",
password: "",
},
validate: zodResolver(schema),
});
const loginMutation = useMutation({
mutationFn: (login: FormValues) => {
mutationFn: (login: LoginFormValues) => {
return axios.post("/api/login", login);
},
onError: () => {
@@ -63,18 +37,25 @@ export const LoginPage = () => {
color: "red",
});
},
onSuccess: () => {
onSuccess: async (data) => {
if (data.data.totpPending) {
window.location.replace(`/totp?redirect_uri=${redirectUri}`);
return;
}
notifications.show({
title: "Logged in",
message: "Welcome back!",
color: "green",
});
setTimeout(() => {
if (redirectUri === "null" || redirectUri === "") {
if (!isQueryValid(redirectUri)) {
window.location.replace("/");
} else {
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
return;
}
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
}, 500);
},
});
@@ -104,81 +85,25 @@ export const LoginPage = () => {
},
});
const handleSubmit = (values: FormValues) => {
const handleSubmit = (values: LoginFormValues) => {
loginMutation.mutate(values);
};
return (
<Layout>
<Title ta="center">Tinyauth</Title>
<Title ta="center">{title}</Title>
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
{oauthProviders.length > 0 && (
<>
<Text size="lg" fw={500} ta="center">
Welcome back, login with
</Text>
<Grid mb="md" mt="md" align="center" justify="center">
{oauthProviders.includes("google") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={
<GoogleIcon style={{ width: 14, height: 14 }} />
}
variant="default"
onClick={() => loginOAuthMutation.mutate("google")}
loading={loginOAuthMutation.isLoading}
>
Google
</Button>
</Grid.Col>
)}
{oauthProviders.includes("github") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={
<GithubIcon style={{ width: 14, height: 14 }} />
}
variant="default"
onClick={() => loginOAuthMutation.mutate("github")}
loading={loginOAuthMutation.isLoading}
>
Github
</Button>
</Grid.Col>
)}
{oauthProviders.includes("tailscale") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={
<TailscaleIcon style={{ width: 14, height: 14 }} />
}
variant="default"
onClick={() => loginOAuthMutation.mutate("tailscale")}
loading={loginOAuthMutation.isLoading}
>
Tailscale
</Button>
</Grid.Col>
)}
{oauthProviders.includes("generic") && (
<Grid.Col span="content">
<Button
radius="xl"
leftSection={
<OAuthIcon style={{ width: 14, height: 14 }} />
}
variant="default"
onClick={() => loginOAuthMutation.mutate("generic")}
loading={loginOAuthMutation.isLoading}
>
Generic
</Button>
</Grid.Col>
)}
</Grid>
<OAuthButtons
oauthProviders={oauthProviders}
isLoading={loginOAuthMutation.isLoading}
mutate={loginOAuthMutation.mutate}
genericName={genericName}
/>
{configuredProviders.includes("username") && (
<Divider
label="Or continue with password"
@@ -189,33 +114,10 @@ export const LoginPage = () => {
</>
)}
{configuredProviders.includes("username") && (
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
label="Username"
placeholder="user@example.com"
required
disabled={loginMutation.isLoading}
key={form.key("username")}
{...form.getInputProps("username")}
/>
<PasswordInput
label="Password"
placeholder="password"
required
mt="md"
disabled={loginMutation.isLoading}
key={form.key("password")}
{...form.getInputProps("password")}
/>
<Button
fullWidth
mt="xl"
type="submit"
loading={loginMutation.isLoading}
>
Login
</Button>
</form>
<LoginForm
isLoading={loginMutation.isLoading}
onSubmit={handleSubmit}
/>
)}
</Paper>
</Layout>

View File

@@ -8,7 +8,7 @@ import { Layout } from "../components/layouts/layout";
import { capitalize } from "../utils/utils";
export const LogoutPage = () => {
const { isLoggedIn, username, oauth, provider } = useUserContext();
const { isLoggedIn, username, oauth, provider, genericName } = useUserContext();
if (!isLoggedIn) {
return <Navigate to="/login" />;
@@ -45,7 +45,7 @@ export const LogoutPage = () => {
</Text>
<Text>
You are currently logged in as <Code>{username}</Code>
{oauth && ` using ${capitalize(provider)} OAuth`}. Click the button
{oauth && ` using ${capitalize(provider === "generic" ? genericName : provider)} OAuth`}. Click the button
below to log out.
</Text>
<Button

View File

@@ -0,0 +1,62 @@
import { Navigate } from "react-router";
import { useUserContext } from "../context/user-context";
import { Title, Paper, Text } from "@mantine/core";
import { Layout } from "../components/layouts/layout";
import { TotpForm } from "../components/auth/totp-form";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { notifications } from "@mantine/notifications";
export const TotpPage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri") ?? "";
const { totpPending, isLoggedIn, title } = useUserContext();
if (isLoggedIn) {
return <Navigate to={`/logout`} />;
}
if (!totpPending) {
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
}
const totpMutation = useMutation({
mutationFn: async (totp: { code: string }) => {
await axios.post("/api/totp", totp);
},
onError: () => {
notifications.show({
title: "Failed to verify code",
message: "Please try again",
color: "red",
});
},
onSuccess: () => {
notifications.show({
title: "Verified",
message: "Redirecting to your app",
color: "green",
});
setTimeout(() => {
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
}, 500);
},
});
return (
<Layout>
<Title ta="center">{title}</Title>
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
<Text size="lg" fw={500} mb="md" ta="center">
Enter your TOTP code
</Text>
<TotpForm
isLoading={totpMutation.isLoading}
onSubmit={(values) => totpMutation.mutate(values)}
/>
</Paper>
</Layout>
);
};

View File

@@ -1,6 +1,7 @@
import { Button, Code, Paper, Text } from "@mantine/core";
import { Layout } from "../components/layouts/layout";
import { Navigate } from "react-router";
import { isQueryValid } from "../utils/utils";
export const UnauthorizedPage = () => {
const queryString = window.location.search;
@@ -8,7 +9,7 @@ export const UnauthorizedPage = () => {
const username = params.get("username") ?? "";
const resource = params.get("resource") ?? "";
if (username === "null" || username === "") {
if (!isQueryValid(username)) {
return <Navigate to="/" />;
}
@@ -20,7 +21,7 @@ export const UnauthorizedPage = () => {
</Text>
<Text>
The user with username <Code>{username}</Code> is not authorized to{" "}
{resource !== "null" && resource !== "" ? (
{isQueryValid(resource) ? (
<span>
access the <Code>{resource}</Code> resource.
</span>

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const loginSchema = z.object({
username: z.string(),
password: z.string(),
});
export type LoginFormValues = z.infer<typeof loginSchema>;

View File

@@ -7,6 +7,9 @@ export const userContextSchema = z.object({
provider: z.string(),
configuredProviders: z.array(z.string()),
disableContinue: z.boolean(),
title: z.string(),
genericName: z.string(),
totpPending: z.boolean(),
});
export type UserContextSchemaType = z.infer<typeof userContextSchema>;

View File

@@ -1 +1,2 @@
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
export const isQueryValid = (value: string) => value.trim() !== "" && value !== "null";