- OAuth: use JWTService+SessionService, httpOnly cookies (VEZA-SEC-001) - Remove PasswordService.GenerateJWT (VEZA-SEC-002) - Hyperswitch webhook: mandatory verification, 500 if secret empty (VEZA-SEC-005) - Auth middleware: TokenBlacklist.IsBlacklisted check (VEZA-SEC-006) - Waveform: ValidateExecPath before exec (VEZA-SEC-007)
315 lines
8.2 KiB
Go
315 lines
8.2 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
|
|
}
|
|
|
|
// 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(),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
_, 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
|
|
}
|
|
|
|
// 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. 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
|
|
}
|