From 5cf4e208c67b3e8bd8162cbf9f9a3efd0140677f Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 6 Apr 2025 18:55:24 +0300 Subject: [PATCH] refactor: use centralized config in auth service --- cmd/root.go | 11 ++- internal/api/api_test.go | 14 +++- internal/auth/auth.go | 73 +++++++----------- internal/auth/auth_test.go | 57 ++++++++++---- internal/types/api.go | 59 ++++++++++++++ internal/types/config.go | 84 ++++++++++++++++++++ internal/types/types.go | 154 ++++--------------------------------- 7 files changed, 252 insertions(+), 200 deletions(-) create mode 100644 internal/types/api.go create mode 100644 internal/types/config.go diff --git a/cmd/root.go b/cmd/root.go index ee75c77..db35e82 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index bebc909..4358b4a 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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{}) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a904d42..33a3a39 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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{ - Docker: docker, - Users: userList, - OAuthWhitelist: oauthWhitelist, - SessionExpiry: sessionExpiry, - LoginTimeout: loginTimeout, - LoginMaxRetries: loginMaxRetries, - LoginAttempts: make(map[string]*LoginAttempt), + Config: config, + Docker: docker, + 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 - 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 + Config types.AuthConfig + Docker *docker.Docker + 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 } @@ -64,25 +49,25 @@ func (auth *Auth) CheckPassword(user types.User, password string) bool { 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.LoginMaxRetries <= 0 || auth.LoginTimeout <= 0 { + 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 } @@ -90,48 +75,48 @@ 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 } - + auth.LoginMutex.Lock() defer auth.LoginMutex.Unlock() - + // 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 } - + // 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.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) { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index ae00bd2..08e6bc9 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -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,24 +101,26 @@ 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"} // 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]) @@ -106,11 +131,13 @@ 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) - + // Now try a failed login again - should not be locked as counter was reset authService.RecordLoginAttempt(identifiers[1], false) locked, _ = authService.IsAccountLocked(identifiers[1]) diff --git a/internal/types/api.go b/internal/types/api.go new file mode 100644 index 0000000..0e92488 --- /dev/null +++ b/internal/types/api.go @@ -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"` +} diff --git a/internal/types/config.go b/internal/types/config.go new file mode 100644 index 0000000..dce9657 --- /dev/null +++ b/internal/types/config.go @@ -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 +} diff --git a/internal/types/types.go b/internal/types/types.go index 80d41e0..19d877d 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -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 }