Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
486 lines
14 KiB
Go
486 lines
14 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"encoding/base32"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image/png"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
qrcode "github.com/skip2/go-qrcode"
|
|
|
|
"github.com/breakpilot/consent-service/internal/models"
|
|
)
|
|
|
|
var (
|
|
ErrTOTPNotEnabled = errors.New("2FA is not enabled for this user")
|
|
ErrTOTPAlreadyEnabled = errors.New("2FA is already enabled for this user")
|
|
ErrTOTPInvalidCode = errors.New("invalid 2FA code")
|
|
ErrTOTPChallengeExpired = errors.New("2FA challenge expired")
|
|
ErrRecoveryCodeInvalid = errors.New("invalid recovery code")
|
|
ErrRecoveryCodeUsed = errors.New("recovery code already used")
|
|
)
|
|
|
|
const (
|
|
TOTPPeriod = 30 // TOTP period in seconds
|
|
TOTPDigits = 6 // Number of digits in TOTP code
|
|
TOTPSecretLen = 20 // Length of TOTP secret in bytes
|
|
RecoveryCodeCount = 10 // Number of recovery codes to generate
|
|
RecoveryCodeLen = 8 // Length of each recovery code
|
|
ChallengeExpiry = 5 * time.Minute // 2FA challenge expiry
|
|
)
|
|
|
|
// TOTPService handles Two-Factor Authentication using TOTP
|
|
type TOTPService struct {
|
|
db *pgxpool.Pool
|
|
issuer string
|
|
}
|
|
|
|
// NewTOTPService creates a new TOTPService
|
|
func NewTOTPService(db *pgxpool.Pool, issuer string) *TOTPService {
|
|
return &TOTPService{
|
|
db: db,
|
|
issuer: issuer,
|
|
}
|
|
}
|
|
|
|
// GenerateSecret generates a new TOTP secret
|
|
func (s *TOTPService) GenerateSecret() (string, error) {
|
|
secret := make([]byte, TOTPSecretLen)
|
|
if _, err := rand.Read(secret); err != nil {
|
|
return "", fmt.Errorf("failed to generate secret: %w", err)
|
|
}
|
|
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret), nil
|
|
}
|
|
|
|
// GenerateRecoveryCodes generates a set of recovery codes
|
|
func (s *TOTPService) GenerateRecoveryCodes() ([]string, error) {
|
|
codes := make([]string, RecoveryCodeCount)
|
|
for i := 0; i < RecoveryCodeCount; i++ {
|
|
codeBytes := make([]byte, RecoveryCodeLen/2)
|
|
if _, err := rand.Read(codeBytes); err != nil {
|
|
return nil, fmt.Errorf("failed to generate recovery code: %w", err)
|
|
}
|
|
codes[i] = strings.ToUpper(hex.EncodeToString(codeBytes))
|
|
}
|
|
return codes, nil
|
|
}
|
|
|
|
// GenerateQRCode generates a QR code for TOTP setup
|
|
func (s *TOTPService) GenerateQRCode(secret, email string) (string, error) {
|
|
// Create otpauth URL
|
|
otpauthURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d",
|
|
s.issuer, email, secret, s.issuer, TOTPDigits, TOTPPeriod)
|
|
|
|
// Generate QR code
|
|
qr, err := qrcode.New(otpauthURL, qrcode.Medium)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate QR code: %w", err)
|
|
}
|
|
|
|
// Convert to PNG
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, qr.Image(256)); err != nil {
|
|
return "", fmt.Errorf("failed to encode QR code: %w", err)
|
|
}
|
|
|
|
// Convert to data URL
|
|
dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
|
|
return dataURL, nil
|
|
}
|
|
|
|
// GenerateTOTP generates the current TOTP code for a secret
|
|
func (s *TOTPService) GenerateTOTP(secret string) (string, error) {
|
|
return s.GenerateTOTPAt(secret, time.Now())
|
|
}
|
|
|
|
// GenerateTOTPAt generates a TOTP code for a specific time
|
|
func (s *TOTPService) GenerateTOTPAt(secret string, t time.Time) (string, error) {
|
|
// Decode secret
|
|
secretBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid secret: %w", err)
|
|
}
|
|
|
|
// Calculate counter
|
|
counter := uint64(t.Unix()) / TOTPPeriod
|
|
|
|
// Generate HOTP
|
|
buf := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(buf, counter)
|
|
|
|
mac := hmac.New(sha1.New, secretBytes)
|
|
mac.Write(buf)
|
|
hash := mac.Sum(nil)
|
|
|
|
// Dynamic truncation
|
|
offset := hash[len(hash)-1] & 0x0f
|
|
code := binary.BigEndian.Uint32(hash[offset:offset+4]) & 0x7fffffff
|
|
|
|
// Format code
|
|
codeStr := fmt.Sprintf("%0*d", TOTPDigits, code%1000000)
|
|
return codeStr, nil
|
|
}
|
|
|
|
// ValidateTOTP validates a TOTP code (allows 1 period drift)
|
|
func (s *TOTPService) ValidateTOTP(secret, code string) bool {
|
|
now := time.Now()
|
|
|
|
// Check current, previous, and next period
|
|
for _, offset := range []int{0, -1, 1} {
|
|
t := now.Add(time.Duration(offset*TOTPPeriod) * time.Second)
|
|
expected, err := s.GenerateTOTPAt(secret, t)
|
|
if err == nil && expected == code {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Setup2FA initiates 2FA setup for a user
|
|
func (s *TOTPService) Setup2FA(ctx context.Context, userID uuid.UUID, email string) (*models.Setup2FAResponse, error) {
|
|
// Check if 2FA is already enabled
|
|
var exists bool
|
|
err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM user_totp WHERE user_id = $1 AND verified = true)`, userID).Scan(&exists)
|
|
if err == nil && exists {
|
|
return nil, ErrTOTPAlreadyEnabled
|
|
}
|
|
|
|
// Generate secret
|
|
secret, err := s.GenerateSecret()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Generate recovery codes
|
|
recoveryCodes, err := s.GenerateRecoveryCodes()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Generate QR code
|
|
qrCode, err := s.GenerateQRCode(secret, email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Hash recovery codes for storage
|
|
hashedCodes := make([]string, len(recoveryCodes))
|
|
for i, code := range recoveryCodes {
|
|
hash := sha256.Sum256([]byte(code))
|
|
hashedCodes[i] = hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
recoveryCodesJSON, _ := json.Marshal(hashedCodes)
|
|
|
|
// Store or update TOTP record (unverified)
|
|
_, err = s.db.Exec(ctx, `
|
|
INSERT INTO user_totp (user_id, secret, verified, recovery_codes, created_at, updated_at)
|
|
VALUES ($1, $2, false, $3, NOW(), NOW())
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
secret = $2,
|
|
verified = false,
|
|
recovery_codes = $3,
|
|
updated_at = NOW()
|
|
`, userID, secret, recoveryCodesJSON)
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to store TOTP: %w", err)
|
|
}
|
|
|
|
return &models.Setup2FAResponse{
|
|
Secret: secret,
|
|
QRCodeDataURL: qrCode,
|
|
RecoveryCodes: recoveryCodes,
|
|
}, nil
|
|
}
|
|
|
|
// Verify2FASetup verifies the 2FA setup with a code
|
|
func (s *TOTPService) Verify2FASetup(ctx context.Context, userID uuid.UUID, code string) error {
|
|
// Get TOTP record
|
|
var secret string
|
|
var verified bool
|
|
err := s.db.QueryRow(ctx, `SELECT secret, verified FROM user_totp WHERE user_id = $1`, userID).Scan(&secret, &verified)
|
|
if err != nil {
|
|
return ErrTOTPNotEnabled
|
|
}
|
|
|
|
if verified {
|
|
return ErrTOTPAlreadyEnabled
|
|
}
|
|
|
|
// Validate code
|
|
if !s.ValidateTOTP(secret, code) {
|
|
return ErrTOTPInvalidCode
|
|
}
|
|
|
|
// Mark as verified and enable 2FA
|
|
_, err = s.db.Exec(ctx, `
|
|
UPDATE user_totp SET verified = true, enabled_at = NOW(), updated_at = NOW() WHERE user_id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to verify TOTP: %w", err)
|
|
}
|
|
|
|
// Update user record
|
|
_, err = s.db.Exec(ctx, `
|
|
UPDATE users SET two_factor_enabled = true, two_factor_verified_at = NOW(), updated_at = NOW() WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update user: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateChallenge creates a 2FA challenge for login
|
|
func (s *TOTPService) CreateChallenge(ctx context.Context, userID uuid.UUID, ipAddress, userAgent string) (string, error) {
|
|
// Generate challenge ID
|
|
challengeBytes := make([]byte, 32)
|
|
if _, err := rand.Read(challengeBytes); err != nil {
|
|
return "", fmt.Errorf("failed to generate challenge: %w", err)
|
|
}
|
|
challengeID := base64.URLEncoding.EncodeToString(challengeBytes)
|
|
|
|
// Store challenge
|
|
_, err := s.db.Exec(ctx, `
|
|
INSERT INTO two_factor_challenges (user_id, challenge_id, ip_address, user_agent, expires_at, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
`, userID, challengeID, ipAddress, userAgent, time.Now().Add(ChallengeExpiry))
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create challenge: %w", err)
|
|
}
|
|
|
|
return challengeID, nil
|
|
}
|
|
|
|
// VerifyChallenge verifies a 2FA challenge with a TOTP code
|
|
func (s *TOTPService) VerifyChallenge(ctx context.Context, challengeID, code string) (*uuid.UUID, error) {
|
|
var challenge models.TwoFactorChallenge
|
|
err := s.db.QueryRow(ctx, `
|
|
SELECT id, user_id, expires_at, used_at FROM two_factor_challenges WHERE challenge_id = $1
|
|
`, challengeID).Scan(&challenge.ID, &challenge.UserID, &challenge.ExpiresAt, &challenge.UsedAt)
|
|
|
|
if err != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
|
|
if challenge.UsedAt != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
|
|
if time.Now().After(challenge.ExpiresAt) {
|
|
return nil, ErrTOTPChallengeExpired
|
|
}
|
|
|
|
// Get TOTP secret
|
|
var secret string
|
|
err = s.db.QueryRow(ctx, `SELECT secret FROM user_totp WHERE user_id = $1 AND verified = true`, challenge.UserID).Scan(&secret)
|
|
if err != nil {
|
|
return nil, ErrTOTPNotEnabled
|
|
}
|
|
|
|
// Validate TOTP code
|
|
if !s.ValidateTOTP(secret, code) {
|
|
return nil, ErrTOTPInvalidCode
|
|
}
|
|
|
|
// Mark challenge as used
|
|
_, err = s.db.Exec(ctx, `UPDATE two_factor_challenges SET used_at = NOW() WHERE id = $1`, challenge.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to mark challenge as used: %w", err)
|
|
}
|
|
|
|
// Update last used time
|
|
_, _ = s.db.Exec(ctx, `UPDATE user_totp SET last_used_at = NOW() WHERE user_id = $1`, challenge.UserID)
|
|
|
|
return &challenge.UserID, nil
|
|
}
|
|
|
|
// VerifyChallengeWithRecoveryCode verifies a 2FA challenge with a recovery code
|
|
func (s *TOTPService) VerifyChallengeWithRecoveryCode(ctx context.Context, challengeID, recoveryCode string) (*uuid.UUID, error) {
|
|
var challenge models.TwoFactorChallenge
|
|
err := s.db.QueryRow(ctx, `
|
|
SELECT id, user_id, expires_at, used_at FROM two_factor_challenges WHERE challenge_id = $1
|
|
`, challengeID).Scan(&challenge.ID, &challenge.UserID, &challenge.ExpiresAt, &challenge.UsedAt)
|
|
|
|
if err != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
|
|
if challenge.UsedAt != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
|
|
if time.Now().After(challenge.ExpiresAt) {
|
|
return nil, ErrTOTPChallengeExpired
|
|
}
|
|
|
|
// Get recovery codes
|
|
var recoveryCodesJSON []byte
|
|
err = s.db.QueryRow(ctx, `SELECT recovery_codes FROM user_totp WHERE user_id = $1 AND verified = true`, challenge.UserID).Scan(&recoveryCodesJSON)
|
|
if err != nil {
|
|
return nil, ErrTOTPNotEnabled
|
|
}
|
|
|
|
var hashedCodes []string
|
|
json.Unmarshal(recoveryCodesJSON, &hashedCodes)
|
|
|
|
// Hash the provided recovery code
|
|
codeHash := sha256.Sum256([]byte(strings.ToUpper(recoveryCode)))
|
|
codeHashStr := hex.EncodeToString(codeHash[:])
|
|
|
|
// Find and remove the recovery code
|
|
found := false
|
|
newCodes := make([]string, 0, len(hashedCodes)-1)
|
|
for _, hc := range hashedCodes {
|
|
if hc == codeHashStr && !found {
|
|
found = true
|
|
continue
|
|
}
|
|
newCodes = append(newCodes, hc)
|
|
}
|
|
|
|
if !found {
|
|
return nil, ErrRecoveryCodeInvalid
|
|
}
|
|
|
|
// Update recovery codes
|
|
newCodesJSON, _ := json.Marshal(newCodes)
|
|
_, err = s.db.Exec(ctx, `UPDATE user_totp SET recovery_codes = $1, updated_at = NOW() WHERE user_id = $2`, newCodesJSON, challenge.UserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to update recovery codes: %w", err)
|
|
}
|
|
|
|
// Mark challenge as used
|
|
_, err = s.db.Exec(ctx, `UPDATE two_factor_challenges SET used_at = NOW() WHERE id = $1`, challenge.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to mark challenge as used: %w", err)
|
|
}
|
|
|
|
return &challenge.UserID, nil
|
|
}
|
|
|
|
// Disable2FA disables 2FA for a user
|
|
func (s *TOTPService) Disable2FA(ctx context.Context, userID uuid.UUID, code string) error {
|
|
// Get TOTP secret
|
|
var secret string
|
|
err := s.db.QueryRow(ctx, `SELECT secret FROM user_totp WHERE user_id = $1 AND verified = true`, userID).Scan(&secret)
|
|
if err != nil {
|
|
return ErrTOTPNotEnabled
|
|
}
|
|
|
|
// Validate code
|
|
if !s.ValidateTOTP(secret, code) {
|
|
return ErrTOTPInvalidCode
|
|
}
|
|
|
|
// Delete TOTP record
|
|
_, err = s.db.Exec(ctx, `DELETE FROM user_totp WHERE user_id = $1`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete TOTP: %w", err)
|
|
}
|
|
|
|
// Update user record
|
|
_, err = s.db.Exec(ctx, `
|
|
UPDATE users SET two_factor_enabled = false, two_factor_verified_at = NULL, updated_at = NOW() WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update user: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetStatus returns the 2FA status for a user
|
|
func (s *TOTPService) GetStatus(ctx context.Context, userID uuid.UUID) (*models.TwoFactorStatusResponse, error) {
|
|
var totp models.UserTOTP
|
|
var recoveryCodesJSON []byte
|
|
|
|
err := s.db.QueryRow(ctx, `
|
|
SELECT id, verified, enabled_at, recovery_codes FROM user_totp WHERE user_id = $1
|
|
`, userID).Scan(&totp.ID, &totp.Verified, &totp.EnabledAt, &recoveryCodesJSON)
|
|
|
|
if err != nil {
|
|
// 2FA not set up
|
|
return &models.TwoFactorStatusResponse{
|
|
Enabled: false,
|
|
Verified: false,
|
|
RecoveryCodesCount: 0,
|
|
}, nil
|
|
}
|
|
|
|
var hashedCodes []string
|
|
json.Unmarshal(recoveryCodesJSON, &hashedCodes)
|
|
|
|
return &models.TwoFactorStatusResponse{
|
|
Enabled: totp.Verified,
|
|
Verified: totp.Verified,
|
|
EnabledAt: totp.EnabledAt,
|
|
RecoveryCodesCount: len(hashedCodes),
|
|
}, nil
|
|
}
|
|
|
|
// RegenerateRecoveryCodes generates new recovery codes (requires current TOTP code)
|
|
func (s *TOTPService) RegenerateRecoveryCodes(ctx context.Context, userID uuid.UUID, code string) ([]string, error) {
|
|
// Get TOTP secret
|
|
var secret string
|
|
err := s.db.QueryRow(ctx, `SELECT secret FROM user_totp WHERE user_id = $1 AND verified = true`, userID).Scan(&secret)
|
|
if err != nil {
|
|
return nil, ErrTOTPNotEnabled
|
|
}
|
|
|
|
// Validate code
|
|
if !s.ValidateTOTP(secret, code) {
|
|
return nil, ErrTOTPInvalidCode
|
|
}
|
|
|
|
// Generate new recovery codes
|
|
recoveryCodes, err := s.GenerateRecoveryCodes()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Hash recovery codes for storage
|
|
hashedCodes := make([]string, len(recoveryCodes))
|
|
for i, rc := range recoveryCodes {
|
|
hash := sha256.Sum256([]byte(rc))
|
|
hashedCodes[i] = hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
recoveryCodesJSON, _ := json.Marshal(hashedCodes)
|
|
|
|
// Update recovery codes
|
|
_, err = s.db.Exec(ctx, `UPDATE user_totp SET recovery_codes = $1, updated_at = NOW() WHERE user_id = $2`, recoveryCodesJSON, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to update recovery codes: %w", err)
|
|
}
|
|
|
|
return recoveryCodes, nil
|
|
}
|
|
|
|
// IsTwoFactorEnabled checks if 2FA is enabled for a user
|
|
func (s *TOTPService) IsTwoFactorEnabled(ctx context.Context, userID uuid.UUID) (bool, error) {
|
|
var enabled bool
|
|
err := s.db.QueryRow(ctx, `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return enabled, nil
|
|
}
|