Files
gochat/internal/handler/api/v1/mfa_handler.go
T

206 lines
6.7 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
}
// --- 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)
}
}
// 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})
}