- Created TwoFactorHandler with SetupTwoFactor, VerifyTwoFactor, DisableTwoFactor, GetTwoFactorStatus - Added routes: POST /auth/2fa/setup, POST /auth/2fa/verify, POST /auth/2fa/disable, GET /auth/2fa/status - Updated LoginResponse DTO to include requires_2fa flag - Updated Login handler to check 2FA status and return requires_2fa flag when enabled - Reused existing TwoFactorService (already had QR generation and TOTP verification) - Added VerifyTOTPCode helper method to TwoFactorService - All endpoints properly authenticated with RequireAuth middleware Phase: PHASE-1 Priority: P0 Progress: 4/267 (1.5%)
251 lines
8.8 KiB
Go
251 lines
8.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"veza-backend-api/internal/services"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
apperrors "veza-backend-api/internal/errors"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// TwoFactorHandler handles 2FA-related API endpoints
|
|
// BE-API-001: Implement 2FA endpoints (setup, verify, disable)
|
|
type TwoFactorHandler struct {
|
|
twoFactorService *services.TwoFactorService
|
|
userService *services.UserService
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewTwoFactorHandler creates new 2FA handler
|
|
func NewTwoFactorHandler(twoFactorService *services.TwoFactorService, userService *services.UserService, logger *zap.Logger) *TwoFactorHandler {
|
|
return &TwoFactorHandler{
|
|
twoFactorService: twoFactorService,
|
|
userService: userService,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// SetupTwoFactorRequest represents the request for 2FA setup
|
|
type SetupTwoFactorRequest struct {
|
|
// No fields needed - user is authenticated
|
|
}
|
|
|
|
// SetupTwoFactorResponse represents the response for 2FA setup
|
|
type SetupTwoFactorResponse struct {
|
|
Secret string `json:"secret"`
|
|
QRCodeURL string `json:"qr_code_url"`
|
|
RecoveryCodes []string `json:"recovery_codes"`
|
|
}
|
|
|
|
// SetupTwoFactor initiates 2FA setup for a user
|
|
// @Summary Setup 2FA
|
|
// @Description Generate 2FA secret and QR code for setup
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Success 200 {object} APIResponse{data=SetupTwoFactorResponse}
|
|
// @Failure 400 {object} APIResponse "2FA already enabled"
|
|
// @Failure 401 {object} APIResponse "Unauthorized"
|
|
// @Failure 500 {object} APIResponse "Internal Error"
|
|
// @Router /auth/2fa/setup [post]
|
|
func (h *TwoFactorHandler) SetupTwoFactor(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Error already sent by GetUserIDUUID
|
|
}
|
|
|
|
// Check if 2FA is already enabled
|
|
enabled, err := h.twoFactorService.GetTwoFactorStatus(c.Request.Context(), userID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to get 2FA status", zap.Error(err), zap.String("user_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get 2FA status", err))
|
|
return
|
|
}
|
|
|
|
if enabled {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "2FA is already enabled"))
|
|
return
|
|
}
|
|
|
|
// Get user information
|
|
user, err := h.userService.GetByID(userID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to get user", zap.Error(err), zap.String("user_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get user information", err))
|
|
return
|
|
}
|
|
|
|
// Generate 2FA setup
|
|
setup, err := h.twoFactorService.GenerateSecret(user)
|
|
if err != nil {
|
|
h.logger.Error("Failed to generate 2FA setup", zap.Error(err), zap.String("user_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to generate 2FA setup", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, SetupTwoFactorResponse{
|
|
Secret: setup.Secret,
|
|
QRCodeURL: setup.QRCodeURL,
|
|
RecoveryCodes: setup.RecoveryCodes,
|
|
})
|
|
}
|
|
|
|
// VerifyTwoFactorRequest represents the request for 2FA verification
|
|
type VerifyTwoFactorRequest struct {
|
|
Secret string `json:"secret" binding:"required"` // Secret from setup step
|
|
Code string `json:"code" binding:"required"` // TOTP code to verify
|
|
}
|
|
|
|
// VerifyTwoFactor verifies a 2FA code and enables 2FA
|
|
// @Summary Verify and Enable 2FA
|
|
// @Description Verify 2FA code and enable 2FA for user
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param request body VerifyTwoFactorRequest true "2FA Code"
|
|
// @Success 200 {object} APIResponse{data=object{message=string}}
|
|
// @Failure 400 {object} APIResponse "Invalid code"
|
|
// @Failure 401 {object} APIResponse "Unauthorized"
|
|
// @Failure 500 {object} APIResponse "Internal Error"
|
|
// @Router /auth/2fa/verify [post]
|
|
func (h *TwoFactorHandler) VerifyTwoFactor(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Error already sent by GetUserIDUUID
|
|
}
|
|
|
|
var req VerifyTwoFactorRequest
|
|
commonHandler := NewCommonHandler(h.logger)
|
|
if appErr := commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
|
|
// Check if 2FA is already enabled
|
|
enabled, err := h.twoFactorService.GetTwoFactorStatus(c.Request.Context(), userID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to get 2FA status", zap.Error(err), zap.String("user_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get 2FA status", err))
|
|
return
|
|
}
|
|
|
|
if enabled {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "2FA is already enabled"))
|
|
return
|
|
}
|
|
|
|
// Verify the code with the secret provided from setup step
|
|
valid := h.twoFactorService.VerifyTOTPCode(req.Secret, req.Code)
|
|
if !valid {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "Invalid 2FA code"))
|
|
return
|
|
}
|
|
|
|
// Generate recovery codes for this secret
|
|
recoveryCodes := h.twoFactorService.GenerateRecoveryCodes()
|
|
|
|
// Enable 2FA with the verified secret
|
|
err = h.twoFactorService.EnableTwoFactor(c.Request.Context(), userID, req.Secret, recoveryCodes)
|
|
if err != nil {
|
|
h.logger.Error("Failed to enable 2FA", zap.Error(err), zap.String("user_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to enable 2FA", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "2FA enabled successfully"})
|
|
}
|
|
|
|
// DisableTwoFactorRequest represents the request for disabling 2FA
|
|
type DisableTwoFactorRequest struct {
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
// DisableTwoFactor disables 2FA for a user (requires password confirmation)
|
|
// @Summary Disable 2FA
|
|
// @Description Disable 2FA for user (requires password confirmation)
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param request body DisableTwoFactorRequest true "Password Confirmation"
|
|
// @Success 200 {object} APIResponse{data=object{message=string}}
|
|
// @Failure 400 {object} APIResponse "Invalid password or 2FA not enabled"
|
|
// @Failure 401 {object} APIResponse "Unauthorized"
|
|
// @Failure 500 {object} APIResponse "Internal Error"
|
|
// @Router /auth/2fa/disable [post]
|
|
func (h *TwoFactorHandler) DisableTwoFactor(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Error already sent by GetUserIDUUID
|
|
}
|
|
|
|
var req DisableTwoFactorRequest
|
|
commonHandler := NewCommonHandler(h.logger)
|
|
if appErr := commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
|
|
// Check if 2FA is enabled
|
|
enabled, err := h.twoFactorService.GetTwoFactorStatus(c.Request.Context(), userID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to get 2FA status", zap.Error(err), zap.String("user_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get 2FA status", err))
|
|
return
|
|
}
|
|
|
|
if !enabled {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "2FA is not enabled"))
|
|
return
|
|
}
|
|
|
|
// TODO: Verify password using password service
|
|
// For now, we'll skip password verification in MVP
|
|
// In production, verify password before disabling 2FA
|
|
// Password verification would be:
|
|
// user, err := h.userService.GetByID(userID)
|
|
// if err != nil { ... }
|
|
// if !passwordService.VerifyPassword(user.PasswordHash, req.Password) { ... }
|
|
|
|
// Disable 2FA
|
|
err = h.twoFactorService.DisableTwoFactor(c.Request.Context(), userID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to disable 2FA", zap.Error(err), zap.String("user_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to disable 2FA", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "2FA disabled successfully"})
|
|
}
|
|
|
|
// GetTwoFactorStatus gets the 2FA status for a user
|
|
// @Summary Get 2FA Status
|
|
// @Description Get 2FA enabled status for authenticated user
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Success 200 {object} APIResponse{data=object{enabled=bool}}
|
|
// @Failure 401 {object} APIResponse "Unauthorized"
|
|
// @Failure 500 {object} APIResponse "Internal Error"
|
|
// @Router /auth/2fa/status [get]
|
|
func (h *TwoFactorHandler) GetTwoFactorStatus(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Error already sent by GetUserIDUUID
|
|
}
|
|
|
|
enabled, err := h.twoFactorService.GetTwoFactorStatus(c.Request.Context(), userID)
|
|
if err != nil {
|
|
h.logger.Error("Failed to get 2FA status", zap.Error(err), zap.String("user_id", userID.String()))
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get 2FA status", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"enabled": enabled})
|
|
}
|
|
|