Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user