Compare commits

...

49 Commits

Author SHA1 Message Date
Stavros
38105d0b4e fix: exp build should wait form arm builder 2025-03-09 19:16:51 +02:00
Stavros
e13bd14eb6 fix: setup docker buildx on exp build 2025-03-09 19:13:28 +02:00
Stavros
43dc3f9aa6 chore: remove create release from experimental build 2025-03-09 19:08:33 +02:00
Stavros
00bfaa1cbe feat: add experimental docker build 2025-03-09 19:07:24 +02:00
Stavros
8cc0f8b31b chore: bump version 2025-03-09 18:44:45 +02:00
Stavros
631059be69 refactor: rename x-tinyauth-user to remote-user 2025-03-09 18:41:20 +02:00
Stavros
5188089673 Feat/totp (#45)
* wip

* feat: finalize totp gen code

* refactor: split login screen and forms

* feat: add totp logic and ui

* refactor: make totp pending expiry time fixed

* refactor: skip all checks when disable continue is enabled

* fix: fix cli not exiting on invalid input
2025-03-09 18:39:25 +02:00
Stavros
47fff12bac chore: fix typo 2025-03-09 16:52:17 +02:00
Stavros
a8c51b649f chore: update funding 2025-03-09 16:51:38 +02:00
Stavros
c2e8f1b473 chore: switch to table for sponsor section 2025-03-09 14:33:01 +02:00
Stavros
bdf327cc9a chore: remove styles are they are not applied in readme 2025-03-09 14:26:27 +02:00
Stavros
46ec623d74 chore: add sponsors section 2025-03-09 14:20:46 +02:00
Stavros
f97c4d7e78 chore: add funding 2025-03-08 23:33:40 +02:00
Stavros
d45b148725 Merge pull request #44 from WilliamB78/main
feat: add Remote-User header
2025-03-04 16:20:47 +02:00
Stavros
33904f7f86 refactor: rename remote user to x tinyauth user 2025-03-04 16:00:28 +02:00
WilliamB78
7e0bc84b0f feat: add Remote-User header 2025-03-04 09:59:42 +01:00
Stavros
fc3f8b5036 refactor: rename the run function to runCheck in the docker helper 2025-02-26 19:31:37 +02:00
Stavros
3030fc5fcf refactor: change error to 500 when there is an error 2025-02-26 19:27:43 +02:00
Stavros
e4379cf3ed feat: allowed paths label 2025-02-26 19:25:54 +02:00
Stavros
30aab17f06 feat: allow custom app and generic oauth title 2025-02-23 20:51:56 +02:00
Stavros
7ee0b645e6 chore: bump version 2025-02-19 17:41:23 +02:00
Stavros
5c34ab96a9 fix: redirect user correctly 2025-02-19 17:05:01 +02:00
Stavros
cb6f93d879 chore: update readme 2025-02-17 07:49:01 +02:00
Stavros
df0c356511 chore: update example and dev compose files 2025-02-16 22:59:04 +02:00
Stavros
d1c6ae1ba1 fix: redirect to frontend when no redirect uri is present in oauth callback 2025-02-16 22:48:04 +02:00
Stavros
0f8d2e7fde fix: make query check account for spaces 2025-02-16 21:14:21 +02:00
Stavros
0da82ae3fe chore: update readme 2025-02-15 19:53:21 +02:00
Stavros
f9ab9a6406 fix: filter oauth whitelist to remove empty strings 2025-02-15 17:23:24 +02:00
Stavros
6f35923735 refactor: use try catch instead of can parse 2025-02-12 18:43:35 +02:00
Stavros
b1dc5cb4cc fix: add check to make sure the url can be parsed 2025-02-11 18:50:33 +02:00
Stavros
3c9bc8c67f fix: use new URL instead of URL.parse 2025-02-11 18:31:31 +02:00
Stavros
b2f4041e09 refactor: handle url queries null values better 2025-02-10 22:12:30 +02:00
Stavros
eb4e157def refactor: small updates in the verify and create subcommands 2025-02-10 21:53:44 +02:00
Stavros
cfe2a1967a refactor: use go's builtin basic auth parser 2025-02-10 21:42:27 +02:00
Stavros
c4ee269283 Merge pull request #33 from robotman4/patch-1
Change forwardauth endpoint in example compose
2025-02-10 21:30:07 +02:00
robotman4
d18fba1ef3 Change forwardauth endpoint in docker-compose.dev.yml 2025-02-10 20:28:01 +01:00
robotman4
acaee5357f Change forwardauth endpoint in example compose 2025-02-10 20:20:52 +01:00
Stavros
38412e1962 tests: add api tests 2025-02-10 19:05:50 +02:00
Stavros
302d9cf2fd chore: add ci to readme 2025-02-10 18:32:47 +02:00
Stavros
caf9cde08f tests: add frontend build to ci 2025-02-10 18:28:06 +02:00
Stavros
2473d3ce34 tests: add simple dist folder 2025-02-10 18:24:16 +02:00
Stavros
d8d347b45f tests: add basic tests for utilities 2025-02-10 18:22:46 +02:00
Stavros
a8da813374 fix: the traefik forward label should be in the tinyauth container 2025-02-09 13:35:23 +02:00
Stavros
6936987c6b chore: add security file 2025-02-08 12:58:37 +02:00
Stavros
9a16737a54 chore: add issue templates 2025-02-08 12:55:46 +02:00
Stavros
ddf40e6d63 chore: add contributing guide from docs 2025-02-08 12:51:31 +02:00
Stavros
9fd1c81f8b chore: add code of conduct 2025-02-08 12:49:45 +02:00
Stavros
567e6f0b5b chore: bump version 2025-02-08 12:44:42 +02:00
Stavros
f7e7dee3da Merge pull request #31 from steveiliop56/chore/comments
chore: add comments to code
2025-02-08 12:40:34 +02:00
42 changed files with 2199 additions and 449 deletions

37
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,37 @@
---
name: Bug report
about: Create a report to help improve Tinyauth
title: "[BUG]"
labels: bug
assignees: steveiliop56
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
Please include the Tinyauth logs below, make sure to not include sensitive info.
**Device (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Tinyauth [e.g. v2.1.1]
- Docker [e.g. 27.3.1]
**
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: enhancement
assignees: steveiliop56
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

42
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Tinyauth CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "^1.23.2"
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install frontend dependencies
run: |
cd site
bun install
- name: Build frontend
run: |
cd site
bun run build
- name: Copy frontend
run: |
cp -r site/dist internal/assets/dist
- name: Run tests
run: go test -v ./...

137
.github/workflows/experimental-build.yml vendored Normal file
View File

@@ -0,0 +1,137 @@
name: Experimental Build
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
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/amd64
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- 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-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-arm:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
id: build
with:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

81
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,81 @@
# Contributing
Contributing is relatively easy.
## Requirements
- Bun
- Golang v1.23.2 and above
- Git
- Docker
## Cloning the repository
You firstly need to clone the repository with:
```sh
git clone https://github.com/steveiliop56/tinyauth
cd tinyauth
```
## Install requirements
Now it's time to install the requirements, firstly the Go ones:
```sh
go mod download
```
And now the site ones:
```sh
cd site
bun i
```
## 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:
```sh
cd site
bun run build
cd ..
```
Copy it to the assets folder:
```sh
rm -rf internal/assets/dist
cp -r site/dist internal/assets/dist
```
Finally either run the app with:
```sh
go run main.go
```
Or build it with:
```sh
go build
```
> [!WARNING]
> Make sure you have set the environment variables when running outside of docker else the app will fail.
## Developing in docker
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.local -> 127.0.0.1
```
Then I can just make sure the domains are correct in the example docker compose file and do:
```sh
docker compose -f docker-compose.dev.yml up --build
```

2
FUNDING.yml Normal file
View File

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

25
Makefile Normal file
View File

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

View File

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

9
SECURITY.md Normal file
View File

@@ -0,0 +1,9 @@
# Security Policy
## Supported Versions
Please always use the latest available Tinyauth version which can be found [here](https://github.com/steveiliop56/tinyauth/releases/latest). Older versions (especially major) may contain security issues which I cannot go back and fix.
## Reporting a Vulnerability
Due to the nature of this app, it needs to be secure. If you find any security issues in the OAuth or login flow of the app please contact me at <steve@doesmycode.work> and include a concise description of the issue. Please do not use the issues section for reporting major security issues.

View File

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

View File

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

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

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

View File

@@ -13,10 +13,12 @@ import (
)
var interactive bool
var username string
var password string
var docker bool
// i stands for input
var iUsername string
var iPassword string
var CreateCmd = &cobra.Command{
Use: "create",
Short: "Create a user",
@@ -30,13 +32,13 @@ var CreateCmd = &cobra.Command{
// Create huh form
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Username").Value(&username).Validate((func(s string) error {
huh.NewInput().Title("Username").Value(&iUsername).Validate((func(s string) error {
if s == "" {
return errors.New("username cannot be empty")
}
return nil
})),
huh.NewInput().Title("Password").Value(&password).Validate((func(s string) error {
huh.NewInput().Title("Password").Value(&iPassword).Validate((func(s string) error {
if s == "" {
return errors.New("password cannot be empty")
}
@@ -57,20 +59,21 @@ var CreateCmd = &cobra.Command{
}
// Do we have username and password?
if username == "" || password == "" {
log.Error().Msg("Username and password cannot be empty")
if iUsername == "" || iPassword == "" {
log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty")
}
log.Info().Str("username", username).Str("password", password).Bool("docker", docker).Msg("Creating user")
log.Info().Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Creating user")
// Hash password
passwordByte, passwordErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
password, passwordErr := bcrypt.GenerateFromPassword([]byte(iPassword), bcrypt.DefaultCost)
if passwordErr != nil {
log.Fatal().Err(passwordErr).Msg("Failed to hash password")
}
passwordString := string(passwordByte)
// Convert password to string
passwordString := string(password)
// Escape $ for docker
if docker {
@@ -78,14 +81,14 @@ var CreateCmd = &cobra.Command{
}
// Log user created
log.Info().Str("user", fmt.Sprintf("%s:%s", username, passwordString)).Msg("User created")
log.Info().Str("user", fmt.Sprintf("%s:%s", iUsername, passwordString)).Msg("User created")
},
}
func init() {
// Flags
CreateCmd.Flags().BoolVar(&interactive, "interactive", false, "Create a user interactively")
CreateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively")
CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker")
CreateCmd.Flags().StringVar(&username, "username", "", "Username")
CreateCmd.Flags().StringVar(&password, "password", "", "Password")
CreateCmd.Flags().StringVar(&iUsername, "username", "", "Username")
CreateCmd.Flags().StringVar(&iPassword, "password", "", "Password")
}

View File

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

View File

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

View File

@@ -7,8 +7,6 @@ services:
- 80:80
volumes:
- /var/run/docker.sock:/var/run/docker.sock
labels:
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth
nginx:
container_name: nginx
@@ -30,3 +28,5 @@ services:
traefik.enable: true
traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`)
traefik.http.services.tinyauth.loadbalancer.server.port: 3000
traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth/traefik
traefik.http.middlewares.tinyauth.forwardauth.authResponseHeaders: Remote-User

31
go.mod
View File

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

78
go.sum
View File

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

View File

@@ -19,6 +19,7 @@ import (
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog/log"
)
@@ -84,7 +85,7 @@ func (api *API) Init() {
Path: "/",
HttpOnly: true,
Secure: api.Config.CookieSecure,
MaxAge: api.Config.CookieExpiry,
MaxAge: api.Config.SessionExpiry,
})
router.Use(sessions.Sessions("tinyauth", store))
@@ -121,12 +122,53 @@ func (api *API) SetupRoutes() {
bindErr := c.BindUri(&proxy)
// Handle error
if api.handleError(c, "Failed to bind URI", bindErr) {
if bindErr != nil {
log.Error().Err(bindErr).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Interface("proxy", proxy.Proxy).Msg("Got proxy")
// Check if using basic auth
_, _, basicAuth := c.Request.BasicAuth()
// 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 using basic auth
if proxy.Proxy == "nginx" || basicAuth {
log.Error().Err(authEnabledErr).Msg("Failed to check if auth is enabled")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
// Return the internal server error page
if api.handleError(c, "Failed to check if auth is enabled", authEnabledErr) {
return
}
}
// If auth is not enabled, return 200
if !authEnabled {
// The user is allowed to access the app
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
})
// Stop further processing
return
}
// Get user context
userContext := api.Hooks.UseUserContext(c)
@@ -140,15 +182,15 @@ func (api *API) SetupRoutes() {
log.Debug().Msg("Authenticated")
// Check if user is allowed to access subdomain, if request is nginx.example.com the subdomain (resource) is nginx
appAllowed, appAllowedErr := api.Auth.ResourceAllowed(userContext, host)
appAllowed, appAllowedErr := api.Auth.ResourceAllowed(c, userContext)
// Check if there was an error
if appAllowedErr != nil {
// Return 501 if nginx is the proxy or if the request is using an Authorization header
if proxy.Proxy == "nginx" || c.GetHeader("Authorization") != "" {
// Return 500 if nginx is the proxy or if the request is using basic auth
if proxy.Proxy == "nginx" || basicAuth {
log.Error().Err(appAllowedErr).Msg("Failed to check if app is allowed")
c.JSON(501, gin.H{
"status": 501,
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
@@ -166,36 +208,24 @@ func (api *API) SetupRoutes() {
if !appAllowed {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed")
// Return 401 if nginx is the proxy or if the request is using an Authorization header
if proxy.Proxy == "nginx" || basicAuth {
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Build query
queries, queryErr := query.Values(types.UnauthorizedQuery{
Username: userContext.Username,
Resource: strings.Split(host, ".")[0],
})
// Check if there was an error
if queryErr != nil {
// Return 501 if nginx is the proxy or if the request is using an Authorization header
if proxy.Proxy == "nginx" || c.GetHeader("Authorization") != "" {
log.Error().Err(queryErr).Msg("Failed to build query")
c.JSON(501, gin.H{
"status": 501,
"message": "Internal Server Error",
})
return
}
// Return the internal server error page
if api.handleError(c, "Failed to build query", queryErr) {
return
}
}
// Return 401 if nginx is the proxy or if the request is using an Authorization header
if proxy.Proxy == "nginx" || c.GetHeader("Authorization") != "" {
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
// Handle error (no need to check for nginx/headers since we are sure we are using caddy/traefik)
if api.handleError(c, "Failed to build query", queryErr) {
return
}
@@ -206,6 +236,9 @@ func (api *API) SetupRoutes() {
return
}
// Set the user header
c.Header("Remote-User", userContext.Username)
// The user is allowed to access the app
c.JSON(200, gin.H{
"status": 200,
@@ -220,7 +253,8 @@ func (api *API) SetupRoutes() {
log.Debug().Msg("Unauthorized")
// Return 401 if nginx is the proxy or if the request is using an Authorization header
if proxy.Proxy == "nginx" || c.GetHeader("Authorization") != "" {
if proxy.Proxy == "nginx" || basicAuth {
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
@@ -233,13 +267,13 @@ func (api *API) SetupRoutes() {
RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri),
})
log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login")
// Handle error (no need to check for nginx/headers since we are sure we are using caddy/traefik)
if api.handleError(c, "Failed to build query", queryErr) {
return
}
log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login")
// Redirect to login
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", api.Config.AppURL, queries.Encode()))
})
@@ -288,7 +322,29 @@ func (api *API) SetupRoutes() {
return
}
log.Debug().Msg("Password correct, logging in")
log.Debug().Msg("Password correct, checking totp")
// Check if user has totp enabled
if user.TotpSecret != "" {
log.Debug().Msg("Totp enabled")
// Set totp pending cookie
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: login.Username,
Provider: "username",
TotpPending: true,
})
// Return totp required
c.JSON(200, gin.H{
"status": 200,
"message": "Waiting for totp",
"totpPending": true,
})
// Stop further processing
return
}
// Create session cookie with username as provider
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
@@ -296,6 +352,80 @@ func (api *API) SetupRoutes() {
Provider: "username",
})
// Return logged in
c.JSON(200, gin.H{
"status": 200,
"message": "Logged in",
"totpPending": false,
})
})
api.Router.POST("/api/totp", func(c *gin.Context) {
// Create totp struct
var totpReq types.Totp
// Bind JSON
err := c.BindJSON(&totpReq)
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Msg("Checking totp")
// Get user context
userContext := api.Hooks.UseUserContext(c)
// Check if we have a user
if userContext.Username == "" {
log.Debug().Msg("No user context")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Get user
user := api.Auth.GetUser(userContext.Username)
// Check if user exists
if user == nil {
log.Debug().Msg("User not found")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Check if totp is correct
totpOk := totp.Validate(totpReq.Code, user.TotpSecret)
// TOTP is incorrect
if !totpOk {
log.Debug().Msg("Totp incorrect")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
log.Debug().Msg("Totp correct")
// Create session cookie with username as provider
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: user.Username,
Provider: "username",
})
// Return logged in
c.JSON(200, gin.H{
"status": 200,
@@ -335,35 +465,33 @@ func (api *API) SetupRoutes() {
configuredProviders = append(configuredProviders, "username")
}
// We are not logged in so return unauthorized
if !userContext.IsLoggedIn {
log.Debug().Msg("Unauthorized")
c.JSON(200, gin.H{
"status": 200,
"message": "Unauthorized",
"username": "",
"isLoggedIn": false,
"oauth": false,
"provider": "",
"configuredProviders": configuredProviders,
"disableContinue": api.Config.DisableContinue,
})
return
// 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,
}
log.Debug().Interface("userContext", userContext).Strs("configuredProviders", configuredProviders).Bool("disableContinue", api.Config.DisableContinue).Msg("Authenticated")
// If we are not logged in we set the status to 401 and add the WWW-Authenticate header else we set it to 200
if !userContext.IsLoggedIn {
log.Debug().Msg("Unauthorized")
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
status.Status = 401
status.Message = "Unauthorized"
} else {
log.Debug().Interface("userContext", userContext).Strs("configuredProviders", configuredProviders).Bool("disableContinue", api.Config.DisableContinue).Msg("Authenticated")
status.Status = 200
status.Message = "Authenticated"
}
// We are logged in so return our user context
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
"username": userContext.Username,
"isLoggedIn": userContext.IsLoggedIn,
"oauth": userContext.OAuth,
"provider": userContext.Provider,
"configuredProviders": configuredProviders,
"disableContinue": api.Config.DisableContinue,
})
// Return data
c.JSON(200, status)
})
api.Router.GET("/api/oauth/url/:provider", func(c *gin.Context) {
@@ -535,10 +663,7 @@ func (api *API) SetupRoutes() {
// If it is empty it means that no redirect_uri was provided to the login screen so we just log in
if redirectURIErr != nil {
c.JSON(200, gin.H{
"status": 200,
"message": "Logged in",
})
c.Redirect(http.StatusPermanentRedirect, api.Config.AppURL)
}
log.Debug().Str("redirectURI", redirectURI).Msg("Got redirect URI")

199
internal/api/api_test.go Normal file
View File

@@ -0,0 +1,199 @@
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"tinyauth/internal/api"
"tinyauth/internal/auth"
"tinyauth/internal/docker"
"tinyauth/internal/hooks"
"tinyauth/internal/providers"
"tinyauth/internal/types"
"github.com/magiconair/properties/assert"
)
// Simple API config for tests
var apiConfig = types.APIConfig{
Port: 8080,
Address: "0.0.0.0",
Secret: "super-secret-api-thing-for-tests", // It is 32 chars long
AppURL: "http://tinyauth.localhost",
CookieSecure: false,
SessionExpiry: 3600,
DisableContinue: false,
}
// Cookie
var cookie string
// User
var user = types.User{
Username: "user",
Password: "$2a$10$AvGHLTYv3xiRJ0xV9xs3XeVIlkGTygI9nqIamFYB5Xu.5.0UWF7B6", // pass
}
// We need all this to be able to test the API
func getAPI(t *testing.T) *api.API {
// Create docker service
docker := docker.NewDocker()
// Initialize docker
dockerErr := docker.Init()
// Check if there was an error
if dockerErr != nil {
t.Fatalf("Failed to initialize docker: %v", dockerErr)
}
// Create auth service
auth := auth.NewAuth(docker, types.Users{
{
Username: user.Username,
Password: user.Password,
},
}, nil, apiConfig.SessionExpiry)
// Create providers service
providers := providers.NewProviders(types.OAuthConfig{})
// Initialize providers
providers.Init()
// Create hooks service
hooks := hooks.NewHooks(auth, providers)
// Create API
api := api.NewAPI(apiConfig, hooks, auth, providers)
// Setup routes
api.Init()
api.SetupRoutes()
return api
}
// Test login (we will need this for the other tests)
func TestLogin(t *testing.T) {
t.Log("Testing login")
// Get API
api := getAPI(t)
// Create recorder
recorder := httptest.NewRecorder()
// Create request
user := types.LoginRequest{
Username: "user",
Password: "pass",
}
json, err := json.Marshal(user)
// Check if there was an error
if err != nil {
t.Fatalf("Error marshalling json: %v", err)
}
// Create request
req, err := http.NewRequest("POST", "/api/login", strings.NewReader(string(json)))
// Check if there was an error
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
// Serve the request
api.Router.ServeHTTP(recorder, req)
// Assert
assert.Equal(t, recorder.Code, http.StatusOK)
// Get the cookie
cookie = recorder.Result().Cookies()[0].Value
// Check if the cookie is set
if cookie == "" {
t.Fatalf("Cookie not set")
}
}
// Test status
func TestStatus(t *testing.T) {
t.Log("Testing status")
// Get API
api := getAPI(t)
// Create recorder
recorder := httptest.NewRecorder()
// Create request
req, err := http.NewRequest("GET", "/api/status", nil)
// Check if there was an error
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
// Set the cookie
req.AddCookie(&http.Cookie{
Name: "tinyauth",
Value: cookie,
})
// Serve the request
api.Router.ServeHTTP(recorder, req)
// Assert
assert.Equal(t, recorder.Code, http.StatusOK)
// Parse the body
body := recorder.Body.String()
if !strings.Contains(body, "user") {
t.Fatalf("Expected user in body")
}
}
// Test logout
func TestLogout(t *testing.T) {
t.Log("Testing logout")
// Get API
api := getAPI(t)
// Create recorder
recorder := httptest.NewRecorder()
// Create request
req, err := http.NewRequest("POST", "/api/logout", nil)
// Check if there was an error
if err != nil {
t.Fatalf("Error creating request: %v", err)
}
// Set the cookie
req.AddCookie(&http.Cookie{
Name: "tinyauth",
Value: cookie,
})
// Serve the request
api.Router.ServeHTTP(recorder, req)
// Assert
assert.Equal(t, recorder.Code, http.StatusOK)
// Check if the cookie is different (means go sessions flushed it)
if recorder.Result().Cookies()[0].Value == cookie {
t.Fatalf("Cookie not flushed")
}
}
// TODO: Testing for the oauth stuff

View File

@@ -1 +1 @@
v2.1.1
v3.1.0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,19 +29,15 @@ func ParseUsers(users string) (types.Users, error) {
// Loop through the users and split them by colon
for _, user := range userList {
// Split the user by colon
userSplit := strings.Split(user, ":")
parsed, parseErr := ParseUser(user)
// Check if the user is in the correct format
if len(userSplit) != 2 {
return types.Users{}, errors.New("invalid user format")
// Check if there was an error
if parseErr != nil {
return types.Users{}, parseErr
}
// Append the user to the users struct
usersParsed = append(usersParsed, types.User{
Username: userSplit[0],
Password: userSplit[1],
})
usersParsed = append(usersParsed, parsed)
}
log.Debug().Msg("Parsed users")
@@ -50,7 +46,7 @@ func ParseUsers(users string) (types.Users, error) {
return usersParsed, nil
}
// Root url parses parses a hostname and returns the root domain (e.g. sub1.sub2.domain.com -> domain.com)
// Root url parses parses a hostname and returns the root domain (e.g. sub1.sub2.domain.com -> sub2.domain.com)
func GetRootURL(urlSrc string) (string, error) {
// Make sure the url is valid
urlParsed, parseErr := url.Parse(urlSrc)
@@ -176,11 +172,6 @@ func GetUsers(conf string, file string) (types.Users, error) {
return ParseUsers(users)
}
// Check if any of the OAuth providers are configured based on the client id and secret
func OAuthConfigured(config types.Config) bool {
return (config.GithubClientId != "" && config.GithubClientSecret != "") || (config.GoogleClientId != "" && config.GoogleClientSecret != "") || (config.GenericClientId != "" && config.GenericClientSecret != "") || (config.TailscaleClientId != "" && config.TailscaleClientSecret != "")
}
// Parse the docker labels to the tinyauth labels struct
func GetTinyauthLabels(labels map[string]string) types.TinyauthLabels {
// Create a new tinyauth labels struct
@@ -200,6 +191,8 @@ func GetTinyauthLabels(labels map[string]string) types.TinyauthLabels {
tinyauthLabels.OAuthWhitelist = strings.Split(value, ",")
case "tinyauth.users":
tinyauthLabels.Users = strings.Split(value, ",")
case "tinyauth.allowed":
tinyauthLabels.Allowed = value
}
}
}
@@ -207,3 +200,58 @@ func GetTinyauthLabels(labels map[string]string) types.TinyauthLabels {
// Return the tinyauth labels
return tinyauthLabels
}
// Check if any of the OAuth providers are configured based on the client id and secret
func OAuthConfigured(config types.Config) bool {
return (config.GithubClientId != "" && config.GithubClientSecret != "") || (config.GoogleClientId != "" && config.GoogleClientSecret != "") || (config.GenericClientId != "" && config.GenericClientSecret != "") || (config.TailscaleClientId != "" && config.TailscaleClientSecret != "")
}
// Filter helper function
func Filter[T any](slice []T, test func(T) bool) (res []T) {
for _, value := range slice {
if test(value) {
res = append(res, value)
}
}
return res
}
// Parse user
func ParseUser(user string) (types.User, error) {
// Check if the user is escaped
if strings.Contains(user, "$$") {
user = strings.ReplaceAll(user, "$$", "$")
}
// Split the user by colon
userSplit := strings.Split(user, ":")
// Check if the user is in the correct format
if len(userSplit) < 2 || len(userSplit) > 3 {
return types.User{}, errors.New("invalid user format")
}
// Check if the user has a totp secret
if len(userSplit) == 2 {
// Check for empty username or password
if userSplit[1] == "" || userSplit[0] == "" {
return types.User{}, errors.New("invalid user format")
}
return types.User{
Username: userSplit[0],
Password: userSplit[1],
}, nil
}
// Check for empty username, password or totp secret
if userSplit[2] == "" || userSplit[1] == "" || userSplit[0] == "" {
return types.User{}, errors.New("invalid user format")
}
// Return the user struct
return types.User{
Username: userSplit[0],
Password: userSplit[1],
TotpSecret: userSplit[2],
}, nil
}

View File

@@ -0,0 +1,386 @@
package utils_test
import (
"os"
"reflect"
"testing"
"tinyauth/internal/types"
"tinyauth/internal/utils"
)
// Test the parse users function
func TestParseUsers(t *testing.T) {
t.Log("Testing parse users with a valid string")
// Test the parse users function with a valid string
users := "user1:pass1,user2:pass2"
expected := types.Users{
{
Username: "user1",
Password: "pass1",
},
{
Username: "user2",
Password: "pass2",
},
}
result, err := utils.ParseUsers(users)
// Check if there was an error
if err != nil {
t.Fatalf("Error parsing users: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test the get root url function
func TestGetRootURL(t *testing.T) {
t.Log("Testing get root url with a valid url")
// Test the get root url function with a valid url
url := "https://sub1.sub2.domain.com:8080"
expected := "sub2.domain.com"
result, err := utils.GetRootURL(url)
// Check if there was an error
if err != nil {
t.Fatalf("Error getting root url: %v", err)
}
// Check if the result is equal to the expected
if expected != result {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test the read file function
func TestReadFile(t *testing.T) {
t.Log("Creating a test file")
// Create a test file
err := os.WriteFile("/tmp/test.txt", []byte("test"), 0644)
// Check if there was an error
if err != nil {
t.Fatalf("Error creating test file: %v", err)
}
// Test the read file function
t.Log("Testing read file with a valid file")
data, err := utils.ReadFile("/tmp/test.txt")
// Check if there was an error
if err != nil {
t.Fatalf("Error reading file: %v", err)
}
// Check if the data is equal to the expected
if data != "test" {
t.Fatalf("Expected test, got %v", data)
}
// Cleanup the test file
t.Log("Cleaning up test file")
err = os.Remove("/tmp/test.txt")
// Check if there was an error
if err != nil {
t.Fatalf("Error cleaning up test file: %v", err)
}
}
// Test the parse file to line function
func TestParseFileToLine(t *testing.T) {
t.Log("Testing parse file to line with a valid string")
// Test the parse file to line function with a valid string
content := "user1:pass1\nuser2:pass2"
expected := "user1:pass1,user2:pass2"
result := utils.ParseFileToLine(content)
// Check if the result is equal to the expected
if expected != result {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test the get secret function
func TestGetSecret(t *testing.T) {
t.Log("Testing get secret with an empty config and file")
// Test the get secret function with an empty config and file
conf := ""
file := "/tmp/test.txt"
expected := "test"
// Create file
err := os.WriteFile(file, []byte(expected), 0644)
// Check if there was an error
if err != nil {
t.Fatalf("Error creating test file: %v", err)
}
// Test
result := utils.GetSecret(conf, file)
// Check if the result is equal to the expected
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing get secret with an empty file and a valid config")
// Test the get secret function with an empty file and a valid config
result = utils.GetSecret(expected, "")
// Check if the result is equal to the expected
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing get secret with both a valid config and file")
// Test the get secret function with both a valid config and file
result = utils.GetSecret(expected, file)
// Check if the result is equal to the expected
if result != expected {
t.Fatalf("Expected %v, got %v", expected, result)
}
// Cleanup the test file
t.Log("Cleaning up test file")
err = os.Remove(file)
// Check if there was an error
if err != nil {
t.Fatalf("Error cleaning up test file: %v", err)
}
}
// Test the get users function
func TestGetUsers(t *testing.T) {
t.Log("Testing get users with a config and no file")
// Test the get users function with a config and no file
conf := "user1:pass1,user2:pass2"
file := ""
expected := types.Users{
{
Username: "user1",
Password: "pass1",
},
{
Username: "user2",
Password: "pass2",
},
}
result, err := utils.GetUsers(conf, file)
// Check if there was an error
if err != nil {
t.Fatalf("Error getting users: %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 get users with a file and no config")
// Test the get users function with a file and no config
conf = ""
file = "/tmp/test.txt"
expected = types.Users{
{
Username: "user1",
Password: "pass1",
},
{
Username: "user2",
Password: "pass2",
},
}
// Create file
err = os.WriteFile(file, []byte("user1:pass1\nuser2:pass2"), 0644)
// Check if there was an error
if err != nil {
t.Fatalf("Error creating test file: %v", err)
}
// Test
result, err = utils.GetUsers(conf, file)
// Check if there was an error
if err != nil {
t.Fatalf("Error getting users: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
// Test the get users function with both a config and file
t.Log("Testing get users with both a config and file")
conf = "user3:pass3"
expected = types.Users{
{
Username: "user3",
Password: "pass3",
},
{
Username: "user1",
Password: "pass1",
},
{
Username: "user2",
Password: "pass2",
},
}
result, err = utils.GetUsers(conf, file)
// Check if there was an error
if err != nil {
t.Fatalf("Error getting users: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
// Cleanup the test file
t.Log("Cleaning up test file")
err = os.Remove(file)
// Check if there was an error
if err != nil {
t.Fatalf("Error cleaning up test file: %v", err)
}
}
// Test the tinyauth labels function
func TestGetTinyauthLabels(t *testing.T) {
t.Log("Testing get tinyauth labels with a valid map")
// Test the get tinyauth labels function with a valid map
labels := map[string]string{
"tinyauth.users": "user1,user2",
"tinyauth.oauth.whitelist": "user1,user2",
"tinyauth.allowed": "random",
"random": "random",
}
expected := types.TinyauthLabels{
Users: []string{"user1", "user2"},
OAuthWhitelist: []string{"user1", "user2"},
Allowed: "random",
}
result := utils.GetTinyauthLabels(labels)
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test the filter function
func TestFilter(t *testing.T) {
t.Log("Testing filter helper")
// Create variables
data := []string{"", "val1", "", "val2", "", "val3", ""}
expected := []string{"val1", "val2", "val3"}
// Test the filter function
result := utils.Filter(data, func(val string) bool {
return val != ""
})
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test parse user
func TestParseUser(t *testing.T) {
t.Log("Testing parse user with a valid user")
// Create variables
user := "user:pass:secret"
expected := types.User{
Username: "user",
Password: "pass",
TotpSecret: "secret",
}
// Test the parse user function
result, err := utils.ParseUser(user)
// Check if there was an error
if err != nil {
t.Fatalf("Error parsing user: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing parse user with an escaped user")
// Create variables
user = "user:p$$ass$$:secret"
expected = types.User{
Username: "user",
Password: "p$ass$",
TotpSecret: "secret",
}
// Test the parse user function
result, err = utils.ParseUser(user)
// Check if there was an error
if err != nil {
t.Fatalf("Error parsing user: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing parse user with an invalid user")
// Create variables
user = "user::pass"
// Test the parse user function
_, err = utils.ParseUser(user)
// Check if there was an error
if err == nil {
t.Fatalf("Expected error parsing user")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,12 @@ import { Navigate } from "react-router";
import { useUserContext } from "../context/user-context";
import { Layout } from "../components/layouts/layout";
import { ReactNode } from "react";
import { isQueryValid } from "../utils/utils";
export const ContinuePage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri");
const redirectUri = params.get("redirect_uri") ?? "";
const { isLoggedIn, disableContinue } = useUserContext();
@@ -16,7 +17,7 @@ export const ContinuePage = () => {
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
}
if (redirectUri === "null") {
if (!isQueryValid(redirectUri)) {
return <Navigate to="/" />;
}
@@ -27,34 +28,30 @@ export const ContinuePage = () => {
color: "blue",
});
setTimeout(() => {
window.location.href = redirectUri!;
window.location.href = redirectUri;
}, 500);
};
const urlParsed = URL.parse(redirectUri!);
let uri;
if (
window.location.protocol === "https:" &&
urlParsed!.protocol === "http:"
) {
try {
uri = new URL(redirectUri);
} catch {
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
Insecure Redirect
Invalid 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.
The redirect URL is invalid, please contact the app owner to fix the
issue.
</Text>
<Button fullWidth mt="xl" onClick={redirect}>
Continue
</Button>
</ContinuePageLayout>
);
}
if (disableContinue) {
window.location.href = redirectUri!;
window.location.href = redirectUri;
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
@@ -65,6 +62,23 @@ export const ContinuePage = () => {
);
}
if (window.location.protocol === "https:" && uri.protocol === "http:") {
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
Insecure Redirect
</Text>
<Text>
Your are trying to redirect from <Code>https</Code> to{" "}
<Code>http</Code>, are you sure you want to continue?
</Text>
<Button fullWidth mt="xl" color="yellow" onClick={redirect}>
Continue
</Button>
</ContinuePageLayout>
);
}
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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