package v1 import ( crypto_rand "crypto/rand" "fmt" math_rand "math/rand" "net/http" "strconv" "strings" "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 profileService *service.ProfileService } // NewAuthHandler creates an auth handler with service dependencies. func NewAuthHandler(authService *service.AuthService, oauthService *auth.OAuthService, profileService ...*service.ProfileService) *AuthHandler { var profileSvc *service.ProfileService if len(profileService) > 0 { profileSvc = profileService[0] } return &AuthHandler{ authService: authService, oauthService: oauthService, profileService: profileSvc, } } // --- Request/Response structs --- // LoginRequest is the JSON body for login endpoint. type LoginRequest struct { Email string `json:"email" binding:"required"` 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, }) } // ChatwootSignIn authenticates through the DeviseTokenAuth-compatible route. // POST /auth/sign_in func (h *AuthHandler) ChatwootSignIn(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 { c.JSON(http.StatusPartialContent, gin.H{ "mfa_required": true, "mfa_token": strconv.FormatUint(uint64(output.User.ID), 10), }) return } h.setChatwootAuthHeaders(c, output) profile, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID) if err != nil { handleServiceError(c, err) return } c.JSON(http.StatusOK, gin.H{"data": profile}) } // ChatwootValidateToken returns the current user payload for Auth.validityCheck. // GET /auth/validate_token func (h *AuthHandler) ChatwootValidateToken(c *gin.Context) { accessToken := extractChatwootAccessToken(c) if accessToken == "" { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "access token required") return } output, err := h.authService.ValidateAccessToken(c.Request.Context(), accessToken) if err != nil { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid token") return } h.setChatwootAuthHeaders(c, &service.LoginOutput{User: output.User, AccountID: output.AccountID}) c.Header("access-token", accessToken) profile, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID) if err != nil { handleServiceError(c, err) return } c.JSON(http.StatusOK, gin.H{ "payload": gin.H{ "success": true, "data": profile, }, }) } // ChatwootSignOut revokes the current session for the DeviseTokenAuth route. // DELETE /auth/sign_out func (h *AuthHandler) ChatwootSignOut(c *gin.Context) { accessToken := extractChatwootAccessToken(c) if accessToken == "" { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "access token required") return } output, err := h.authService.ValidateAccessToken(c.Request.Context(), accessToken) if err != nil { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid token") return } if err := h.authService.Logout(c.Request.Context(), output.User.ID); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Logout failed") return } c.JSON(http.StatusOK, gin.H{"success": true}) } // 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) } } // RegisterChatwootAuthRoutes maps the root auth routes consumed by the Chatwoot frontend. func RegisterChatwootAuthRoutes(rg *gin.RouterGroup, handler *AuthHandler) { rg.POST("/sign_in", handler.ChatwootSignIn) rg.DELETE("/sign_out", handler.ChatwootSignOut) rg.GET("/validate_token", handler.ChatwootValidateToken) rg.POST("/password", handler.ResetPassword) } func (h *AuthHandler) chatwootUserPayload(c *gin.Context, userID uint, accountID uint) (any, error) { if h.profileService == nil { return gin.H{"id": userID, "account_id": accountID}, nil } return h.profileService.Get(c.Request.Context(), userID, accountID) } func (h *AuthHandler) setChatwootAuthHeaders(c *gin.Context, output *service.LoginOutput) { c.Header("token-type", "Bearer") if output == nil || output.User == nil { return } c.Header("uid", output.User.Email) if output.TokenPair == nil { return } c.Header("access-token", output.TokenPair.AccessToken) c.Header("client", output.TokenPair.RefreshToken) c.Header("expiry", strconv.FormatInt(output.TokenPair.ExpiresAt.Unix(), 10)) } func extractChatwootAccessToken(c *gin.Context) string { if token := strings.TrimSpace(c.GetHeader("access-token")); token != "" { return token } authHeader := strings.TrimSpace(c.GetHeader("Authorization")) if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { return strings.TrimSpace(authHeader[7:]) } return "" } // 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) }