veza/veza-backend-api/internal/services/two_factor_service.go
senke f6ab2c6eeb [BE-API-002] api: Implement playlist collaborators endpoints
- Added routes in router.go: POST, GET, PUT, DELETE /playlists/:id/collaborators
- Applied RequireOwnershipOrAdmin middleware to POST, PUT, DELETE routes
- GET route accessible to collaborators (service layer checks permissions)
- Fixed UpdateCollaboratorPermission handler to use RespondWithAppError
- All handlers already existed in playlist_handler.go
- All endpoints properly authenticated and ownership checks enforced

Phase: PHASE-1
Priority: P0
Progress: 5/267 (1.9%)
2025-12-23 01:41:43 +01:00

238 lines
6.8 KiB
Go

package services
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base32"
"fmt"
mathrand "math/rand"
"github.com/google/uuid"
"veza-backend-api/internal/database"
"veza-backend-api/internal/models"
"github.com/pquerna/otp/totp"
"go.uber.org/zap"
)
// TwoFactorService handles 2FA operations
type TwoFactorService struct {
db *database.Database
logger *zap.Logger
}
// NewTwoFactorService creates a new 2FA service
func NewTwoFactorService(db *database.Database, logger *zap.Logger) *TwoFactorService {
return &TwoFactorService{
db: db,
logger: logger,
}
}
// TwoFactorSetup represents 2FA setup information
type TwoFactorSetup struct {
Secret string `json:"secret"`
QRCodeURL string `json:"qr_code_url"`
RecoveryCodes []string `json:"recovery_codes"`
}
// TwoFactorVerification represents 2FA verification
type TwoFactorVerification struct {
Code string `json:"code" binding:"required"`
RecoveryCode string `json:"recovery_code,omitempty"`
}
// GenerateSecret generates a new TOTP secret
func (s *TwoFactorService) GenerateSecret(user *models.User) (*TwoFactorSetup, error) {
// Generate a random secret
secret := make([]byte, 20)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("failed to generate secret: %w", err)
}
// Encode as base32
secretBase32 := base32.StdEncoding.EncodeToString(secret)
// Generate QR code URL
qrCodeURL := fmt.Sprintf("otpauth://totp/Veza:%s?secret=%s&issuer=Veza&algorithm=SHA1&digits=6&period=30",
user.Email, secretBase32)
// Generate recovery codes
recoveryCodes := s.generateRecoveryCodes()
setup := &TwoFactorSetup{
Secret: secretBase32,
QRCodeURL: qrCodeURL,
RecoveryCodes: recoveryCodes,
}
return setup, nil
}
// EnableTwoFactor enables 2FA for a user
func (s *TwoFactorService) EnableTwoFactor(ctx context.Context, userID uuid.UUID, secret string, recoveryCodes []string) error {
// Hash the recovery codes before storing
hashedCodes := make([]string, len(recoveryCodes))
for i, code := range recoveryCodes {
hashedCodes[i] = s.hashRecoveryCode(code)
}
// Update user with 2FA settings
query := `
UPDATE users
SET two_factor_enabled = true,
two_factor_secret = $1,
backup_codes = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $3
`
_, err := s.db.ExecContext(ctx, query, secret, hashedCodes, userID)
if err != nil {
s.logger.Error("Failed to enable 2FA", zap.Error(err), zap.String("user_id", userID.String()))
return fmt.Errorf("failed to enable 2FA: %w", err)
}
s.logger.Info("2FA enabled successfully", zap.String("user_id", userID.String()))
return nil
}
// DisableTwoFactor disables 2FA for a user
func (s *TwoFactorService) DisableTwoFactor(ctx context.Context, userID uuid.UUID) error {
query := `
UPDATE users
SET two_factor_enabled = false,
two_factor_secret = '',
backup_codes = '{}',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
_, err := s.db.ExecContext(ctx, query, userID)
if err != nil {
s.logger.Error("Failed to disable 2FA", zap.Error(err), zap.String("user_id", userID.String()))
return fmt.Errorf("failed to disable 2FA: %w", err)
}
s.logger.Info("2FA disabled successfully", zap.String("user_id", userID.String()))
return nil
}
// VerifyTwoFactor verifies a 2FA code
func (s *TwoFactorService) VerifyTwoFactor(ctx context.Context, userID uuid.UUID, code string) (bool, error) {
// Get user's 2FA secret
var secret string
var recoveryCodes []string
query := `SELECT two_factor_secret, backup_codes FROM users WHERE id = $1 AND two_factor_enabled = true`
err := s.db.QueryRowContext(ctx, query, userID).Scan(&secret, &recoveryCodes)
if err != nil {
if err == sql.ErrNoRows {
return false, fmt.Errorf("2FA not enabled for user")
}
return false, fmt.Errorf("failed to get 2FA secret: %w", err)
}
// Check if it's a recovery code
if s.isRecoveryCode(code, recoveryCodes) {
// Remove the used recovery code
s.removeRecoveryCode(ctx, userID, code)
return true, nil
}
// Verify TOTP code
valid := totp.Validate(code, secret)
if !valid {
s.logger.Warn("Invalid 2FA code", zap.String("user_id", userID.String()))
return false, nil
}
return true, nil
}
// VerifyTOTPCode verifies a TOTP code against a secret
// BE-API-001: Helper method for 2FA verification
func (s *TwoFactorService) VerifyTOTPCode(secret, code string) bool {
return totp.Validate(code, secret)
}
// GetTwoFactorStatus gets the 2FA status for a user
func (s *TwoFactorService) GetTwoFactorStatus(ctx context.Context, userID uuid.UUID) (bool, error) {
var enabled bool
query := `SELECT two_factor_enabled FROM users WHERE id = $1`
err := s.db.QueryRowContext(ctx, query, userID).Scan(&enabled)
if err != nil {
return false, fmt.Errorf("failed to get 2FA status: %w", err)
}
return enabled, nil
}
// GenerateRecoveryCodes generates 8 recovery codes (public method)
// BE-API-001: Public method for generating recovery codes
func (s *TwoFactorService) GenerateRecoveryCodes() []string {
return s.generateRecoveryCodes()
}
// generateRecoveryCodes generates 8 recovery codes (internal)
func (s *TwoFactorService) generateRecoveryCodes() []string {
codes := make([]string, 8)
for i := 0; i < 8; i++ {
// Generate 8-character alphanumeric code
code := make([]byte, 8)
for j := 0; j < 8; j++ {
code[j] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"[mathrand.Intn(36)]
}
codes[i] = string(code)
}
return codes
}
// hashRecoveryCode hashes a recovery code for storage
func (s *TwoFactorService) hashRecoveryCode(code string) string {
// In production, use proper hashing (bcrypt, argon2, etc.)
// For now, using a simple hash for demonstration
return fmt.Sprintf("hashed_%s", code)
}
// isRecoveryCode checks if a code is a valid recovery code
func (s *TwoFactorService) isRecoveryCode(code string, storedCodes []string) bool {
for _, storedCode := range storedCodes {
if s.hashRecoveryCode(code) == storedCode {
return true
}
}
return false
}
// removeRecoveryCode removes a used recovery code
func (s *TwoFactorService) removeRecoveryCode(ctx context.Context, userID uuid.UUID, usedCode string) {
// Get current recovery codes
var recoveryCodes []string
query := `SELECT backup_codes FROM users WHERE id = $1`
err := s.db.QueryRowContext(ctx, query, userID).Scan(&recoveryCodes)
if err != nil {
s.logger.Error("Failed to get recovery codes", zap.Error(err))
return
}
// Remove the used code
newCodes := make([]string, 0)
hashedUsedCode := s.hashRecoveryCode(usedCode)
for _, code := range recoveryCodes {
if code != hashedUsedCode {
newCodes = append(newCodes, code)
}
}
// Update the user
updateQuery := `UPDATE users SET backup_codes = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`
_, err = s.db.ExecContext(ctx, updateQuery, newCodes, userID)
if err != nil {
s.logger.Error("Failed to remove recovery code", zap.Error(err))
}
}