后端移除: - SAML: auth/saml.go, handler/saml_handler.go, account_saml_settings_handler.go, model/account_saml_settings.go, model/saml_idp_config.go, repo/*.go - LDAP: auth/ldap.go, handler/ldap_handler.go, model/account_ldap_settings.go, repo/account_ldap_settings_repo.go - MFA: auth/mfa.go, handler/mfa_handler.go - auth_service: 移除 mfaService 依赖、MFARequired 字段、LoginWithMFA 方法 - auth_handler: 移除 LoginMFA handler、MFA 分支逻辑 - bootstrap: 移除 SAML/LDAP/MFA service 初始化和 handler 注册 - sso_middleware: 精简为仅支持 OIDC provider - router: 移除 SAML/LDAP/MFA 路由注册 - config: 移除 SAMLConfig/LDAPConfig struct 和 defaults 前端移除: - v3/login: 移除 MFA 验证流程和 SAML 登录入口 - v3/api/auth: 移除 MFA 响应处理 - v3/routes: 移除 SSO login 路由 - dashboard: 移除 MFA 设置页面、SAML 安全设置页面 - i18n: 移除 mfa.json - featureFlags: 移除 SAML feature flag .env.example / .env: 移除 SAML/LDAP 配置段
542 lines
18 KiB
Go
542 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
|
|
"github.com/gochat/gochat/internal/auth"
|
|
"github.com/gochat/gochat/internal/model"
|
|
pkgcrypto "github.com/gochat/gochat/pkg/crypto"
|
|
)
|
|
|
|
const ChatwootPasswordResetMessage = "Request for password reset is successful. A email with instructions will be sent to your email if it exists."
|
|
|
|
// Reference: P2E §1 — Auth business logic service
|
|
// Encapsulates all authentication business logic so handlers remain thin wrappers.
|
|
// Pattern follows Chatwoot's service_object pattern (app/services/).
|
|
|
|
// AuthService provides all authentication business logic.
|
|
type AuthService struct {
|
|
db *gorm.DB
|
|
jwtService *auth.JWTService
|
|
refreshStore *auth.RefreshTokenStore
|
|
}
|
|
|
|
// NewAuthService creates an auth service with all required dependencies.
|
|
func NewAuthService(
|
|
db *gorm.DB,
|
|
jwtService *auth.JWTService,
|
|
refreshStore *auth.RefreshTokenStore,
|
|
) *AuthService {
|
|
return &AuthService{
|
|
db: db,
|
|
jwtService: jwtService,
|
|
refreshStore: refreshStore,
|
|
}
|
|
}
|
|
|
|
// --- Login / Registration ---
|
|
|
|
// LoginInput holds login request parameters.
|
|
type LoginInput struct {
|
|
Email string
|
|
Password string
|
|
}
|
|
|
|
// LoginOutput holds login response data.
|
|
type LoginOutput struct {
|
|
User *model.User
|
|
TokenPair *auth.TokenPair
|
|
AccountID uint
|
|
Role string
|
|
ClientID string
|
|
}
|
|
|
|
func (s *AuthService) TrackChatwootSession(ctx context.Context, output *LoginOutput, requestedClientID, ipAddress, userAgent string) error {
|
|
if output == nil || output.User == nil {
|
|
return errors.New("login output is required")
|
|
}
|
|
clientID := strings.TrimSpace(requestedClientID)
|
|
if clientID == "" {
|
|
bytes := make([]byte, 16)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return fmt.Errorf("generate session client id: %w", err)
|
|
}
|
|
clientID = hex.EncodeToString(bytes)
|
|
}
|
|
pair, err := s.jwtService.GenerateTokenPairForClient(output.User, output.AccountID, output.Role, clientID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.refreshStore.StoreForClient(ctx, output.User.ID, clientID, pair.RefreshToken); err != nil {
|
|
return err
|
|
}
|
|
_ = s.refreshStore.Revoke(ctx, output.User.ID)
|
|
now := time.Now().UTC()
|
|
browserName, browserVersion, deviceName, platformName, platformVersion := chatwootSessionUserAgent(userAgent)
|
|
session := model.UserSession{
|
|
UserID: output.User.ID,
|
|
ClientID: clientID,
|
|
IPAddress: ipAddress,
|
|
UserAgent: userAgent,
|
|
BrowserName: browserName,
|
|
BrowserVersion: browserVersion,
|
|
DeviceName: deviceName,
|
|
PlatformName: platformName,
|
|
PlatformVersion: platformVersion,
|
|
LastActivityAt: &now,
|
|
}
|
|
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}},
|
|
DoUpdates: clause.AssignmentColumns([]string{"ip_address", "user_agent", "browser_name", "browser_version", "device_name", "platform_name", "platform_version", "last_activity_at", "updated_at"}),
|
|
}).Create(&session).Error; err != nil {
|
|
return err
|
|
}
|
|
output.TokenPair = pair
|
|
output.ClientID = clientID
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) RevokeChatwootSession(ctx context.Context, userID uint, clientID string) error {
|
|
if strings.TrimSpace(clientID) != "" {
|
|
if err := s.db.WithContext(ctx).Where("user_id = ? AND client_id = ?", userID, clientID).Delete(&model.UserSession{}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return s.refreshStore.RevokeClient(ctx, userID, clientID)
|
|
}
|
|
|
|
func chatwootSessionUserAgent(userAgent string) (browserName, browserVersion, deviceName, platformName, platformVersion string) {
|
|
browserName = "Unknown"
|
|
deviceName = "Desktop"
|
|
platformName = "Unknown"
|
|
for _, candidate := range []struct{ marker, name string }{{"Edg/", "Edge"}, {"Chrome/", "Chrome"}, {"Firefox/", "Firefox"}, {"Version/", "Safari"}} {
|
|
if idx := strings.Index(userAgent, candidate.marker); idx >= 0 {
|
|
browserName = candidate.name
|
|
if fields := strings.Fields(userAgent[idx+len(candidate.marker):]); len(fields) > 0 {
|
|
browserVersion = strings.TrimRight(fields[0], ");")
|
|
}
|
|
break
|
|
}
|
|
}
|
|
switch {
|
|
case strings.Contains(userAgent, "Windows NT"):
|
|
platformName = "Windows"
|
|
case strings.Contains(userAgent, "Mac OS X"):
|
|
platformName = "macOS"
|
|
case strings.Contains(userAgent, "Android"):
|
|
platformName, deviceName = "Android", "Mobile"
|
|
case strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "iPad"):
|
|
platformName, deviceName = "iOS", "Mobile"
|
|
case strings.Contains(userAgent, "Linux"):
|
|
platformName = "Linux"
|
|
}
|
|
return
|
|
}
|
|
|
|
// Login authenticates a user by email+password.
|
|
// Flow: verify credentials → generate JWT pair.
|
|
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 = ?", email).First(&user).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, fmt.Errorf("invalid email or password")
|
|
}
|
|
return nil, fmt.Errorf("database error: %w", err)
|
|
}
|
|
|
|
// Check if user is active — inactive users cannot login
|
|
if !user.Active {
|
|
return nil, fmt.Errorf("user account is inactive")
|
|
}
|
|
|
|
// Verify password using bcrypt
|
|
// OAuth-only users (provider != email) cannot login with password
|
|
if user.Provider != "email" && user.Provider != "" {
|
|
return nil, fmt.Errorf("this account uses %s authentication, please login via that provider", user.Provider)
|
|
}
|
|
|
|
if !pkgcrypto.CheckPassword(input.Password, user.PasswordDigest) && !pkgcrypto.CheckPassword(input.Password, user.Password) {
|
|
return nil, fmt.Errorf("invalid email or password")
|
|
}
|
|
|
|
// Check if email is confirmed
|
|
if user.ConfirmedAt == nil {
|
|
return nil, fmt.Errorf("email not confirmed, please verify your email first")
|
|
}
|
|
|
|
// Get user's first active account (Chatwoot: AccountUser join)
|
|
accountID, role, err := s.getUserDefaultAccount(&user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user account: %w", err)
|
|
}
|
|
|
|
// Generate JWT token pair
|
|
tokenPair, err := s.jwtService.GenerateTokenPair(&user, accountID, role)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate tokens: %w", err)
|
|
}
|
|
|
|
// Store refresh token in Redis
|
|
if err := s.refreshStore.Store(ctx, user.ID, tokenPair.RefreshToken); err != nil {
|
|
return nil, fmt.Errorf("failed to store refresh token: %w", err)
|
|
}
|
|
|
|
// SSO session creation is deferred until auth.SSOSessionStore is implemented.
|
|
// Reference: M13 §5 — SSO session creation in login flow
|
|
|
|
// Update sign-in tracking
|
|
user.SignInCount++
|
|
now := time.Now()
|
|
user.LastSignInAt = user.CurrentSignInAt
|
|
user.CurrentSignInAt = &now
|
|
s.db.Save(&user)
|
|
|
|
return &LoginOutput{
|
|
User: &user,
|
|
TokenPair: tokenPair,
|
|
AccountID: accountID,
|
|
Role: role,
|
|
}, 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
|
|
}
|
|
if claims.ClientID != "" {
|
|
var session model.UserSession
|
|
if err := s.db.WithContext(ctx).Where("user_id = ? AND client_id = ?", claims.UserID, claims.ClientID).First(&session).Error; err != nil {
|
|
return nil, errors.New("session revoked")
|
|
}
|
|
if session.LastActivityAt == nil || session.LastActivityAt.Before(time.Now().Add(-5*time.Minute)) {
|
|
now := time.Now().UTC()
|
|
_ = s.db.WithContext(ctx).Model(&session).Updates(map[string]any{"last_activity_at": now, "updated_at": now}).Error
|
|
}
|
|
}
|
|
|
|
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, ClientID: claims.ClientID}, nil
|
|
}
|
|
|
|
// --- Token Refresh / Rotation ---
|
|
|
|
// RefreshInput holds refresh token request parameters.
|
|
type RefreshInput struct {
|
|
RefreshToken string
|
|
}
|
|
|
|
// RefreshOutput holds refresh token response data.
|
|
type RefreshOutput struct {
|
|
TokenPair *auth.TokenPair
|
|
User *model.User
|
|
}
|
|
|
|
// Refresh rotates a refresh token: validates old token, generates new pair.
|
|
// Implements refresh token rotation per P2E §1.4 security requirement.
|
|
func (s *AuthService) Refresh(ctx context.Context, input *RefreshInput) (*RefreshOutput, error) {
|
|
// Validate the refresh token JWT first
|
|
claims, err := s.jwtService.ValidateRefreshToken(input.RefreshToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid refresh token: %w", err)
|
|
}
|
|
|
|
// Check refresh token exists in Redis (prevents reuse after logout)
|
|
valid, err := s.refreshStore.ValidateForClient(ctx, claims.UserID, claims.ClientID, input.RefreshToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refresh token validation failed: %w", err)
|
|
}
|
|
if !valid {
|
|
return nil, fmt.Errorf("refresh token expired or revoked")
|
|
}
|
|
|
|
// Find user
|
|
var user model.User
|
|
if err := s.db.First(&user, claims.UserID).Error; err != nil {
|
|
return nil, fmt.Errorf("user not found: %w", err)
|
|
}
|
|
|
|
// Get user's default account
|
|
accountID, role, err := s.getUserDefaultAccount(&user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user account: %w", err)
|
|
}
|
|
|
|
// Generate new token pair
|
|
tokenPair, err := s.jwtService.GenerateTokenPairForClient(&user, accountID, role, claims.ClientID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate tokens: %w", err)
|
|
}
|
|
|
|
// Rotate refresh token in Redis (old token revoked, new token stored)
|
|
if err := s.refreshStore.RotateForClient(ctx, claims.UserID, claims.ClientID, tokenPair.RefreshToken); err != nil {
|
|
return nil, fmt.Errorf("failed to rotate refresh token: %w", err)
|
|
}
|
|
|
|
return &RefreshOutput{
|
|
TokenPair: tokenPair,
|
|
User: &user,
|
|
}, nil
|
|
}
|
|
|
|
// --- Logout ---
|
|
|
|
// Logout revokes a user's refresh token, effectively logging them out.
|
|
// Access tokens will still be valid until expiry, but refresh is revoked.
|
|
func (s *AuthService) Logout(ctx context.Context, userID uint) error {
|
|
return s.refreshStore.Revoke(ctx, userID)
|
|
}
|
|
|
|
// --- Account Switching ---
|
|
|
|
// SwitchAccountInput holds account switch parameters.
|
|
type SwitchAccountInput struct {
|
|
UserID uint
|
|
AccountID uint
|
|
}
|
|
|
|
// SwitchAccountOutput holds account switch response.
|
|
type SwitchAccountOutput struct {
|
|
TokenPair *auth.TokenPair
|
|
AccountID uint
|
|
Role string
|
|
}
|
|
|
|
// SwitchAccount generates new tokens with a different account scope.
|
|
// Ref: Chatwoot's account switch in the UI — user can operate in multiple accounts.
|
|
func (s *AuthService) SwitchAccount(ctx context.Context, input *SwitchAccountInput) (*SwitchAccountOutput, error) {
|
|
// Verify user belongs to the target account
|
|
var accountUser AccountUser
|
|
if err := s.db.Where("user_id = ? AND account_id = ?", input.UserID, input.AccountID).First(&accountUser).Error; err != nil {
|
|
return nil, fmt.Errorf("user does not belong to account %d", input.AccountID)
|
|
}
|
|
|
|
// Find user
|
|
var user model.User
|
|
if err := s.db.First(&user, input.UserID).Error; err != nil {
|
|
return nil, fmt.Errorf("user not found: %w", err)
|
|
}
|
|
|
|
// Generate new token pair with switched account
|
|
tokenPair, err := s.jwtService.GenerateTokenPair(&user, input.AccountID, accountUser.Role)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate tokens: %w", err)
|
|
}
|
|
|
|
// Rotate refresh token
|
|
if err := s.refreshStore.Rotate(ctx, input.UserID, tokenPair.RefreshToken); err != nil {
|
|
// If no old token to rotate, just store new one
|
|
if err := s.refreshStore.Store(ctx, input.UserID, tokenPair.RefreshToken); err != nil {
|
|
return nil, fmt.Errorf("failed to store refresh token: %w", err)
|
|
}
|
|
}
|
|
|
|
return &SwitchAccountOutput{
|
|
TokenPair: tokenPair,
|
|
AccountID: input.AccountID,
|
|
Role: accountUser.Role,
|
|
}, nil
|
|
}
|
|
|
|
// --- Password Reset ---
|
|
|
|
// ResetPasswordInput holds password reset request parameters.
|
|
type ResetPasswordInput struct {
|
|
Email string
|
|
}
|
|
|
|
// ResetPassword initiates a password reset flow.
|
|
// Generates a reset token stored in Redis, sends email with reset link.
|
|
func (s *AuthService) ResetPassword(ctx context.Context, input *ResetPasswordInput) error {
|
|
var user model.User
|
|
email := strings.TrimSpace(strings.ToLower(input.Email))
|
|
if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
|
|
// Don't reveal whether email exists — security best practice
|
|
return nil
|
|
}
|
|
|
|
token, err := generateAuthToken()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate reset token: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
return s.db.WithContext(ctx).Model(&user).Updates(map[string]interface{}{
|
|
"reset_password_token": digestAuthToken(token),
|
|
"reset_password_sent_at": now,
|
|
}).Error
|
|
}
|
|
|
|
// ConfirmResetPasswordInput holds password reset confirmation parameters.
|
|
type ConfirmResetPasswordInput struct {
|
|
Token string
|
|
Password string
|
|
PasswordConfirmation string
|
|
}
|
|
|
|
// ConfirmResetPassword completes password reset by verifying token and updating password.
|
|
func (s *AuthService) ConfirmResetPassword(ctx context.Context, input *ConfirmResetPasswordInput) (*LoginOutput, error) {
|
|
token := strings.TrimSpace(input.Token)
|
|
if token == "" {
|
|
return nil, fmt.Errorf("Invalid token")
|
|
}
|
|
if input.Password == "" || input.Password != input.PasswordConfirmation {
|
|
return nil, fmt.Errorf("invalid password confirmation")
|
|
}
|
|
|
|
var user model.User
|
|
digest := digestAuthToken(token)
|
|
if err := s.db.WithContext(ctx).Where("reset_password_token IN ?", []string{digest, token}).First(&user).Error; err != nil {
|
|
return nil, fmt.Errorf("Invalid token")
|
|
}
|
|
if user.ResetPasswordSentAt != nil && time.Since(*user.ResetPasswordSentAt) > 6*time.Hour {
|
|
return nil, fmt.Errorf("Invalid token")
|
|
}
|
|
|
|
passwordDigest, err := pkgcrypto.HashPassword(input.Password)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
updates := map[string]interface{}{
|
|
"password": passwordDigest,
|
|
"password_digest": passwordDigest,
|
|
"reset_password_token": "",
|
|
"reset_password_sent_at": nil,
|
|
"confirmation_token": "",
|
|
}
|
|
if user.ConfirmedAt == nil {
|
|
updates["confirmed_at"] = now
|
|
user.ConfirmedAt = &now
|
|
}
|
|
if err := s.db.WithContext(ctx).Model(&user).Updates(updates).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
user.Password = passwordDigest
|
|
user.PasswordDigest = passwordDigest
|
|
user.ResetPasswordToken = ""
|
|
user.ResetPasswordSentAt = nil
|
|
user.ConfirmationToken = ""
|
|
|
|
return s.issueLoginOutput(ctx, &user)
|
|
}
|
|
|
|
// --- Email Confirmation ---
|
|
|
|
// ConfirmEmailInput holds email confirmation parameters.
|
|
type ConfirmEmailInput struct {
|
|
Token string
|
|
}
|
|
|
|
// ConfirmEmail verifies a confirmation token and marks user email as confirmed.
|
|
func (s *AuthService) ConfirmEmail(ctx context.Context, input *ConfirmEmailInput) (*LoginOutput, error) {
|
|
token := strings.TrimSpace(input.Token)
|
|
if token == "" {
|
|
return nil, fmt.Errorf("Invalid token")
|
|
}
|
|
|
|
var user model.User
|
|
if err := s.db.WithContext(ctx).Where("confirmation_token = ?", token).First(&user).Error; err != nil {
|
|
return nil, fmt.Errorf("Invalid token")
|
|
}
|
|
if user.ConfirmedAt != nil {
|
|
return nil, fmt.Errorf("Already confirmed")
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := s.db.WithContext(ctx).Model(&user).Updates(map[string]interface{}{
|
|
"confirmed_at": now,
|
|
"confirmation_token": "",
|
|
}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
user.ConfirmedAt = &now
|
|
user.ConfirmationToken = ""
|
|
|
|
return s.issueLoginOutput(ctx, &user)
|
|
}
|
|
|
|
// --- Internal Helpers ---
|
|
|
|
// AccountUser represents the join between User and Account (many-to-many).
|
|
// Ref: Chatwoot AccountUser model (role assignment per account).
|
|
type AccountUser struct {
|
|
ID uint `gorm:"primaryKey"`
|
|
UserID uint `gorm:"not null;index"`
|
|
AccountID uint `gorm:"not null;index"`
|
|
Role string `gorm:"size:50;default:agent"` // agent/administrator/custom_role_id
|
|
}
|
|
|
|
func (AccountUser) TableName() string { return "account_users" }
|
|
|
|
// getUserDefaultAccount finds the user's first active account and role.
|
|
func (s *AuthService) getUserDefaultAccount(user *model.User) (uint, string, error) {
|
|
var accountUser AccountUser
|
|
if err := s.db.Where("user_id = ?", user.ID).Order("id ASC").First(&accountUser).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return 0, "", fmt.Errorf("user has no account")
|
|
}
|
|
return 0, "", err
|
|
}
|
|
return accountUser.AccountID, accountUser.Role, nil
|
|
}
|
|
|
|
func (s *AuthService) issueLoginOutput(ctx context.Context, user *model.User) (*LoginOutput, error) {
|
|
accountID, role, err := s.getUserDefaultAccount(user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user account: %w", err)
|
|
}
|
|
tokenPair, err := s.jwtService.GenerateTokenPair(user, accountID, role)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate tokens: %w", err)
|
|
}
|
|
if err := s.refreshStore.Store(ctx, user.ID, tokenPair.RefreshToken); err != nil {
|
|
return nil, fmt.Errorf("failed to store refresh token: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
user.SignInCount++
|
|
user.LastSignInAt = user.CurrentSignInAt
|
|
user.CurrentSignInAt = &now
|
|
if err := s.db.WithContext(ctx).Save(user).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &LoginOutput{User: user, TokenPair: tokenPair, AccountID: accountID, Role: role}, nil
|
|
}
|
|
|
|
func generateAuthToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
func digestAuthToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|