Files
gochat/backend/internal/handler/api/v1/auth_handler.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

458 lines
15 KiB
Go

package v1
import (
crypto_rand "crypto/rand"
"fmt"
math_rand "math/rand"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
)
// Reference: P2E §1 + P2C §Auth routes
// Auth HTTP handlers are thin wrappers around auth_service business logic.
// Maps to Chatwoot auth controllers:
// - auth/sessions_controller → Login
// - auth/registrations_controller → Register
// - auth/token_refreshes_controller → Refresh
// - auth/sessions_controller#destroy → Logout
// - accounts_controller#switch → SwitchAccount
// - auth/passwords_controller → ResetPassword
// - auth/confirmations_controller → ConfirmEmail
// - omniauth_callbacks_controller → OAuthCallback
// AuthHandler handles authentication HTTP endpoints.
type AuthHandler struct {
authService *service.AuthService
profileService *service.ProfileService
}
// NewAuthHandler creates an auth handler with service dependencies.
func NewAuthHandler(authService *service.AuthService, profileService ...*service.ProfileService) *AuthHandler {
var profileSvc *service.ProfileService
if len(profileService) > 0 {
profileSvc = profileService[0]
}
return &AuthHandler{
authService: authService,
profileService: profileSvc,
}
}
// --- Request/Response structs ---
// LoginRequest is the JSON body for login endpoint.
type LoginRequest struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
}
// RefreshRequest is the JSON body for refresh endpoint.
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
// SwitchAccountRequest is the JSON body for account switching.
type SwitchAccountRequest struct {
AccountID uint `json:"account_id" binding:"required"`
}
// ResetPasswordRequest is the JSON body for password reset.
type ResetPasswordRequest struct {
Email string `json:"email" binding:"required,email"`
}
type ConfirmResetPasswordRequest struct {
ResetPasswordToken string `json:"reset_password_token" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
PasswordConfirmation string `json:"password_confirmation" binding:"required,min=6"`
}
type ConfirmEmailRequest struct {
ConfirmationToken string `json:"confirmation_token" binding:"required"`
}
// --- Handlers ---
// Login authenticates a user with email/password and returns JWT tokens.
// POST /api/v1/auth/login
func (h *AuthHandler) Login(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
}
response.OK(c, gin.H{
"user": output.User,
"access_token": output.TokenPair.AccessToken,
"refresh_token": output.TokenPair.RefreshToken,
"expires_at": output.TokenPair.ExpiresAt,
"account_id": output.AccountID,
"role": output.Role,
})
}
// 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 err := h.trackChatwootSession(c, output); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session")
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.RevokeChatwootSession(c.Request.Context(), output.User.ID, c.GetHeader("client")); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Logout failed")
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// Refresh rotates a refresh token and returns new JWT pair.
// POST /api/v1/auth/refresh
// Implements refresh token rotation per P2E §1.4 security requirement.
func (h *AuthHandler) Refresh(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
output, err := h.authService.Refresh(c.Request.Context(), &service.RefreshInput{
RefreshToken: req.RefreshToken,
})
if err != nil {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, err.Error())
return
}
response.OK(c, gin.H{
"user": output.User,
"access_token": output.TokenPair.AccessToken,
"refresh_token": output.TokenPair.RefreshToken,
"expires_at": output.TokenPair.ExpiresAt,
})
}
// Logout revokes a user's refresh token.
// DELETE /api/v1/auth/logout
// Requires authentication — uses user_id from JWT context.
func (h *AuthHandler) Logout(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required")
return
}
if err := h.authService.Logout(c.Request.Context(), userID); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Logout failed")
return
}
response.NoContent(c)
}
// SwitchAccount generates new tokens with a different account scope.
// POST /api/v1/auth/switch_account
// Requires authentication — uses user_id from JWT context.
func (h *AuthHandler) SwitchAccount(c *gin.Context) {
userID := c.GetUint("user_id")
if userID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Authentication required")
return
}
var req SwitchAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
output, err := h.authService.SwitchAccount(c.Request.Context(), &service.SwitchAccountInput{
UserID: userID,
AccountID: req.AccountID,
})
if err != nil {
response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, err.Error())
return
}
response.OK(c, gin.H{
"access_token": output.TokenPair.AccessToken,
"refresh_token": output.TokenPair.RefreshToken,
"expires_at": output.TokenPair.ExpiresAt,
"account_id": output.AccountID,
"role": output.Role,
})
}
// ResetPassword initiates password reset by sending reset email.
// POST /api/v1/auth/reset_password
func (h *AuthHandler) ResetPassword(c *gin.Context) {
var req ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
// Always return success even if email doesn't exist (security best practice)
h.authService.ResetPassword(c.Request.Context(), &service.ResetPasswordInput{
Email: req.Email,
})
c.JSON(http.StatusOK, gin.H{"message": service.ChatwootPasswordResetMessage})
}
// ConfirmResetPassword completes Chatwoot's Devise-compatible password reset.
// PUT /auth/password
func (h *AuthHandler) ConfirmResetPassword(c *gin.Context) {
var req ConfirmResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
output, err := h.authService.ConfirmResetPassword(c.Request.Context(), &service.ConfirmResetPasswordInput{
Token: req.ResetPasswordToken,
Password: req.Password,
PasswordConfirmation: req.PasswordConfirmation,
})
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error(), "redirect_url": "/"})
return
}
if err := h.trackChatwootSession(c, output); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session")
return
}
h.setChatwootAuthHeaders(c, output)
data, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID)
if err != nil {
handleServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"data": data})
}
// ConfirmEmail verifies email confirmation token.
// GET /api/v1/auth/confirm_email?token=xxx
func (h *AuthHandler) ConfirmEmail(c *gin.Context) {
token := c.Query("token")
if token == "" {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "Confirmation token required")
return
}
output, err := h.authService.ConfirmEmail(c.Request.Context(), &service.ConfirmEmailInput{
Token: token,
})
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
return
}
response.OK(c, gin.H{
"user": output.User,
"message": "Email confirmed successfully.",
})
}
// ChatwootConfirmEmail verifies the confirmation token from the reused frontend.
// POST /auth/confirmation
func (h *AuthHandler) ChatwootConfirmEmail(c *gin.Context) {
var req ConfirmEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
output, err := h.authService.ConfirmEmail(c.Request.Context(), &service.ConfirmEmailInput{Token: req.ConfirmationToken})
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error(), "redirect_url": "/"})
return
}
if err := h.trackChatwootSession(c, output); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create session")
return
}
h.setChatwootAuthHeaders(c, output)
data, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID)
if err != nil {
handleServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"data": data})
}
// RegisterAuthRoutes sets up auth routes on a Gin router group.
// These routes are PUBLIC — no AuthRequired middleware.
func RegisterAuthRoutes(rg *gin.RouterGroup, handler *AuthHandler) {
authGroup := rg.Group("/auth")
{
// Core auth endpoints
authGroup.POST("/login", handler.Login)
authGroup.POST("/refresh", handler.Refresh)
authGroup.DELETE("/logout", handler.Logout)
// Account management
authGroup.POST("/switch_account", handler.SwitchAccount)
// Password & email
authGroup.POST("/reset_password", handler.ResetPassword)
authGroup.PUT("/reset_password", handler.ConfirmResetPassword)
authGroup.GET("/confirm_email", handler.ConfirmEmail)
}
}
// 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)
rg.PUT("/password", handler.ConfirmResetPassword)
rg.POST("/confirmation", handler.ChatwootConfirmEmail)
}
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)
clientID := output.ClientID
if clientID == "" {
clientID = output.TokenPair.RefreshToken
}
c.Header("client", clientID)
c.Header("expiry", strconv.FormatInt(output.TokenPair.ExpiresAt.Unix(), 10))
}
func (h *AuthHandler) trackChatwootSession(c *gin.Context, output *service.LoginOutput) error {
return h.authService.TrackChatwootSession(c.Request.Context(), output, c.GetHeader("client"), c.ClientIP(), c.GetHeader("User-Agent"))
}
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 CSRF protection.
// Used by OIDC and other auth flows. Production note: state should also be stored
// server-side (Redis) and validated on callback.
func generateOAuthState() string {
return "gochat_oauth_" + randomHex(16)
}
// randomHex generates n cryptographically random bytes as hex string.
func randomHex(n int) string {
b := make([]byte, n)
if _, err := crypto_rand.Read(b); err != nil {
// Fallback: math/rand should never be reached in production,
// but prevents a panic if /dev/urandom is temporarily unavailable.
math_rand.Read(b)
}
return fmt.Sprintf("%x", b)
}