feat(profile): align mfa payloads

This commit is contained in:
2026-06-06 22:37:26 +08:00
parent 6e66af78c7
commit 21421c6a42
8 changed files with 392 additions and 19 deletions
+122
View File
@@ -52,6 +52,17 @@ type DisableMFARequest struct {
TOTPCode string `json:"totp_code" binding:"required"` // current TOTP code for verification
}
type profileMFAVerifyRequest struct {
OTPCode string `json:"otp_code"`
TOTPCode string `json:"totp_code"`
}
type profileMFADisableRequest struct {
Password string `json:"password"`
OTPCode string `json:"otp_code"`
BackupCode string `json:"backup_code"`
}
// --- Handlers ---
// EnableMFA initiates MFA setup: generates a TOTP secret and QR URI.
@@ -187,6 +198,117 @@ func RegisterMFARoutes(rg *gin.RouterGroup, handler *MFAHandler) {
}
}
// ProfileMFAStatus matches Chatwoot Profile::MfaController#show.
func (h *MFAHandler) ProfileMFAStatus(c *gin.Context) {
userID := getUserID(c)
if userID == 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
enabled, err := h.mfaService.IsMFAEnabled(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
backupCodesGenerated, err := h.mfaService.BackupCodesGenerated(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"feature_available": true,
"enabled": enabled,
"backup_codes_generated": backupCodesGenerated,
})
}
// ProfileEnableMFA matches Chatwoot Profile::MfaController#create.
func (h *MFAHandler) ProfileEnableMFA(c *gin.Context) {
userID := getUserID(c)
if userID == 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
enabled, err := h.mfaService.IsMFAEnabled(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enabled {
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": "MFA is already enabled"})
return
}
secret, uri, err := h.mfaService.BeginTOTPSetup(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"provisioning_url": uri, "secret": secret})
}
// ProfileVerifyMFA matches Chatwoot Profile::MfaController#verify.
func (h *MFAHandler) ProfileVerifyMFA(c *gin.Context) {
userID := getUserID(c)
if userID == 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req profileMFAVerifyRequest
_ = c.ShouldBindJSON(&req)
code := req.OTPCode
if code == "" {
code = req.TOTPCode
}
backupCodes, err := h.mfaService.VerifyAndActivateTOTP(userID, code)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enabled": true, "backup_codes": backupCodes})
}
// ProfileDisableMFA matches Chatwoot Profile::MfaController#destroy.
func (h *MFAHandler) ProfileDisableMFA(c *gin.Context) {
userID := getUserID(c)
if userID == 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req profileMFADisableRequest
_ = c.ShouldBindJSON(&req)
if err := h.mfaService.DisableTOTPWithPassword(userID, req.Password, req.OTPCode, req.BackupCode); err != nil {
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enabled": false})
}
// ProfileBackupCodes matches Chatwoot Profile::MfaController#backup_codes.
func (h *MFAHandler) ProfileBackupCodes(c *gin.Context) {
userID := getUserID(c)
if userID == 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req profileMFAVerifyRequest
_ = c.ShouldBindJSON(&req)
code := req.OTPCode
if code == "" {
code = req.TOTPCode
}
valid, err := h.mfaService.VerifyTOTPCode(userID, code)
if err != nil || !valid {
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid totp code"})
return
}
codes, err := h.mfaService.GenerateBackupCodes(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"backup_codes": codes})
}
// BackupCodes generates one-time MFA backup codes.
// POST /api/v1/profile/mfa/backup_codes or /api/v1/auth/mfa/backup_codes
// Reference: Chatwoot MfaController#backup_codes
+61 -2
View File
@@ -24,6 +24,7 @@ import (
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/model"
pkgcrypto "github.com/gochat/gochat/pkg/crypto"
"github.com/gochat/gochat/pkg/response"
)
@@ -107,6 +108,11 @@ func (s *MFAHandlerTestSuite) setupRouter(userID uint) {
mfaGroup.POST("/verify", s.handler.VerifyMFA)
mfaGroup.POST("/disable", s.handler.DisableMFA)
}
r.GET("/api/v1/profile/mfa", s.handler.ProfileMFAStatus)
r.POST("/api/v1/profile/mfa", s.handler.ProfileEnableMFA)
r.DELETE("/api/v1/profile/mfa", s.handler.ProfileDisableMFA)
r.POST("/api/v1/profile/mfa/verify", s.handler.ProfileVerifyMFA)
r.POST("/api/v1/profile/mfa/backup_codes", s.handler.ProfileBackupCodes)
s.router = r
}
@@ -213,7 +219,7 @@ func generateTOTPFromKey(key []byte, timeCounter int64, cfg auth.TOTPConfig) str
truncated := (int32(hash[offset]&0x7f) << 24) |
(int32(hash[offset+1]&0xff) << 16) |
(int32(hash[offset+2]&0xff) << 8) |
(int32(hash[offset+3]&0xff))
(int32(hash[offset+3] & 0xff))
// Modulo 10^digits
mod := int32(math.Pow10(cfg.Digits))
@@ -660,5 +666,58 @@ func (s *MFAHandlerTestSuite) TestStatus_Unauthorized_NoUserID() {
s.Equal(response.ErrUnauthorized, respStruct.Error.Code)
}
func (s *MFAHandlerTestSuite) TestProfileMFA_StatusUsesChatwootRawPayload() {
w := s.doRequest(http.MethodGet, "/api/v1/profile/mfa", "")
s.Equal(http.StatusOK, w.Code)
var payload map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
s.Equal(true, payload["feature_available"])
s.Equal(false, payload["enabled"])
s.Equal(false, payload["backup_codes_generated"])
s.NotContains(payload, "data")
}
func (s *MFAHandlerTestSuite) TestProfileMFA_EnableVerifyBackupAndDisableUseFrontendPayloads() {
passwordHash, err := pkgcrypto.HashPassword("current-password")
s.Require().NoError(err)
s.Require().NoError(s.db.Model(&model.User{}).Where("id = ?", s.userID).Updates(map[string]interface{}{
"password": passwordHash,
"password_digest": passwordHash,
}).Error)
enableRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa", "")
s.Equal(http.StatusOK, enableRec.Code)
var enablePayload map[string]string
s.Require().NoError(json.Unmarshal(enableRec.Body.Bytes(), &enablePayload))
s.NotEmpty(enablePayload["secret"])
s.Contains(enablePayload["provisioning_url"], "otpauth://totp/")
code := s.generateValidTOTPCode(enablePayload["secret"])
verifyRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa/verify", jsonBody(map[string]interface{}{"otp_code": code}))
s.Equal(http.StatusOK, verifyRec.Code)
var verifyPayload struct {
Enabled bool `json:"enabled"`
BackupCodes []string `json:"backup_codes"`
}
s.Require().NoError(json.Unmarshal(verifyRec.Body.Bytes(), &verifyPayload))
s.True(verifyPayload.Enabled)
s.Len(verifyPayload.BackupCodes, 10)
backupRec := s.doRequest(http.MethodPost, "/api/v1/profile/mfa/backup_codes", jsonBody(map[string]interface{}{"otp_code": code}))
s.Equal(http.StatusOK, backupRec.Code)
var backupPayload struct {
BackupCodes []string `json:"backup_codes"`
}
s.Require().NoError(json.Unmarshal(backupRec.Body.Bytes(), &backupPayload))
s.Len(backupPayload.BackupCodes, 10)
disableRec := s.doRequest(http.MethodDelete, "/api/v1/profile/mfa", jsonBody(map[string]interface{}{"password": "current-password", "otp_code": code}))
s.Equal(http.StatusOK, disableRec.Code)
var disablePayload map[string]bool
s.Require().NoError(json.Unmarshal(disableRec.Body.Bytes(), &disablePayload))
s.False(disablePayload["enabled"])
}
// Ensure unused import warning doesn't cause issues
var _ = assert.Equal
var _ = assert.Equal