veza/veza-backend-api/internal/services/password_history_service.go

82 lines
2.3 KiB
Go
Raw Normal View History

package services
import (
"context"
"fmt"
"veza-backend-api/internal/database"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
const passwordHistoryLimit = 5
// PasswordHistoryService manages password history to prevent reuse.
// F014: ORIGIN_FEATURES_REGISTRY.md — stores last 5 password hashes.
type PasswordHistoryService struct {
db *database.Database
logger *zap.Logger
}
// NewPasswordHistoryService creates a new password history service.
func NewPasswordHistoryService(db *database.Database, logger *zap.Logger) *PasswordHistoryService {
return &PasswordHistoryService{db: db, logger: logger}
}
// CheckReuse compares newPassword against the last 5 stored hashes.
// Returns an error if the password was previously used.
func (s *PasswordHistoryService) CheckReuse(ctx context.Context, userID uuid.UUID, newPassword string) error {
rows, err := s.db.QueryContext(ctx, `
SELECT password_hash FROM password_history
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2
`, userID, passwordHistoryLimit)
if err != nil {
// Table may not exist yet — not a blocking error
s.logger.Debug("password_history query failed (table may not exist)", zap.Error(err))
return nil
}
defer rows.Close()
for rows.Next() {
var hash string
if err := rows.Scan(&hash); err != nil {
continue
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(newPassword)) == nil {
return fmt.Errorf("password was recently used — choose a different password")
}
}
return nil
}
// Record stores the current password hash in history after a password change.
// Automatically prunes entries beyond the limit.
func (s *PasswordHistoryService) Record(ctx context.Context, userID uuid.UUID, passwordHash string) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO password_history (id, user_id, password_hash, created_at)
VALUES ($1, $2, $3, NOW())
`, uuid.New(), userID, passwordHash)
if err != nil {
s.logger.Warn("failed to record password history (table may not exist)", zap.Error(err))
return nil // non-blocking
}
// Prune old entries beyond limit
_, _ = s.db.ExecContext(ctx, `
DELETE FROM password_history
WHERE user_id = $1
AND id NOT IN (
SELECT id FROM password_history
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2
)
`, userID, passwordHistoryLimit)
return nil
}