second commit
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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.StatusInternalServerError, 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.StatusInternalServerError, 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})
|
||||
}
|
||||
Reference in New Issue
Block a user