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.
328 lines
10 KiB
Go
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})
|
|
}
|