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 483 additions and 153 deletions

View File

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

View File

@@ -3,7 +3,7 @@ name: Publish translations
on:
push:
branches:
- main
- i18n_v*
workflow_dispatch:
permissions:
@@ -16,7 +16,53 @@ concurrency:
cancel-in-progress: false
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:
needs: get-translations
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -25,10 +71,14 @@ jobs:
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Move translations
- name: Prepare output directory
run: |
mkdir -p dist
mv frontend/src/lib/i18n/locales dist/i18n
mkdir -p dist/i18n/
- name: Download translations
uses: actions/download-artifact@v4
with:
path: dist/i18n/
- name: Upload artifact
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.
- **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 (
"errors"
"fmt"
"os"
"strings"
"time"
@@ -94,10 +93,8 @@ var rootCmd = &cobra.Command{
}
// Create handlers config
serverConfig := types.HandlersConfig{
handlersConfig := types.HandlersConfig{
AppURL: config.AppURL,
Domain: fmt.Sprintf(".%s", domain),
CookieSecure: config.CookieSecure,
DisableContinue: config.DisableContinue,
Title: config.Title,
GenericName: config.GenericName,
@@ -109,6 +106,18 @@ var rootCmd = &cobra.Command{
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
docker := docker.NewDocker()
@@ -117,14 +126,7 @@ var rootCmd = &cobra.Command{
HandleError(err, "Failed to initialize docker")
// Create auth service
auth := auth.NewAuth(types.AuthConfig{
Domain: domain,
Secret: config.Secret,
SessionExpiry: config.SessionExpiry,
CookieSecure: config.CookieSecure,
Users: users,
OAuthWhitelist: oauthWhitelist,
}, docker)
auth := auth.NewAuth(authConfig, docker)
// Create OAuth providers service
providers := providers.NewProviders(oauthConfig)
@@ -136,7 +138,7 @@ var rootCmd = &cobra.Command{
hooks := hooks.NewHooks(auth, providers)
// Create handlers
handlers := handlers.NewHandlers(serverConfig, auth, hooks, providers, docker)
handlers := handlers.NewHandlers(handlersConfig, auth, hooks, providers, docker)
// Create API
api := api.NewAPI(apiConfig, handlers)
@@ -201,6 +203,8 @@ func init() {
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("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().String("app-title", "Tinyauth", "Title of the app.")
@@ -235,6 +239,8 @@ func init() {
viper.BindEnv("session-expiry", "SESSION_EXPIRY")
viper.BindEnv("log-level", "LOG_LEVEL")
viper.BindEnv("app-title", "APP_TITLE")
viper.BindEnv("login-timeout", "LOGIN_TIMEOUT")
viper.BindEnv("login-max-retries", "LOGIN_MAX_RETRIES")
// Bind flags to viper
viper.BindPFlags(rootCmd.Flags())

View File

@@ -28,7 +28,7 @@ i18n
],
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",
"loginFailTitle": "Failed to log in",
"loginFailSubtitle": "Please check your username and password",
"loginFailRateLimit": "You failed to login too many times, please try again later",
"loginSuccessTitle": "Logged in",
"loginSuccessSubtitle": "Welcome back!",
"loginOauthFailTitle": "Internal error",

View File

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

View File

@@ -1,7 +1,7 @@
import { Paper, Title, Text, Divider } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import axios, { type AxiosError } from "axios";
import { useUserContext } from "../context/user-context";
import { Navigate } from "react-router";
import { Layout } from "../components/layouts/layout";
@@ -33,7 +33,17 @@ export const LoginPage = () => {
mutationFn: (login: LoginFormValues) => {
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({
title: t("loginFailTitle"),
message: t("loginFailSubtitle"),

View File

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

View File

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

View File

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

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

@@ -19,20 +19,20 @@ import (
func NewHandlers(config types.HandlersConfig, auth *auth.Auth, hooks *hooks.Hooks, providers *providers.Providers, docker *docker.Docker) *Handlers {
return &Handlers{
Config: config,
Auth: auth,
Hooks: hooks,
Providers: providers,
Docker: docker,
Config: config,
}
}
type Handlers struct {
Config types.HandlersConfig
Auth *auth.Auth
Hooks *hooks.Hooks
Providers *providers.Providers
Docker *docker.Docker
Config types.HandlersConfig
}
func (h *Handlers) AuthHandler(c *gin.Context) {
@@ -249,12 +249,34 @@ func (h *Handlers) LoginHandler(c *gin.Context) {
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
user := h.Auth.GetUser(login.Username)
// User does not exist
if user == nil {
log.Debug().Str("username", login.Username).Msg("User not found")
// Record failed login attempt
h.Auth.RecordLoginAttempt(rateIdentifier, false)
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
@@ -267,6 +289,8 @@ func (h *Handlers) LoginHandler(c *gin.Context) {
// Check if password is correct
if !h.Auth.CheckPassword(*user, login.Password) {
log.Debug().Str("username", login.Username).Msg("Password incorrect")
// Record failed login attempt
h.Auth.RecordLoginAttempt(rateIdentifier, false)
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
@@ -276,6 +300,9 @@ func (h *Handlers) LoginHandler(c *gin.Context) {
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
if user.TotpSecret != "" {
log.Debug().Msg("Totp enabled")
@@ -393,9 +420,6 @@ func (h *Handlers) LogoutHandler(c *gin.Context) {
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
c.JSON(200, gin.H{
"status": 200,
@@ -502,7 +526,9 @@ func (h *Handlers) OauthUrlHandler(c *gin.Context) {
// Set redirect cookie if redirect URI is provided
if redirectURI != "" {
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
@@ -624,28 +650,25 @@ func (h *Handlers) OauthCallbackHandler(c *gin.Context) {
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{
Username: email,
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 err != nil {
c.Redirect(http.StatusPermanentRedirect, h.Config.AppURL)
}
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)
log.Debug().Str("redirectURI", cookie.RedirectURI).Msg("Got redirect URI")
// Build query
queries, err := query.Values(types.LoginQuery{
RedirectURI: redirectURI,
RedirectURI: cookie.RedirectURI,
})
log.Debug().Msg("Got redirect query")

View File

@@ -24,12 +24,6 @@ type Hooks struct {
func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
// Get session cookie and basic auth
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)
// Check if basic auth is set
@@ -52,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
if cookie.TotpPending {
log.Debug().Msg("Totp pending")

View File

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

View File

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

View File

@@ -43,6 +43,16 @@ type UserContextResponse struct {
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"`

View File

@@ -33,12 +33,16 @@ type Config struct {
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"`
}
// APIConfig is the configuration for the API
type APIConfig struct {
Port int
Address string
// Server configuration
type HandlersConfig struct {
AppURL string
DisableContinue bool
GenericName string
Title string
}
// OAuthConfig is the configuration for the providers
@@ -58,22 +62,20 @@ type OAuthConfig struct {
AppURL string
}
// Server configuration
type HandlersConfig struct {
AppURL string
Domain string
CookieSecure bool
DisableContinue bool
GenericName string
Title string
// APIConfig is the configuration for the API
type APIConfig struct {
Port int
Address string
}
// Auth configuration
// AuthConfig is the configuration for the auth service
type AuthConfig struct {
Domain string
Secret string
CookieSecure bool
SessionExpiry int
Users Users
OAuthWhitelist []string
Users Users
OauthWhitelist []string
SessionExpiry int
Secret string
CookieSecure bool
Domain string
LoginTimeout int
LoginMaxRetries int
}

View File

@@ -1,6 +1,9 @@
package types
import "tinyauth/internal/oauth"
import (
"time"
"tinyauth/internal/oauth"
)
// User is the struct for a user
type User struct {
@@ -24,6 +27,7 @@ type SessionCookie struct {
Username string
Provider string
TotpPending bool
RedirectURI string
}
// TinyauthLabels is the labels for the tinyauth container
@@ -43,12 +47,9 @@ type UserContext struct {
TotpPending bool
}
// 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"`
// LoginAttempt tracks information about login attempts for rate limiting
type LoginAttempt struct {
FailedAttempts int
LastAttempt time.Time
LockedUntil time.Time
}