391 lines
12 KiB
Go
391 lines
12 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"encoding/base32"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
pkgcrypto "github.com/gochat/gochat/pkg/crypto"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const mfaBackupCodesAttribute = "mfa_backup_code_hashes"
|
|
|
|
// 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
|
|
}
|
|
|
|
// BeginTOTPSetup creates and stores a pending TOTP secret for Chatwoot's
|
|
// profile MFA setup flow. The user is activated only after VerifyAndActivateTOTP.
|
|
func (s *MFAService) BeginTOTPSetup(userID uint) (string, string, error) {
|
|
secret, uri, err := s.GenerateTOTPSecret(userID)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
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 = false
|
|
if err := s.db.Save(&user).Error; err != nil {
|
|
return "", "", fmt.Errorf("failed to store pending totp secret: %w", err)
|
|
}
|
|
return secret, uri, nil
|
|
}
|
|
|
|
// VerifyAndActivateTOTP validates the pending profile MFA code, enables MFA,
|
|
// and returns the one-time backup codes expected by Chatwoot's verify response.
|
|
func (s *MFAService) VerifyAndActivateTOTP(userID uint, code string) ([]string, error) {
|
|
var user model.User
|
|
if err := s.db.First(&user, userID).Error; err != nil {
|
|
return nil, fmt.Errorf("user not found: %w", err)
|
|
}
|
|
if user.TOTPSecret == "" {
|
|
return nil, fmt.Errorf("mfa setup not started for user")
|
|
}
|
|
if !validateTOTP(user.TOTPSecret, code, DefaultTOTPConfig()) {
|
|
return nil, fmt.Errorf("invalid totp code")
|
|
}
|
|
user.TOTPEnabled = true
|
|
if err := s.db.Save(&user).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to enable totp: %w", err)
|
|
}
|
|
return s.GenerateBackupCodes(userID)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// DisableTOTPWithPassword mirrors Chatwoot profile MFA destroy: the current
|
|
// password and either an OTP code or a backup code must be provided.
|
|
func (s *MFAService) DisableTOTPWithPassword(userID uint, password, code, backupCode string) error {
|
|
var user model.User
|
|
if err := s.db.First(&user, userID).Error; err != nil {
|
|
return fmt.Errorf("user not found: %w", err)
|
|
}
|
|
if !user.TOTPEnabled || user.TOTPSecret == "" {
|
|
return fmt.Errorf("mfa not enabled for user")
|
|
}
|
|
if !pkgcrypto.CheckPassword(password, user.PasswordDigest) && !pkgcrypto.CheckPassword(password, user.Password) {
|
|
return fmt.Errorf("invalid credentials")
|
|
}
|
|
if backupCode != "" {
|
|
if err := s.consumeBackupCode(&user, backupCode); err != nil {
|
|
return err
|
|
}
|
|
} else if !validateTOTP(user.TOTPSecret, code, DefaultTOTPConfig()) {
|
|
return fmt.Errorf("invalid totp code")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// BackupCodesGenerated reports whether the user currently has MFA backup codes.
|
|
func (s *MFAService) BackupCodesGenerated(userID uint) (bool, error) {
|
|
var user model.User
|
|
if err := s.db.Select("custom_attributes").First(&user, userID).Error; err != nil {
|
|
return false, fmt.Errorf("user not found: %w", err)
|
|
}
|
|
codes := backupCodeHashes(user.CustomAttributes)
|
|
return len(codes) > 0, 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 user model.User
|
|
if err := s.db.First(&user, userID).Error; err != nil {
|
|
return nil, fmt.Errorf("user not found: %w", err)
|
|
}
|
|
var codes []string
|
|
var hashes []string
|
|
for i := 0; i < 10; i++ {
|
|
code := cryptoRandomString(8)
|
|
codes = append(codes, code)
|
|
hash, err := pkgcrypto.HashPassword(code)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to hash backup code: %w", err)
|
|
}
|
|
hashes = append(hashes, hash)
|
|
}
|
|
attrs := customAttributesMap(user.CustomAttributes)
|
|
attrs[mfaBackupCodesAttribute] = hashes
|
|
encoded, err := json.Marshal(attrs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode backup codes: %w", err)
|
|
}
|
|
user.CustomAttributes = encoded
|
|
if err := s.db.Save(&user).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to store backup codes: %w", err)
|
|
}
|
|
return codes, nil
|
|
}
|
|
|
|
func (s *MFAService) consumeBackupCode(user *model.User, code string) error {
|
|
hashes := backupCodeHashes(user.CustomAttributes)
|
|
for i, hash := range hashes {
|
|
if pkgcrypto.CheckPassword(code, hash) {
|
|
hashes = append(hashes[:i], hashes[i+1:]...)
|
|
attrs := customAttributesMap(user.CustomAttributes)
|
|
attrs[mfaBackupCodesAttribute] = hashes
|
|
encoded, err := json.Marshal(attrs)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encode backup codes: %w", err)
|
|
}
|
|
user.CustomAttributes = encoded
|
|
if err := s.db.Save(user).Error; err != nil {
|
|
return fmt.Errorf("failed to consume backup code: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("invalid backup code")
|
|
}
|
|
|
|
func backupCodeHashes(raw []byte) []string {
|
|
attrs := customAttributesMap(raw)
|
|
value, ok := attrs[mfaBackupCodesAttribute]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
hashes := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
if text, ok := item.(string); ok && text != "" {
|
|
hashes = append(hashes, text)
|
|
}
|
|
}
|
|
return hashes
|
|
}
|
|
|
|
func customAttributesMap(raw []byte) map[string]any {
|
|
attrs := map[string]any{}
|
|
if len(raw) == 0 {
|
|
return attrs
|
|
}
|
|
_ = json.Unmarshal(raw, &attrs)
|
|
return attrs
|
|
}
|
|
|
|
// 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)
|
|
}
|