diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 28e88a66..392dea6f 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -100,6 +100,7 @@ DELETE /api/v1/profile/avatar DELETE /api/v1/profile/mfa/ DELETE /api/v1/push_subscriptions/:id DELETE /api/v1/widget/labels/:label_id +DELETE /auth/sign_out DELETE /platform/api/v1/accounts/:account_id DELETE /platform/api/v1/accounts/:account_id/account_users/:user_id DELETE /platform/api/v1/agent_bots/:id @@ -389,6 +390,7 @@ GET /api/v2/accounts/:account_id/summary_reports/channel GET /api/v2/accounts/:account_id/summary_reports/inbox GET /api/v2/accounts/:account_id/summary_reports/label GET /api/v2/accounts/:account_id/summary_reports/team +GET /auth/validate_token GET /cable GET /health GET /platform/api/v1/accounts @@ -687,6 +689,8 @@ POST /api/v1/widget/integrations/dyte/add_participant_to_meeting POST /api/v1/widget/labels POST /api/v1/widget/messages POST /api/v2/accounts/ +POST /auth/password +POST /auth/sign_in POST /platform/api/v1/accounts POST /platform/api/v1/accounts/:account_id/account_users POST /platform/api/v1/agent_bots @@ -799,4 +803,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 801 +TOTAL: 805 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 72942ebe..b10a17cd 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -683,7 +683,7 @@ func Bootstrap(env string) (*App, error) { contactMergeRepo := repository.NewContactMergeRepo(db) contactMergeService := service.NewContactMergeService(contactMergeRepo, db) handlers := &router.Handlers{ - Auth: v1.NewAuthHandler(authService, oauthService), + Auth: v1.NewAuthHandler(authService, oauthService, profileService), MFA: v1.NewMFAHandler(mfaService), SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML), Account: v1.NewAccountHandler(accountService), diff --git a/internal/auth/refresh_store.go b/internal/auth/refresh_store.go index b2619f21..498f7903 100644 --- a/internal/auth/refresh_store.go +++ b/internal/auth/refresh_store.go @@ -3,6 +3,7 @@ package auth import ( "context" "fmt" + "sync" "time" "github.com/redis/go-redis/v9" @@ -18,16 +19,24 @@ import ( type RefreshTokenStore struct { rdb *redis.Client cfg *config.JWTConfig + mu sync.RWMutex + mem map[uint]string } // NewRefreshTokenStore creates a refresh token store backed by Redis. func NewRefreshTokenStore(rdb *redis.Client, cfg *config.JWTConfig) *RefreshTokenStore { - return &RefreshTokenStore{rdb: rdb, cfg: cfg} + return &RefreshTokenStore{rdb: rdb, cfg: cfg, mem: map[uint]string{}} } // Store saves a refresh token in Redis with TTL. // Key pattern: refresh_token:{user_id}:{token_hash} func (s *RefreshTokenStore) Store(ctx context.Context, userID uint, refreshToken string) error { + if s.rdb == nil { + s.mu.Lock() + defer s.mu.Unlock() + s.mem[userID] = refreshToken + return nil + } key := fmt.Sprintf("gochat:refresh_token:%d", userID) ttl := time.Duration(s.cfg.RefreshExpiryHours) * time.Hour return s.rdb.Set(ctx, key, refreshToken, ttl).Err() @@ -35,6 +44,12 @@ func (s *RefreshTokenStore) Store(ctx context.Context, userID uint, refreshToken // Validate checks if a refresh token exists and matches the stored value. func (s *RefreshTokenStore) Validate(ctx context.Context, userID uint, refreshToken string) (bool, error) { + if s.rdb == nil { + s.mu.RLock() + defer s.mu.RUnlock() + stored, ok := s.mem[userID] + return ok && stored == refreshToken, nil + } key := fmt.Sprintf("gochat:refresh_token:%d", userID) stored, err := s.rdb.Get(ctx, key).Result() if err == redis.Nil { @@ -48,6 +63,12 @@ func (s *RefreshTokenStore) Validate(ctx context.Context, userID uint, refreshTo // Revoke removes a refresh token from Redis (logout). func (s *RefreshTokenStore) Revoke(ctx context.Context, userID uint) error { + if s.rdb == nil { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.mem, userID) + return nil + } key := fmt.Sprintf("gochat:refresh_token:%d", userID) return s.rdb.Del(ctx, key).Err() } @@ -56,4 +77,4 @@ func (s *RefreshTokenStore) Revoke(ctx context.Context, userID uint) error { // This ensures each refresh token can only be used once. func (s *RefreshTokenStore) Rotate(ctx context.Context, userID uint, newRefreshToken string) error { return s.Store(ctx, userID, newRefreshToken) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/auth_handler.go b/internal/handler/api/v1/auth_handler.go index df179204..99c2bc67 100644 --- a/internal/handler/api/v1/auth_handler.go +++ b/internal/handler/api/v1/auth_handler.go @@ -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) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/auth_handler_test.go b/internal/handler/api/v1/auth_handler_test.go new file mode 100644 index 00000000..2858aa56 --- /dev/null +++ b/internal/handler/api/v1/auth_handler_test.go @@ -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") +} diff --git a/internal/router/router.go b/internal/router/router.go index 90c6fb4f..cc71aa79 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -191,6 +191,7 @@ func RegisterRoutes( // Auth routes — PUBLIC, no AuthRequired middleware v1.RegisterAuthRoutes(engine.Group("/api/v1"), handlers.Auth) + v1.RegisterChatwootAuthRoutes(engine.Group("/auth"), handlers.Auth) // SAML routes — PUBLIC, no AuthRequired middleware (SAML flow is external) v1.RegisterSAMLRoutes(engine.Group("/api/v1"), handlers.SAML) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 870aa2ed..90e0f517 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "strings" "time" "gorm.io/gorm" @@ -18,11 +19,11 @@ import ( // AuthService provides all authentication business logic. type AuthService struct { - db *gorm.DB - jwtService *auth.JWTService - refreshStore *auth.RefreshTokenStore - oauthService *auth.OAuthService - mfaService *auth.MFAService + db *gorm.DB + jwtService *auth.JWTService + refreshStore *auth.RefreshTokenStore + oauthService *auth.OAuthService + mfaService *auth.MFAService } // NewAuthService creates an auth service with all required dependencies. @@ -34,11 +35,11 @@ func NewAuthService( mfaService *auth.MFAService, ) *AuthService { return &AuthService{ - db: db, - jwtService: jwtService, - refreshStore: refreshStore, - oauthService: oauthService, - mfaService: mfaService, + db: db, + jwtService: jwtService, + refreshStore: refreshStore, + oauthService: oauthService, + mfaService: mfaService, } } @@ -52,20 +53,21 @@ type LoginInput struct { // LoginOutput holds login response data. type LoginOutput struct { - User *model.User - TokenPair *auth.TokenPair - AccountID uint - Role string - MFARequired bool + User *model.User + TokenPair *auth.TokenPair + AccountID uint + Role string + MFARequired bool } // Login authenticates a user by email+password. // Flow: verify credentials → check MFA → generate JWT pair. // If MFA is enabled, returns MFARequired=true without tokens; client must verify TOTP first. func (s *AuthService) Login(ctx context.Context, input *LoginInput) (*LoginOutput, error) { + email := strings.TrimSpace(strings.ToLower(input.Email)) // Find user by email var user model.User - if err := s.db.Where("email = ?", input.Email).First(&user).Error; err != nil { + if err := s.db.Where("email = ?", email).First(&user).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("invalid email or password") } @@ -135,6 +137,30 @@ func (s *AuthService) Login(ctx context.Context, input *LoginInput) (*LoginOutpu }, nil } +// ValidateAccessToken returns the current user/session context for a Chatwoot auth token. +func (s *AuthService) ValidateAccessToken(ctx context.Context, accessToken string) (*LoginOutput, error) { + claims, err := s.jwtService.ValidateAccessToken(accessToken) + if err != nil { + return nil, err + } + + var user model.User + if err := s.db.WithContext(ctx).First(&user, claims.UserID).Error; err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } + + accountID := claims.AccountID + role := claims.Role + if accountID == 0 || role == "" { + accountID, role, err = s.getUserDefaultAccount(&user) + if err != nil { + return nil, fmt.Errorf("failed to get user account: %w", err) + } + } + + return &LoginOutput{User: &user, AccountID: accountID, Role: role}, nil +} + // LoginWithMFA completes login after MFA verification. // Called after user provides valid TOTP code. func (s *AuthService) LoginWithMFA(ctx context.Context, userID uint, totpCode string) (*LoginOutput, error) { @@ -509,4 +535,4 @@ func (s *AuthService) createDefaultAccount(user *model.User) (uint, string, erro } return account.ID, "administrator", nil -} \ No newline at end of file +}