feat(auth): add chatwoot session routes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user