feat: add brute force protection (#59)

* feat: add brute force protection

* fix: bind flags to env

---------

Co-authored-by: Stavros <steveiliop56@gmail.com>
This commit is contained in:
Alexander
2025-04-07 00:28:20 +09:00
committed by GitHub
parent 98abe514e1
commit 07ddd4f917
7 changed files with 237 additions and 3 deletions

View File

@@ -4,6 +4,7 @@ import (
"regexp"
"slices"
"strings"
"sync"
"time"
"tinyauth/internal/docker"
"tinyauth/internal/types"
@@ -14,20 +15,34 @@ import (
"golang.org/x/crypto/bcrypt"
)
func NewAuth(docker *docker.Docker, userList types.Users, oauthWhitelist []string, sessionExpiry int) *Auth {
func NewAuth(docker *docker.Docker, userList types.Users, oauthWhitelist []string, sessionExpiry int, loginTimeout int, loginMaxRetries int) *Auth {
return &Auth{
Docker: docker,
Users: userList,
OAuthWhitelist: oauthWhitelist,
SessionExpiry: sessionExpiry,
LoginTimeout: loginTimeout,
LoginMaxRetries: loginMaxRetries,
LoginAttempts: make(map[string]*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
}
func (auth *Auth) GetUser(username string) *types.User {
@@ -45,6 +60,70 @@ 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.LoginMaxRetries <= 0 || auth.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.LoginMaxRetries <= 0 || auth.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{}
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")
}
}
func (auth *Auth) EmailWhitelisted(emailSrc string) bool {
// If the whitelist is empty, allow all emails
if len(auth.OAuthWhitelist) == 0 {