package auth import ( "context" "fmt" "net/http" "sync" "time" "github.com/google/uuid" "github.com/gorilla/sessions" "github.com/mattnite/forgejo-tickets/internal/email" "github.com/mattnite/forgejo-tickets/internal/models" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) const ( maxLoginAttempts = 5 lockoutDuration = 15 * time.Minute ) type loginAttempt struct { mu sync.Mutex count int lastFail time.Time } type Service struct { db *gorm.DB store *PGStore email *email.Client loginAttempts sync.Map } func NewService(db *gorm.DB, store *PGStore, emailClient *email.Client) *Service { return &Service{ db: db, store: store, email: emailClient, } } func (s *Service) Register(ctx context.Context, emailAddr, password, name string) (*models.User, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("hash password: %w", err) } hashStr := string(hash) user := models.User{ Email: emailAddr, PasswordHash: &hashStr, Name: name, EmailVerified: false, } if err := s.db.WithContext(ctx).Create(&user).Error; err != nil { return nil, fmt.Errorf("create user: %w", err) } return &user, nil } 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) attempt.mu.Lock() locked := attempt.count >= maxLoginAttempts && time.Since(attempt.lastFail) < lockoutDuration expired := time.Since(attempt.lastFail) >= lockoutDuration attempt.mu.Unlock() if locked { return nil, fmt.Errorf("account temporarily locked, try again later") } if expired { 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") } if user.PasswordHash == nil { return nil, fmt.Errorf("this account uses social login") } if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil { s.recordFailedAttempt(emailAddr) return nil, fmt.Errorf("invalid email or password") } if !user.EmailVerified { return nil, fmt.Errorf("please verify your email before logging in") } if !user.Approved { 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.mu.Lock() attempt.count++ attempt.lastFail = time.Now() attempt.mu.Unlock() } func (s *Service) CreateSession(r *http.Request, w http.ResponseWriter, userID uuid.UUID) error { // Always destroy any existing session first to prevent session fixation. s.DestroySession(r, w) // Create a brand new session with a fresh ID (bypass cookie reuse). session := sessions.NewSession(s.store, sessionCookieName) session.Options = s.store.Options() session.IsNew = true session.Values["user_id"] = userID.String() return s.store.Save(r, w, session) } func (s *Service) DestroySession(r *http.Request, w http.ResponseWriter) error { session, err := s.store.Get(r, sessionCookieName) if err != nil { return nil } session.Options.MaxAge = -1 return s.store.Save(r, w, session) } func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error { var user models.User if err := s.db.WithContext(ctx).First(&user, "id = ?", userID).Error; err != nil { return fmt.Errorf("user not found") } if user.PasswordHash == nil { return fmt.Errorf("this account uses social login and has no password to change") } if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(currentPassword)); err != nil { return fmt.Errorf("current password is incorrect") } hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("hash password: %w", err) } hashStr := string(hash) return s.db.WithContext(ctx).Model(&user).Update("password_hash", hashStr).Error } func (s *Service) CreateUserWithPassword(ctx context.Context, emailAddr, password, name string, verified bool, approved bool) (*models.User, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("hash password: %w", err) } hashStr := string(hash) user := models.User{ Email: emailAddr, PasswordHash: &hashStr, Name: name, EmailVerified: verified, Approved: approved, } if err := s.db.WithContext(ctx).Create(&user).Error; err != nil { return nil, fmt.Errorf("create user: %w", err) } return &user, nil }