package auth import ( "context" "fmt" "net/http" "sync" "time" "github.com/google/uuid" "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 { 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) 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") } 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.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 { session, err = s.store.New(r, sessionCookieName) if err != nil { return err } } 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) 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 }