package services import ( "context" "crypto/rand" "database/sql" "encoding/base64" "fmt" "strings" "time" "github.com/golang-jwt/jwt/v5" "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 } // 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(), } } // 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) // Store in database ctx := context.Background() _, err = ps.db.ExecContext(ctx, ` INSERT INTO password_reset_tokens (user_id, token, expires_at, used) VALUES ($1, $2, $3, FALSE) `, userID, token, 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() // Get token info 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 `, token).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 _, err = ps.db.ExecContext(ctx, ` UPDATE users SET password_hash = $1, updated_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 } // Hash new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } // Update password _, err = ps.db.ExecContext(ctx, ` UPDATE users SET password_hash = $1, updated_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 } // GenerateJWT generates a JWT token for the user (used internally) func (ps *PasswordService) GenerateJWT(userID uuid.UUID, secret []byte) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "user_id": userID.String(), // Convert UUID to string for JWT claims "exp": time.Now().Add(time.Hour * 24).Unix(), }) return token.SignedString(secret) } // 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 _, err = ps.db.ExecContext(ctx, ` UPDATE users SET password_hash = $1, updated_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. Truncate longer passwords to avoid errors. // This matches the behavior expected by tests and is a reasonable security practice. if len(password) > 72 { password = password[:72] } 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 }