Files
gochat/internal/auth/mfa.go
T
2026-06-04 15:44:48 +08:00

239 lines
6.8 KiB
Go

package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"math"
"strings"
"time"
"github.com/gochat/gochat/internal/model"
"gorm.io/gorm"
)
// Reference: P2E §1.5 — MFA (TOTP) support
// Implements time-based one-time password (TOTP) per RFC 6238.
// Corresponds to Chatwoot enterprise TwoFactorAuthController pattern.
// MFAService manages multi-factor authentication using TOTP.
type MFAService struct {
db *gorm.DB
}
// NewMFAService creates a MFA service backed by GORM.
func NewMFAService(db *gorm.DB) *MFAService {
return &MFAService{db: db}
}
// TOTPConfig holds TOTP algorithm parameters.
// Standard parameters per RFC 6238: SHA-1, 30-second step, 6 digits.
type TOTPConfig struct {
Period uint64 // time step in seconds (default: 30)
Digits int // number of digits (default: 6)
Algorithm string // hash algorithm (default: SHA1)
Issuer string // issuer name for QR code (default: GoChat)
}
// DefaultTOTPConfig returns standard TOTP parameters.
func DefaultTOTPConfig() TOTPConfig {
return TOTPConfig{
Period: 30,
Digits: 6,
Algorithm: "SHA1",
Issuer: "GoChat",
}
}
// GenerateTOTPSecret creates a random base32-encoded TOTP secret for a user.
// The secret is 160 bits (20 bytes) per RFC 4226 recommendation.
func (s *MFAService) GenerateTOTPSecret(userID uint) (string, string, error) {
// Generate 20 random bytes for 160-bit secret
secretBytes := make([]byte, 20)
if _, err := rand.Read(secretBytes); err != nil {
return "", "", fmt.Errorf("failed to generate random secret: %w", err)
}
// Encode as base32 (uppercase, no padding per RFC 4648)
secret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secretBytes)
// Look up user email for QR code URI
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return "", "", fmt.Errorf("user not found: %w", err)
}
// Generate otpauth URI for QR code scanning
cfg := DefaultTOTPConfig()
uri := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=%s&digits=%d&period=%d",
cfg.Issuer,
user.Email,
secret,
cfg.Issuer,
cfg.Algorithm,
cfg.Digits,
cfg.Period,
)
return secret, uri, nil
}
// EnableTOTP stores the TOTP secret for a user after successful verification.
// This is a two-step process: user must verify a TOTP code before enabling.
func (s *MFAService) EnableTOTP(userID uint, secret string) error {
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return fmt.Errorf("user not found: %w", err)
}
user.TOTPSecret = secret
user.TOTPEnabled = true
if err := s.db.Save(&user).Error; err != nil {
return fmt.Errorf("failed to enable totp: %w", err)
}
return nil
}
// VerifyTOTPCode validates a TOTP code against the user's stored secret.
// Uses a 1-period window (±30 seconds) to account for clock drift per RFC 6238.
func (s *MFAService) VerifyTOTPCode(userID uint, code string) (bool, error) {
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return false, fmt.Errorf("user not found: %w", err)
}
if !user.TOTPEnabled || user.TOTPSecret == "" {
return false, fmt.Errorf("mfa not enabled for user")
}
cfg := DefaultTOTPConfig()
return validateTOTP(user.TOTPSecret, code, cfg), nil
}
// DisableTOTP removes TOTP configuration for a user.
// Requires verification of current TOTP code before disabling.
func (s *MFAService) DisableTOTP(userID uint, code string) error {
// Verify current code before allowing disable
valid, err := s.VerifyTOTPCode(userID, code)
if err != nil {
return fmt.Errorf("verification failed: %w", err)
}
if !valid {
return fmt.Errorf("invalid totp code")
}
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return fmt.Errorf("user not found: %w", err)
}
user.TOTPSecret = ""
user.TOTPEnabled = false
if err := s.db.Save(&user).Error; err != nil {
return fmt.Errorf("failed to disable totp: %w", err)
}
return nil
}
// IsMFAEnabled checks whether MFA is enabled for a user.
func (s *MFAService) IsMFAEnabled(userID uint) (bool, error) {
var user model.User
if err := s.db.Select("totp_enabled").First(&user, userID).Error; err != nil {
return false, fmt.Errorf("user not found: %w", err)
}
return user.TOTPEnabled, nil
}
// validateTOTP validates a TOTP code against a secret using the given config.
// Allows ±1 period window for clock drift tolerance.
func validateTOTP(secret string, code string, cfg TOTPConfig) bool {
now := time.Now().Unix()
period := int64(cfg.Period)
// Check current period and ±1 period for clock drift
for offset := -1; offset <= 1; offset++ {
t := (now + int64(offset)*period) / period
expected := generateTOTP(secret, t, cfg)
if expected == strings.TrimSpace(code) {
return true
}
}
return false
}
// generateTOTP generates a TOTP code for the given time counter.
// Implements RFC 6238 algorithm: HMAC-SHA1 with time-based counter.
func generateTOTP(secret string, timeCounter int64, cfg TOTPConfig) string {
// Decode base32 secret
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
if err != nil {
return ""
}
// Encode time counter as 8-byte big-endian
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, uint64(timeCounter))
// HMAC-SHA1
h := hmac.New(sha1.New, key)
h.Write(buf)
hash := h.Sum(nil)
// Dynamic truncation per RFC 4226
offset := hash[len(hash)-1] & 0x0f
truncated := (int32(hash[offset]&0x7f) << 24) |
(int32(hash[offset+1]&0xff) << 16) |
(int32(hash[offset+2]&0xff) << 8) |
(int32(hash[offset+3]&0xff))
// Modulo 10^digits
mod := int32(math.Pow10(cfg.Digits))
code := truncated % mod
// Format with leading zeros to achieve correct digit count
return fmt.Sprintf("%0*d", cfg.Digits, code)
}
// ValidateTOTPCode is a public helper that validates a TOTP code against a given secret.
// Used by handlers to verify TOTP codes during MFA setup before enabling on the user.
func ValidateTOTPCode(secret string, code string, cfg TOTPConfig) bool {
return validateTOTP(secret, code, cfg)
}
// --- math.Pow10 helper ---
func init() {
// Ensure math package is linked
_ = math.E
}
// GenerateBackupCodes creates a set of one-time backup codes for MFA recovery.
// Reference: Chatwoot MfaController#backup_codes
func (s *MFAService) GenerateBackupCodes(userID uint) ([]string, error) {
var codes []string
for i := 0; i < 10; i++ {
code := cryptoRandomString(8)
codes = append(codes, code)
}
// TODO: store hashed backup codes in DB for later verification
return codes, nil
}
// cryptoRandomString generates a random alphanumeric string of given length.
func cryptoRandomString(length int) string {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
buf := make([]byte, 1)
_, _ = rand.Read(buf)
b[i] = charset[int(buf[0])%len(charset)]
}
return string(b)
}