diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9d27c2f..c2bced6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net/http" + "sync" + "time" "github.com/google/uuid" "github.com/mattnite/forgejo-tickets/internal/email" @@ -12,10 +14,21 @@ import ( "gorm.io/gorm" ) +const ( + maxLoginAttempts = 5 + lockoutDuration = 15 * time.Minute +) + +type loginAttempt struct { + count int + lastFail time.Time +} + type Service struct { - db *gorm.DB - store *PGStore - email *email.Client + db *gorm.DB + store *PGStore + email *email.Client + loginAttempts sync.Map } func NewService(db *gorm.DB, store *PGStore, emailClient *email.Client) *Service { @@ -48,8 +61,21 @@ func (s *Service) Register(ctx context.Context, emailAddr, password, name string } func (s *Service) Login(ctx context.Context, emailAddr, password string) (*models.User, error) { + // Check if the account is locked due to too many failed attempts + if val, ok := s.loginAttempts.Load(emailAddr); ok { + attempt := val.(*loginAttempt) + if attempt.count >= maxLoginAttempts && time.Since(attempt.lastFail) < lockoutDuration { + return nil, fmt.Errorf("account temporarily locked, try again later") + } + // Reset if the lockout window has expired + if time.Since(attempt.lastFail) >= lockoutDuration { + s.loginAttempts.Delete(emailAddr) + } + } + var user models.User if err := s.db.WithContext(ctx).Where("email = ?", emailAddr).First(&user).Error; err != nil { + s.recordFailedAttempt(emailAddr) return nil, fmt.Errorf("invalid email or password") } @@ -58,6 +84,7 @@ func (s *Service) Login(ctx context.Context, emailAddr, password string) (*model } if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil { + s.recordFailedAttempt(emailAddr) return nil, fmt.Errorf("invalid email or password") } @@ -69,9 +96,19 @@ func (s *Service) Login(ctx context.Context, emailAddr, password string) (*model return nil, fmt.Errorf("your account is pending admin approval") } + // Clear failed attempts on successful login + s.loginAttempts.Delete(emailAddr) + return &user, nil } +func (s *Service) recordFailedAttempt(emailAddr string) { + val, _ := s.loginAttempts.LoadOrStore(emailAddr, &loginAttempt{}) + attempt := val.(*loginAttempt) + attempt.count++ + attempt.lastFail = time.Now() +} + func (s *Service) CreateSession(r *http.Request, w http.ResponseWriter, userID uuid.UUID) error { session, err := s.store.Get(r, sessionCookieName) if err != nil {