Merge pull request #402 from svc-design/codex/refactor-multi-factor-authentication-workflow

This commit is contained in:
shenlan 2025-10-05 08:46:58 +08:00 committed by GitHub
commit d76f7ed2ef
3 changed files with 256 additions and 90 deletions

View File

@ -3,6 +3,8 @@ package api
import (
"context"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/hex"
"errors"
"fmt"
@ -26,6 +28,8 @@ const defaultMFAChallengeTTL = 10 * time.Minute
const defaultTOTPIssuer = "XControl Account"
const defaultEmailVerificationTTL = 24 * time.Hour
const defaultPasswordResetTTL = 30 * time.Minute
const maxMFAVerificationAttempts = 5
const defaultMFALockoutDuration = 5 * time.Minute
type session struct {
userID string
@ -52,8 +56,14 @@ type handler struct {
}
type mfaChallenge struct {
userID string
expiresAt time.Time
userID string
expiresAt time.Time
totpSecret string
totpIssuer string
totpAccount string
totpIssuedAt time.Time
failedAttempts int
lockedUntil time.Time
}
type emailVerification struct {
@ -285,7 +295,7 @@ func (h *handler) register(c *gin.Context) {
response := gin.H{
"message": message,
"user": sanitizeUser(user),
"user": sanitizeUser(user, nil),
}
c.JSON(http.StatusCreated, response)
}
@ -348,7 +358,7 @@ func (h *handler) verifyEmail(c *gin.Context) {
"message": "email verified",
"token": sessionToken,
"expiresAt": expiresAt.UTC(),
"user": sanitizeUser(user),
"user": sanitizeUser(user, nil),
})
}
@ -464,7 +474,7 @@ func (h *handler) confirmPasswordReset(c *gin.Context) {
"message": "password reset successful",
"token": sessionToken,
"expiresAt": expiresAt.UTC(),
"user": sanitizeUser(user),
"user": sanitizeUser(user, nil),
})
}
@ -558,7 +568,7 @@ func (h *handler) login(c *gin.Context) {
"message": "login successful",
"token": token,
"expiresAt": expiresAt.UTC(),
"user": sanitizeUser(user),
"user": sanitizeUser(user, nil),
})
return
}
@ -573,7 +583,7 @@ func (h *handler) login(c *gin.Context) {
"message": "login successful",
"token": token,
"expiresAt": expiresAt.UTC(),
"user": sanitizeUser(user),
"user": sanitizeUser(user, nil),
}
if challengeToken, err := h.createMFAChallenge(user.ID); err != nil {
@ -620,7 +630,7 @@ func (h *handler) session(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"user": sanitizeUser(user)})
c.JSON(http.StatusOK, gin.H{"user": sanitizeUser(user, nil)})
}
func (h *handler) deleteSession(c *gin.Context) {
@ -684,15 +694,59 @@ func (h *handler) newRandomToken() (string, error) {
return hex.EncodeToString(buffer), nil
}
func (h *handler) effectiveMFAChallengeTTL() time.Duration {
ttl := h.mfaChallengeTTL
if ttl <= 0 {
ttl = defaultMFAChallengeTTL
}
return ttl
}
func clearMFAChallenge(ch *mfaChallenge) {
if ch == nil {
return
}
ch.totpSecret = ""
ch.totpIssuer = ""
ch.totpAccount = ""
ch.totpIssuedAt = time.Time{}
ch.failedAttempts = 0
ch.lockedUntil = time.Time{}
}
func (h *handler) updateMFAChallenge(token string, update func(*mfaChallenge) bool) (mfaChallenge, bool) {
h.mfaMu.Lock()
defer h.mfaMu.Unlock()
challenge, ok := h.mfaChallenges[token]
if !ok {
return mfaChallenge{}, false
}
if time.Now().After(challenge.expiresAt) {
clearMFAChallenge(&challenge)
delete(h.mfaChallenges, token)
return mfaChallenge{}, false
}
if update != nil {
if !update(&challenge) {
clearMFAChallenge(&challenge)
delete(h.mfaChallenges, token)
return mfaChallenge{}, false
}
h.mfaChallenges[token] = challenge
}
return challenge, true
}
func (h *handler) createMFAChallenge(userID string) (string, error) {
token, err := h.newRandomToken()
if err != nil {
return "", err
}
ttl := h.mfaChallengeTTL
if ttl <= 0 {
ttl = defaultMFAChallengeTTL
}
ttl := h.effectiveMFAChallengeTTL()
challenge := mfaChallenge{userID: userID, expiresAt: time.Now().Add(ttl)}
h.mfaMu.Lock()
h.mfaChallenges[token] = challenge
@ -701,39 +755,15 @@ func (h *handler) createMFAChallenge(userID string) (string, error) {
}
func (h *handler) lookupMFAChallenge(token string) (mfaChallenge, bool) {
h.mfaMu.RLock()
challenge, ok := h.mfaChallenges[token]
h.mfaMu.RUnlock()
if !ok {
return mfaChallenge{}, false
}
if time.Now().After(challenge.expiresAt) {
h.removeMFAChallenge(token)
return mfaChallenge{}, false
}
return challenge, true
return h.updateMFAChallenge(token, nil)
}
func (h *handler) refreshMFAChallenge(token string) (mfaChallenge, bool) {
ttl := h.mfaChallengeTTL
if ttl <= 0 {
ttl = defaultMFAChallengeTTL
}
h.mfaMu.Lock()
challenge, ok := h.mfaChallenges[token]
if ok {
challenge.expiresAt = time.Now().Add(ttl)
h.mfaChallenges[token] = challenge
}
h.mfaMu.Unlock()
if !ok {
return mfaChallenge{}, false
}
if time.Now().After(challenge.expiresAt) {
h.removeMFAChallenge(token)
return mfaChallenge{}, false
}
return challenge, true
ttl := h.effectiveMFAChallengeTTL()
return h.updateMFAChallenge(token, func(ch *mfaChallenge) bool {
ch.expiresAt = time.Now().Add(ttl)
return true
})
}
func (h *handler) enqueueEmailVerification(ctx context.Context, user *store.User) error {
@ -894,7 +924,10 @@ func (h *handler) removePasswordReset(token string) {
func (h *handler) removeMFAChallenge(token string) {
h.mfaMu.Lock()
delete(h.mfaChallenges, token)
if challenge, ok := h.mfaChallenges[token]; ok {
clearMFAChallenge(&challenge)
delete(h.mfaChallenges, token)
}
h.mfaMu.Unlock()
}
@ -905,6 +938,7 @@ func (h *handler) removeMFAChallengesForUser(userID string) {
h.mfaMu.Lock()
for token, challenge := range h.mfaChallenges {
if challenge.userID == userID {
clearMFAChallenge(&challenge)
delete(h.mfaChallenges, token)
}
}
@ -949,18 +983,15 @@ func (h *handler) provisionTOTP(c *gin.Context) {
issuer := strings.TrimSpace(req.Issuer)
if issuer == "" {
issuer = h.totpIssuer
issuer = strings.TrimSpace(h.totpIssuer)
if issuer == "" {
issuer = defaultTOTPIssuer
}
}
accountName := strings.TrimSpace(req.Account)
if accountName == "" {
accountName = strings.TrimSpace(user.Email)
}
if accountName == "" {
accountName = strings.TrimSpace(user.Name)
}
if accountName == "" {
accountName = strings.TrimSpace(user.ID)
accountName = deriveDefaultAccountLabel(user, issuer)
}
key, err := totp.Generate(totp.GenerateOpts{
@ -975,26 +1006,64 @@ func (h *handler) provisionTOTP(c *gin.Context) {
return
}
user.MFATOTPSecret = key.Secret()
user.MFAEnabled = false
user.MFASecretIssuedAt = time.Now().UTC()
user.MFAConfirmedAt = time.Time{}
if err := h.store.UpdateUser(ctx, user); err != nil {
respondError(c, http.StatusInternalServerError, "mfa_secret_persist_failed", "failed to persist totp secret")
issuedAt := time.Now().UTC()
ttl := h.effectiveMFAChallengeTTL()
pendingChallenge, ok := h.updateMFAChallenge(token, func(ch *mfaChallenge) bool {
if ch.userID != user.ID {
return false
}
ch.totpSecret = key.Secret()
ch.totpIssuer = issuer
ch.totpAccount = accountName
ch.totpIssuedAt = issuedAt
ch.failedAttempts = 0
ch.lockedUntil = time.Time{}
ch.expiresAt = time.Now().Add(ttl)
return true
})
if !ok {
respondError(c, http.StatusUnauthorized, "invalid_mfa_token", "mfa token is invalid or expired")
return
}
state := buildMFAState(user, &pendingChallenge)
sanitized := sanitizeUser(user, &pendingChallenge)
c.JSON(http.StatusOK, gin.H{
"secret": user.MFATOTPSecret,
"uri": key.URL(),
"issuer": issuer,
"account": accountName,
"mfaToken": token,
"user": sanitizeUser(user),
"secret": key.Secret(),
"otpauth_url": key.URL(),
"issuer": issuer,
"account": accountName,
"mfaToken": token,
"mfa": state,
"user": sanitized,
})
}
func deriveDefaultAccountLabel(user *store.User, issuer string) string {
if user == nil {
if issuer == "" {
return "account"
}
return fmt.Sprintf("%s account", issuer)
}
identifier := strings.TrimSpace(user.ID)
if identifier == "" {
if issuer == "" {
return "account"
}
return fmt.Sprintf("%s account", issuer)
}
sum := sha1.Sum([]byte(identifier))
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
encoded := strings.ToLower(encoder.EncodeToString(sum[:]))
if len(encoded) > 10 {
encoded = encoded[:10]
}
return fmt.Sprintf("user-%s", encoded)
}
func (h *handler) verifyTOTP(c *gin.Context) {
var req struct {
Token string `json:"token"`
@ -1025,7 +1094,35 @@ func (h *handler) verifyTOTP(c *gin.Context) {
return
}
if strings.TrimSpace(user.MFATOTPSecret) == "" {
challenge, ok = h.updateMFAChallenge(token, func(ch *mfaChallenge) bool {
if ch.userID != user.ID {
return false
}
ch.expiresAt = time.Now().Add(h.effectiveMFAChallengeTTL())
return true
})
if !ok {
respondError(c, http.StatusUnauthorized, "invalid_mfa_token", "mfa token is invalid or expired")
return
}
now := time.Now()
if !challenge.lockedUntil.IsZero() && now.Before(challenge.lockedUntil) {
retryAt := challenge.lockedUntil.UTC()
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "mfa_challenge_locked",
"message": "too many invalid mfa attempts, try again later",
"retryAt": retryAt,
"mfaToken": token,
})
return
}
secret := strings.TrimSpace(user.MFATOTPSecret)
if secret == "" {
secret = strings.TrimSpace(challenge.totpSecret)
}
if secret == "" {
respondError(c, http.StatusBadRequest, "mfa_secret_missing", "mfa secret has not been provisioned")
return
}
@ -1036,7 +1133,7 @@ func (h *handler) verifyTOTP(c *gin.Context) {
return
}
valid, err := totp.ValidateCustom(code, user.MFATOTPSecret, time.Now().UTC(), totp.ValidateOpts{
valid, err := totp.ValidateCustom(code, secret, time.Now().UTC(), totp.ValidateOpts{
Period: 30,
Skew: 1,
Digits: otp.DigitsSix,
@ -1047,12 +1144,55 @@ func (h *handler) verifyTOTP(c *gin.Context) {
return
}
if !valid {
ttl := h.effectiveMFAChallengeTTL()
updatedChallenge, ok := h.updateMFAChallenge(token, func(ch *mfaChallenge) bool {
if ch.userID != user.ID {
return false
}
if now.Before(ch.lockedUntil) {
return true
}
ch.failedAttempts++
if ch.failedAttempts >= maxMFAVerificationAttempts {
ch.failedAttempts = 0
ch.lockedUntil = now.Add(defaultMFALockoutDuration)
}
ch.expiresAt = time.Now().Add(ttl)
return true
})
if ok {
challenge = updatedChallenge
}
if !challenge.lockedUntil.IsZero() && now.Before(challenge.lockedUntil) {
retryAt := challenge.lockedUntil.UTC()
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "mfa_challenge_locked",
"message": "too many invalid mfa attempts, try again later",
"retryAt": retryAt,
"mfaToken": token,
})
return
}
respondError(c, http.StatusUnauthorized, "invalid_mfa_code", "invalid totp code")
return
}
confirmationTime := time.Now().UTC()
issuedAt := challenge.totpIssuedAt
if issuedAt.IsZero() {
issuedAt = confirmationTime
}
if strings.TrimSpace(user.MFATOTPSecret) == "" {
user.MFATOTPSecret = secret
}
if user.MFASecretIssuedAt.IsZero() {
user.MFASecretIssuedAt = issuedAt
}
user.MFAEnabled = true
user.MFAConfirmedAt = time.Now().UTC()
user.MFAConfirmedAt = confirmationTime
if err := h.store.UpdateUser(ctx, user); err != nil {
respondError(c, http.StatusInternalServerError, "mfa_update_failed", "failed to enable mfa")
@ -1071,7 +1211,7 @@ func (h *handler) verifyTOTP(c *gin.Context) {
"message": "mfa_verified",
"token": sessionToken,
"expiresAt": expiresAt.UTC(),
"user": sanitizeUser(user),
"user": sanitizeUser(user, nil),
})
}
@ -1089,8 +1229,9 @@ func (h *handler) mfaStatus(c *gin.Context) {
authToken := extractToken(c.GetHeader("Authorization"))
var (
user *store.User
err error
user *store.User
err error
challenge *mfaChallenge
)
ctx := c.Request.Context()
@ -1107,12 +1248,19 @@ func (h *handler) mfaStatus(c *gin.Context) {
}
}
if user == nil && token != "" {
if challenge, ok := h.refreshMFAChallenge(token); ok {
user, err = h.store.GetUserByID(ctx, challenge.userID)
if err != nil {
respondError(c, http.StatusInternalServerError, "mfa_status_failed", "failed to load user for status")
return
if token != "" {
if refreshed, ok := h.refreshMFAChallenge(token); ok {
if user != nil && user.ID != refreshed.userID {
challenge = nil
} else {
challenge = &refreshed
if user == nil {
user, err = h.store.GetUserByID(ctx, refreshed.userID)
if err != nil {
respondError(c, http.StatusInternalServerError, "mfa_status_failed", "failed to load user for status")
return
}
}
}
}
}
@ -1134,13 +1282,15 @@ func (h *handler) mfaStatus(c *gin.Context) {
return
}
state := buildMFAState(user, challenge)
c.JSON(http.StatusOK, gin.H{
"mfa": buildMFAState(user),
"user": sanitizeUser(user),
"enabled": user.MFAEnabled,
"mfa": state,
"user": sanitizeUser(user, challenge),
})
}
func sanitizeUser(user *store.User) gin.H {
func sanitizeUser(user *store.User, challenge *mfaChallenge) gin.H {
identifier := strings.TrimSpace(user.ID)
return gin.H{
"id": identifier,
@ -1150,21 +1300,36 @@ func sanitizeUser(user *store.User) gin.H {
"email": user.Email,
"emailVerified": user.EmailVerified,
"mfaEnabled": user.MFAEnabled,
"mfa": buildMFAState(user),
"mfa": buildMFAState(user, challenge),
}
}
func buildMFAState(user *store.User) gin.H {
func buildMFAState(user *store.User, challenge *mfaChallenge) gin.H {
pending := strings.TrimSpace(user.MFATOTPSecret) != "" && !user.MFAEnabled
issuedAt := user.MFASecretIssuedAt
if challenge != nil && !user.MFAEnabled {
if strings.TrimSpace(challenge.totpSecret) != "" {
pending = true
}
if issuedAt.IsZero() && !challenge.totpIssuedAt.IsZero() {
issuedAt = challenge.totpIssuedAt
}
}
state := gin.H{
"totpEnabled": user.MFAEnabled,
"totpPending": strings.TrimSpace(user.MFATOTPSecret) != "" && !user.MFAEnabled,
"totpPending": pending,
}
if !user.MFASecretIssuedAt.IsZero() {
state["totpSecretIssuedAt"] = user.MFASecretIssuedAt.UTC()
if !issuedAt.IsZero() {
state["totpSecretIssuedAt"] = issuedAt.UTC()
}
if !user.MFAConfirmedAt.IsZero() {
state["totpConfirmedAt"] = user.MFAConfirmedAt.UTC()
}
if challenge != nil && !challenge.lockedUntil.IsZero() && time.Now().Before(challenge.lockedUntil) {
state["totpLockedUntil"] = challenge.lockedUntil.UTC()
}
return state
}
@ -1211,7 +1376,7 @@ func (h *handler) disableMFA(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "mfa_disabled",
"user": sanitizeUser(user),
"user": sanitizeUser(user, nil),
})
}

View File

@ -26,7 +26,7 @@ type apiResponse struct {
User map[string]interface{} `json:"user"`
MFA map[string]interface{} `json:"mfa"`
Secret string `json:"secret"`
URI string `json:"uri"`
Otpauth string `json:"otpauth_url"`
ExpiresAt string `json:"expiresAt"`
}
@ -328,7 +328,7 @@ func TestMFATOTPFlow(t *testing.T) {
if resp.Secret == "" {
t.Fatalf("expected totp secret in provisioning response")
}
if resp.URI == "" {
if resp.Otpauth == "" {
t.Fatalf("expected otpauth uri in provisioning response")
}
secret := resp.Secret

View File

@ -17,10 +17,11 @@ type TotpStatus = {
type ProvisionResponse = {
secret?: string
uri?: string
otpauth_url?: string
issuer?: string
account?: string
qr?: string
mfa?: TotpStatus
user?: { mfa?: TotpStatus }
}
@ -119,11 +120,11 @@ export default function MfaSetupPanel() {
}
const data = payload.data
setSecret(data?.secret ?? '')
const nextUri = data?.uri ?? ''
const nextUri = data?.otpauth_url ?? ''
setUri(nextUri)
const nextQr = data?.qr ?? (nextUri ? generateQrImage(nextUri) : '')
setQrImage(nextQr)
setStatus(data?.user?.mfa ?? status)
setStatus(data?.mfa ?? data?.user?.mfa ?? status)
} catch (err) {
console.warn('Provision TOTP failed', err)
setError(copy.error)