package v1 import ( crypto_rand "crypto/rand" "fmt" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/auth" "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 wsTickets *auth.WSTicketStore secureCookies bool } const ( browserRefreshCookie = "_gochat_refresh" browserSessionMarker = "cw_d_session_state" ) func (h *AuthHandler) WithWSTicketStore(store *auth.WSTicketStore) *AuthHandler { h.wsTickets = store return h } func (h *AuthHandler) WithSecureCookies(secure bool) *AuthHandler { h.secureCookies = secure return h } // 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 } if err := h.trackChatwootSession(c, output); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session") 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.setBrowserSession(c, output) 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) var output *service.LoginOutput var err error if accessToken != "" { output, err = h.authService.ValidateAccessToken(c.Request.Context(), accessToken) } if err != nil { accessToken = "" } if accessToken == "" { refreshToken, cookieErr := c.Cookie(browserRefreshCookie) if cookieErr != nil { h.clearBrowserSession(c) response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid session") return } refreshed, refreshErr := h.authService.Refresh(c.Request.Context(), &service.RefreshInput{RefreshToken: refreshToken}) if refreshErr != nil { h.clearBrowserSession(c) response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid session") return } output = &service.LoginOutput{ User: refreshed.User, TokenPair: refreshed.TokenPair, AccountID: refreshed.AccountID, Role: refreshed.Role, ClientID: refreshed.ClientID, } accessToken = refreshed.TokenPair.AccessToken h.setBrowserSession(c, output) } h.setChatwootAuthHeaders(c, output) 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) { h.clearBrowserSession(c) accessToken := extractChatwootAccessToken(c) authenticated := false var revokeErr error if accessToken != "" { if output, validateErr := h.authService.ValidateAccessToken(c.Request.Context(), accessToken); validateErr == nil { authenticated = true revokeErr = h.authService.RevokeChatwootSession(c.Request.Context(), output.User.ID, output.ClientID) } } if refreshToken, cookieErr := c.Cookie(browserRefreshCookie); cookieErr == nil { if err := h.authService.RevokeBrowserSession(c.Request.Context(), refreshToken); err == nil { authenticated = true } else if !authenticated { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid session") return } } if !authenticated { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "authentication required") return } if revokeErr != 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) { token := extractChatwootAccessToken(c) if token == "" || h.authService == nil { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") return } output, err := h.authService.ValidateAccessToken(c.Request.Context(), token) if err != nil { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") return } if err := h.authService.RevokeChatwootSession(c.Request.Context(), output.User.ID, output.ClientID); err != nil { response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Logout failed") return } response.NoContent(c) } // IssueWSTicket returns a short-lived, one-time credential for WebSocket upgrade. func (h *AuthHandler) IssueWSTicket(c *gin.Context) { if h.wsTickets == nil || h.authService == nil { response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrInternal, "websocket authentication unavailable") return } token := extractChatwootAccessToken(c) if token == "" { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid token") return } output, err := h.authService.ValidateAccessToken(c.Request.Context(), token) if err != nil { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "invalid token") return } ticket, err := h.wsTickets.Issue(c.Request.Context(), auth.WSTicketClaims{ UserID: output.User.ID, AccountID: output.AccountID, Role: output.Role, Provider: output.User.Provider, ClientID: output.ClientID, }) if err != nil { response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrInternal, "websocket authentication unavailable") return } response.OK(c, gin.H{"ticket": ticket}) } // 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) { token := extractChatwootAccessToken(c) if _, hasLegacyUserContext := c.Get("user_id"); token == "" && !hasLegacyUserContext { 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 } if token == "" || h.authService == nil { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") return } login, err := h.authService.ValidateAccessToken(c.Request.Context(), token) if err != nil { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required") return } output, err := h.authService.SwitchAccount(c.Request.Context(), &service.SwitchAccountInput{ UserID: login.User.ID, AccountID: req.AccountID, ClientID: login.ClientID, }) 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.setBrowserSession(c, output) 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.setBrowserSession(c, output) 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.POST("/ws_ticket", handler.IssueWSTicket) 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 (h *AuthHandler) setBrowserSession(c *gin.Context, output *service.LoginOutput) { if output == nil || output.TokenPair == nil || output.TokenPair.RefreshToken == "" { return } c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(browserRefreshCookie, output.TokenPair.RefreshToken, 0, "/", "", h.secureCookies, true) c.SetCookie(browserSessionMarker, "1", 0, "/", "", h.secureCookies, false) c.Header("Cache-Control", "no-store") } func (h *AuthHandler) clearBrowserSession(c *gin.Context) { c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(browserRefreshCookie, "", -1, "/", "", h.secureCookies, true) c.SetCookie(browserSessionMarker, "", -1, "/", "", h.secureCookies, false) c.Header("Cache-Control", "no-store") } 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) }