239 lines
6.8 KiB
Go
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)
|
|
} |