second commit
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
crypto_rand "crypto/rand"
|
||||
"fmt"
|
||||
math_rand "math/rand"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gochat/gochat/internal/auth"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
// Reference: P2E §1 + P2C §Auth routes
|
||||
// Auth HTTP handlers are thin wrappers around auth_service business logic.
|
||||
// Maps to Chatwoot auth controllers:
|
||||
// - auth/sessions_controller → Login
|
||||
// - auth/registrations_controller → Register
|
||||
// - auth/token_refreshes_controller → Refresh
|
||||
// - auth/sessions_controller#destroy → Logout
|
||||
// - accounts_controller#switch → SwitchAccount
|
||||
// - auth/passwords_controller → ResetPassword
|
||||
// - auth/confirmations_controller → ConfirmEmail
|
||||
// - omniauth_callbacks_controller → OAuthCallback
|
||||
|
||||
// AuthHandler handles authentication HTTP endpoints.
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
oauthService *auth.OAuthService
|
||||
}
|
||||
|
||||
// NewAuthHandler creates an auth handler with service dependencies.
|
||||
func NewAuthHandler(authService *service.AuthService, oauthService *auth.OAuthService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
oauthService: oauthService,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Request/Response structs ---
|
||||
|
||||
// LoginRequest is the JSON body for login endpoint.
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// LoginMFAResquest is the JSON body for MFA login verification.
|
||||
type LoginMFAResquest struct {
|
||||
UserID uint `json:"user_id" binding:"required"`
|
||||
TOTPCode string `json:"totp_code" binding:"required"`
|
||||
}
|
||||
|
||||
// RegisterRequest is the JSON body for register endpoint.
|
||||
type RegisterRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// RefreshRequest is the JSON body for refresh endpoint.
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// SwitchAccountRequest is the JSON body for account switching.
|
||||
type SwitchAccountRequest struct {
|
||||
AccountID uint `json:"account_id" binding:"required"`
|
||||
}
|
||||
|
||||
// ResetPasswordRequest is the JSON body for password reset.
|
||||
type ResetPasswordRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
// OAuthCallbackRequest is the JSON body for OAuth callback.
|
||||
type OAuthCallbackRequest struct {
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
// Login authenticates a user with email/password and returns JWT tokens.
|
||||
// POST /api/v1/auth/login
|
||||
// If MFA is enabled, returns mfa_required=true with user_id for TOTP verification.
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
output, err := h.authService.Login(c.Request.Context(), &service.LoginInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if output.MFARequired {
|
||||
response.OK(c, gin.H{
|
||||
"mfa_required": true,
|
||||
"user_id": output.User.ID,
|
||||
"message": "MFA verification required, please provide TOTP code",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"user": output.User,
|
||||
"access_token": output.TokenPair.AccessToken,
|
||||
"refresh_token": output.TokenPair.RefreshToken,
|
||||
"expires_at": output.TokenPair.ExpiresAt,
|
||||
"account_id": output.AccountID,
|
||||
"role": output.Role,
|
||||
})
|
||||
}
|
||||
|
||||
// LoginMFA completes login after MFA TOTP code verification.
|
||||
// POST /api/v1/auth/login/mfa
|
||||
func (h *AuthHandler) LoginMFA(c *gin.Context) {
|
||||
var req LoginMFAResquest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
output, err := h.authService.LoginWithMFA(c.Request.Context(), req.UserID, req.TOTPCode)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"user": output.User,
|
||||
"access_token": output.TokenPair.AccessToken,
|
||||
"refresh_token": output.TokenPair.RefreshToken,
|
||||
"expires_at": output.TokenPair.ExpiresAt,
|
||||
"account_id": output.AccountID,
|
||||
"role": output.Role,
|
||||
})
|
||||
}
|
||||
|
||||
// Register creates a new user account.
|
||||
// POST /api/v1/auth/register
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.Register(c.Request.Context(), &service.RegisterInput{
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusConflict, response.ErrConflict, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, gin.H{
|
||||
"user": user,
|
||||
"message": "Registration successful. Please verify your email.",
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh rotates a refresh token and returns new JWT pair.
|
||||
// POST /api/v1/auth/refresh
|
||||
// Implements refresh token rotation per P2E §1.4 security requirement.
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
output, err := h.authService.Refresh(c.Request.Context(), &service.RefreshInput{
|
||||
RefreshToken: req.RefreshToken,
|
||||
})
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"user": output.User,
|
||||
"access_token": output.TokenPair.AccessToken,
|
||||
"refresh_token": output.TokenPair.RefreshToken,
|
||||
"expires_at": output.TokenPair.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Logout revokes a user's refresh token.
|
||||
// DELETE /api/v1/auth/logout
|
||||
// Requires authentication — uses user_id from JWT context.
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.Logout(c.Request.Context(), userID); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Logout failed")
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
// SwitchAccount generates new tokens with a different account scope.
|
||||
// POST /api/v1/auth/switch_account
|
||||
// Requires authentication — uses user_id from JWT context.
|
||||
func (h *AuthHandler) SwitchAccount(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req SwitchAccountRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
output, err := h.authService.SwitchAccount(c.Request.Context(), &service.SwitchAccountInput{
|
||||
UserID: userID,
|
||||
AccountID: req.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"access_token": output.TokenPair.AccessToken,
|
||||
"refresh_token": output.TokenPair.RefreshToken,
|
||||
"expires_at": output.TokenPair.ExpiresAt,
|
||||
"account_id": output.AccountID,
|
||||
"role": output.Role,
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPassword initiates password reset by sending reset email.
|
||||
// POST /api/v1/auth/reset_password
|
||||
func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
var req ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Always return success even if email doesn't exist (security best practice)
|
||||
h.authService.ResetPassword(c.Request.Context(), &service.ResetPasswordInput{
|
||||
Email: req.Email,
|
||||
})
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"message": "If the email exists, a reset link has been sent.",
|
||||
})
|
||||
}
|
||||
|
||||
// ConfirmEmail verifies email confirmation token.
|
||||
// GET /api/v1/auth/confirm_email?token=xxx
|
||||
func (h *AuthHandler) ConfirmEmail(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Confirmation token required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.ConfirmEmail(c.Request.Context(), &service.ConfirmEmailInput{
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"user": user,
|
||||
"message": "Email confirmed successfully.",
|
||||
})
|
||||
}
|
||||
|
||||
// OAuthCallback handles OAuth2 provider callback.
|
||||
// POST /api/v1/auth/oauth/callback
|
||||
// Receives provider + code from frontend (frontend handles redirect flow).
|
||||
func (h *AuthHandler) OAuthCallback(c *gin.Context) {
|
||||
var req OAuthCallbackRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
provider := auth.OAuthProviderType(req.Provider)
|
||||
if !h.oauthService.IsProviderConfigured(provider) {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "OAuth provider not configured: "+req.Provider)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := h.authService.OAuthLogin(c.Request.Context(), &service.OAuthLoginInput{
|
||||
Provider: provider,
|
||||
Code: req.Code,
|
||||
State: req.State,
|
||||
})
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"user": output.User,
|
||||
"access_token": output.TokenPair.AccessToken,
|
||||
"refresh_token": output.TokenPair.RefreshToken,
|
||||
"expires_at": output.TokenPair.ExpiresAt,
|
||||
"account_id": output.AccountID,
|
||||
"role": output.Role,
|
||||
"is_new_user": output.IsNewUser,
|
||||
})
|
||||
}
|
||||
|
||||
// OAuthAuthorize generates the OAuth2 authorization URL for a provider.
|
||||
// GET /api/v1/auth/oauth/authorize?provider=google
|
||||
// Frontend redirects user to this URL to start OAuth flow.
|
||||
func (h *AuthHandler) OAuthAuthorize(c *gin.Context) {
|
||||
providerStr := c.Query("provider")
|
||||
provider := auth.OAuthProviderType(providerStr)
|
||||
|
||||
if !h.oauthService.IsProviderConfigured(provider) {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "OAuth provider not configured: "+providerStr)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate state for CSRF protection (store in Redis for validation)
|
||||
state := c.Query("state")
|
||||
if state == "" {
|
||||
state = generateOAuthState()
|
||||
}
|
||||
|
||||
url, err := h.oauthService.GetAuthURL(provider, state)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"authorize_url": url,
|
||||
"state": state,
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterAuthRoutes sets up auth routes on a Gin router group.
|
||||
// These routes are PUBLIC — no AuthRequired middleware.
|
||||
func RegisterAuthRoutes(rg *gin.RouterGroup, handler *AuthHandler) {
|
||||
authGroup := rg.Group("/auth")
|
||||
{
|
||||
// Core auth endpoints
|
||||
authGroup.POST("/login", handler.Login)
|
||||
authGroup.POST("/login/mfa", handler.LoginMFA)
|
||||
authGroup.POST("/register", handler.Register)
|
||||
authGroup.POST("/refresh", handler.Refresh)
|
||||
authGroup.DELETE("/logout", handler.Logout)
|
||||
|
||||
// Account management
|
||||
authGroup.POST("/switch_account", handler.SwitchAccount)
|
||||
|
||||
// Password & email
|
||||
authGroup.POST("/reset_password", handler.ResetPassword)
|
||||
authGroup.GET("/confirm_email", handler.ConfirmEmail)
|
||||
|
||||
// OAuth
|
||||
authGroup.GET("/oauth/authorize", handler.OAuthAuthorize)
|
||||
authGroup.POST("/oauth/callback", handler.OAuthCallback)
|
||||
}
|
||||
}
|
||||
|
||||
// generateOAuthState creates a cryptographically random state token for OAuth CSRF protection.
|
||||
// Production note: state should also be stored server-side (Redis) and validated on callback.
|
||||
func generateOAuthState() string {
|
||||
return "gochat_oauth_" + randomHex(16)
|
||||
}
|
||||
|
||||
// randomHex generates n cryptographically random bytes as hex string.
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := crypto_rand.Read(b); err != nil {
|
||||
// Fallback: math/rand should never be reached in production,
|
||||
// but prevents a panic if /dev/urandom is temporarily unavailable.
|
||||
math_rand.Read(b)
|
||||
}
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
Reference in New Issue
Block a user