refactor: use centralized config in auth service

This commit is contained in:
Stavros
2025-04-06 18:55:24 +03:00
parent 07ddd4f917
commit 5cf4e208c6
7 changed files with 252 additions and 200 deletions

View File

@@ -113,6 +113,15 @@ var rootCmd = &cobra.Command{
Domain: domain,
}
// Create auth config
authConfig := types.AuthConfig{
Users: users,
OauthWhitelist: oauthWhitelist,
SessionExpiry: config.SessionExpiry,
LoginTimeout: config.LoginTimeout,
LoginMaxRetries: config.LoginMaxRetries,
}
// Create docker service
docker := docker.NewDocker()
@@ -121,7 +130,7 @@ var rootCmd = &cobra.Command{
HandleError(err, "Failed to initialize docker")
// Create auth service
auth := auth.NewAuth(docker, users, oauthWhitelist, config.SessionExpiry, config.LoginTimeout, config.LoginMaxRetries)
auth := auth.NewAuth(authConfig, docker)
// Create OAuth providers service
providers := providers.NewProviders(oauthConfig)

View File

@@ -38,6 +38,15 @@ var handlersConfig = types.HandlersConfig{
GenericName: "Generic",
}
// Simple auth config for tests
var authConfig = types.AuthConfig{
Users: types.Users{},
OauthWhitelist: []string{},
SessionExpiry: 3600,
LoginTimeout: 0,
LoginMaxRetries: 0,
}
// Cookie
var cookie string
@@ -61,12 +70,13 @@ func getAPI(t *testing.T) *api.API {
}
// Create auth service
auth := auth.NewAuth(docker, types.Users{
authConfig.Users = types.Users{
{
Username: user.Username,
Password: user.Password,
},
}, nil, apiConfig.SessionExpiry, 300, 5)
}
auth := auth.NewAuth(authConfig, docker)
// Create providers service
providers := providers.NewProviders(types.OAuthConfig{})

View File

@@ -15,39 +15,24 @@ import (
"golang.org/x/crypto/bcrypt"
)
func NewAuth(docker *docker.Docker, userList types.Users, oauthWhitelist []string, sessionExpiry int, loginTimeout int, loginMaxRetries int) *Auth {
func NewAuth(config types.AuthConfig, docker *docker.Docker) *Auth {
return &Auth{
Config: config,
Docker: docker,
Users: userList,
OAuthWhitelist: oauthWhitelist,
SessionExpiry: sessionExpiry,
LoginTimeout: loginTimeout,
LoginMaxRetries: loginMaxRetries,
LoginAttempts: make(map[string]*LoginAttempt),
LoginAttempts: make(map[string]*types.LoginAttempt),
}
}
// LoginAttempt tracks information about login attempts for rate limiting
type LoginAttempt struct {
FailedAttempts int
LastAttempt time.Time
LockedUntil time.Time
}
type Auth struct {
Users types.Users
Config types.AuthConfig
Docker *docker.Docker
OAuthWhitelist []string
SessionExpiry int
LoginTimeout int
LoginMaxRetries int
LoginAttempts map[string]*LoginAttempt // Map of username/IP to login attempts
LoginMutex sync.RWMutex // Mutex to protect the LoginAttempts map
LoginAttempts map[string]*types.LoginAttempt
LoginMutex sync.RWMutex
}
func (auth *Auth) GetUser(username string) *types.User {
// 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 {
return &user
}
@@ -66,7 +51,7 @@ func (auth *Auth) IsAccountLocked(identifier string) (bool, int) {
defer auth.LoginMutex.RUnlock()
// Return false if rate limiting is not configured
if auth.LoginMaxRetries <= 0 || auth.LoginTimeout <= 0 {
if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 {
return false, 0
}
@@ -90,7 +75,7 @@ func (auth *Auth) IsAccountLocked(identifier string) (bool, int) {
// 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.LoginMaxRetries <= 0 || auth.LoginTimeout <= 0 {
if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 {
return
}
@@ -100,7 +85,7 @@ func (auth *Auth) RecordLoginAttempt(identifier string, success bool) {
// Get current attempt record or create a new one
attempt, exists := auth.LoginAttempts[identifier]
if !exists {
attempt = &LoginAttempt{}
attempt = &types.LoginAttempt{}
auth.LoginAttempts[identifier] = attempt
}
@@ -118,20 +103,20 @@ func (auth *Auth) RecordLoginAttempt(identifier string, success bool) {
attempt.FailedAttempts++
// If max retries reached, lock the account
if attempt.FailedAttempts >= auth.LoginMaxRetries {
attempt.LockedUntil = time.Now().Add(time.Duration(auth.LoginTimeout) * time.Second)
log.Warn().Str("identifier", identifier).Int("timeout", auth.LoginTimeout).Msg("Account locked due to too many failed login attempts")
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.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.OAuthWhitelist {
for _, email := range auth.Config.OauthWhitelist {
if email == emailSrc {
return true
}
@@ -155,7 +140,7 @@ func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie)
if data.TotpPending {
sessionExpiry = 3600
} else {
sessionExpiry = auth.SessionExpiry
sessionExpiry = auth.Config.SessionExpiry
}
// Set data
@@ -228,7 +213,7 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie {
func (auth *Auth) UserAuthConfigured() bool {
// 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) {

View File

@@ -8,26 +8,38 @@ import (
"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
authService := auth.NewAuth(&docker.Docker{}, types.Users{}, []string{}, 3600, 5, 3)
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")
}
@@ -36,6 +48,7 @@ func TestLoginRateLimiting(t *testing.T) {
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")
}
@@ -45,32 +58,42 @@ func TestLoginRateLimiting(t *testing.T) {
// Test reset after waiting for timeout - use 1 second timeout for fast testing
t.Log("Testing unlocking after timeout")
// Create a new service for this test with very short timeout (1 second)
authService2 := auth.NewAuth(&docker.Docker{}, types.Users{}, []string{}, 3600, 1, 3)
// 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++ {
authService2.RecordLoginAttempt(identifier, false)
authService.RecordLoginAttempt(identifier, false)
}
// Verify it's locked
locked, _ = authService2.IsAccountLocked(identifier)
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, _ = authService2.IsAccountLocked(identifier)
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")
authDisabled := auth.NewAuth(&docker.Docker{}, types.Users{}, []string{}, 3600, 0, 0)
config.LoginMaxRetries = 0
config.LoginTimeout = 0
authService = auth.NewAuth(config, &docker.Docker{})
for i := 0; i < 10; i++ {
authDisabled.RecordLoginAttempt(identifier, false)
authService.RecordLoginAttempt(identifier, false)
}
locked, _ = authDisabled.IsAccountLocked(identifier)
locked, _ = authService.IsAccountLocked(identifier)
if locked {
t.Fatalf("Account should not be locked when rate limiting is disabled")
}
@@ -78,7 +101,9 @@ func TestLoginRateLimiting(t *testing.T) {
func TestConcurrentLoginAttempts(t *testing.T) {
// Initialize a new auth service with 2 max retries and 5 seconds timeout
authService := auth.NewAuth(&docker.Docker{}, types.Users{}, []string{}, 3600, 5, 2)
config.LoginMaxRetries = 2
config.LoginTimeout = 5
authService := auth.NewAuth(config, &docker.Docker{})
// Test multiple identifiers
identifiers := []string{"user1", "user2", "user3"}
@@ -106,8 +131,10 @@ func TestConcurrentLoginAttempts(t *testing.T) {
// 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)

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"`
}

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

@@ -0,0 +1,84 @@
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
Domain string
CookieSecure bool
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
Secret string
CookieSecure bool
SessionExpiry int
Domain string
}
// AuthConfig is the configuration for the auth service
type AuthConfig struct {
Users Users
OauthWhitelist []string
SessionExpiry int
LoginTimeout int
LoginMaxRetries int
}

View File

@@ -1,17 +1,9 @@
package types
import "tinyauth/internal/oauth"
// 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"`
}
import (
"time"
"tinyauth/internal/oauth"
)
// User is the struct for a user
type User struct {
@@ -23,84 +15,6 @@ type User struct {
// Users is a list of users
type Users []User
// 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"`
}
// UserContext is the context for the user
type UserContext struct {
Username string
IsLoggedIn bool
OAuth bool
Provider string
TotpPending bool
}
// APIConfig is the configuration for the API
type APIConfig struct {
Port int
Address string
Secret string
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
@@ -108,12 +22,6 @@ type OAuthProviders struct {
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
@@ -129,48 +37,18 @@ type TinyauthLabels struct {
Headers map[string]string
}
// TailscaleQuery is the query parameters for the tailscale endpoint
type TailscaleQuery struct {
Code int `url:"code"`
// UserContext is the context for the user
type UserContext struct {
Username string
IsLoggedIn bool
OAuth bool
Provider string
TotpPending bool
}
// 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
// LoginAttempt tracks information about login attempts for rate limiting
type LoginAttempt struct {
FailedAttempts int
LastAttempt time.Time
LockedUntil time.Time
}