veza/veza-backend-api/internal/services/totp_service.go
2025-12-03 20:29:37 +01:00

456 lines
11 KiB
Go

package services
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base32"
"fmt"
"time"
"veza-backend-api/internal/database"
"github.com/google/uuid"
"github.com/pquerna/otp/totp"
"go.uber.org/zap"
)
// TOTPService gère l'authentification à deux facteurs
type TOTPService struct {
db *database.Database
logger *zap.Logger
}
// TOTPSecret représente un secret TOTP pour un utilisateur
type TOTPSecret struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Secret string `json:"-" db:"secret"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
Enabled bool `json:"enabled" db:"enabled"`
}
// TOTPSetupResponse réponse pour la configuration 2FA
type TOTPSetupResponse struct {
Secret string `json:"secret"`
QRCodeURL string `json:"qr_code_url"`
BackupCodes []string `json:"backup_codes"`
}
// TOTPVerificationRequest requête de vérification 2FA
type TOTPVerificationRequest struct {
UserID uuid.UUID `json:"user_id"`
Code string `json:"code"`
BackupCode string `json:"backup_code,omitempty"`
}
// BackupCode représente un code de sauvegarde
type BackupCode struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Code string `json:"code" db:"code"`
Used bool `json:"used" db:"used"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UsedAt *time.Time `json:"used_at" db:"used_at"`
}
// NewTOTPService crée un nouveau service TOTP
func NewTOTPService(db *database.Database, logger *zap.Logger) *TOTPService {
return &TOTPService{
db: db,
logger: logger,
}
}
// SetupTOTP configure le 2FA pour un utilisateur
func (ts *TOTPService) SetupTOTP(ctx context.Context, userID uuid.UUID, email string) (*TOTPSetupResponse, error) {
// Vérifier si l'utilisateur a déjà un secret TOTP
var existingSecret TOTPSecret
err := ts.db.QueryRowContext(ctx, `
SELECT id, user_id, secret, created_at, enabled
FROM totp_secrets
WHERE user_id = $1
`, userID).Scan(
&existingSecret.ID,
&existingSecret.UserID,
&existingSecret.Secret,
&existingSecret.CreatedAt,
&existingSecret.Enabled,
)
if err != nil && err != sql.ErrNoRows {
ts.logger.Error("Failed to check existing TOTP secret",
zap.Error(err),
zap.String("user_id", userID.String()),
)
return nil, fmt.Errorf("failed to check existing TOTP secret: %w", err)
}
var secret string
var secretID uuid.UUID
if err == sql.ErrNoRows {
// Créer un nouveau secret
secret = ts.generateSecret()
secretID = uuid.New()
_, err = ts.db.ExecContext(ctx, `
INSERT INTO totp_secrets (id, user_id, secret, created_at, enabled)
VALUES ($1, $2, $3, $4, $5)
`, secretID, userID, secret, time.Now(), false)
if err != nil {
ts.logger.Error("Failed to create TOTP secret",
zap.Error(err),
zap.String("user_id", userID.String()),
)
return nil, fmt.Errorf("failed to create TOTP secret: %w", err)
}
} else {
// Utiliser le secret existant
secret = existingSecret.Secret
secretID = existingSecret.ID
}
// Générer les codes de sauvegarde
backupCodes, err := ts.generateBackupCodes(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to generate backup codes: %w", err)
}
// Générer l'URL QR Code
issuer := "Veza Platform"
accountName := email
qrCodeURL := ts.generateQRCodeURL(issuer, accountName, secret)
ts.logger.Info("TOTP setup initiated",
zap.String("user_id", userID.String()),
zap.String("secret_id", secretID.String()),
)
return &TOTPSetupResponse{
Secret: secret,
QRCodeURL: qrCodeURL,
BackupCodes: backupCodes,
}, nil
}
// VerifyTOTP vérifie un code TOTP
func (ts *TOTPService) VerifyTOTP(ctx context.Context, req *TOTPVerificationRequest) (bool, error) {
// Récupérer le secret TOTP de l'utilisateur
var secret string
var enabled bool
err := ts.db.QueryRowContext(ctx, `
SELECT secret, enabled
FROM totp_secrets
WHERE user_id = $1
`, req.UserID).Scan(&secret, &enabled)
if err != nil {
if err == sql.ErrNoRows {
return false, fmt.Errorf("TOTP not configured for user")
}
ts.logger.Error("Failed to get TOTP secret",
zap.Error(err),
zap.String("user_id", req.UserID.String()),
)
return false, fmt.Errorf("failed to get TOTP secret: %w", err)
}
// Vérifier le code TOTP
valid := totp.Validate(req.Code, secret)
if valid {
ts.logger.Info("TOTP verification successful",
zap.String("user_id", req.UserID.String()),
)
return true, nil
}
// Si le code TOTP n'est pas valide, vérifier les codes de sauvegarde
if req.BackupCode != "" {
valid, err := ts.verifyBackupCode(ctx, req.UserID, req.BackupCode)
if err != nil {
return false, fmt.Errorf("failed to verify backup code: %w", err)
}
if valid {
ts.logger.Info("Backup code verification successful",
zap.String("user_id", req.UserID.String()),
)
return true, nil
}
}
ts.logger.Warn("TOTP verification failed",
zap.String("user_id", req.UserID.String()),
)
return false, nil
}
// EnableTOTP active le 2FA pour un utilisateur
func (ts *TOTPService) EnableTOTP(ctx context.Context, userID uuid.UUID, code string) error {
// Vérifier le code avant d'activer
valid, err := ts.VerifyTOTP(ctx, &TOTPVerificationRequest{
UserID: userID,
Code: code,
})
if err != nil {
return fmt.Errorf("failed to verify TOTP code: %w", err)
}
if !valid {
return fmt.Errorf("invalid TOTP code")
}
// Activer le 2FA
_, err = ts.db.ExecContext(ctx, `
UPDATE totp_secrets
SET enabled = true
WHERE user_id = $1
`, userID)
if err != nil {
ts.logger.Error("Failed to enable TOTP",
zap.Error(err),
zap.String("user_id", userID.String()),
)
return fmt.Errorf("failed to enable TOTP: %w", err)
}
ts.logger.Info("TOTP enabled",
zap.String("user_id", userID.String()),
)
return nil
}
// DisableTOTP désactive le 2FA pour un utilisateur
func (ts *TOTPService) DisableTOTP(ctx context.Context, userID uuid.UUID, code string) error {
// Vérifier le code avant de désactiver
valid, err := ts.VerifyTOTP(ctx, &TOTPVerificationRequest{
UserID: userID,
Code: code,
})
if err != nil {
return fmt.Errorf("failed to verify TOTP code: %w", err)
}
if !valid {
return fmt.Errorf("invalid TOTP code")
}
// Désactiver le 2FA
_, err = ts.db.ExecContext(ctx, `
UPDATE totp_secrets
SET enabled = false
WHERE user_id = $1
`, userID)
if err != nil {
ts.logger.Error("Failed to disable TOTP",
zap.Error(err),
zap.String("user_id", userID.String()),
)
return fmt.Errorf("failed to disable TOTP: %w", err)
}
// Supprimer les codes de sauvegarde
_, err = ts.db.ExecContext(ctx, `
DELETE FROM backup_codes
WHERE user_id = $1
`, userID)
if err != nil {
ts.logger.Warn("Failed to delete backup codes",
zap.Error(err),
zap.String("user_id", userID.String()),
)
}
ts.logger.Info("TOTP disabled",
zap.String("user_id", userID.String()),
)
return nil
}
// IsTOTPEnabled vérifie si le 2FA est activé pour un utilisateur
func (ts *TOTPService) IsTOTPEnabled(ctx context.Context, userID uuid.UUID) (bool, error) {
var enabled bool
err := ts.db.QueryRowContext(ctx, `
SELECT enabled
FROM totp_secrets
WHERE user_id = $1
`, userID).Scan(&enabled)
if err != nil {
if err == sql.ErrNoRows {
return false, nil
}
ts.logger.Error("Failed to check TOTP status",
zap.Error(err),
zap.String("user_id", userID.String()),
)
return false, fmt.Errorf("failed to check TOTP status: %w", err)
}
return enabled, nil
}
// generateSecret génère un secret TOTP
func (ts *TOTPService) generateSecret() string {
// Générer 20 bytes aléatoires
secret := make([]byte, 20)
rand.Read(secret)
// Encoder en base32
return base32.StdEncoding.EncodeToString(secret)
}
// generateQRCodeURL génère l'URL du QR Code
func (ts *TOTPService) generateQRCodeURL(issuer, accountName, secret string) string {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: accountName,
Secret: []byte(secret),
})
if err != nil {
ts.logger.Error("Failed to generate TOTP key",
zap.Error(err),
)
return ""
}
return key.URL()
}
// generateBackupCodes génère des codes de sauvegarde
func (ts *TOTPService) generateBackupCodes(ctx context.Context, userID uuid.UUID) ([]string, error) {
// Supprimer les anciens codes
_, err := ts.db.ExecContext(ctx, `
DELETE FROM backup_codes
WHERE user_id = $1
`, userID)
if err != nil {
return nil, fmt.Errorf("failed to delete old backup codes: %w", err)
}
// Générer 10 nouveaux codes
codes := make([]string, 10)
for i := 0; i < 10; i++ {
code := ts.generateBackupCode()
codes[i] = code
// Insérer en base
_, err = ts.db.ExecContext(ctx, `
INSERT INTO backup_codes (id, user_id, code, created_at, used)
VALUES ($1, $2, $3, $4, $5)
`, uuid.New(), userID, code, time.Now(), false)
if err != nil {
ts.logger.Error("Failed to insert backup code",
zap.Error(err),
zap.String("user_id", userID.String()),
zap.Int("code_index", i),
)
return nil, fmt.Errorf("failed to insert backup code: %w", err)
}
}
return codes, nil
}
// generateBackupCode génère un code de sauvegarde
func (ts *TOTPService) generateBackupCode() string {
// Générer 8 bytes aléatoires
code := make([]byte, 8)
rand.Read(code)
// Encoder en base32 et prendre les 8 premiers caractères
encoded := base32.StdEncoding.EncodeToString(code)
return encoded[:8]
}
// verifyBackupCode vérifie un code de sauvegarde
func (ts *TOTPService) verifyBackupCode(ctx context.Context, userID uuid.UUID, code string) (bool, error) {
var backupCode BackupCode
err := ts.db.QueryRowContext(ctx, `
SELECT id, user_id, code, used, created_at, used_at
FROM backup_codes
WHERE user_id = $1 AND code = $2 AND used = false
`, userID, code).Scan(
&backupCode.ID,
&backupCode.UserID,
&backupCode.Code,
&backupCode.Used,
&backupCode.CreatedAt,
&backupCode.UsedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return false, nil
}
ts.logger.Error("Failed to verify backup code",
zap.Error(err),
zap.String("user_id", userID.String()),
)
return false, fmt.Errorf("failed to verify backup code: %w", err)
}
// Marquer le code comme utilisé
_, err = ts.db.ExecContext(ctx, `
UPDATE backup_codes
SET used = true, used_at = NOW()
WHERE id = $1
`, backupCode.ID)
if err != nil {
ts.logger.Error("Failed to mark backup code as used",
zap.Error(err),
zap.String("backup_code_id", backupCode.ID.String()),
)
return false, fmt.Errorf("failed to mark backup code as used: %w", err)
}
ts.logger.Info("Backup code used",
zap.String("user_id", userID.String()),
zap.String("backup_code_id", backupCode.ID.String()),
)
return true, nil
}
// GetBackupCodes récupère les codes de sauvegarde d'un utilisateur
func (ts *TOTPService) GetBackupCodes(ctx context.Context, userID uuid.UUID) ([]string, error) {
rows, err := ts.db.QueryContext(ctx, `
SELECT code
FROM backup_codes
WHERE user_id = $1 AND used = false
ORDER BY created_at ASC
`, userID)
if err != nil {
ts.logger.Error("Failed to get backup codes",
zap.Error(err),
zap.String("user_id", userID.String()),
)
return nil, fmt.Errorf("failed to get backup codes: %w", err)
}
defer rows.Close()
var codes []string
for rows.Next() {
var code string
err := rows.Scan(&code)
if err != nil {
ts.logger.Error("Failed to scan backup code",
zap.Error(err),
)
continue
}
codes = append(codes, code)
}
return codes, nil
}