Files
gochat/backend/internal/service/auth_service.go
T
Rogee 851ca7e372 refactor: 移除 SAML/LDAP/MFA 登录方式,仅保留本地账号密码和 OIDC
后端移除:
- 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 配置段
2026-07-29 19:03:04 +08:00

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[:])
}