Compare commits

...

36 Commits

Author SHA1 Message Date
Stavros
be836c296c chore: remove auth response headers from example compose since it's an advanced feature 2025-03-10 22:11:32 +02:00
Stavros
6f6b1f4862 chore: use v prefix in versioning 2025-03-10 21:44:41 +02:00
Stavros
ada3531565 chore: use correct timezone in discohook 2025-03-10 21:25:49 +02:00
Stavros
939ed26fd0 chore: update discohook date 2025-03-10 21:22:01 +02:00
Stavros
da0641c115 chore: migrate to new domain 2025-03-10 21:21:31 +02:00
Stavros
753b95baff docs: change warning to note in contributing 2025-03-10 18:50:43 +02:00
Stavros
9dd9829058 docs: update contributing 2025-03-10 18:47:54 +02:00
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
38 changed files with 1292 additions and 560 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 name: Release
on: on:
workflow_dispatch: workflow_dispatch:
push:
tags:
- "v*"
jobs: jobs:
get-tag: build:
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
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU - name: Docker meta
uses: docker/setup-qemu-action@v3 id: meta
uses: docker/metadata-action@v5
- name: Set up Docker Buildx with:
uses: docker/setup-buildx-action@v3 images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -35,21 +25,112 @@ jobs:
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
id: build
with: with:
context: . platforms: linux/amd64
push: true labels: ${{ steps.meta.outputs.labels }}
platforms: linux/arm64, linux/amd64 tags: ghcr.io/${{ github.repository_owner }}/tinyauth
tags: ghcr.io/${{ github.repository_owner }}/tinyauth:${{ needs.get-tag.outputs.tag }}, ghcr.io/${{ github.repository_owner }}/tinyauth:latest outputs: type=image,push-by-digest=true,name-canonical=true,push=true
release: - name: Export digest
needs: [get-tag, build-docker] run: |
runs-on: ubuntu-latest mkdir -p ${{ runner.temp }}/digests
steps: digest="${{ steps.build.outputs.digest }}"
- name: Create release touch "${{ runner.temp }}/digests/${digest#sha256:}"
uses: softprops/action-gh-release@v2
- name: Upload digest
uses: actions/upload-artifact@v4
with: with:
prerelease: false name: digests-linux-amd64
make_latest: false path: ${{ runner.temp }}/digests/*
tag_name: ${{ needs.get-tag.outputs.tag }} 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=v{{version}}
type=semver,pattern=v{{major}}
type=semver,pattern=v{{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 ' *)

View File

@@ -1,6 +1,6 @@
# Contributing # Contributing
Contributing is relatively easy. Contributing is relatively easy, you just need to follow the steps carefully and you will be up and running with a development server in less than 5 minutes.
## Requirements ## Requirements
@@ -8,6 +8,7 @@ Contributing is relatively easy.
- Golang v1.23.2 and above - Golang v1.23.2 and above
- Git - Git
- Docker - Docker
- Make (not required but it will make your life easier)
## Cloning the repository ## Cloning the repository
@@ -20,48 +21,42 @@ cd tinyauth
## Install requirements ## Install requirements
Now it's time to install the requirements, firstly the Go ones: To install the requirements simply run:
```sh ```sh
go mod download make requirements
``` ```
And now the site ones: It will download all the node packages required by the frontend as well as all the go requirements.
```sh
cd site
bun i
```
## Developing locally ## Developing locally
In order to develop the app locally you need to build the frontend and copy it to the assets folder in order for Go to embed it and host it. In order to build the frontend run: In order to develop the app you need to firstly compile the frontend and then the go app. To avoid running the same commands over and over again you can just run:
```sh ```sh
cd site make run
bun run build
cd ..
``` ```
Copy it to the assets folder: This is the equivalent of `go run main.go`, if you would like to build a binary run:
```sh ```sh
rm -rf internal/assets/dist make build
cp -r site/dist internal/assets/dist
``` ```
Finally either run the app with: To avoid rebuilding the frontend every time you can run:
```sh ```sh
go run main.go make run-no-web
``` ```
Or build it with: And:
```sh ```sh
go build make build-no-web
``` ```
For these commands to succeed you must have built the frontend at least once.
> [!WARNING] > [!WARNING]
> Make sure you have set the environment variables when running outside of docker else the app will fail. > Make sure you have set the environment variables when running outside of docker else the app will fail.
@@ -70,8 +65,8 @@ go build
My recommended development method is docker so I can test that both my image works and that the app responds correctly to traefik. In my setup I have set these two DNS records in my DNS server: My recommended development method is docker so I can test that both my image works and that the app responds correctly to traefik. In my setup I have set these two DNS records in my DNS server:
``` ```
*.dev.local -> 127.0.0.1 *.dev.example.com -> 127.0.0.1
dev.local -> 127.0.0.1 dev.example.com -> 127.0.0.1
``` ```
Then I can just make sure the domains are correct in the example docker compose file and do: Then I can just make sure the domains are correct in the example docker compose file and do:
@@ -79,3 +74,6 @@ Then I can just make sure the domains are correct in the example docker compose
```sh ```sh
docker compose -f docker-compose.dev.yml up --build docker compose -f docker-compose.dev.yml up --build
``` ```
> [!NOTE]
> I would recommend copying the example `docker-compose.dev.yml` into a `docker-compose.test.yml` file, so as you don't accidentally commit any sensitive information.

2
FUNDING.yml Normal file
View File

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

34
Makefile Normal file
View File

@@ -0,0 +1,34 @@
# Build website
web:
cd site; bun run build
# Requirements
requirements:
cd site; bun install
go mod tidy
# 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
# Run development binary without compiling the frontend
run-skip-web:
go run main.go
# Test
test:
go test ./...
# Build
build: assets
go build -o tinyauth
# Build the binary without compiling the frontend
build-skip-web:
go build -o tinyauth

View File

@@ -28,11 +28,11 @@ I just made a Discord server for Tinyauth! It is not only for Tinyauth but gener
## Getting Started ## Getting Started
You can easily get started with tinyauth by following the guide on the [documentation](https://tinyauth.doesmycode.work/docs/getting-started.html). There is also an available [docker compose file](./docker-compose.example.yml) that has traefik, nginx and tinyauth to demonstrate its capabilities. You can easily get started with tinyauth by following the guide on the [documentation](https://tinyauth.app/docs/getting-started.html). There is also an available [docker compose file](./docker-compose.example.yml) that has traefik, nginx and tinyauth to demonstrate its capabilities.
## Documentation ## Documentation
You can find documentation and guides on all available configuration of tinyauth [here](https://tinyauth.doesmycode.work). You can find documentation and guides on all available configuration of tinyauth [here](https://tinyauth.app).
## Contributing ## Contributing
@@ -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. 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 ## Acknowledgements
Credits for the logo of this app go to: 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. - **Renee French** for the original gopher logo.

View File

@@ -3,8 +3,8 @@
"embeds": [ "embeds": [
{ {
"title": "Welcome to Tinyauth Discord!", "title": "Welcome to Tinyauth Discord!",
"description": "Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic OAuth provider to all of your docker apps.\n\n**Information**\n\n• Github: <https://github.com/steveiliop56/tinyauth>\n• Website: <https://tinyauth.doesmycode.work>", "description": "Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic OAuth provider to all of your docker apps.\n\n**Information**\n\n• Github: <https://github.com/steveiliop56/tinyauth>\n• Website: <https://tinyauth.app>",
"url": "https://tinyauth.doesmycode.work", "url": "https://tinyauth.app",
"color": 7002085, "color": 7002085,
"author": { "author": {
"name": "Tinyauth" "name": "Tinyauth"
@@ -12,7 +12,7 @@
"footer": { "footer": {
"text": "Updated at" "text": "Updated at"
}, },
"timestamp": "2025-02-06T22:00:00.000Z", "timestamp": "2025-03-10T19:00:00.000Z",
"thumbnail": { "thumbnail": {
"url": "https://github.com/steveiliop56/tinyauth/blob/main/site/public/logo.png?raw=true" "url": "https://github.com/steveiliop56/tinyauth/blob/main/site/public/logo.png?raw=true"
} }

View File

@@ -5,7 +5,8 @@ import (
"os" "os"
"strings" "strings"
"time" "time"
cmd "tinyauth/cmd/user" totpCmd "tinyauth/cmd/totp"
userCmd "tinyauth/cmd/user"
"tinyauth/internal/api" "tinyauth/internal/api"
"tinyauth/internal/assets" "tinyauth/internal/assets"
"tinyauth/internal/auth" "tinyauth/internal/auth"
@@ -113,7 +114,9 @@ var rootCmd = &cobra.Command{
AppURL: config.AppURL, AppURL: config.AppURL,
CookieSecure: config.CookieSecure, CookieSecure: config.CookieSecure,
DisableContinue: config.DisableContinue, DisableContinue: config.DisableContinue,
CookieExpiry: config.SessionExpiry, SessionExpiry: config.SessionExpiry,
Title: config.Title,
GenericName: config.GenericName,
}, hooks, auth, providers) }, hooks, auth, providers)
// Setup routes // Setup routes
@@ -139,7 +142,10 @@ func HandleError(err error, msg string) {
func init() { func init() {
// Add user command // Add user command
rootCmd.AddCommand(cmd.UserCmd()) rootCmd.AddCommand(userCmd.UserCmd())
// Add totp command
rootCmd.AddCommand(totpCmd.TotpCmd())
// Read environment variables // Read environment variables
viper.AutomaticEnv() viper.AutomaticEnv()
@@ -169,10 +175,12 @@ func init() {
rootCmd.Flags().String("generic-auth-url", "", "Generic OAuth auth URL.") rootCmd.Flags().String("generic-auth-url", "", "Generic OAuth auth URL.")
rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token 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-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().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().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("session-expiry", 86400, "Session (cookie) expiration time in seconds.")
rootCmd.Flags().Int("log-level", 1, "Log level.") rootCmd.Flags().Int("log-level", 1, "Log level.")
rootCmd.Flags().String("app-title", "Tinyauth", "Title of the app.")
// Bind flags to environment // Bind flags to environment
viper.BindEnv("port", "PORT") viper.BindEnv("port", "PORT")
@@ -199,10 +207,12 @@ func init() {
viper.BindEnv("generic-auth-url", "GENERIC_AUTH_URL") viper.BindEnv("generic-auth-url", "GENERIC_AUTH_URL")
viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL") viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL")
viper.BindEnv("generic-user-url", "GENERIC_USER_URL") viper.BindEnv("generic-user-url", "GENERIC_USER_URL")
viper.BindEnv("generic-name", "GENERIC_NAME")
viper.BindEnv("disable-continue", "DISABLE_CONTINUE") viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST") viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST")
viper.BindEnv("session-expiry", "SESSION_EXPIRY") viper.BindEnv("session-expiry", "SESSION_EXPIRY")
viper.BindEnv("log-level", "LOG_LEVEL") viper.BindEnv("log-level", "LOG_LEVEL")
viper.BindEnv("app-title", "APP_TITLE")
// Bind flags to viper // Bind flags to viper
viper.BindPFlags(rootCmd.Flags()) 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? // Do we have username and password?
if iUsername == "" || iPassword == "" { 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") log.Info().Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Creating user")

View File

@@ -2,9 +2,10 @@ package verify
import ( import (
"errors" "errors"
"strings" "tinyauth/internal/utils"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -17,22 +18,26 @@ var docker bool
// i stands for input // i stands for input
var iUsername string var iUsername string
var iPassword string var iPassword string
var iTotp string
var iUser string var iUser string
var VerifyCmd = &cobra.Command{ var VerifyCmd = &cobra.Command{
Use: "verify", Use: "verify",
Short: "Verify a user is set up correctly", 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) { Run: func(cmd *cobra.Command, args []string) {
// Setup logger // Setup logger
log.Logger = log.Level(zerolog.InfoLevel) log.Logger = log.Level(zerolog.InfoLevel)
// Use simple theme
var baseTheme *huh.Theme = huh.ThemeBase()
// Check if interactive // Check if interactive
if interactive { if interactive {
// Create huh form // Create huh form
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup( 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 == "" { if s == "" {
return errors.New("user cannot be empty") return errors.New("user cannot be empty")
} }
@@ -50,13 +55,11 @@ var VerifyCmd = &cobra.Command{
} }
return nil 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 // Run form
var baseTheme *huh.Theme = huh.ThemeBase()
formErr := form.WithTheme(baseTheme).Run() formErr := form.WithTheme(baseTheme).Run()
if formErr != nil { if formErr != nil {
@@ -64,33 +67,44 @@ var VerifyCmd = &cobra.Command{
} }
} }
// Do we have username, password and user? // Parse user
if iUsername == "" || iPassword == "" || iUser == "" { user, userErr := utils.ParseUser(iUser)
log.Fatal().Msg("Username, password and user cannot be empty")
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") // Compare username
if user.Username != iUsername {
// Split username and password hash log.Fatal().Msg("Username is incorrect")
username, hash, ok := strings.Cut(iUser, ":")
if !ok {
log.Fatal().Msg("User is not formatted correctly")
} }
// Replace $$ with $ if formatted for docker // Compare password
if docker { verifyErr := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(iPassword))
hash = strings.ReplaceAll(hash, "$$", "$")
if verifyErr != nil {
log.Fatal().Msg("Ppassword is incorrect")
} }
// Compare username and password // Check if user has 2fa code
verifyErr := bcrypt.CompareHashAndPassword([]byte(hash), []byte(iPassword)) if user.TotpSecret == "" {
if iTotp != "" {
if verifyErr != nil || username != iUsername { log.Warn().Msg("User does not have 2fa secret")
log.Fatal().Msg("Username or password incorrect")
} else {
log.Info().Msg("Verification successful")
} }
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().BoolVar(&docker, "docker", false, "Is the user formatted for docker?")
VerifyCmd.Flags().StringVar(&iUsername, "username", "", "Username") VerifyCmd.Flags().StringVar(&iUsername, "username", "", "Username")
VerifyCmd.Flags().StringVar(&iPassword, "password", "", "Password") 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: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
nginx: whoami:
container_name: nginx container_name: whoami
image: nginx:latest image: traefik/whoami:latest
labels: labels:
traefik.enable: true traefik.enable: true
traefik.http.routers.nginx.rule: Host(`nginx.dev.local`) traefik.http.routers.nginx.rule: Host(`whoami.dev.example.com`)
traefik.http.services.nginx.loadbalancer.server.port: 80 traefik.http.services.nginx.loadbalancer.server.port: 80
traefik.http.routers.nginx.middlewares: tinyauth traefik.http.routers.nginx.middlewares: tinyauth
@@ -24,10 +24,10 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
environment: environment:
- SECRET=some-random-32-chars-string - SECRET=some-random-32-chars-string
- APP_URL=http://tinyauth.dev.local - APP_URL=http://tinyauth.dev.example.com
- USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password - USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password
labels: labels:
traefik.enable: true traefik.enable: true
traefik.http.routers.tinyauth.rule: Host(`tinyauth.dev.local`) traefik.http.routers.tinyauth.rule: Host(`tinyauth.dev.example.com`)
traefik.http.services.tinyauth.loadbalancer.server.port: 3000 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

View File

@@ -8,12 +8,12 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
nginx: whoami:
container_name: nginx container_name: whoami
image: nginx:latest image: traefik/whoami:latest
labels: labels:
traefik.enable: true traefik.enable: true
traefik.http.routers.nginx.rule: Host(`nginx.example.com`) traefik.http.routers.nginx.rule: Host(`whoami.example.com`)
traefik.http.services.nginx.loadbalancer.server.port: 80 traefik.http.services.nginx.loadbalancer.server.port: 80
traefik.http.routers.nginx.middlewares: tinyauth traefik.http.routers.nginx.middlewares: tinyauth
@@ -28,4 +28,4 @@ services:
traefik.enable: true traefik.enable: true
traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`) traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`)
traefik.http.services.tinyauth.loadbalancer.server.port: 3000 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

31
go.mod
View File

@@ -7,29 +7,43 @@ require (
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.24.0 github.com/go-playground/validator/v10 v10.24.0
github.com/google/go-querystring v1.1.0 github.com/google/go-querystring v1.1.0
github.com/mdp/qrterminal/v3 v3.2.0
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.19.0
golang.org/x/crypto v0.32.0 golang.org/x/crypto v0.32.0
) )
require (
github.com/containerd/log v0.1.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 ( require (
github.com/Microsoft/go-winio v0.4.14 // indirect github.com/Microsoft/go-winio v0.4.14 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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 v1.12.7 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/catppuccin/go v0.2.0 // indirect github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/bubbles v0.20.0 // indirect github.com/charmbracelet/bubbles v0.20.0 // indirect
github.com/charmbracelet/bubbletea v1.1.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/lipgloss v0.13.0 // indirect
github.com/charmbracelet/x/ansi v0.2.3 // 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/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/distribution/reference v0.6.0 // 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-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // 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/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // 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-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.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/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // 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/opencontainers/image-spec v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // 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/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // 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/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.13.0 // indirect golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.34.0 // 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/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.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 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= 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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q=
github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I= github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 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/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 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 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 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= 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 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 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/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/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/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= 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 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 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.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.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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= 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 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 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.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 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 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 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 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 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 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 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 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= 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-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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 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= 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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/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 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 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 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 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.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 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 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/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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/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 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 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.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= 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 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 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/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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 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-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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 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 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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= 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-contrib/sessions/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/go-querystring/query" "github.com/google/go-querystring/query"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -84,7 +85,7 @@ func (api *API) Init() {
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: api.Config.CookieSecure, Secure: api.Config.CookieSecure,
MaxAge: api.Config.CookieExpiry, MaxAge: api.Config.SessionExpiry,
}) })
router.Use(sessions.Sessions("tinyauth", store)) router.Use(sessions.Sessions("tinyauth", store))
@@ -130,14 +131,53 @@ func (api *API) SetupRoutes() {
return 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") 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 // Get user context
userContext := api.Hooks.UseUserContext(c) userContext := api.Hooks.UseUserContext(c)
// Check if using basic auth
_, _, basicAuth := c.Request.BasicAuth()
// Get headers // Get headers
uri := c.Request.Header.Get("X-Forwarded-Uri") uri := c.Request.Header.Get("X-Forwarded-Uri")
proto := c.Request.Header.Get("X-Forwarded-Proto") proto := c.Request.Header.Get("X-Forwarded-Proto")
@@ -148,15 +188,15 @@ func (api *API) SetupRoutes() {
log.Debug().Msg("Authenticated") log.Debug().Msg("Authenticated")
// Check if user is allowed to access subdomain, if request is nginx.example.com the subdomain (resource) is nginx // 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 // Check if there was an error
if appAllowedErr != nil { if appAllowedErr != nil {
// Return 501 if nginx is the proxy or if the request is using basic auth // Return 500 if nginx is the proxy or if the request is not coming from a browser
if proxy.Proxy == "nginx" || basicAuth { if proxy.Proxy == "nginx" || !isBrowser {
log.Error().Err(appAllowedErr).Msg("Failed to check if app is allowed") log.Error().Err(appAllowedErr).Msg("Failed to check if app is allowed")
c.JSON(501, gin.H{ c.JSON(500, gin.H{
"status": 501, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
}) })
return return
@@ -174,9 +214,11 @@ func (api *API) SetupRoutes() {
if !appAllowed { if !appAllowed {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed") 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 // Set WWW-Authenticate header
if proxy.Proxy == "nginx" || basicAuth {
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"") 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{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -202,6 +244,9 @@ func (api *API) SetupRoutes() {
return return
} }
// Set the user header
c.Header("Remote-User", userContext.Username)
// The user is allowed to access the app // The user is allowed to access the app
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
@@ -215,9 +260,11 @@ func (api *API) SetupRoutes() {
// The user is not logged in // The user is not logged in
log.Debug().Msg("Unauthorized") log.Debug().Msg("Unauthorized")
// Return 401 if nginx is the proxy or if the request is using an Authorization header // Set www-authenticate header
if proxy.Proxy == "nginx" || basicAuth {
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"") 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{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -285,7 +332,29 @@ func (api *API) SetupRoutes() {
return 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 // Create session cookie with username as provider
api.Auth.CreateSessionCookie(c, &types.SessionCookie{ api.Auth.CreateSessionCookie(c, &types.SessionCookie{
@@ -293,6 +362,80 @@ func (api *API) SetupRoutes() {
Provider: "username", 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 // Return logged in
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
@@ -332,36 +475,33 @@ func (api *API) SetupRoutes() {
configuredProviders = append(configuredProviders, "username") 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 { if !userContext.IsLoggedIn {
log.Debug().Msg("Unauthorized") log.Debug().Msg("Unauthorized")
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"") c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
c.JSON(200, gin.H{ status.Status = 401
"status": 200, status.Message = "Unauthorized"
"message": "Unauthorized", } else {
"username": "", log.Debug().Interface("userContext", userContext).Strs("configuredProviders", configuredProviders).Bool("disableContinue", api.Config.DisableContinue).Msg("Authenticated")
"isLoggedIn": false, status.Status = 200
"oauth": false, status.Message = "Authenticated"
"provider": "",
"configuredProviders": configuredProviders,
"disableContinue": api.Config.DisableContinue,
})
return
} }
log.Debug().Interface("userContext", userContext).Strs("configuredProviders", configuredProviders).Bool("disableContinue", api.Config.DisableContinue).Msg("Authenticated") // Return data
c.JSON(200, status)
// 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,
})
}) })
api.Router.GET("/api/oauth/url/:provider", func(c *gin.Context) { 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 it is empty it means that no redirect_uri was provided to the login screen so we just log in
if redirectURIErr != nil { if redirectURIErr != nil {
c.JSON(200, gin.H{ c.Redirect(http.StatusPermanentRedirect, api.Config.AppURL)
"status": 200,
"message": "Logged in",
})
} }
log.Debug().Str("redirectURI", redirectURI).Msg("Got redirect URI") 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 Secret: "super-secret-api-thing-for-tests", // It is 32 chars long
AppURL: "http://tinyauth.localhost", AppURL: "http://tinyauth.localhost",
CookieSecure: false, CookieSecure: false,
CookieExpiry: 3600, SessionExpiry: 3600,
DisableContinue: false, DisableContinue: false,
} }
@@ -55,7 +55,7 @@ func getAPI(t *testing.T) *api.API {
Username: user.Username, Username: user.Username,
Password: user.Password, Password: user.Password,
}, },
}, nil, apiConfig.CookieExpiry) }, nil, apiConfig.SessionExpiry)
// Create providers service // Create providers service
providers := providers.NewProviders(types.OAuthConfig{}) providers := providers.NewProviders(types.OAuthConfig{})

View File

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

View File

@@ -1,12 +1,12 @@
package auth package auth
import ( import (
"regexp"
"slices" "slices"
"strings" "strings"
"time" "time"
"tinyauth/internal/docker" "tinyauth/internal/docker"
"tinyauth/internal/types" "tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "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") log.Debug().Msg("Setting session cookie")
// Calculate expiry
var sessionExpiry int
if data.TotpPending {
sessionExpiry = 3600
} else {
sessionExpiry = auth.SessionExpiry
}
// Set data // Set data
sessions.Set("username", data.Username) sessions.Set("username", data.Username)
sessions.Set("provider", data.Provider) 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 // Save session
sessions.Save() sessions.Save()
@@ -102,14 +112,16 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie {
cookieUsername := sessions.Get("username") cookieUsername := sessions.Get("username")
cookieProvider := sessions.Get("provider") cookieProvider := sessions.Get("provider")
cookieExpiry := sessions.Get("expiry") cookieExpiry := sessions.Get("expiry")
cookieTotpPending := sessions.Get("totpPending")
// Convert interfaces to correct types // Convert interfaces to correct types
username, usernameOk := cookieUsername.(string) username, usernameOk := cookieUsername.(string)
provider, providerOk := cookieProvider.(string) provider, providerOk := cookieProvider.(string)
expiry, expiryOk := cookieExpiry.(int64) expiry, expiryOk := cookieExpiry.(int64)
totpPending, totpPendingOk := cookieTotpPending.(bool)
// Check if the cookie is invalid // Check if the cookie is invalid
if !usernameOk || !providerOk || !expiryOk { if !usernameOk || !providerOk || !expiryOk || !totpPendingOk {
log.Warn().Msg("Session cookie invalid") log.Warn().Msg("Session cookie invalid")
return types.SessionCookie{} return types.SessionCookie{}
} }
@@ -125,12 +137,13 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie {
return 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 the cookie
return types.SessionCookie{ return types.SessionCookie{
Username: username, Username: username,
Provider: provider, Provider: provider,
TotpPending: totpPending,
} }
} }
@@ -139,53 +152,20 @@ func (auth *Auth) UserAuthConfigured() bool {
return len(auth.Users) > 0 return len(auth.Users) > 0
} }
func (auth *Auth) ResourceAllowed(context types.UserContext, host string) (bool, error) { func (auth *Auth) ResourceAllowed(c *gin.Context, context types.UserContext) (bool, error) {
// Check if we have access to the Docker API // Get headers
isConnected := auth.Docker.DockerConnected() host := c.Request.Header.Get("X-Forwarded-Host")
// If we don't have access, it is assumed that the user has access // Get app id
if !isConnected {
log.Debug().Msg("Docker not connected, allowing access")
return true, nil
}
// Get the app ID from the host
appId := strings.Split(host, ".")[0] appId := strings.Split(host, ".")[0]
// Get the containers // Check if resource is allowed
containers, containersErr := auth.Docker.GetContainers() allowed, allowedErr := auth.Docker.ContainerAction(appId, func(labels types.TinyauthLabels) (bool, error) {
// 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 := auth.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")
// If the container has an oauth whitelist, check if the user is in it // If the container has an oauth whitelist, check if the user is in it
if context.OAuth && len(labels.OAuthWhitelist) != 0 { if context.OAuth {
if len(labels.OAuthWhitelist) == 0 {
return true, nil
}
log.Debug().Msg("Checking OAuth whitelist") log.Debug().Msg("Checking OAuth whitelist")
if slices.Contains(labels.OAuthWhitelist, context.Username) { if slices.Contains(labels.OAuthWhitelist, context.Username) {
return true, nil return true, nil
@@ -201,14 +181,63 @@ func (auth *Auth) ResourceAllowed(context types.UserContext, host string) (bool,
} }
return false, nil return false, nil
} }
}
} // Allowed
log.Debug().Msg("No matching container found, allowing access")
// If no matching container is found, allow access
return true, nil return true, nil
})
// If there is an error, return false
if allowedErr != nil {
log.Error().Err(allowedErr).Msg("Error checking if resource is allowed")
return false, allowedErr
}
// Return if the resource is allowed
return allowed, nil
}
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")
// 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
}
// Compile regex
regex, regexErr := regexp.Compile(labels.Allowed)
// 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
}
return enabled, nil
} }
func (auth *Auth) GetBasicAuth(c *gin.Context) *types.User { func (auth *Auth) GetBasicAuth(c *gin.Context) *types.User {

View File

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

View File

@@ -2,10 +2,14 @@ package docker
import ( import (
"context" "context"
"strings"
appTypes "tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/docker/docker/api/types" apiTypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" containerTypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/rs/zerolog/log"
) )
func NewDocker() *Docker { func NewDocker() *Docker {
@@ -34,9 +38,9 @@ func (docker *Docker) Init() error {
return nil return nil
} }
func (docker *Docker) GetContainers() ([]types.Container, error) { func (docker *Docker) GetContainers() ([]apiTypes.Container, error) {
// Get the list of containers // 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 // Check if there was an error
if err != nil { if err != nil {
@@ -47,13 +51,13 @@ func (docker *Docker) GetContainers() ([]types.Container, error) {
return containers, nil 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 the container
inspect, err := docker.Client.ContainerInspect(docker.Context, containerId) inspect, err := docker.Client.ContainerInspect(docker.Context, containerId)
// Check if there was an error // Check if there was an error
if err != nil { if err != nil {
return types.ContainerJSON{}, err return apiTypes.ContainerJSON{}, err
} }
// Return the inspect // Return the inspect
@@ -65,3 +69,57 @@ func (docker *Docker) DockerConnected() bool {
_, err := docker.Client.Ping(docker.Context) _, err := docker.Client.Ping(docker.Context)
return err == nil 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

@@ -40,11 +40,25 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
IsLoggedIn: true, IsLoggedIn: true,
OAuth: false, OAuth: false,
Provider: "basic", 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 // Check if session cookie is username/password auth
if cookie.Provider == "username" { if cookie.Provider == "username" {
log.Debug().Msg("Provider is username") log.Debug().Msg("Provider is username")
@@ -59,6 +73,7 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
IsLoggedIn: true, IsLoggedIn: true,
OAuth: false, OAuth: false,
Provider: "username", Provider: "username",
TotpPending: false,
} }
} }
} }
@@ -85,6 +100,7 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
IsLoggedIn: false, IsLoggedIn: false,
OAuth: false, OAuth: false,
Provider: "", Provider: "",
TotpPending: false,
} }
} }
@@ -96,6 +112,7 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
IsLoggedIn: true, IsLoggedIn: true,
OAuth: true, OAuth: true,
Provider: cookie.Provider, Provider: cookie.Provider,
TotpPending: false,
} }
} }
@@ -105,5 +122,6 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
IsLoggedIn: false, IsLoggedIn: false,
OAuth: false, OAuth: false,
Provider: "", Provider: "",
TotpPending: false,
} }
} }

View File

@@ -17,6 +17,7 @@ type LoginRequest struct {
type User struct { type User struct {
Username string Username string
Password string Password string
TotpSecret string
} }
// Users is a list of users // Users is a list of users
@@ -48,10 +49,12 @@ type Config struct {
GenericAuthURL string `mapstructure:"generic-auth-url"` GenericAuthURL string `mapstructure:"generic-auth-url"`
GenericTokenURL string `mapstructure:"generic-token-url"` GenericTokenURL string `mapstructure:"generic-token-url"`
GenericUserURL string `mapstructure:"generic-user-url"` GenericUserURL string `mapstructure:"generic-user-url"`
GenericName string `mapstructure:"generic-name"`
DisableContinue bool `mapstructure:"disable-continue"` DisableContinue bool `mapstructure:"disable-continue"`
OAuthWhitelist string `mapstructure:"oauth-whitelist"` OAuthWhitelist string `mapstructure:"oauth-whitelist"`
SessionExpiry int `mapstructure:"session-expiry"` SessionExpiry int `mapstructure:"session-expiry"`
LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"` LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"`
Title string `mapstructure:"app-title"`
} }
// UserContext is the context for the user // UserContext is the context for the user
@@ -60,6 +63,7 @@ type UserContext struct {
IsLoggedIn bool IsLoggedIn bool
OAuth bool OAuth bool
Provider string Provider string
TotpPending bool
} }
// APIConfig is the configuration for the API // APIConfig is the configuration for the API
@@ -69,8 +73,10 @@ type APIConfig struct {
Secret string Secret string
AppURL string AppURL string
CookieSecure bool CookieSecure bool
CookieExpiry int SessionExpiry int
DisableContinue bool DisableContinue bool
GenericName string
Title string
} }
// OAuthConfig is the configuration for the providers // OAuthConfig is the configuration for the providers
@@ -112,12 +118,14 @@ type UnauthorizedQuery struct {
type SessionCookie struct { type SessionCookie struct {
Username string Username string
Provider string Provider string
TotpPending bool
} }
// TinyauthLabels is the labels for the tinyauth container // TinyauthLabels is the labels for the tinyauth container
type TinyauthLabels struct { type TinyauthLabels struct {
OAuthWhitelist []string OAuthWhitelist []string
Users []string Users []string
Allowed string
} }
// TailscaleQuery is the query parameters for the tailscale endpoint // TailscaleQuery is the query parameters for the tailscale endpoint
@@ -129,3 +137,23 @@ type TailscaleQuery struct {
type Proxy struct { type Proxy struct {
Proxy string `uri:"proxy" binding:"required"` 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 // Loop through the users and split them by colon
for _, user := range userList { for _, user := range userList {
// Split the user by colon parsed, parseErr := ParseUser(user)
userSplit := strings.Split(user, ":")
// Check if the user is in the correct format // Check if there was an error
if len(userSplit) != 2 { if parseErr != nil {
return types.Users{}, errors.New("invalid user format") return types.Users{}, parseErr
} }
// Append the user to the users struct // Append the user to the users struct
usersParsed = append(usersParsed, types.User{ usersParsed = append(usersParsed, parsed)
Username: userSplit[0],
Password: userSplit[1],
})
} }
log.Debug().Msg("Parsed users") log.Debug().Msg("Parsed users")
@@ -195,6 +191,8 @@ func GetTinyauthLabels(labels map[string]string) types.TinyauthLabels {
tinyauthLabels.OAuthWhitelist = strings.Split(value, ",") tinyauthLabels.OAuthWhitelist = strings.Split(value, ",")
case "tinyauth.users": case "tinyauth.users":
tinyauthLabels.Users = strings.Split(value, ",") tinyauthLabels.Users = strings.Split(value, ",")
case "tinyauth.allowed":
tinyauthLabels.Allowed = value
} }
} }
} }
@@ -217,3 +215,43 @@ func Filter[T any](slice []T, test func(T) bool) (res []T) {
} }
return res 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) { if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", 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 // Test the get root url function
@@ -298,12 +286,14 @@ func TestGetTinyauthLabels(t *testing.T) {
labels := map[string]string{ labels := map[string]string{
"tinyauth.users": "user1,user2", "tinyauth.users": "user1,user2",
"tinyauth.oauth.whitelist": "user1,user2", "tinyauth.oauth.whitelist": "user1,user2",
"tinyauth.allowed": "random",
"random": "random", "random": "random",
} }
expected := types.TinyauthLabels{ expected := types.TinyauthLabels{
Users: []string{"user1", "user2"}, Users: []string{"user1", "user2"},
OAuthWhitelist: []string{"user1", "user2"}, OAuthWhitelist: []string{"user1", "user2"},
Allowed: "random",
} }
result := utils.GetTinyauthLabels(labels) result := utils.GetTinyauthLabels(labels)
@@ -332,3 +322,65 @@ func TestFilter(t *testing.T) {
t.Fatalf("Expected %v, got %v", 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 { useQuery } from "@tanstack/react-query";
import React, { createContext, useContext } from "react"; import React, { createContext, useContext } from "react";
import { UserContextSchemaType } from "../schemas/user-context-schema";
import axios from "axios"; import axios from "axios";
import { UserContextSchemaType } from "../schemas/user-context-schema";
const UserContext = createContext<UserContextSchemaType | null>(null); const UserContext = createContext<UserContextSchemaType | null>(null);
@@ -15,7 +15,7 @@ export const UserContextProvider = ({
isLoading, isLoading,
error, error,
} = useQuery({ } = useQuery({
queryKey: ["isLoggedIn"], queryKey: ["userContext"],
queryFn: async () => { queryFn: async () => {
const res = await axios.get("/api/status"); const res = await axios.get("/api/status");
return res.data; 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 { NotFoundPage } from "./pages/not-found-page.tsx";
import { UnauthorizedPage } from "./pages/unauthorized-page.tsx"; import { UnauthorizedPage } from "./pages/unauthorized-page.tsx";
import { InternalServerError } from "./pages/internal-server-error.tsx"; import { InternalServerError } from "./pages/internal-server-error.tsx";
import { TotpPage } from "./pages/totp-page.tsx";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -34,6 +35,7 @@ createRoot(document.getElementById("root")!).render(
<Routes> <Routes>
<Route path="/" element={<App />} /> <Route path="/" element={<App />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/totp" element={<TotpPage />} />
<Route path="/logout" element={<LogoutPage />} /> <Route path="/logout" element={<LogoutPage />} />
<Route path="/continue" element={<ContinuePage />} /> <Route path="/continue" element={<ContinuePage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} /> <Route path="/unauthorized" element={<UnauthorizedPage />} />

View File

@@ -50,26 +50,6 @@ export const ContinuePage = () => {
); );
} }
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) { if (disableContinue) {
window.location.href = redirectUri; window.location.href = redirectUri;
return ( return (
@@ -82,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 ( return (
<ContinuePageLayout> <ContinuePageLayout>
<Text size="xl" fw={700}> <Text size="xl" fw={700}>

View File

@@ -1,25 +1,13 @@
import { import { Paper, Title, Text, Divider } from "@mantine/core";
Button,
Paper,
PasswordInput,
TextInput,
Title,
Text,
Divider,
Grid,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { z } from "zod";
import { useUserContext } from "../context/user-context"; import { useUserContext } from "../context/user-context";
import { Navigate } from "react-router"; import { Navigate } from "react-router";
import { Layout } from "../components/layouts/layout"; import { Layout } from "../components/layouts/layout";
import { GoogleIcon } from "../icons/google"; import { OAuthButtons } from "../components/auth/oauth-buttons";
import { GithubIcon } from "../icons/github"; import { LoginFormValues } from "../schemas/login-schema";
import { OAuthIcon } from "../icons/oauth"; import { LoginForm } from "../components/auth/login-forn";
import { TailscaleIcon } from "../icons/tailscale";
import { isQueryValid } from "../utils/utils"; import { isQueryValid } from "../utils/utils";
export const LoginPage = () => { export const LoginPage = () => {
@@ -27,7 +15,8 @@ export const LoginPage = () => {
const params = new URLSearchParams(queryString); const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri") ?? ""; const redirectUri = params.get("redirect_uri") ?? "";
const { isLoggedIn, configuredProviders } = useUserContext(); const { isLoggedIn, configuredProviders, title, genericName } =
useUserContext();
const oauthProviders = configuredProviders.filter( const oauthProviders = configuredProviders.filter(
(value) => value !== "username", (value) => value !== "username",
@@ -37,24 +26,8 @@ export const LoginPage = () => {
return <Navigate to="/logout" />; 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({ const loginMutation = useMutation({
mutationFn: (login: FormValues) => { mutationFn: (login: LoginFormValues) => {
return axios.post("/api/login", login); return axios.post("/api/login", login);
}, },
onError: () => { onError: () => {
@@ -64,18 +37,25 @@ export const LoginPage = () => {
color: "red", color: "red",
}); });
}, },
onSuccess: () => { onSuccess: async (data) => {
if (data.data.totpPending) {
window.location.replace(`/totp?redirect_uri=${redirectUri}`);
return;
}
notifications.show({ notifications.show({
title: "Logged in", title: "Logged in",
message: "Welcome back!", message: "Welcome back!",
color: "green", color: "green",
}); });
setTimeout(() => { setTimeout(() => {
if (isQueryValid(redirectUri)) { if (!isQueryValid(redirectUri)) {
window.location.replace("/"); window.location.replace("/");
} else { return;
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
} }
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
}, 500); }, 500);
}, },
}); });
@@ -105,81 +85,25 @@ export const LoginPage = () => {
}, },
}); });
const handleSubmit = (values: FormValues) => { const handleSubmit = (values: LoginFormValues) => {
loginMutation.mutate(values); loginMutation.mutate(values);
}; };
return ( return (
<Layout> <Layout>
<Title ta="center">Tinyauth</Title> <Title ta="center">{title}</Title>
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder> <Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
{oauthProviders.length > 0 && ( {oauthProviders.length > 0 && (
<> <>
<Text size="lg" fw={500} ta="center"> <Text size="lg" fw={500} ta="center">
Welcome back, login with Welcome back, login with
</Text> </Text>
<Grid mb="md" mt="md" align="center" justify="center"> <OAuthButtons
{oauthProviders.includes("google") && ( oauthProviders={oauthProviders}
<Grid.Col span="content"> isLoading={loginOAuthMutation.isLoading}
<Button mutate={loginOAuthMutation.mutate}
radius="xl" genericName={genericName}
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>
{configuredProviders.includes("username") && ( {configuredProviders.includes("username") && (
<Divider <Divider
label="Or continue with password" label="Or continue with password"
@@ -190,33 +114,10 @@ export const LoginPage = () => {
</> </>
)} )}
{configuredProviders.includes("username") && ( {configuredProviders.includes("username") && (
<form onSubmit={form.onSubmit(handleSubmit)}> <LoginForm
<TextInput isLoading={loginMutation.isLoading}
label="Username" onSubmit={handleSubmit}
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>
)} )}
</Paper> </Paper>
</Layout> </Layout>

View File

@@ -8,7 +8,7 @@ import { Layout } from "../components/layouts/layout";
import { capitalize } from "../utils/utils"; import { capitalize } from "../utils/utils";
export const LogoutPage = () => { export const LogoutPage = () => {
const { isLoggedIn, username, oauth, provider } = useUserContext(); const { isLoggedIn, username, oauth, provider, genericName } = useUserContext();
if (!isLoggedIn) { if (!isLoggedIn) {
return <Navigate to="/login" />; return <Navigate to="/login" />;
@@ -45,7 +45,7 @@ export const LogoutPage = () => {
</Text> </Text>
<Text> <Text>
You are currently logged in as <Code>{username}</Code> 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. below to log out.
</Text> </Text>
<Button <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

@@ -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(), provider: z.string(),
configuredProviders: z.array(z.string()), configuredProviders: z.array(z.string()),
disableContinue: z.boolean(), disableContinue: z.boolean(),
title: z.string(),
genericName: z.string(),
totpPending: z.boolean(),
}); });
export type UserContextSchemaType = z.infer<typeof userContextSchema>; export type UserContextSchemaType = z.infer<typeof userContextSchema>;