package v1 import ( crypto_rand "crypto/rand" "fmt" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "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 profileService *service.ProfileService } // NewAuthHandler creates an auth handler with service dependencies. func NewAuthHandler(authService *service.AuthService, profileService ...*service.ProfileService) *AuthHandler { var profileSvc *service.ProfileService if len(profileService) > 0 { profileSvc = profileService[0] } return &AuthHandler{ authService: authService, 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"` } // 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"` } type ConfirmResetPasswordRequest struct { ResetPasswordToken string `json:"reset_password_token" binding:"required"` Password string `json:"password" binding:"required,min=6"` PasswordConfirmation string `json:"password_confirmation" binding:"required,min=6"` } type ConfirmEmailRequest struct { ConfirmationToken string `json:"confirmation_token" binding:"required"` } // --- Handlers --- // Login authenticates a user with email/password and returns JWT tokens. // POST /api/v1/auth/login 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 } 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 err := h.trackChatwootSession(c, output); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session") 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.RevokeChatwootSession(c.Request.Context(), output.User.ID, c.GetHeader("client")); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Logout failed") return } c.JSON(http.StatusOK, gin.H{"success": true}) } // 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) if err := h.authService.ResetPassword(c.Request.Context(), &service.ResetPasswordInput{ Email: req.Email, }); err != nil { applogger.L().Warnf("reset password request failed: %v", err) } c.JSON(http.StatusOK, gin.H{"message": service.ChatwootPasswordResetMessage}) } // ConfirmResetPassword completes Chatwoot's Devise-compatible password reset. // PUT /auth/password func (h *AuthHandler) ConfirmResetPassword(c *gin.Context) { var req ConfirmResetPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } output, err := h.authService.ConfirmResetPassword(c.Request.Context(), &service.ConfirmResetPasswordInput{ Token: req.ResetPasswordToken, Password: req.Password, PasswordConfirmation: req.PasswordConfirmation, }) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error(), "redirect_url": "/"}) return } if err := h.trackChatwootSession(c, output); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session") return } h.setChatwootAuthHeaders(c, output) data, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID) if err != nil { handleServiceError(c, err) return } c.JSON(http.StatusOK, gin.H{"data": data}) } // 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 } output, 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": output.User, "message": "Email confirmed successfully.", }) } // ChatwootConfirmEmail verifies the confirmation token from the reused frontend. // POST /auth/confirmation func (h *AuthHandler) ChatwootConfirmEmail(c *gin.Context) { var req ConfirmEmailRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } output, err := h.authService.ConfirmEmail(c.Request.Context(), &service.ConfirmEmailInput{Token: req.ConfirmationToken}) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error(), "redirect_url": "/"}) return } if err := h.trackChatwootSession(c, output); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session") return } h.setChatwootAuthHeaders(c, output) data, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID) if err != nil { handleServiceError(c, err) return } c.JSON(http.StatusOK, gin.H{"data": data}) } // 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("/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.PUT("/reset_password", handler.ConfirmResetPassword) authGroup.GET("/confirm_email", handler.ConfirmEmail) } } // 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) rg.PUT("/password", handler.ConfirmResetPassword) rg.POST("/confirmation", handler.ChatwootConfirmEmail) } 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) clientID := output.ClientID if clientID == "" { clientID = output.TokenPair.RefreshToken } c.Header("client", clientID) c.Header("expiry", strconv.FormatInt(output.TokenPair.ExpiresAt.Unix(), 10)) } func (h *AuthHandler) trackChatwootSession(c *gin.Context, output *service.LoginOutput) error { return h.authService.TrackChatwootSession(c.Request.Context(), output, c.GetHeader("client"), c.ClientIP(), c.GetHeader("User-Agent")) } 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 CSRF protection. // Used by OIDC and other auth flows. 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 { panic(fmt.Sprintf("generate OAuth state: %v", err)) } return fmt.Sprintf("%x", b) }