Files
gochat/backend/internal/handler/api/v1/mfa_handler.go
T
rogee aeddedf2a3 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.
2026-07-07 14:44:12 +08:00

328 lines
10 KiB
Go

package v1
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/pkg/response"
)
// Reference: P2E §1.5 — MFA HTTP handlers
// Maps to Chatwoot enterprise TwoFactorAuthController:
// - enable → POST /api/v1/auth/mfa/enable (generates secret + QR URI)
// - verify → POST /api/v1/auth/mfa/verify (validates TOTP code, enables MFA)
// - disable → POST /api/v1/auth/mfa/disable (disables MFA after code verification)
// MFAHandler handles MFA (TOTP) HTTP endpoints.
type MFAHandler struct {
mfaService *auth.MFAService
}
// NewMFAHandler creates a MFA handler with service dependency.
func NewMFAHandler(mfaService *auth.MFAService) *MFAHandler {
return &MFAHandler{
mfaService: mfaService,
}
}
// --- Request/Response structs ---
// EnableMFARequest is the JSON body for MFA enablement initiation.
type EnableMFARequest struct {
// No body required — user_id comes from auth context
}
// EnableMFAResponse is the JSON response for MFA enablement initiation.
type EnableMFAResponse struct {
TOTPSecret string `json:"totp_secret"` // base32 secret for manual entry
QRURI string `json:"qr_uri"` // otpauth:// URI for QR code generation
Message string `json:"message"`
}
// VerifyMFARequest is the JSON body for MFA TOTP verification.
type VerifyMFARequest struct {
TOTPSecret string `json:"totp_secret" binding:"required"` // secret from enable step
TOTPCode string `json:"totp_code" binding:"required"` // 6-digit code from authenticator app
}
// DisableMFARequest is the JSON body for MFA disablement.
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.
// POST /api/v1/auth/mfa/enable
// Requires authentication — uses user_id from JWT context.
// The user must verify a TOTP code before MFA is actually activated.
func (h *MFAHandler) EnableMFA(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required")
return
}
// Check if MFA is already enabled
enabled, err := h.mfaService.IsMFAEnabled(userID)
if err != nil {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, err.Error())
return
}
if enabled {
response.AbortWithStatusError(c, http.StatusConflict, response.ErrConflict, "MFA is already enabled for this user")
return
}
// Generate new TOTP secret + QR URI
secret, qrURI, err := h.mfaService.GenerateTOTPSecret(userID)
if err != nil {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, err.Error())
return
}
response.OK(c, EnableMFAResponse{
TOTPSecret: secret,
QRURI: qrURI,
Message: "Scan QR code with your authenticator app, then verify with a TOTP code",
})
}
// VerifyMFA completes MFA setup: verifies TOTP code and enables MFA on the user.
// POST /api/v1/auth/mfa/verify
// Requires authentication — uses user_id from JWT context.
// This is the second step: user provides secret + code from authenticator app.
func (h *MFAHandler) VerifyMFA(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required")
return
}
var req VerifyMFARequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
// Validate the TOTP code against the provided secret
cfg := auth.DefaultTOTPConfig()
if !auth.ValidateTOTPCode(req.TOTPSecret, req.TOTPCode, cfg) {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Invalid TOTP code, please try again")
return
}
// Enable TOTP on the user (stores secret in DB)
if err := h.mfaService.EnableTOTP(userID, req.TOTPSecret); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error())
return
}
response.OK(c, gin.H{
"message": "MFA enabled successfully",
"mfa_enabled": true,
})
}
// DisableMFA disables MFA after verifying the current TOTP code.
// POST /api/v1/auth/mfa/disable
// Requires authentication — uses user_id from JWT context.
func (h *MFAHandler) DisableMFA(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required")
return
}
var req DisableMFARequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
// Disable TOTP (requires valid current code for security)
if err := h.mfaService.DisableTOTP(userID, req.TOTPCode); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
return
}
response.OK(c, gin.H{
"message": "MFA disabled successfully",
"mfa_enabled": false,
})
}
// MFAStatus returns the current MFA status for the authenticated user.
// GET /api/v1/auth/mfa/status
func (h *MFAHandler) MFAStatus(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required")
return
}
enabled, err := h.mfaService.IsMFAEnabled(userID)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error())
return
}
response.OK(c, gin.H{
"mfa_enabled": enabled,
})
}
// RegisterMFARoutes sets up MFA routes on a Gin router group.
// These routes require authentication (AuthRequired middleware).
func RegisterMFARoutes(rg *gin.RouterGroup, handler *MFAHandler) {
mfaGroup := rg.Group("/auth/mfa")
{
mfaGroup.POST("/enable", handler.EnableMFA)
mfaGroup.POST("/verify", handler.VerifyMFA)
mfaGroup.POST("/disable", handler.DisableMFA)
mfaGroup.GET("/status", handler.MFAStatus)
mfaGroup.POST("/backup_codes", handler.BackupCodes)
}
}
// 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
func (h *MFAHandler) BackupCodes(c *gin.Context) {
userID := getUserID(c)
if userID == 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
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})
}