Add account lockout after failed login attempts

Fixes #32

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthew Knight 2026-02-17 16:15:02 -08:00
parent fdcccce476
commit f258429557
No known key found for this signature in database
1 changed files with 40 additions and 3 deletions

View File

@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"sync"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/mattnite/forgejo-tickets/internal/email" "github.com/mattnite/forgejo-tickets/internal/email"
@ -12,10 +14,21 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const (
maxLoginAttempts = 5
lockoutDuration = 15 * time.Minute
)
type loginAttempt struct {
count int
lastFail time.Time
}
type Service struct { type Service struct {
db *gorm.DB db *gorm.DB
store *PGStore store *PGStore
email *email.Client email *email.Client
loginAttempts sync.Map
} }
func NewService(db *gorm.DB, store *PGStore, emailClient *email.Client) *Service { 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) { 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 var user models.User
if err := s.db.WithContext(ctx).Where("email = ?", emailAddr).First(&user).Error; err != nil { 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") 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 { if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
s.recordFailedAttempt(emailAddr)
return nil, fmt.Errorf("invalid email or password") 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") return nil, fmt.Errorf("your account is pending admin approval")
} }
// Clear failed attempts on successful login
s.loginAttempts.Delete(emailAddr)
return &user, nil 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 { func (s *Service) CreateSession(r *http.Request, w http.ResponseWriter, userID uuid.UUID) error {
session, err := s.store.Get(r, sessionCookieName) session, err := s.store.Get(r, sessionCookieName)
if err != nil { if err != nil {