veza/veza-backend-api/internal/services/password_service.go
senke a1000ce7fb style(backend): gofmt -w on 85 files (whitespace only)
backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.

The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
2026-04-14 12:22:14 +02:00

372 lines
10 KiB
Go

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(&currentHash)
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
}