Compare commits

..

2 Commits

Author SHA1 Message Date
Stavros
df10eb65ce chore: tidy go mod 2025-04-03 15:45:03 +03:00
Stavros
8bf5a6067e wip 2025-04-03 15:44:47 +03:00
19 changed files with 153 additions and 483 deletions

View File

@@ -26,7 +26,5 @@ 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:
- i18n_v* - main
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -16,53 +16,7 @@ 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
@@ -71,14 +25,10 @@ jobs:
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v4 uses: actions/configure-pages@v4
- name: Prepare output directory - name: Move translations
run: | run: |
mkdir -p dist/i18n/ mkdir -p dist
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,7 +61,3 @@ 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,6 +2,7 @@ package cmd
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"strings" "strings"
"time" "time"
@@ -93,8 +94,10 @@ var rootCmd = &cobra.Command{
} }
// Create handlers config // Create handlers config
handlersConfig := types.HandlersConfig{ serverConfig := 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,
@@ -106,18 +109,6 @@ var rootCmd = &cobra.Command{
Address: config.Address, Address: config.Address,
} }
// Create auth config
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
docker := docker.NewDocker() docker := docker.NewDocker()
@@ -126,7 +117,14 @@ 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(authConfig, docker) auth := auth.NewAuth(types.AuthConfig{
Domain: domain,
Secret: config.Secret,
SessionExpiry: config.SessionExpiry,
CookieSecure: config.CookieSecure,
Users: users,
OAuthWhitelist: oauthWhitelist,
}, docker)
// Create OAuth providers service // Create OAuth providers service
providers := providers.NewProviders(oauthConfig) providers := providers.NewProviders(oauthConfig)
@@ -138,7 +136,7 @@ var rootCmd = &cobra.Command{
hooks := hooks.NewHooks(auth, providers) hooks := hooks.NewHooks(auth, providers)
// Create handlers // Create handlers
handlers := handlers.NewHandlers(handlersConfig, auth, hooks, providers, docker) handlers := handlers.NewHandlers(serverConfig, auth, hooks, providers, docker)
// Create API // Create API
api := api.NewAPI(apiConfig, handlers) api := api.NewAPI(apiConfig, handlers)
@@ -203,8 +201,6 @@ 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.")
@@ -239,8 +235,6 @@ 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/v1/{{lng}}.json", loadPath: "https://cdn.tinyauth.app/i18n/{{lng}}.json",
}, },
], ],
}, },

View File

@@ -6,7 +6,6 @@
"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,7 +6,6 @@
"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, { type AxiosError } from "axios"; import axios 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,17 +33,7 @@ export const LoginPage = () => {
mutationFn: (login: LoginFormValues) => { mutationFn: (login: LoginFormValues) => {
return axios.post("/api/login", login); return axios.post("/api/login", login);
}, },
onError: (data: AxiosError) => { onError: () => {
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"),

View File

@@ -17,15 +17,15 @@ import (
func NewAPI(config types.APIConfig, handlers *handlers.Handlers) *API { func NewAPI(config types.APIConfig, handlers *handlers.Handlers) *API {
return &API{ return &API{
Config: config,
Handlers: handlers, Handlers: handlers,
Config: config,
} }
} }
type API struct { type API struct {
Config types.APIConfig
Router *gin.Engine Router *gin.Engine
Handlers *handlers.Handlers Handlers *handlers.Handlers
Config types.APIConfig
} }
func (api *API) Init() { func (api *API) Init() {

View File

@@ -19,6 +19,12 @@ import (
"github.com/magiconair/properties/assert" "github.com/magiconair/properties/assert"
) )
// User
var User = types.User{
Username: "user",
Password: "$2a$10$AvGHLTYv3xiRJ0xV9xs3XeVIlkGTygI9nqIamFYB5Xu.5.0UWF7B6", // pass
}
// Simple API config for tests // Simple API config for tests
var apiConfig = types.APIConfig{ var apiConfig = types.APIConfig{
Port: 8080, Port: 8080,
@@ -28,6 +34,8 @@ var apiConfig = types.APIConfig{
// 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",
@@ -35,24 +43,19 @@ var handlersConfig = types.HandlersConfig{
// Simple auth config for tests // Simple auth config for tests
var authConfig = types.AuthConfig{ var authConfig = types.AuthConfig{
Users: types.Users{}, Domain: "localhost",
OauthWhitelist: []string{}, Secret: "super-secret-api-thing-for-tests", // It is 32 chars long
Secret: "super-secret-api-thing-for-tests", // It is 32 chars long CookieSecure: false,
CookieSecure: false, SessionExpiry: 3600,
SessionExpiry: 3600, Users: types.Users{
LoginTimeout: 0, User,
LoginMaxRetries: 0, },
OAuthWhitelist: []string{},
} }
// Cookie // Cookie
var cookie string 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 // We need all this to be able to test the API
func getAPI(t *testing.T) *api.API { func getAPI(t *testing.T) *api.API {
// Create docker service // Create docker service
@@ -67,12 +70,6 @@ func getAPI(t *testing.T) *api.API {
} }
// Create auth service // Create auth service
authConfig.Users = types.Users{
{
Username: user.Username,
Password: user.Password,
},
}
auth := auth.NewAuth(authConfig, docker) auth := auth.NewAuth(authConfig, docker)
// Create providers service // Create providers service

View File

@@ -2,11 +2,9 @@ package auth
import ( import (
"fmt" "fmt"
"net/http"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
"sync"
"time" "time"
"tinyauth/internal/docker" "tinyauth/internal/docker"
"tinyauth/internal/types" "tinyauth/internal/types"
@@ -19,41 +17,14 @@ import (
func NewAuth(config types.AuthConfig, docker *docker.Docker) *Auth { func NewAuth(config types.AuthConfig, docker *docker.Docker) *Auth {
return &Auth{ return &Auth{
Config: config, Docker: docker,
Docker: docker, Config: config,
LoginAttempts: make(map[string]*types.LoginAttempt),
} }
} }
type Auth struct { type Auth struct {
Config types.AuthConfig Docker *docker.Docker
Docker *docker.Docker Config types.AuthConfig
LoginAttempts map[string]*types.LoginAttempt
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 {
@@ -71,78 +42,14 @@ 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.Config.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.Config.OauthWhitelist { for _, email := range auth.Config.OAuthWhitelist {
if email == emailSrc { if email == emailSrc {
return true return true
} }
@@ -152,13 +59,33 @@ func (auth *Auth) EmailWhitelisted(emailSrc string) bool {
return false return false
} }
func (auth *Auth) GetCookieStore() *sessions.CookieStore {
// Create a new cookie store
store := sessions.NewCookieStore([]byte(auth.Config.Secret))
// Configure the cookie store
store.Options = &sessions.Options{
Path: "/",
Domain: fmt.Sprintf(".%s", auth.Config.Domain),
Secure: auth.Config.CookieSecure,
MaxAge: auth.Config.SessionExpiry,
HttpOnly: true,
}
// Set the cookie store
return store
}
func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) error { func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) error {
log.Debug().Msg("Creating session cookie") log.Debug().Msg("Creating session cookie")
// Get cookie store
store := auth.GetCookieStore()
// Get session // Get session
session, err := auth.GetSession(c) sessions, err := store.Get(c.Request, "tinyauth")
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to get session")
return err return err
} }
@@ -174,16 +101,15 @@ func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie)
} }
// Set data // Set data
session.Values["username"] = data.Username sessions.Values["username"] = data.Username
session.Values["provider"] = data.Provider sessions.Values["provider"] = data.Provider
session.Values["expiry"] = time.Now().Add(time.Duration(sessionExpiry) * time.Second).Unix() sessions.Values["expiry"] = time.Now().Add(time.Duration(sessionExpiry) * time.Second).Unix()
session.Values["totpPending"] = data.TotpPending sessions.Values["totpPending"] = data.TotpPending
session.Values["redirectURI"] = data.RedirectURI
// Save session // Save session
err = session.Save(c.Request, c.Writer) err = sessions.Save(c.Request, c.Writer)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to save session")
return err return err
} }
@@ -194,22 +120,25 @@ func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie)
func (auth *Auth) DeleteSessionCookie(c *gin.Context) error { func (auth *Auth) DeleteSessionCookie(c *gin.Context) error {
log.Debug().Msg("Deleting session cookie") log.Debug().Msg("Deleting session cookie")
// Get cookie store
store := auth.GetCookieStore()
// Get session // Get session
session, err := auth.GetSession(c) sessions, err := store.Get(c.Request, "tinyauth")
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to get session")
return err return err
} }
// Delete all values in the session // Clear session
for key := range session.Values { for key := range sessions.Values {
delete(session.Values, key) delete(sessions.Values, key)
} }
// Save session // Save session
err = session.Save(c.Request, c.Writer) err = sessions.Save(c.Request, c.Writer)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to save session")
return err return err
} }
@@ -220,22 +149,31 @@ func (auth *Auth) DeleteSessionCookie(c *gin.Context) error {
func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error) { func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error) {
log.Debug().Msg("Getting session cookie") log.Debug().Msg("Getting session cookie")
// Get cookie store
store := auth.GetCookieStore()
// Get session // Get session
session, err := auth.GetSession(c) sessions, err := store.Get(c.Request, "tinyauth")
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to get session")
return types.SessionCookie{}, err return types.SessionCookie{}, err
} }
// Get data from session // Get data
username, usernameOk := session.Values["username"].(string) cookieUsername := sessions.Values["username"]
provider, providerOK := session.Values["provider"].(string) cookieProvider := sessions.Values["provider"]
redirectURI, redirectOK := session.Values["redirectURI"].(string) cookieExpiry := sessions.Values["expiry"]
expiry, expiryOk := session.Values["expiry"].(int64) cookieTotpPending := sessions.Values["totpPending"]
totpPending, totpPendingOk := session.Values["totpPending"].(bool)
if !usernameOk || !providerOK || !expiryOk || !redirectOK || !totpPendingOk { // Convert interfaces to correct types
log.Warn().Msg("Session cookie is missing data") 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 || !totpPendingOk {
log.Warn().Msg("Session cookie invalid")
return types.SessionCookie{}, nil return types.SessionCookie{}, nil
} }
@@ -257,7 +195,6 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error)
Username: username, Username: username,
Provider: provider, Provider: provider,
TotpPending: totpPending, TotpPending: totpPending,
RedirectURI: redirectURI,
}, nil }, nil
} }

View File

@@ -1,147 +0,0 @@
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

@@ -19,20 +19,20 @@ import (
func NewHandlers(config types.HandlersConfig, auth *auth.Auth, hooks *hooks.Hooks, providers *providers.Providers, docker *docker.Docker) *Handlers { func NewHandlers(config types.HandlersConfig, auth *auth.Auth, hooks *hooks.Hooks, providers *providers.Providers, docker *docker.Docker) *Handlers {
return &Handlers{ return &Handlers{
Config: config,
Auth: auth, Auth: auth,
Hooks: hooks, Hooks: hooks,
Providers: providers, Providers: providers,
Docker: docker, Docker: docker,
Config: config,
} }
} }
type Handlers struct { type Handlers struct {
Config types.HandlersConfig
Auth *auth.Auth Auth *auth.Auth
Hooks *hooks.Hooks Hooks *hooks.Hooks
Providers *providers.Providers Providers *providers.Providers
Docker *docker.Docker Docker *docker.Docker
Config types.HandlersConfig
} }
func (h *Handlers) AuthHandler(c *gin.Context) { func (h *Handlers) AuthHandler(c *gin.Context) {
@@ -249,34 +249,12 @@ 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",
@@ -289,8 +267,6 @@ 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",
@@ -300,9 +276,6 @@ 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")
@@ -420,6 +393,9 @@ 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,
@@ -526,9 +502,7 @@ 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")
h.Auth.CreateSessionCookie(c, &types.SessionCookie{ c.SetCookie("tinyauth_redirect_uri", redirectURI, 3600, "/", h.Config.Domain, h.Config.CookieSecure, true)
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
@@ -650,25 +624,28 @@ func (h *Handlers) OauthCallbackHandler(c *gin.Context) {
log.Debug().Msg("Email whitelisted") log.Debug().Msg("Email whitelisted")
// Get redirect URI // Create session cookie
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", cookie.RedirectURI).Msg("Got redirect URI") log.Debug().Str("redirectURI", 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: cookie.RedirectURI, RedirectURI: redirectURI,
}) })
log.Debug().Msg("Got redirect query") log.Debug().Msg("Got redirect query")

View File

@@ -24,6 +24,12 @@ 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, err := hooks.Auth.GetSessionCookie(c) cookie, err := hooks.Auth.GetSessionCookie(c)
if err != nil {
log.Error().Err(err).Msg("Failed to get session cookie")
return types.UserContext{}
}
basic := hooks.Auth.GetBasicAuth(c) basic := hooks.Auth.GetBasicAuth(c)
// Check if basic auth is set // Check if basic auth is set
@@ -46,19 +52,6 @@ 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")

View File

@@ -14,10 +14,10 @@ func NewOAuth(config oauth2.Config) *OAuth {
} }
type OAuth struct { type OAuth struct {
Config oauth2.Config Verifier string
Context context.Context Context context.Context
Token *oauth2.Token Token *oauth2.Token
Verifier string Config oauth2.Config
} }
func (oauth *OAuth) Init() { func (oauth *OAuth) Init() {

View File

@@ -17,11 +17,11 @@ func NewProviders(config types.OAuthConfig) *Providers {
} }
type Providers struct { type Providers struct {
Config types.OAuthConfig
Github *oauth.OAuth Github *oauth.OAuth
Google *oauth.OAuth Google *oauth.OAuth
Tailscale *oauth.OAuth Tailscale *oauth.OAuth
Generic *oauth.OAuth Generic *oauth.OAuth
Config types.OAuthConfig
} }
func (providers *Providers) Init() { func (providers *Providers) Init() {

View File

@@ -43,16 +43,6 @@ type UserContextResponse struct {
TotpPending bool `json:"totpPending"` 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 // Totp request is the request for the totp endpoint
type TotpRequest struct { type TotpRequest struct {
Code string `json:"code"` Code string `json:"code"`

View File

@@ -33,16 +33,12 @@ type Config struct {
LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"` LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"`
Title string `mapstructure:"app-title"` Title string `mapstructure:"app-title"`
EnvFile string `mapstructure:"env-file"` EnvFile string `mapstructure:"env-file"`
LoginTimeout int `mapstructure:"login-timeout"`
LoginMaxRetries int `mapstructure:"login-max-retries"`
} }
// Server configuration // APIConfig is the configuration for the API
type HandlersConfig struct { type APIConfig struct {
AppURL string Port int
DisableContinue bool Address string
GenericName string
Title string
} }
// OAuthConfig is the configuration for the providers // OAuthConfig is the configuration for the providers
@@ -62,20 +58,22 @@ type OAuthConfig struct {
AppURL string AppURL string
} }
// APIConfig is the configuration for the API // Server configuration
type APIConfig struct { type HandlersConfig struct {
Port int AppURL string
Address string Domain string
CookieSecure bool
DisableContinue bool
GenericName string
Title string
} }
// AuthConfig is the configuration for the auth service // Auth configuration
type AuthConfig struct { type AuthConfig struct {
Users Users Domain string
OauthWhitelist []string Secret string
SessionExpiry int CookieSecure bool
Secret string SessionExpiry int
CookieSecure bool Users Users
Domain string OAuthWhitelist []string
LoginTimeout int
LoginMaxRetries int
} }

View File

@@ -1,9 +1,6 @@
package types package types
import ( import "tinyauth/internal/oauth"
"time"
"tinyauth/internal/oauth"
)
// User is the struct for a user // User is the struct for a user
type User struct { type User struct {
@@ -27,7 +24,6 @@ type SessionCookie struct {
Username string Username string
Provider string Provider string
TotpPending bool TotpPending bool
RedirectURI string
} }
// TinyauthLabels is the labels for the tinyauth container // TinyauthLabels is the labels for the tinyauth container
@@ -47,9 +43,12 @@ type UserContext struct {
TotpPending bool TotpPending bool
} }
// LoginAttempt tracks information about login attempts for rate limiting // App Context is the response for the app context endpoint
type LoginAttempt struct { type AppContext struct {
FailedAttempts int Status int `json:"status"`
LastAttempt time.Time Message string `json:"message"`
LockedUntil time.Time ConfiguredProviders []string `json:"configuredProviders"`
DisableContinue bool `json:"disableContinue"`
Title string `json:"title"`
GenericName string `json:"genericName"`
} }