package services import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "fmt" "strings" "time" "github.com/google/uuid" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" "veza-backend-api/internal/database" "veza-backend-api/internal/validators" ) const bcryptCost = 12 // PasswordService handles password operations type PasswordService struct { db *database.Database logger *zap.Logger passwordValidator *validators.PasswordValidator historyService *PasswordHistoryService expirationDays int // F016: 0 = disabled, >0 = password expires after N days } // PasswordResetToken represents a password reset token type PasswordResetToken struct { ID int64 `db:"id"` UserID uuid.UUID `db:"user_id"` Token string `db:"token"` ExpiresAt time.Time `db:"expires_at"` Used bool `db:"used"` CreatedAt time.Time `db:"created_at"` } // UserInfo represents a user from the database type UserInfo struct { ID uuid.UUID `db:"id"` Email string `db:"email"` Username string `db:"username"` } // NewPasswordService creates a new password service func NewPasswordService(db *database.Database, logger *zap.Logger) *PasswordService { return &PasswordService{ db: db, logger: logger, passwordValidator: validators.NewPasswordValidator(), historyService: NewPasswordHistoryService(db, logger), } } // NewPasswordServiceWithPolicy creates a password service with configurable policy. // F015 + F016: Configurable password policy + expiration. func NewPasswordServiceWithPolicy(db *database.Database, logger *zap.Logger, policy validators.PasswordPolicyConfig, expirationDays int) *PasswordService { return &PasswordService{ db: db, logger: logger, passwordValidator: validators.NewPasswordValidatorWithPolicy(policy), historyService: NewPasswordHistoryService(db, logger), expirationDays: expirationDays, } } // CheckPasswordExpiration checks if a user's password has expired. // F016: Returns an error if the password is expired. func (ps *PasswordService) CheckPasswordExpiration(ctx context.Context, userID uuid.UUID) error { if ps.expirationDays <= 0 { return nil // Expiration disabled } var passwordChangedAt *time.Time err := ps.db.QueryRowContext(ctx, ` SELECT password_changed_at FROM users WHERE id = $1 `, userID).Scan(&passwordChangedAt) if err != nil { // Non-blocking: column may not exist yet ps.logger.Debug("password expiration check skipped", zap.Error(err)) return nil } if passwordChangedAt == nil { // Never set — treat as expired to force user to set a strong password return fmt.Errorf("password_expired") } deadline := passwordChangedAt.AddDate(0, 0, ps.expirationDays) if time.Now().After(deadline) { return fmt.Errorf("password_expired") } return nil } // hashToken returns the hex-encoded SHA-256 hash of the token. // INF-10: Tokens are stored hashed in the DB; only the hash is persisted. func (ps *PasswordService) hashToken(token string) string { h := sha256.Sum256([]byte(token)) return hex.EncodeToString(h[:]) } // GetUserByEmail retrieves a user by email func (ps *PasswordService) GetUserByEmail(email string) (*UserInfo, error) { ctx := context.Background() var user UserInfo err := ps.db.QueryRowContext(ctx, ` SELECT id, email, username FROM users WHERE email = $1 `, email).Scan(&user.ID, &user.Email, &user.Username) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") } return nil, err } return &user, nil } // GeneratePasswordResetToken generates a secure password reset token func (ps *PasswordService) GeneratePasswordResetToken(userID uuid.UUID) (string, time.Time, error) { // Generate random token tokenBytes := make([]byte, 32) _, err := rand.Read(tokenBytes) if err != nil { return "", time.Time{}, err } token := base64.URLEncoding.EncodeToString(tokenBytes) // Set expiration (1 hour) expiresAt := time.Now().Add(1 * time.Hour) // INF-10: Store hash in DB, return plain token to user ctx := context.Background() tokenHash := ps.hashToken(token) _, err = ps.db.ExecContext(ctx, ` INSERT INTO password_reset_tokens (user_id, token, expires_at, used) VALUES ($1, $2, $3, FALSE) `, userID, tokenHash, expiresAt) if err != nil { return "", time.Time{}, err } ps.logger.Info("Password reset token generated", zap.String("user_id", userID.String()), ) return token, expiresAt, nil } // ResetPassword validates and processes password reset func (ps *PasswordService) ResetPassword(token, newPassword string) error { ctx := context.Background() // INF-10: Hash received token and look up by hash tokenHash := ps.hashToken(token) var resetToken PasswordResetToken err := ps.db.QueryRowContext(ctx, ` SELECT id, user_id, token, expires_at, used, created_at FROM password_reset_tokens WHERE token = $1 AND used = FALSE `, tokenHash).Scan( &resetToken.ID, &resetToken.UserID, &resetToken.Token, &resetToken.ExpiresAt, &resetToken.Used, &resetToken.CreatedAt, ) if err != nil { if err == sql.ErrNoRows { return fmt.Errorf("invalid or expired reset token") } return err } // Check if expired if time.Now().After(resetToken.ExpiresAt) { return fmt.Errorf("reset token has expired") } // Validate password strength // BE-SEC-006: Use comprehensive password validator strength, err := ps.passwordValidator.Validate(newPassword) if err != nil || !strength.Valid { if err != nil { return err } return fmt.Errorf("weak password: %s", strings.Join(strength.Details, ", ")) } // Hash new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } // Update user password + password_changed_at (F016) _, err = ps.db.ExecContext(ctx, ` UPDATE users SET password_hash = $1, updated_at = NOW(), password_changed_at = NOW() WHERE id = $2 `, string(hashedPassword), resetToken.UserID) if err != nil { return fmt.Errorf("failed to update password: %w", err) } // Mark token as used _, err = ps.db.ExecContext(ctx, ` UPDATE password_reset_tokens SET used = TRUE WHERE id = $1 `, resetToken.ID) if err != nil { ps.logger.Warn("Failed to mark reset token as used", zap.Error(err), zap.Int64("token_id", resetToken.ID), ) } ps.logger.Info("Password reset successful", zap.String("user_id", resetToken.UserID.String()), ) return nil } // ValidatePassword validates password strength // BE-SEC-006: Uses comprehensive password validator func (ps *PasswordService) ValidatePassword(password string) error { strength, err := ps.passwordValidator.Validate(password) if err != nil { return err } if !strength.Valid { return fmt.Errorf("weak password: %s", strings.Join(strength.Details, ", ")) } return nil } // ChangePassword changes user's password (for authenticated users) func (ps *PasswordService) ChangePassword(userID uuid.UUID, oldPassword, newPassword string) error { ctx := context.Background() // Get current password hash var currentHash string err := ps.db.QueryRowContext(ctx, ` SELECT password_hash FROM users WHERE id = $1 `, userID).Scan(¤tHash) if err != nil { return fmt.Errorf("user not found") } // Verify old password err = bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(oldPassword)) if err != nil { return fmt.Errorf("incorrect old password") } // Validate new password if err := ps.ValidatePassword(newPassword); err != nil { return err } // F014: Check password history — prevent reuse of last 5 passwords if ps.historyService != nil { if err := ps.historyService.CheckReuse(ctx, userID, newPassword); err != nil { return err } } // Hash new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } // F014: Record old password hash in history before updating if ps.historyService != nil { _ = ps.historyService.Record(ctx, userID, currentHash) } // Update password + password_changed_at (F016) _, err = ps.db.ExecContext(ctx, ` UPDATE users SET password_hash = $1, updated_at = NOW(), password_changed_at = NOW() WHERE id = $2 `, string(hashedPassword), userID) if err != nil { return fmt.Errorf("failed to update password: %w", err) } ps.logger.Info("Password changed successfully", zap.String("user_id", userID.String()), ) return nil } // UpdatePassword updates a user's password by user ID // T0194: Updates password with bcrypt hash func (ps *PasswordService) UpdatePassword(userID uuid.UUID, newPassword string) error { ctx := context.Background() // Validate password strength // BE-SEC-006: Use comprehensive password validator strength, err := ps.passwordValidator.Validate(newPassword) if err != nil || !strength.Valid { if err != nil { return err } return fmt.Errorf("weak password: %s", strings.Join(strength.Details, ", ")) } // Hash new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } // Update user password + password_changed_at (F016) _, err = ps.db.ExecContext(ctx, ` UPDATE users SET password_hash = $1, updated_at = NOW(), password_changed_at = NOW() WHERE id = $2 `, string(hashedPassword), userID) if err != nil { return fmt.Errorf("failed to update password: %w", err) } ps.logger.Info("Password updated successfully", zap.String("user_id", userID.String()), ) return nil } // Hash hashes a password using bcrypt with cost 12 // This is a standalone method for T0154 that can be used independently func (s *PasswordService) Hash(password string) (string, error) { // Bcrypt has a limit of 72 bytes. Reject longer passwords instead of truncating silently. if len(password) > 72 { return "", fmt.Errorf("password exceeds maximum length of 72 bytes") } bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { return "", err } return string(bytes), nil } // Compare compares a password with a hashed password // Returns true if the password matches the hash func (s *PasswordService) Compare(hashedPassword, password string) bool { err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) return err == nil }