456 lines
11 KiB
Go
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
|
|
}
|