Compare commits

...

8 Commits

Author SHA1 Message Date
Stavros
bafcb9a867 feat: add rate limit warning to frontend 2025-04-08 14:52:02 +03:00
Stavros
d322c13791 chore: add star history to readme 2025-04-08 09:23:08 +03:00
Stavros
8e84e59c2f refactor: simplify the get cookie data handling 2025-04-06 20:53:24 +03:00
Stavros
bd7e160e10 refactor: store redirect URI in tinyauth session cookie 2025-04-06 20:37:02 +03:00
Stavros
df849d5a5c refactor: remove dependency on gin sessions 2025-04-06 19:13:09 +03:00
Stavros
5cf4e208c6 refactor: use centralized config in auth service 2025-04-06 18:55:24 +03:00
Alexander
07ddd4f917 feat: add brute force protection (#59)
* feat: add brute force protection

* fix: bind flags to env

---------

Co-authored-by: Stavros <steveiliop56@gmail.com>
2025-04-06 18:28:20 +03:00
Stavros
98abe514e1 refactor: add basic versioning to translations 2025-04-06 15:43:30 +03:00
19 changed files with 640 additions and 261 deletions

View File

@@ -26,5 +26,7 @@ DISABLE_CONTINUE=false
OAUTH_WHITELIST= OAUTH_WHITELIST=
GENERIC_NAME=My OAuth GENERIC_NAME=My OAuth
SESSION_EXPIRY=7200 SESSION_EXPIRY=7200
LOGIN_TIMEOUT=300
LOGIN_MAX_RETRIES=5
LOG_LEVEL=0 LOG_LEVEL=0
APP_TITLE=Tinyauth SSO APP_TITLE=Tinyauth SSO

View File

@@ -3,7 +3,7 @@ name: Publish translations
on: on:
push: push:
branches: branches:
- main - i18n_v*
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -16,7 +16,53 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
get-branches:
runs-on: ubuntu-latest
outputs:
i18n-branches: ${{ steps.get-branches.outputs.result }}
steps:
- name: Get branches
id: get-branches
uses: actions/github-script@v7
with:
script: |
const { data: repos } = await github.rest.repos.listBranches({
owner: context.repo.owner,
repo: context.repo.repo,
})
const i18nBranches = repos.filter((branch) => branch.name.startsWith("i18n_v"))
const i18nBranchNames = i18nBranches.map((branch) => branch.name)
return i18nBranchNames
get-translations:
needs: get-branches
runs-on: ubuntu-latest
strategy:
matrix:
branch: ${{ fromJson(needs.get-branches.outputs.i18n-branches) }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ matrix.branch }}
- name: Get translation version
id: get-version
run: |
branch=${{ matrix.branch }}
version=${branch#i18n_}
echo "version=$version" >> $GITHUB_OUTPUT
- name: Upload translations
uses: actions/upload-artifact@v4
with:
name: ${{ steps.get-version.outputs.version }}
path: frontend/src/lib/i18n/locales
build: build:
needs: get-translations
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -25,10 +71,14 @@ jobs:
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v4 uses: actions/configure-pages@v4
- name: Move translations - name: Prepare output directory
run: | run: |
mkdir -p dist mkdir -p dist/i18n/
mv frontend/src/lib/i18n/locales dist/i18n
- name: Download translations
uses: actions/download-artifact@v4
with:
path: dist/i18n/
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v3

View File

@@ -61,3 +61,7 @@ Credits for the logo of this app go to:
- **Freepik** for providing the police hat and badge. - **Freepik** for providing the police hat and badge.
- **Renee French** for the original gopher logo. - **Renee French** for the original gopher logo.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=steveiliop56/tinyauth&type=Date)](https://www.star-history.com/#steveiliop56/tinyauth&Date)

View File

@@ -2,7 +2,6 @@ package cmd
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"strings" "strings"
"time" "time"
@@ -94,10 +93,8 @@ var rootCmd = &cobra.Command{
} }
// Create handlers config // Create handlers config
serverConfig := types.HandlersConfig{ handlersConfig := types.HandlersConfig{
AppURL: config.AppURL, AppURL: config.AppURL,
Domain: fmt.Sprintf(".%s", domain),
CookieSecure: config.CookieSecure,
DisableContinue: config.DisableContinue, DisableContinue: config.DisableContinue,
Title: config.Title, Title: config.Title,
GenericName: config.GenericName, GenericName: config.GenericName,
@@ -105,12 +102,20 @@ var rootCmd = &cobra.Command{
// Create api config // Create api config
apiConfig := types.APIConfig{ apiConfig := types.APIConfig{
Port: config.Port, Port: config.Port,
Address: config.Address, Address: config.Address,
Secret: config.Secret, }
CookieSecure: config.CookieSecure,
SessionExpiry: config.SessionExpiry, // Create auth config
Domain: domain, authConfig := types.AuthConfig{
Users: users,
OauthWhitelist: oauthWhitelist,
Secret: config.Secret,
CookieSecure: config.CookieSecure,
SessionExpiry: config.SessionExpiry,
Domain: domain,
LoginTimeout: config.LoginTimeout,
LoginMaxRetries: config.LoginMaxRetries,
} }
// Create docker service // Create docker service
@@ -121,7 +126,7 @@ var rootCmd = &cobra.Command{
HandleError(err, "Failed to initialize docker") HandleError(err, "Failed to initialize docker")
// Create auth service // Create auth service
auth := auth.NewAuth(docker, users, oauthWhitelist, config.SessionExpiry) auth := auth.NewAuth(authConfig, docker)
// Create OAuth providers service // Create OAuth providers service
providers := providers.NewProviders(oauthConfig) providers := providers.NewProviders(oauthConfig)
@@ -133,7 +138,7 @@ var rootCmd = &cobra.Command{
hooks := hooks.NewHooks(auth, providers) hooks := hooks.NewHooks(auth, providers)
// Create handlers // Create handlers
handlers := handlers.NewHandlers(serverConfig, auth, hooks, providers, docker) handlers := handlers.NewHandlers(handlersConfig, auth, hooks, providers, docker)
// Create API // Create API
api := api.NewAPI(apiConfig, handlers) api := api.NewAPI(apiConfig, handlers)
@@ -198,6 +203,8 @@ func init() {
rootCmd.Flags().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.") rootCmd.Flags().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.")
rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.") rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.")
rootCmd.Flags().Int("session-expiry", 86400, "Session (cookie) expiration time in seconds.") rootCmd.Flags().Int("session-expiry", 86400, "Session (cookie) expiration time in seconds.")
rootCmd.Flags().Int("login-timeout", 300, "Login timeout in seconds after max retries reached (0 to disable).")
rootCmd.Flags().Int("login-max-retries", 5, "Maximum login attempts before timeout (0 to disable).")
rootCmd.Flags().Int("log-level", 1, "Log level.") rootCmd.Flags().Int("log-level", 1, "Log level.")
rootCmd.Flags().String("app-title", "Tinyauth", "Title of the app.") rootCmd.Flags().String("app-title", "Tinyauth", "Title of the app.")
@@ -232,6 +239,8 @@ func init() {
viper.BindEnv("session-expiry", "SESSION_EXPIRY") viper.BindEnv("session-expiry", "SESSION_EXPIRY")
viper.BindEnv("log-level", "LOG_LEVEL") viper.BindEnv("log-level", "LOG_LEVEL")
viper.BindEnv("app-title", "APP_TITLE") viper.BindEnv("app-title", "APP_TITLE")
viper.BindEnv("login-timeout", "LOGIN_TIMEOUT")
viper.BindEnv("login-max-retries", "LOGIN_MAX_RETRIES")
// Bind flags to viper // Bind flags to viper
viper.BindPFlags(rootCmd.Flags()) viper.BindPFlags(rootCmd.Flags())

View File

@@ -28,7 +28,7 @@ i18n
], ],
backendOptions: [ backendOptions: [
{ {
loadPath: "https://cdn.tinyauth.app/i18n/{{lng}}.json", loadPath: "https://cdn.tinyauth.app/i18n/v1/{{lng}}.json",
}, },
], ],
}, },

View File

@@ -6,6 +6,7 @@
"loginSubmit": "Login", "loginSubmit": "Login",
"loginFailTitle": "Failed to log in", "loginFailTitle": "Failed to log in",
"loginFailSubtitle": "Please check your username and password", "loginFailSubtitle": "Please check your username and password",
"loginFailRateLimit": "You failed to login too many times, please try again later",
"loginSuccessTitle": "Logged in", "loginSuccessTitle": "Logged in",
"loginSuccessSubtitle": "Welcome back!", "loginSuccessSubtitle": "Welcome back!",
"loginOauthFailTitle": "Internal error", "loginOauthFailTitle": "Internal error",

View File

@@ -6,6 +6,7 @@
"loginSubmit": "Login", "loginSubmit": "Login",
"loginFailTitle": "Failed to log in", "loginFailTitle": "Failed to log in",
"loginFailSubtitle": "Please check your username and password", "loginFailSubtitle": "Please check your username and password",
"loginFailRateLimit": "You failed to login too many times, please try again later",
"loginSuccessTitle": "Logged in", "loginSuccessTitle": "Logged in",
"loginSuccessSubtitle": "Welcome back!", "loginSuccessSubtitle": "Welcome back!",
"loginOauthFailTitle": "Internal error", "loginOauthFailTitle": "Internal error",

View File

@@ -1,7 +1,7 @@
import { Paper, Title, Text, Divider } from "@mantine/core"; import { Paper, Title, Text, Divider } from "@mantine/core";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import axios from "axios"; import axios, { type AxiosError } from "axios";
import { useUserContext } from "../context/user-context"; import { useUserContext } from "../context/user-context";
import { Navigate } from "react-router"; import { Navigate } from "react-router";
import { Layout } from "../components/layouts/layout"; import { Layout } from "../components/layouts/layout";
@@ -33,7 +33,17 @@ export const LoginPage = () => {
mutationFn: (login: LoginFormValues) => { mutationFn: (login: LoginFormValues) => {
return axios.post("/api/login", login); return axios.post("/api/login", login);
}, },
onError: () => { onError: (data: AxiosError) => {
if (data.response) {
if (data.response.status === 429) {
notifications.show({
title: t("loginFailTitle"),
message: t("loginFailRateLimit"),
color: "red",
});
return;
}
}
notifications.show({ notifications.show({
title: t("loginFailTitle"), title: t("loginFailTitle"),
message: t("loginFailSubtitle"), message: t("loginFailSubtitle"),

4
go.mod
View File

@@ -3,7 +3,6 @@ module tinyauth
go 1.23.2 go 1.23.2
require ( require (
github.com/gin-contrib/sessions v1.0.2
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.24.0 github.com/go-playground/validator/v10 v10.24.0
github.com/google/go-querystring v1.1.0 github.com/google/go-querystring v1.1.0
@@ -58,9 +57,8 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.4 // indirect github.com/goccy/go-json v0.10.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.2.2 // indirect github.com/gorilla/sessions v1.2.2
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect

4
go.sum
View File

@@ -65,8 +65,6 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sessions v1.0.2 h1:UaIjUvTH1cMeOdj3in6dl+Xb6It8RiKRF9Z1anbUyCA=
github.com/gin-contrib/sessions v1.0.2/go.mod h1:KxKxWqWP5LJVDCInulOl4WbLzK2KSPlLesfZ66wRvMs=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
@@ -99,8 +97,6 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=

View File

@@ -11,8 +11,6 @@ import (
"tinyauth/internal/handlers" "tinyauth/internal/handlers"
"tinyauth/internal/types" "tinyauth/internal/types"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -51,21 +49,6 @@ func (api *API) Init() {
log.Debug().Msg("Setting up file server") log.Debug().Msg("Setting up file server")
fileServer := http.FileServer(http.FS(dist)) fileServer := http.FileServer(http.FS(dist))
// Setup cookie store
log.Debug().Msg("Setting up cookie store")
store := cookie.NewStore([]byte(api.Config.Secret))
// Use session middleware
store.Options(sessions.Options{
Domain: api.Config.Domain,
Path: "/",
HttpOnly: true,
Secure: api.Config.CookieSecure,
MaxAge: api.Config.SessionExpiry,
})
router.Use(sessions.Sessions("tinyauth", store))
// UI middleware // UI middleware
router.Use(func(c *gin.Context) { router.Use(func(c *gin.Context) {
// If not an API request, serve the UI // If not an API request, serve the UI

View File

@@ -21,23 +21,29 @@ import (
// Simple API config for tests // Simple API config for tests
var apiConfig = types.APIConfig{ var apiConfig = types.APIConfig{
Port: 8080, Port: 8080,
Address: "0.0.0.0", Address: "0.0.0.0",
Secret: "super-secret-api-thing-for-tests", // It is 32 chars long
CookieSecure: false,
SessionExpiry: 3600,
} }
// Simple handlers config for tests // Simple handlers config for tests
var handlersConfig = types.HandlersConfig{ var handlersConfig = types.HandlersConfig{
AppURL: "http://localhost:8080", AppURL: "http://localhost:8080",
Domain: ".localhost",
CookieSecure: false,
DisableContinue: false, DisableContinue: false,
Title: "Tinyauth", Title: "Tinyauth",
GenericName: "Generic", GenericName: "Generic",
} }
// Simple auth config for tests
var authConfig = types.AuthConfig{
Users: types.Users{},
OauthWhitelist: []string{},
Secret: "super-secret-api-thing-for-tests", // It is 32 chars long
CookieSecure: false,
SessionExpiry: 3600,
LoginTimeout: 0,
LoginMaxRetries: 0,
}
// Cookie // Cookie
var cookie string var cookie string
@@ -61,12 +67,13 @@ func getAPI(t *testing.T) *api.API {
} }
// Create auth service // Create auth service
auth := auth.NewAuth(docker, types.Users{ authConfig.Users = types.Users{
{ {
Username: user.Username, Username: user.Username,
Password: user.Password, Password: user.Password,
}, },
}, nil, apiConfig.SessionExpiry) }
auth := auth.NewAuth(authConfig, docker)
// Create providers service // Create providers service
providers := providers.NewProviders(types.OAuthConfig{}) providers := providers.NewProviders(types.OAuthConfig{})

View File

@@ -1,38 +1,64 @@
package auth package auth
import ( import (
"fmt"
"net/http"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
"sync"
"time" "time"
"tinyauth/internal/docker" "tinyauth/internal/docker"
"tinyauth/internal/types" "tinyauth/internal/types"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func NewAuth(docker *docker.Docker, userList types.Users, oauthWhitelist []string, sessionExpiry int) *Auth { func NewAuth(config types.AuthConfig, docker *docker.Docker) *Auth {
return &Auth{ return &Auth{
Docker: docker, Config: config,
Users: userList, Docker: docker,
OAuthWhitelist: oauthWhitelist, LoginAttempts: make(map[string]*types.LoginAttempt),
SessionExpiry: sessionExpiry,
} }
} }
type Auth struct { type Auth struct {
Users types.Users Config types.AuthConfig
Docker *docker.Docker Docker *docker.Docker
OAuthWhitelist []string LoginAttempts map[string]*types.LoginAttempt
SessionExpiry int LoginMutex sync.RWMutex
}
func (auth *Auth) GetSession(c *gin.Context) (*sessions.Session, error) {
// Create cookie store
store := sessions.NewCookieStore([]byte(auth.Config.Secret))
// Configure cookie store
store.Options = &sessions.Options{
Path: "/",
MaxAge: auth.Config.SessionExpiry,
Secure: auth.Config.CookieSecure,
HttpOnly: true,
SameSite: http.SameSiteDefaultMode,
Domain: fmt.Sprintf(".%s", auth.Config.Domain),
}
// Get session
session, err := store.Get(c.Request, "tinyauth")
if err != nil {
log.Error().Err(err).Msg("Failed to get session")
return nil, err
}
return session, nil
} }
func (auth *Auth) GetUser(username string) *types.User { func (auth *Auth) GetUser(username string) *types.User {
// Loop through users and return the user if the username matches // Loop through users and return the user if the username matches
for _, user := range auth.Users { for _, user := range auth.Config.Users {
if user.Username == username { if user.Username == username {
return &user return &user
} }
@@ -45,14 +71,78 @@ func (auth *Auth) CheckPassword(user types.User, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
} }
// IsAccountLocked checks if a username or IP is locked due to too many failed login attempts
func (auth *Auth) IsAccountLocked(identifier string) (bool, int) {
auth.LoginMutex.RLock()
defer auth.LoginMutex.RUnlock()
// Return false if rate limiting is not configured
if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 {
return false, 0
}
// Check if the identifier exists in the map
attempt, exists := auth.LoginAttempts[identifier]
if !exists {
return false, 0
}
// If account is locked, check if lock time has expired
if attempt.LockedUntil.After(time.Now()) {
// Calculate remaining lockout time in seconds
remaining := int(time.Until(attempt.LockedUntil).Seconds())
return true, remaining
}
// Lock has expired
return false, 0
}
// RecordLoginAttempt records a login attempt for rate limiting
func (auth *Auth) RecordLoginAttempt(identifier string, success bool) {
// Skip if rate limiting is not configured
if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 {
return
}
auth.LoginMutex.Lock()
defer auth.LoginMutex.Unlock()
// Get current attempt record or create a new one
attempt, exists := auth.LoginAttempts[identifier]
if !exists {
attempt = &types.LoginAttempt{}
auth.LoginAttempts[identifier] = attempt
}
// Update last attempt time
attempt.LastAttempt = time.Now()
// If successful login, reset failed attempts
if success {
attempt.FailedAttempts = 0
attempt.LockedUntil = time.Time{} // Reset lock time
return
}
// Increment failed attempts
attempt.FailedAttempts++
// If max retries reached, lock the account
if attempt.FailedAttempts >= auth.Config.LoginMaxRetries {
attempt.LockedUntil = time.Now().Add(time.Duration(auth.Config.LoginTimeout) * time.Second)
log.Warn().Str("identifier", identifier).Int("timeout", auth.Config.LoginTimeout).Msg("Account locked due to too many failed login attempts")
}
}
func (auth *Auth) EmailWhitelisted(emailSrc string) bool { func (auth *Auth) EmailWhitelisted(emailSrc string) bool {
// If the whitelist is empty, allow all emails // If the whitelist is empty, allow all emails
if len(auth.OAuthWhitelist) == 0 { if len(auth.Config.OauthWhitelist) == 0 {
return true return true
} }
// Loop through the whitelist and return true if the email matches // Loop through the whitelist and return true if the email matches
for _, email := range auth.OAuthWhitelist { for _, email := range auth.Config.OauthWhitelist {
if email == emailSrc { if email == emailSrc {
return true return true
} }
@@ -62,11 +152,15 @@ func (auth *Auth) EmailWhitelisted(emailSrc string) bool {
return false return false
} }
func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) { func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) error {
log.Debug().Msg("Creating session cookie") log.Debug().Msg("Creating session cookie")
// Get session // Get session
sessions := sessions.Default(c) session, err := auth.GetSession(c)
if err != nil {
log.Error().Err(err).Msg("Failed to get session")
return err
}
log.Debug().Msg("Setting session cookie") log.Debug().Msg("Setting session cookie")
@@ -76,54 +170,73 @@ func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie)
if data.TotpPending { if data.TotpPending {
sessionExpiry = 3600 sessionExpiry = 3600
} else { } else {
sessionExpiry = auth.SessionExpiry sessionExpiry = auth.Config.SessionExpiry
} }
// Set data // Set data
sessions.Set("username", data.Username) session.Values["username"] = data.Username
sessions.Set("provider", data.Provider) session.Values["provider"] = data.Provider
sessions.Set("expiry", time.Now().Add(time.Duration(sessionExpiry)*time.Second).Unix()) session.Values["expiry"] = time.Now().Add(time.Duration(sessionExpiry) * time.Second).Unix()
sessions.Set("totpPending", data.TotpPending) session.Values["totpPending"] = data.TotpPending
session.Values["redirectURI"] = data.RedirectURI
// Save session // Save session
sessions.Save() err = session.Save(c.Request, c.Writer)
if err != nil {
log.Error().Err(err).Msg("Failed to save session")
return err
}
// Return nil
return nil
} }
func (auth *Auth) DeleteSessionCookie(c *gin.Context) { func (auth *Auth) DeleteSessionCookie(c *gin.Context) error {
log.Debug().Msg("Deleting session cookie") log.Debug().Msg("Deleting session cookie")
// Get session // Get session
sessions := sessions.Default(c) session, err := auth.GetSession(c)
if err != nil {
log.Error().Err(err).Msg("Failed to get session")
return err
}
// Clear session // Delete all values in the session
sessions.Clear() for key := range session.Values {
delete(session.Values, key)
}
// Save session // Save session
sessions.Save() err = session.Save(c.Request, c.Writer)
if err != nil {
log.Error().Err(err).Msg("Failed to save session")
return err
}
// Return nil
return nil
} }
func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie { func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error) {
log.Debug().Msg("Getting session cookie") log.Debug().Msg("Getting session cookie")
// Get session // Get session
sessions := sessions.Default(c) session, err := auth.GetSession(c)
if err != nil {
log.Error().Err(err).Msg("Failed to get session")
return types.SessionCookie{}, err
}
// Get data // Get data from session
cookieUsername := sessions.Get("username") username, usernameOk := session.Values["username"].(string)
cookieProvider := sessions.Get("provider") provider, providerOK := session.Values["provider"].(string)
cookieExpiry := sessions.Get("expiry") redirectURI, redirectOK := session.Values["redirectURI"].(string)
cookieTotpPending := sessions.Get("totpPending") expiry, expiryOk := session.Values["expiry"].(int64)
totpPending, totpPendingOk := session.Values["totpPending"].(bool)
// Convert interfaces to correct types if !usernameOk || !providerOK || !expiryOk || !redirectOK || !totpPendingOk {
username, usernameOk := cookieUsername.(string) log.Warn().Msg("Session cookie is missing data")
provider, providerOk := cookieProvider.(string) return types.SessionCookie{}, nil
expiry, expiryOk := cookieExpiry.(int64)
totpPending, totpPendingOk := cookieTotpPending.(bool)
// Check if the cookie is invalid
if !usernameOk || !providerOk || !expiryOk || !totpPendingOk {
log.Warn().Msg("Session cookie invalid")
return types.SessionCookie{}
} }
// Check if the cookie has expired // Check if the cookie has expired
@@ -134,7 +247,7 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie {
auth.DeleteSessionCookie(c) auth.DeleteSessionCookie(c)
// Return empty cookie // Return empty cookie
return types.SessionCookie{} return types.SessionCookie{}, nil
} }
log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Bool("totpPending", totpPending).Msg("Parsed cookie") log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Bool("totpPending", totpPending).Msg("Parsed cookie")
@@ -144,12 +257,13 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie {
Username: username, Username: username,
Provider: provider, Provider: provider,
TotpPending: totpPending, TotpPending: totpPending,
} RedirectURI: redirectURI,
}, nil
} }
func (auth *Auth) UserAuthConfigured() bool { func (auth *Auth) UserAuthConfigured() bool {
// If there are users, return true // If there are users, return true
return len(auth.Users) > 0 return len(auth.Config.Users) > 0
} }
func (auth *Auth) ResourceAllowed(c *gin.Context, context types.UserContext) (bool, error) { func (auth *Auth) ResourceAllowed(c *gin.Context, context types.UserContext) (bool, error) {

147
internal/auth/auth_test.go Normal file
View File

@@ -0,0 +1,147 @@
package auth_test
import (
"testing"
"time"
"tinyauth/internal/auth"
"tinyauth/internal/docker"
"tinyauth/internal/types"
)
var config = types.AuthConfig{
Users: types.Users{},
OauthWhitelist: []string{},
SessionExpiry: 3600,
}
func TestLoginRateLimiting(t *testing.T) {
// Initialize a new auth service with 3 max retries and 5 seconds timeout
config.LoginMaxRetries = 3
config.LoginTimeout = 5
authService := auth.NewAuth(config, &docker.Docker{})
// Test identifier
identifier := "test_user"
// Test successful login - should not lock account
t.Log("Testing successful login")
authService.RecordLoginAttempt(identifier, true)
locked, _ := authService.IsAccountLocked(identifier)
if locked {
t.Fatalf("Account should not be locked after successful login")
}
// Test 2 failed attempts - should not lock account yet
t.Log("Testing 2 failed login attempts")
authService.RecordLoginAttempt(identifier, false)
authService.RecordLoginAttempt(identifier, false)
locked, _ = authService.IsAccountLocked(identifier)
if locked {
t.Fatalf("Account should not be locked after only 2 failed attempts")
}
// Add one more failed attempt (total 3) - should lock account with maxRetries=3
t.Log("Testing 3 failed login attempts")
authService.RecordLoginAttempt(identifier, false)
locked, remainingTime := authService.IsAccountLocked(identifier)
if !locked {
t.Fatalf("Account should be locked after reaching max retries")
}
if remainingTime <= 0 || remainingTime > 5 {
t.Fatalf("Expected remaining time between 1-5 seconds, got %d", remainingTime)
}
// Test reset after waiting for timeout - use 1 second timeout for fast testing
t.Log("Testing unlocking after timeout")
// Reinitialize auth service with a shorter timeout for testing
config.LoginTimeout = 1
config.LoginMaxRetries = 3
authService = auth.NewAuth(config, &docker.Docker{})
// Add enough failed attempts to lock the account
for i := 0; i < 3; i++ {
authService.RecordLoginAttempt(identifier, false)
}
// Verify it's locked
locked, _ = authService.IsAccountLocked(identifier)
if !locked {
t.Fatalf("Account should be locked initially")
}
// Wait a bit and verify it gets unlocked after timeout
time.Sleep(1500 * time.Millisecond) // Wait longer than the timeout
locked, _ = authService.IsAccountLocked(identifier)
if locked {
t.Fatalf("Account should be unlocked after timeout period")
}
// Test disabled rate limiting
t.Log("Testing disabled rate limiting")
config.LoginMaxRetries = 0
config.LoginTimeout = 0
authService = auth.NewAuth(config, &docker.Docker{})
for i := 0; i < 10; i++ {
authService.RecordLoginAttempt(identifier, false)
}
locked, _ = authService.IsAccountLocked(identifier)
if locked {
t.Fatalf("Account should not be locked when rate limiting is disabled")
}
}
func TestConcurrentLoginAttempts(t *testing.T) {
// Initialize a new auth service with 2 max retries and 5 seconds timeout
config.LoginMaxRetries = 2
config.LoginTimeout = 5
authService := auth.NewAuth(config, &docker.Docker{})
// Test multiple identifiers
identifiers := []string{"user1", "user2", "user3"}
// Test that locking one identifier doesn't affect others
t.Log("Testing multiple identifiers")
// Add enough failed attempts to lock first user (2 attempts with maxRetries=2)
authService.RecordLoginAttempt(identifiers[0], false)
authService.RecordLoginAttempt(identifiers[0], false)
// Check if first user is locked
locked, _ := authService.IsAccountLocked(identifiers[0])
if !locked {
t.Fatalf("User1 should be locked after reaching max retries")
}
// Check that other users are not affected
for i := 1; i < len(identifiers); i++ {
locked, _ := authService.IsAccountLocked(identifiers[i])
if locked {
t.Fatalf("User%d should not be locked", i+1)
}
}
// Test successful login after failed attempts (but before lock)
t.Log("Testing successful login after failed attempts but before lock")
// One failed attempt for user2
authService.RecordLoginAttempt(identifiers[1], false)
// Successful login should reset the counter
authService.RecordLoginAttempt(identifiers[1], true)
// Now try a failed login again - should not be locked as counter was reset
authService.RecordLoginAttempt(identifiers[1], false)
locked, _ = authService.IsAccountLocked(identifiers[1])
if locked {
t.Fatalf("User2 should not be locked after successful login reset")
}
}

View File

@@ -249,12 +249,34 @@ func (h *Handlers) LoginHandler(c *gin.Context) {
log.Debug().Msg("Got login request") log.Debug().Msg("Got login request")
// Get client IP for rate limiting
clientIP := c.ClientIP()
// Create an identifier for rate limiting (username or IP if username doesn't exist yet)
rateIdentifier := login.Username
if rateIdentifier == "" {
rateIdentifier = clientIP
}
// Check if the account is locked due to too many failed attempts
locked, remainingTime := h.Auth.IsAccountLocked(rateIdentifier)
if locked {
log.Warn().Str("identifier", rateIdentifier).Int("remaining_seconds", remainingTime).Msg("Account is locked due to too many failed login attempts")
c.JSON(429, gin.H{
"status": 429,
"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remainingTime),
})
return
}
// Get user based on username // Get user based on username
user := h.Auth.GetUser(login.Username) user := h.Auth.GetUser(login.Username)
// User does not exist // User does not exist
if user == nil { if user == nil {
log.Debug().Str("username", login.Username).Msg("User not found") log.Debug().Str("username", login.Username).Msg("User not found")
// Record failed login attempt
h.Auth.RecordLoginAttempt(rateIdentifier, false)
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -267,6 +289,8 @@ func (h *Handlers) LoginHandler(c *gin.Context) {
// Check if password is correct // Check if password is correct
if !h.Auth.CheckPassword(*user, login.Password) { if !h.Auth.CheckPassword(*user, login.Password) {
log.Debug().Str("username", login.Username).Msg("Password incorrect") log.Debug().Str("username", login.Username).Msg("Password incorrect")
// Record failed login attempt
h.Auth.RecordLoginAttempt(rateIdentifier, false)
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -276,6 +300,9 @@ func (h *Handlers) LoginHandler(c *gin.Context) {
log.Debug().Msg("Password correct, checking totp") log.Debug().Msg("Password correct, checking totp")
// Record successful login attempt (will reset failed attempt counter)
h.Auth.RecordLoginAttempt(rateIdentifier, true)
// Check if user has totp enabled // Check if user has totp enabled
if user.TotpSecret != "" { if user.TotpSecret != "" {
log.Debug().Msg("Totp enabled") log.Debug().Msg("Totp enabled")
@@ -393,9 +420,6 @@ func (h *Handlers) LogoutHandler(c *gin.Context) {
log.Debug().Msg("Cleaning up redirect cookie") log.Debug().Msg("Cleaning up redirect cookie")
// Clean up redirect cookie if it exists
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", h.Config.Domain, h.Config.CookieSecure, true)
// Return logged out // Return logged out
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
@@ -502,7 +526,9 @@ func (h *Handlers) OauthUrlHandler(c *gin.Context) {
// Set redirect cookie if redirect URI is provided // Set redirect cookie if redirect URI is provided
if redirectURI != "" { if redirectURI != "" {
log.Debug().Str("redirectURI", redirectURI).Msg("Setting redirect cookie") log.Debug().Str("redirectURI", redirectURI).Msg("Setting redirect cookie")
c.SetCookie("tinyauth_redirect_uri", redirectURI, 3600, "/", h.Config.Domain, h.Config.CookieSecure, true) h.Auth.CreateSessionCookie(c, &types.SessionCookie{
RedirectURI: redirectURI,
})
} }
// Tailscale does not have an auth url so we create a random code (does not need to be secure) to avoid caching and send it // Tailscale does not have an auth url so we create a random code (does not need to be secure) to avoid caching and send it
@@ -624,28 +650,25 @@ func (h *Handlers) OauthCallbackHandler(c *gin.Context) {
log.Debug().Msg("Email whitelisted") log.Debug().Msg("Email whitelisted")
// Create session cookie // Get redirect URI
cookie, err := h.Auth.GetSessionCookie(c)
// Create session cookie (also cleans up redirect cookie)
h.Auth.CreateSessionCookie(c, &types.SessionCookie{ h.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: email, Username: email,
Provider: providerName.Provider, Provider: providerName.Provider,
}) })
// Get redirect URI
redirectURI, err := c.Cookie("tinyauth_redirect_uri")
// If it is empty it means that no redirect_uri was provided to the login screen so we just log in // If it is empty it means that no redirect_uri was provided to the login screen so we just log in
if err != nil { if err != nil {
c.Redirect(http.StatusPermanentRedirect, h.Config.AppURL) c.Redirect(http.StatusPermanentRedirect, h.Config.AppURL)
} }
log.Debug().Str("redirectURI", redirectURI).Msg("Got redirect URI") log.Debug().Str("redirectURI", cookie.RedirectURI).Msg("Got redirect URI")
// Clean up redirect cookie since we already have the value
c.SetCookie("tinyauth_redirect_uri", "", -1, "/", h.Config.Domain, h.Config.CookieSecure, true)
// Build query // Build query
queries, err := query.Values(types.LoginQuery{ queries, err := query.Values(types.LoginQuery{
RedirectURI: redirectURI, RedirectURI: cookie.RedirectURI,
}) })
log.Debug().Msg("Got redirect query") log.Debug().Msg("Got redirect query")

View File

@@ -23,7 +23,7 @@ type Hooks struct {
func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext { func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
// Get session cookie and basic auth // Get session cookie and basic auth
cookie := hooks.Auth.GetSessionCookie(c) cookie, err := hooks.Auth.GetSessionCookie(c)
basic := hooks.Auth.GetBasicAuth(c) basic := hooks.Auth.GetBasicAuth(c)
// Check if basic auth is set // Check if basic auth is set
@@ -46,6 +46,19 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
} }
// Check cookie error after basic auth
if err != nil {
log.Error().Err(err).Msg("Failed to get session cookie")
// Return empty context
return types.UserContext{
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
TotpPending: false,
}
}
// Check if session cookie has totp pending // Check if session cookie has totp pending
if cookie.TotpPending { if cookie.TotpPending {
log.Debug().Msg("Totp pending") log.Debug().Msg("Totp pending")

59
internal/types/api.go Normal file
View File

@@ -0,0 +1,59 @@
package types
// LoginQuery is the query parameters for the login endpoint
type LoginQuery struct {
RedirectURI string `url:"redirect_uri"`
}
// LoginRequest is the request body for the login endpoint
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// OAuthRequest is the request for the OAuth endpoint
type OAuthRequest struct {
Provider string `uri:"provider" binding:"required"`
}
// UnauthorizedQuery is the query parameters for the unauthorized endpoint
type UnauthorizedQuery struct {
Username string `url:"username"`
Resource string `url:"resource"`
}
// TailscaleQuery is the query parameters for the tailscale endpoint
type TailscaleQuery struct {
Code int `url:"code"`
}
// Proxy is the uri parameters for the proxy endpoint
type Proxy struct {
Proxy string `uri:"proxy" binding:"required"`
}
// User Context response is the response for the user context endpoint
type UserContextResponse 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"`
TotpPending bool `json:"totpPending"`
}
// App Context is the response for the app context endpoint
type AppContext struct {
Status int `json:"status"`
Message string `json:"message"`
ConfiguredProviders []string `json:"configuredProviders"`
DisableContinue bool `json:"disableContinue"`
Title string `json:"title"`
GenericName string `json:"genericName"`
}
// Totp request is the request for the totp endpoint
type TotpRequest struct {
Code string `json:"code"`
}

81
internal/types/config.go Normal file
View File

@@ -0,0 +1,81 @@
package types
// Config is the configuration for the tinyauth server
type Config struct {
Port int `mapstructure:"port" validate:"required"`
Address string `validate:"required,ip4_addr" mapstructure:"address"`
Secret string `validate:"required,len=32" mapstructure:"secret"`
SecretFile string `mapstructure:"secret-file"`
AppURL string `validate:"required,url" mapstructure:"app-url"`
Users string `mapstructure:"users"`
UsersFile string `mapstructure:"users-file"`
CookieSecure bool `mapstructure:"cookie-secure"`
GithubClientId string `mapstructure:"github-client-id"`
GithubClientSecret string `mapstructure:"github-client-secret"`
GithubClientSecretFile string `mapstructure:"github-client-secret-file"`
GoogleClientId string `mapstructure:"google-client-id"`
GoogleClientSecret string `mapstructure:"google-client-secret"`
GoogleClientSecretFile string `mapstructure:"google-client-secret-file"`
TailscaleClientId string `mapstructure:"tailscale-client-id"`
TailscaleClientSecret string `mapstructure:"tailscale-client-secret"`
TailscaleClientSecretFile string `mapstructure:"tailscale-client-secret-file"`
GenericClientId string `mapstructure:"generic-client-id"`
GenericClientSecret string `mapstructure:"generic-client-secret"`
GenericClientSecretFile string `mapstructure:"generic-client-secret-file"`
GenericScopes string `mapstructure:"generic-scopes"`
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"`
EnvFile string `mapstructure:"env-file"`
LoginTimeout int `mapstructure:"login-timeout"`
LoginMaxRetries int `mapstructure:"login-max-retries"`
}
// Server configuration
type HandlersConfig struct {
AppURL string
DisableContinue bool
GenericName string
Title string
}
// OAuthConfig is the configuration for the providers
type OAuthConfig struct {
GithubClientId string
GithubClientSecret string
GoogleClientId string
GoogleClientSecret string
TailscaleClientId string
TailscaleClientSecret string
GenericClientId string
GenericClientSecret string
GenericScopes []string
GenericAuthURL string
GenericTokenURL string
GenericUserURL string
AppURL string
}
// APIConfig is the configuration for the API
type APIConfig struct {
Port int
Address string
}
// AuthConfig is the configuration for the auth service
type AuthConfig struct {
Users Users
OauthWhitelist []string
SessionExpiry int
Secret string
CookieSecure bool
Domain string
LoginTimeout int
LoginMaxRetries int
}

View File

@@ -1,17 +1,9 @@
package types package types
import "tinyauth/internal/oauth" import (
"time"
// LoginQuery is the query parameters for the login endpoint "tinyauth/internal/oauth"
type LoginQuery struct { )
RedirectURI string `url:"redirect_uri"`
}
// LoginRequest is the request body for the login endpoint
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// User is the struct for a user // User is the struct for a user
type User struct { type User struct {
@@ -23,39 +15,27 @@ type User struct {
// Users is a list of users // Users is a list of users
type Users []User type Users []User
// Config is the configuration for the tinyauth server // OAuthProviders is the struct for the OAuth providers
type Config struct { type OAuthProviders struct {
Port int `mapstructure:"port" validate:"required"` Github *oauth.OAuth
Address string `validate:"required,ip4_addr" mapstructure:"address"` Google *oauth.OAuth
Secret string `validate:"required,len=32" mapstructure:"secret"` Microsoft *oauth.OAuth
SecretFile string `mapstructure:"secret-file"` }
AppURL string `validate:"required,url" mapstructure:"app-url"`
Users string `mapstructure:"users"` // SessionCookie is the cookie for the session (exculding the expiry)
UsersFile string `mapstructure:"users-file"` type SessionCookie struct {
CookieSecure bool `mapstructure:"cookie-secure"` Username string
GithubClientId string `mapstructure:"github-client-id"` Provider string
GithubClientSecret string `mapstructure:"github-client-secret"` TotpPending bool
GithubClientSecretFile string `mapstructure:"github-client-secret-file"` RedirectURI string
GoogleClientId string `mapstructure:"google-client-id"` }
GoogleClientSecret string `mapstructure:"google-client-secret"`
GoogleClientSecretFile string `mapstructure:"google-client-secret-file"` // TinyauthLabels is the labels for the tinyauth container
TailscaleClientId string `mapstructure:"tailscale-client-id"` type TinyauthLabels struct {
TailscaleClientSecret string `mapstructure:"tailscale-client-secret"` OAuthWhitelist []string
TailscaleClientSecretFile string `mapstructure:"tailscale-client-secret-file"` Users []string
GenericClientId string `mapstructure:"generic-client-id"` Allowed string
GenericClientSecret string `mapstructure:"generic-client-secret"` Headers map[string]string
GenericClientSecretFile string `mapstructure:"generic-client-secret-file"`
GenericScopes string `mapstructure:"generic-scopes"`
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"`
EnvFile string `mapstructure:"env-file"`
} }
// UserContext is the context for the user // UserContext is the context for the user
@@ -67,108 +47,9 @@ type UserContext struct {
TotpPending bool TotpPending bool
} }
// APIConfig is the configuration for the API // LoginAttempt tracks information about login attempts for rate limiting
type APIConfig struct { type LoginAttempt struct {
Port int FailedAttempts int
Address string LastAttempt time.Time
Secret string LockedUntil time.Time
CookieSecure bool
SessionExpiry int
Domain string
}
// OAuthConfig is the configuration for the providers
type OAuthConfig struct {
GithubClientId string
GithubClientSecret string
GoogleClientId string
GoogleClientSecret string
TailscaleClientId string
TailscaleClientSecret string
GenericClientId string
GenericClientSecret string
GenericScopes []string
GenericAuthURL string
GenericTokenURL string
GenericUserURL string
AppURL string
}
// OAuthRequest is the request for the OAuth endpoint
type OAuthRequest struct {
Provider string `uri:"provider" binding:"required"`
}
// OAuthProviders is the struct for the OAuth providers
type OAuthProviders struct {
Github *oauth.OAuth
Google *oauth.OAuth
Microsoft *oauth.OAuth
}
// UnauthorizedQuery is the query parameters for the unauthorized endpoint
type UnauthorizedQuery struct {
Username string `url:"username"`
Resource string `url:"resource"`
}
// SessionCookie is the cookie for the session (exculding the expiry)
type SessionCookie struct {
Username string
Provider string
TotpPending bool
}
// TinyauthLabels is the labels for the tinyauth container
type TinyauthLabels struct {
OAuthWhitelist []string
Users []string
Allowed string
Headers map[string]string
}
// TailscaleQuery is the query parameters for the tailscale endpoint
type TailscaleQuery struct {
Code int `url:"code"`
}
// Proxy is the uri parameters for the proxy endpoint
type Proxy struct {
Proxy string `uri:"proxy" binding:"required"`
}
// User Context response is the response for the user context endpoint
type UserContextResponse 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"`
TotpPending bool `json:"totpPending"`
}
// App Context is the response for the app context endpoint
type AppContext struct {
Status int `json:"status"`
Message string `json:"message"`
ConfiguredProviders []string `json:"configuredProviders"`
DisableContinue bool `json:"disableContinue"`
Title string `json:"title"`
GenericName string `json:"genericName"`
}
// Totp request is the request for the totp endpoint
type TotpRequest struct {
Code string `json:"code"`
}
// Server configuration
type HandlersConfig struct {
AppURL string
Domain string
CookieSecure bool
DisableContinue bool
GenericName string
Title string
} }