feat(auth): add chatwoot session routes

This commit is contained in:
2026-06-05 01:17:32 +08:00
parent c1fd56eb40
commit e59f9f3c4a
7 changed files with 351 additions and 28 deletions
+141 -7
View File
@@ -5,6 +5,8 @@ import (
"fmt"
math_rand "math/rand"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -27,15 +29,21 @@ import (
// AuthHandler handles authentication HTTP endpoints.
type AuthHandler struct {
authService *service.AuthService
oauthService *auth.OAuthService
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) *AuthHandler {
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,
authService: authService,
oauthService: oauthService,
profileService: profileSvc,
}
}
@@ -43,7 +51,7 @@ func NewAuthHandler(authService *service.AuthService, oauthService *auth.OAuthSe
// LoginRequest is the JSON body for login endpoint.
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
}
@@ -122,6 +130,92 @@ func (h *AuthHandler) Login(c *gin.Context) {
})
}
// 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) {
@@ -384,6 +478,46 @@ func RegisterAuthRoutes(rg *gin.RouterGroup, handler *AuthHandler) {
}
}
// 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 {
@@ -399,4 +533,4 @@ func randomHex(n int) string {
math_rand.Read(b)
}
return fmt.Sprintf("%x", b)
}
}
@@ -0,0 +1,137 @@
package v1
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/crypto"
)
func setupChatwootAuthTest(t *testing.T) (*gin.Engine, *model.User) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.AccessToken{}))
account := &model.Account{Name: "Auth Account", Status: "active", OnboardingStep: "invite_team"}
require.NoError(t, db.Create(account).Error)
passwordDigest, err := crypto.HashPassword("password123")
require.NoError(t, err)
confirmedAt := time.Now().UTC()
user := &model.User{
AccountID: account.ID,
Name: "Auth User",
Email: "auth@example.com",
PasswordDigest: passwordDigest,
Provider: "email",
DisplayName: "Auth Display",
MessageSignature: "Cheers",
ConfirmedAt: &confirmedAt,
Active: true,
}
require.NoError(t, db.Create(user).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator", Availability: "online", AutoOffline: true}).Error)
require.NoError(t, db.Create(&model.AccessToken{OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: user.ID, Token: "auth-profile-token", TokenPrefix: "auth-pro", Name: "Personal Access Token"}).Error)
jwtCfg := &config.JWTConfig{Secret: "auth-test-secret", ExpiryHours: 1, RefreshExpiryHours: 24}
jwtSvc := auth.NewJWTService(jwtCfg)
refreshStore := auth.NewRefreshTokenStore(nil, jwtCfg)
authSvc := service.NewAuthService(db, jwtSvc, refreshStore, nil, nil)
profileSvc := service.NewProfileService(repository.NewUserRepo(db), repository.NewAccountUserRepo(db), repository.NewAccessTokenRepo(db))
handler := NewAuthHandler(authSvc, nil, profileSvc)
router := gin.New()
RegisterChatwootAuthRoutes(router.Group("/auth"), handler)
return router, user
}
func TestChatwootAuthSignInReturnsDeviseHeadersAndUserPayload(t *testing.T) {
router, _ := setupChatwootAuthTest(t)
body, _ := json.Marshal(map[string]string{"email": " AUTH@example.com ", "password": "password123"})
req, _ := http.NewRequest(http.MethodPost, "/auth/sign_in", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.NotEmpty(t, w.Header().Get("access-token"))
require.Equal(t, "Bearer", w.Header().Get("token-type"))
require.NotEmpty(t, w.Header().Get("client"))
require.NotEmpty(t, w.Header().Get("expiry"))
require.Equal(t, "auth@example.com", w.Header().Get("uid"))
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
data := payload["data"].(map[string]any)
require.Equal(t, "Auth User", data["name"])
require.Equal(t, "Auth Display", data["available_name"])
require.Equal(t, "auth-profile-token", data["access_token"])
require.Equal(t, "administrator", data["role"])
accounts := data["accounts"].([]any)
account := accounts[0].(map[string]any)
require.Equal(t, "online", account["availability"])
require.Equal(t, true, account["auto_offline"])
}
func TestChatwootAuthValidateTokenReturnsPayloadData(t *testing.T) {
router, _ := setupChatwootAuthTest(t)
token := signInAndReturnAccessToken(t, router)
req, _ := http.NewRequest(http.MethodGet, "/auth/validate_token", nil)
req.Header.Set("access-token", token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
payload := body["payload"].(map[string]any)
require.Equal(t, true, payload["success"])
data := payload["data"].(map[string]any)
require.Equal(t, "auth@example.com", data["email"])
}
func TestChatwootAuthSignOutRevokesRefreshSession(t *testing.T) {
router, _ := setupChatwootAuthTest(t)
token := signInAndReturnAccessToken(t, router)
req, _ := http.NewRequest(http.MethodDelete, "/auth/sign_out", nil)
req.Header.Set("access-token", token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, true, body["success"])
}
func signInAndReturnAccessToken(t *testing.T, router *gin.Engine) string {
t.Helper()
body, _ := json.Marshal(map[string]string{"email": "auth@example.com", "password": "password123"})
req, _ := http.NewRequest(http.MethodPost, "/auth/sign_in", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
return w.Header().Get("access-token")
}