Backend: - permission_handler: 完善权限 CRUD 接口(列表/创建/更新/删除) - auth_handler: 修复认证处理逻辑 - router: 新增权限管理路由 - handler_test: 新增权限 handler 测试覆盖 Frontend: - permissions.ts/test.ts: 权限服务层完整实现 - profile/settings/service_tests: 服务适配器修正 - client.ts: HTTP 客户端健壮性增强 - vite.config.js: 构建配置优化 - E2E 脚本: run-playwright-cdp-e2e 大幅增强(权限流程覆盖) Docs: - REAL_PROJECT_STATUS: 状态更新 - PRODUCTION_CHECKLIST/QUALITY_STANDARD/TECHNICAL_GUIDE/PROJECT_EXPERIENCE_SUMMARY: 团队规范完善 - plans/2026-04-23: 权限浏览器 CRUD 设计方案 验证: go build 0错误
811 lines
26 KiB
Go
811 lines
26 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"crypto/subtle"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
apierrors "github.com/user-management-system/internal/pkg/errors"
|
||
"github.com/user-management-system/internal/service"
|
||
)
|
||
|
||
const (
|
||
refreshTokenCookieName = "ums_refresh_token"
|
||
sessionPresenceCookieName = "ums_session_present"
|
||
)
|
||
|
||
// newBackgroundCtx 创建用于后台 goroutine 的带超时独立 context(与请求 context 无关)
|
||
func newBackgroundCtx(timeoutSec int) (context.Context, context.CancelFunc) {
|
||
return context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
|
||
}
|
||
|
||
// ActivateEmailRequest 邮箱激活请求
|
||
type ActivateEmailRequest struct {
|
||
Token string `json:"token" binding:"required"`
|
||
}
|
||
|
||
// AuthHandler handles authentication requests
|
||
type AuthHandler struct {
|
||
authService *service.AuthService
|
||
passwordResetEnabled bool
|
||
}
|
||
|
||
// NewAuthHandler creates a new AuthHandler
|
||
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
|
||
return &AuthHandler{authService: authService}
|
||
}
|
||
|
||
func (h *AuthHandler) SetPasswordResetEnabled(enabled bool) {
|
||
if h == nil {
|
||
return
|
||
}
|
||
h.passwordResetEnabled = enabled
|
||
}
|
||
|
||
// Register 用户注册
|
||
// @Summary 用户注册
|
||
// @Description 用户注册新账号,支持用户名+密码或手机号注册
|
||
// @Tags 认证
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body service.RegisterRequest true "注册请求"
|
||
// @Success 201 {object} Response{data=service.UserInfo} "注册成功"
|
||
// @Failure 400 {object} Response{code=int,message=string} "请求参数错误"
|
||
// @Failure 409 {object} Response{code=int,message=string} "用户已存在"
|
||
// @Router /api/v1/auth/register [post]
|
||
func (h *AuthHandler) Register(c *gin.Context) {
|
||
var req struct {
|
||
Username string `json:"username" binding:"required"`
|
||
Email string `json:"email"`
|
||
Phone string `json:"phone"`
|
||
Password string `json:"password" binding:"required"`
|
||
Nickname string `json:"nickname"`
|
||
}
|
||
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
registerReq := &service.RegisterRequest{
|
||
Username: req.Username,
|
||
Email: req.Email,
|
||
Phone: req.Phone,
|
||
Password: req.Password,
|
||
Nickname: req.Nickname,
|
||
}
|
||
|
||
userInfo, err := h.authService.Register(c.Request.Context(), registerReq)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusCreated, gin.H{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": userInfo,
|
||
})
|
||
}
|
||
|
||
// Login 用户登录
|
||
// @Summary 用户登录
|
||
// @Description 用户使用账号密码登录,支持多种认证方式(用户名/邮箱/手机号)
|
||
// @Tags 认证
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body service.LoginRequest true "登录请求"
|
||
// @Success 200 {object} Response{data=service.LoginResponse} "登录成功"
|
||
// @Failure 400 {object} Response{code=int,message=string} "请求参数错误"
|
||
// @Failure 401 {object} Response{code=int,message=string} "认证失败"
|
||
// @Failure 429 {object} Response{code=int,message=string} "登录尝试过多"
|
||
// @Router /api/v1/auth/login [post]
|
||
func (h *AuthHandler) Login(c *gin.Context) {
|
||
var req struct {
|
||
Account string `json:"account"`
|
||
Username string `json:"username"`
|
||
Email string `json:"email"`
|
||
Phone string `json:"phone"`
|
||
Password string `json:"password"`
|
||
DeviceID string `json:"device_id"`
|
||
DeviceName string `json:"device_name"`
|
||
DeviceBrowser string `json:"device_browser"`
|
||
DeviceOS string `json:"device_os"`
|
||
}
|
||
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
loginReq := &service.LoginRequest{
|
||
Account: req.Account,
|
||
Username: req.Username,
|
||
Email: req.Email,
|
||
Phone: req.Phone,
|
||
Password: req.Password,
|
||
DeviceID: req.DeviceID,
|
||
DeviceName: req.DeviceName,
|
||
DeviceBrowser: req.DeviceBrowser,
|
||
DeviceOS: req.DeviceOS,
|
||
}
|
||
|
||
clientIP := c.ClientIP()
|
||
resp, err := h.authService.Login(c.Request.Context(), loginReq, clientIP)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
setSessionCookies(c, h.authService, resp.RefreshToken)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": resp,
|
||
})
|
||
}
|
||
|
||
// VerifyTOTPAfterPasswordLogin 完成密码登录后的TOTP验证
|
||
// @Summary TOTP验证(密码登录后)
|
||
// @Description 当登录返回requires_totp=true时,使用此接口完成TOTP验证
|
||
// @Tags 认证
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body TOTPVerifyRequest true "TOTP验证请求"
|
||
// @Success 200 {object} Response{data=service.LoginResponse} "验证成功"
|
||
// @Failure 400 {object} Response "请求参数错误"
|
||
// @Failure 401 {object} Response "TOTP验证失败"
|
||
// @Router /api/v1/auth/login/totp-verify [post]
|
||
func (h *AuthHandler) VerifyTOTPAfterPasswordLogin(c *gin.Context) {
|
||
var req struct {
|
||
UserID int64 `json:"user_id" binding:"required"`
|
||
Code string `json:"code" binding:"required"`
|
||
DeviceID string `json:"device_id"`
|
||
TempToken string `json:"temp_token"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||
return
|
||
}
|
||
|
||
resp, err := h.authService.VerifyTOTPAfterPasswordLogin(
|
||
c.Request.Context(),
|
||
req.UserID,
|
||
req.Code,
|
||
req.DeviceID,
|
||
req.TempToken,
|
||
)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
setSessionCookies(c, h.authService, resp.RefreshToken)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": resp,
|
||
})
|
||
}
|
||
|
||
// Logout 用户登出
|
||
// @Summary 用户登出
|
||
// @Description 使当前 access_token 和 refresh_token 失效
|
||
// @Tags 认证
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Param request body service.LogoutRequest false "登出请求(token可从header获取)"
|
||
// @Success 200 {object} Response{code=int,message=string} "登出成功"
|
||
// @Router /api/v1/auth/logout [post]
|
||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||
var req struct {
|
||
AccessToken string `json:"access_token"`
|
||
RefreshToken string `json:"refresh_token"`
|
||
}
|
||
// 允许 body 为空(仅凭 Authorization header 里的 access_token 注销也可以)
|
||
_ = c.ShouldBindJSON(&req)
|
||
|
||
// 如果 body 里没有 access_token,则从 Authorization header 中取
|
||
if req.AccessToken == "" {
|
||
if bearer := c.GetHeader("Authorization"); len(bearer) > 7 {
|
||
req.AccessToken = bearer[7:] // 去掉 "Bearer "
|
||
}
|
||
}
|
||
|
||
if req.RefreshToken == "" {
|
||
req.RefreshToken, _ = c.Cookie(refreshTokenCookieName)
|
||
}
|
||
|
||
username, _ := c.Get("username")
|
||
usernameStr, _ := username.(string)
|
||
|
||
logoutReq := &service.LogoutRequest{
|
||
AccessToken: req.AccessToken,
|
||
RefreshToken: req.RefreshToken,
|
||
}
|
||
_ = h.authService.Logout(c.Request.Context(), usernameStr, logoutReq)
|
||
|
||
clearSessionCookies(c)
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||
}
|
||
|
||
// RefreshToken 刷新访问令牌
|
||
// @Summary 刷新访问令牌
|
||
// @Description 使用 refresh_token 获取新的 access_token
|
||
// @Tags 认证
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body RefreshTokenRequest true "刷新令牌请求"
|
||
// @Success 200 {object} Response{data=service.LoginResponse} "刷新成功"
|
||
// @Failure 400 {object} Response{code=int,message=string} "请求参数错误"
|
||
// @Failure 401 {object} Response{code=int,message=string} "refresh_token无效或已过期"
|
||
// @Router /api/v1/auth/refresh-token [post]
|
||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||
var req struct {
|
||
RefreshToken string `json:"refresh_token"`
|
||
}
|
||
|
||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if req.RefreshToken == "" {
|
||
req.RefreshToken, _ = c.Cookie(refreshTokenCookieName)
|
||
}
|
||
if req.RefreshToken == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "refresh_token is required"})
|
||
return
|
||
}
|
||
|
||
resp, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
setSessionCookies(c, h.authService, resp.RefreshToken)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": resp,
|
||
})
|
||
}
|
||
|
||
// GetUserInfo 获取当前用户信息
|
||
// @Summary 获取当前用户信息
|
||
// @Description 获取已登录用户的详细信息
|
||
// @Tags 认证
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Success 200 {object} Response{data=service.UserInfo} "用户信息"
|
||
// @Failure 401 {object} Response{code=int,message=string} "未认证"
|
||
// @Router /api/v1/auth/userinfo [get]
|
||
func (h *AuthHandler) GetUserInfo(c *gin.Context) {
|
||
userID, ok := getUserIDFromContext(c)
|
||
if !ok {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||
return
|
||
}
|
||
|
||
userInfo, err := h.authService.GetUserInfo(c.Request.Context(), userID)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": userInfo,
|
||
})
|
||
}
|
||
|
||
// GetCSRFToken 获取CSRF令牌
|
||
// @Summary 获取CSRF令牌
|
||
// @Description 由于系统使用JWT Bearer Token认证,不存在CSRF风险,返回空token
|
||
// @Tags 认证
|
||
// @Produce json
|
||
// @Success 200 {object} map "CSRF token(为空)"
|
||
// @Router /api/v1/auth/csrf-token [get]
|
||
func (h *AuthHandler) GetCSRFToken(c *gin.Context) {
|
||
// 系统使用 JWT Bearer Token 认证,Bearer Token 不会被浏览器自动携带(非 cookie)
|
||
// 因此不存在传统意义上的 CSRF 风险,此端点返回空 token 作为兼容响应
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"csrf_token": "",
|
||
"note": "JWT Bearer Token authentication; CSRF protection not required",
|
||
})
|
||
}
|
||
|
||
// GetAuthCapabilities 获取认证能力
|
||
// @Summary 获取系统认证能力
|
||
// @Description 返回系统支持的认证方式和配置(如是否需要邮件激活、是否支持OAuth等)
|
||
// @Tags 认证
|
||
// @Produce json
|
||
// @Success 200 {object} Response{data=service.AuthCapabilities} "认证能力配置"
|
||
// @Router /api/v1/auth/capabilities [get]
|
||
func (h *AuthHandler) GetAuthCapabilities(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
caps := h.authService.GetAuthCapabilities(ctx)
|
||
caps.PasswordReset = h.SupportsPasswordReset()
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": caps,
|
||
})
|
||
}
|
||
|
||
// OAuthLogin OAuth登录初始化
|
||
// @Summary OAuth登录初始化
|
||
// @Description 发起OAuth登录流程(当前未配置)
|
||
// @Tags OAuth
|
||
// @Produce json
|
||
// @Param provider path string true "OAuth提供商(如 github, google)"
|
||
// @Success 200 {object} Response "OAuth未配置"
|
||
// @Router /api/v1/auth/oauth/{provider} [get]
|
||
func (h *AuthHandler) OAuthLogin(c *gin.Context) {
|
||
provider := c.Param("provider")
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "OAuth not configured", "data": gin.H{"provider": provider}})
|
||
}
|
||
|
||
// OAuthCallback OAuth回调
|
||
// @Summary OAuth回调处理
|
||
// @Description 处理OAuth provider回调(当前未配置)
|
||
// @Tags OAuth
|
||
// @Produce json
|
||
// @Param provider path string true "OAuth提供商"
|
||
// @Success 200 {object} Response "OAuth未配置"
|
||
// @Router /api/v1/auth/oauth/{provider}/callback [get]
|
||
func (h *AuthHandler) OAuthCallback(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "OAuth not configured"})
|
||
}
|
||
|
||
// OAuthExchange OAuth令牌交换
|
||
// @Summary OAuth令牌交换
|
||
// @Description 使用OAuth code交换access_token(当前未配置)
|
||
// @Tags OAuth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param provider path string true "OAuth提供商"
|
||
// @Success 200 {object} Response "OAuth未配置"
|
||
// @Router /api/v1/auth/oauth/{provider}/exchange [post]
|
||
func (h *AuthHandler) OAuthExchange(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "OAuth not configured"})
|
||
}
|
||
|
||
// GetEnabledOAuthProviders 获取已启用的OAuth提供商
|
||
// @Summary 获取OAuth提供商列表
|
||
// @Description 返回系统已配置并启用的OAuth提供商列表
|
||
// @Tags OAuth
|
||
// @Produce json
|
||
// @Success 200 {object} Response{data=map} "提供商列表"
|
||
// @Router /api/v1/auth/oauth/providers [get]
|
||
func (h *AuthHandler) GetEnabledOAuthProviders(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "success", "data": gin.H{"providers": []string{}}})
|
||
}
|
||
|
||
// ActivateEmail 激活邮箱
|
||
// @Summary 激活用户邮箱
|
||
// @Description 使用邮箱激活token激活用户账号
|
||
// @Tags 邮箱认证
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body ActivateEmailRequest true "激活请求"
|
||
// @Success 200 {object} Response "激活成功"
|
||
// @Failure 400 {object} Response "token缺失"
|
||
// @Failure 401 {object} Response "token无效或已过期"
|
||
// @Router /api/v1/auth/activate-email [post]
|
||
func (h *AuthHandler) ActivateEmail(c *gin.Context) {
|
||
var req ActivateEmailRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "token is required"})
|
||
return
|
||
}
|
||
if err := h.authService.ActivateEmail(c.Request.Context(), req.Token); err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "email activated successfully"})
|
||
}
|
||
|
||
// ResendActivationEmail 重发激活邮件
|
||
// @Summary 重发激活邮件
|
||
// @Description 重新发送账号激活邮件(防枚举:无论邮箱是否注册都返回成功)
|
||
// @Tags 邮箱认证
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body ResendActivationRequest true "邮箱地址"
|
||
// @Success 200 {object} Response "激活邮件已发送(如果邮箱已注册)"
|
||
// @Failure 400 {object} Response "邮箱格式错误"
|
||
// @Router /api/v1/auth/resend-activation-email [post]
|
||
func (h *AuthHandler) ResendActivationEmail(c *gin.Context) {
|
||
var req struct {
|
||
Email string `json:"email" binding:"required,email"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||
return
|
||
}
|
||
if err := h.authService.ResendActivationEmail(c.Request.Context(), req.Email); err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
// 防枚举:无论邮箱是否存在,统一返回成功
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "activation email sent if address is registered"})
|
||
}
|
||
|
||
// SendEmailCode 发送邮箱验证码
|
||
// @Summary 发送邮箱验证码
|
||
// @Description 发送邮箱登录验证码(防枚举:无论邮箱是否注册都返回成功)
|
||
// @Tags 邮箱认证
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body SendEmailCodeRequest true "邮箱地址"
|
||
// @Success 200 {object} Response "验证码已发送"
|
||
// @Failure 400 {object} Response "邮箱格式错误"
|
||
// @Router /api/v1/auth/send-email-code [post]
|
||
func (h *AuthHandler) SendEmailCode(c *gin.Context) {
|
||
var req struct {
|
||
Email string `json:"email" binding:"required,email"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||
return
|
||
}
|
||
|
||
// SendEmailLoginCode 内部会忽略未注册邮箱(防枚举),始终返回 ok
|
||
if err := h.authService.SendEmailLoginCode(c.Request.Context(), req.Email); err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "验证码已发送"})
|
||
}
|
||
|
||
// LoginByEmailCode 使用邮箱验证码登录
|
||
// @Summary 邮箱验证码登录
|
||
// @Description 使用邮箱和验证码完成登录
|
||
// @Tags 邮箱认证
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body LoginByEmailCodeRequest true "登录请求"
|
||
// @Success 200 {object} Response{data=service.LoginResponse} "登录成功"
|
||
// @Failure 400 {object} Response "请求参数错误"
|
||
// @Failure 401 {object} Response "验证码错误或已过期"
|
||
// @Router /api/v1/auth/login-by-email-code [post]
|
||
func (h *AuthHandler) LoginByEmailCode(c *gin.Context) {
|
||
var req struct {
|
||
Email string `json:"email" binding:"required,email"`
|
||
Code string `json:"code" binding:"required"`
|
||
DeviceID string `json:"device_id"`
|
||
DeviceName string `json:"device_name"`
|
||
DeviceBrowser string `json:"device_browser"`
|
||
DeviceOS string `json:"device_os"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||
return
|
||
}
|
||
|
||
clientIP := c.ClientIP()
|
||
resp, err := h.authService.LoginByEmailCode(c.Request.Context(), req.Email, req.Code, clientIP)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
|
||
// 异步注册设备(不阻塞主流程)
|
||
// 注意:必须用 context.WithTimeout(context.Background()) 而非 c.Request.Context()
|
||
// gin 在 c.JSON 返回后会回收 context,goroutine 中引用会得到已取消的 context
|
||
if req.DeviceID != "" && resp != nil && resp.User != nil {
|
||
loginReq := &service.LoginRequest{
|
||
DeviceID: req.DeviceID,
|
||
DeviceName: req.DeviceName,
|
||
DeviceBrowser: req.DeviceBrowser,
|
||
DeviceOS: req.DeviceOS,
|
||
}
|
||
userID := resp.User.ID
|
||
go func() {
|
||
devCtx, cancel := newBackgroundCtx(5)
|
||
defer cancel()
|
||
h.authService.BestEffortRegisterDevicePublic(devCtx, userID, loginReq)
|
||
}()
|
||
}
|
||
setSessionCookies(c, h.authService, resp.RefreshToken)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": resp,
|
||
})
|
||
}
|
||
|
||
// BootstrapAdmin 引导初始化管理员
|
||
// @Summary 引导初始化管理员账号
|
||
// @Description 在系统未配置管理员时,创建第一个管理员账号(需要BOOTSTRAP_SECRET)
|
||
// @Tags 系统初始化
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security BootstrapSecret
|
||
// @Param X-Bootstrap-Secret header string true "引导密钥"
|
||
// @Param request body BootstrapAdminRequest true "管理员信息"
|
||
// @Success 201 {object} Response{data=service.UserInfo} "管理员创建成功"
|
||
// @Failure 401 {object} Response "引导密钥无效"
|
||
// @Failure 403 {object} Response "引导初始化未授权"
|
||
// @Router /api/v1/auth/bootstrap-admin [post]
|
||
func (h *AuthHandler) BootstrapAdmin(c *gin.Context) {
|
||
// P0 修复:BootstrapAdmin 端点需要 bootstrap secret 验证
|
||
bootstrapSecret := os.Getenv("BOOTSTRAP_SECRET")
|
||
if bootstrapSecret == "" {
|
||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "引导初始化未授权"})
|
||
return
|
||
}
|
||
|
||
providedSecret := c.GetHeader("X-Bootstrap-Secret")
|
||
if providedSecret == "" {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "缺少引导密钥"})
|
||
return
|
||
}
|
||
|
||
// 使用恒定时间比较防止时序攻击
|
||
if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(bootstrapSecret)) != 1 {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "引导密钥无效"})
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Username string `json:"username" binding:"required"`
|
||
Email string `json:"email" binding:"required"`
|
||
Password string `json:"password" binding:"required"`
|
||
}
|
||
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||
return
|
||
}
|
||
|
||
bootstrapReq := &service.BootstrapAdminRequest{
|
||
Username: req.Username,
|
||
Email: req.Email,
|
||
Password: req.Password,
|
||
}
|
||
|
||
clientIP := c.ClientIP()
|
||
resp, err := h.authService.BootstrapAdmin(c.Request.Context(), bootstrapReq, clientIP)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
setSessionCookies(c, h.authService, resp.RefreshToken)
|
||
|
||
c.JSON(http.StatusCreated, gin.H{
|
||
"code": 0,
|
||
"message": "success",
|
||
"data": resp,
|
||
})
|
||
}
|
||
|
||
// SendEmailBindCode 发送邮箱绑定验证码
|
||
// @Summary 发送邮箱绑定验证码
|
||
// @Description 发送验证码到邮箱以绑定邮箱(当前未配置)
|
||
// @Tags 邮箱绑定
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} Response "功能未配置"
|
||
// @Router /api/v1/auth/email/bind/send [post]
|
||
func (h *AuthHandler) SendEmailBindCode(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "email bind not configured"})
|
||
}
|
||
|
||
// BindEmail 绑定邮箱
|
||
// @Summary 绑定邮箱
|
||
// @Description 使用邮箱验证码绑定账号(当前未配置)
|
||
// @Tags 邮箱绑定
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} Response "功能未配置"
|
||
// @Router /api/v1/auth/email/bind [post]
|
||
func (h *AuthHandler) BindEmail(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "email bind not configured"})
|
||
}
|
||
|
||
// UnbindEmail 解绑邮箱
|
||
// @Summary 解绑邮箱
|
||
// @Description 解绑账号关联的邮箱(当前未配置)
|
||
// @Tags 邮箱绑定
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} Response "功能未配置"
|
||
// @Router /api/v1/auth/email/unbind [post]
|
||
func (h *AuthHandler) UnbindEmail(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "email unbind not configured"})
|
||
}
|
||
|
||
// SendPhoneBindCode 发送手机绑定验证码
|
||
// @Summary 发送手机绑定验证码
|
||
// @Description 发送验证码到手机以绑定手机号(当前未配置)
|
||
// @Tags 手机绑定
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} Response "功能未配置"
|
||
// @Router /api/v1/auth/phone/bind/send [post]
|
||
func (h *AuthHandler) SendPhoneBindCode(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "phone bind not configured"})
|
||
}
|
||
|
||
// BindPhone 绑定手机号
|
||
// @Summary 绑定手机号
|
||
// @Description 使用手机验证码绑定账号(当前未配置)
|
||
// @Tags 手机绑定
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} Response "功能未配置"
|
||
// @Router /api/v1/auth/phone/bind [post]
|
||
func (h *AuthHandler) BindPhone(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "phone bind not configured"})
|
||
}
|
||
|
||
// UnbindPhone 解绑手机号
|
||
// @Summary 解绑手机号
|
||
// @Description 解绑账号关联的手机号(当前未配置)
|
||
// @Tags 手机绑定
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} Response "功能未配置"
|
||
// @Router /api/v1/auth/phone/unbind [post]
|
||
func (h *AuthHandler) UnbindPhone(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "phone unbind not configured"})
|
||
}
|
||
|
||
// GetSocialAccounts 获取社交账号列表
|
||
// @Summary 获取已绑定的社交账号列表
|
||
// @Description 获取当前用户绑定的第三方社交账号列表
|
||
// @Tags 社交账号
|
||
// @Produce json
|
||
// @Security BearerAuth
|
||
// @Success 200 {object} Response "社交账号列表"
|
||
// @Router /api/v1/auth/social-accounts [get]
|
||
func (h *AuthHandler) GetSocialAccounts(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "success", "data": gin.H{"accounts": []interface{}{}}})
|
||
}
|
||
|
||
// BindSocialAccount 绑定社交账号
|
||
// @Summary 绑定社交账号
|
||
// @Description 绑定第三方社交账号到当前用户(当前未配置)
|
||
// @Tags 社交账号
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} Response "功能未配置"
|
||
// @Router /api/v1/auth/social/bind [post]
|
||
func (h *AuthHandler) BindSocialAccount(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "social binding not configured"})
|
||
}
|
||
|
||
// UnbindSocialAccount 解绑社交账号
|
||
// @Summary 解绑社交账号
|
||
// @Description 解绑当前用户关联的第三方社交账号(当前未配置)
|
||
// @Tags 社交账号
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} Response "功能未配置"
|
||
// @Router /api/v1/auth/social/unbind [post]
|
||
func (h *AuthHandler) UnbindSocialAccount(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "social unbinding not configured"})
|
||
}
|
||
|
||
func (h *AuthHandler) SupportsEmailCodeLogin() bool {
|
||
return h.authService.HasEmailCodeService()
|
||
}
|
||
|
||
func getUserIDFromContext(c *gin.Context) (int64, bool) {
|
||
userID, exists := c.Get("user_id")
|
||
if !exists {
|
||
return 0, false
|
||
}
|
||
id, ok := userID.(int64)
|
||
return id, ok
|
||
}
|
||
|
||
func setSessionCookies(c *gin.Context, authService *service.AuthService, refreshToken string) {
|
||
if c == nil || strings.TrimSpace(refreshToken) == "" {
|
||
return
|
||
}
|
||
|
||
maxAge := 0
|
||
if authService != nil {
|
||
if ttl := authService.RefreshTokenTTLSeconds(); ttl > 0 {
|
||
maxAge = int(ttl)
|
||
}
|
||
}
|
||
secure := requestUsesHTTPS(c)
|
||
|
||
c.SetSameSite(http.SameSiteLaxMode)
|
||
c.SetCookie(refreshTokenCookieName, refreshToken, maxAge, "/", "", secure, true)
|
||
c.SetCookie(sessionPresenceCookieName, "1", maxAge, "/", "", secure, false)
|
||
}
|
||
|
||
func clearSessionCookies(c *gin.Context) {
|
||
if c == nil {
|
||
return
|
||
}
|
||
|
||
secure := requestUsesHTTPS(c)
|
||
|
||
c.SetSameSite(http.SameSiteLaxMode)
|
||
c.SetCookie(refreshTokenCookieName, "", -1, "/", "", secure, true)
|
||
c.SetCookie(sessionPresenceCookieName, "", -1, "/", "", secure, false)
|
||
}
|
||
|
||
func requestUsesHTTPS(c *gin.Context) bool {
|
||
if c == nil || c.Request == nil {
|
||
return false
|
||
}
|
||
if c.Request.TLS != nil {
|
||
return true
|
||
}
|
||
return strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https")
|
||
}
|
||
|
||
func (h *AuthHandler) SupportsPasswordReset() bool {
|
||
return h != nil && h.passwordResetEnabled
|
||
}
|
||
|
||
// handleError 将 error 转换为对应的 HTTP 响应。
|
||
// 优先识别 ApplicationError,其次通过关键词推断业务错误类型,兜底返回 500。
|
||
func handleError(c *gin.Context, err error) {
|
||
if err == nil {
|
||
return
|
||
}
|
||
|
||
// 优先尝试 ApplicationError(内置 HTTP 状态码)
|
||
var appErr *apierrors.ApplicationError
|
||
if errors.As(err, &appErr) {
|
||
c.JSON(int(appErr.Code), gin.H{"code": appErr.Code, "message": appErr.Message})
|
||
return
|
||
}
|
||
|
||
// 对普通 errors.New 按关键词推断语义,但只返回通用错误信息给客户端
|
||
httpCode := classifyErrorMessage(err.Error())
|
||
c.JSON(httpCode, gin.H{"code": httpCode, "message": "服务器内部错误"})
|
||
}
|
||
|
||
// classifyErrorMessage 通过错误信息关键词推断 HTTP 状态码,避免业务错误被 500 吞掉
|
||
func classifyErrorMessage(msg string) int {
|
||
lower := strings.ToLower(msg)
|
||
switch {
|
||
case contains(lower, "not found", "不存在", "找不到"):
|
||
return http.StatusNotFound
|
||
case contains(lower, "already exists", "已存在", "已注册", "duplicate"):
|
||
return http.StatusConflict
|
||
case contains(lower, "unauthorized", "invalid token", "token", "令牌", "未认证"):
|
||
return http.StatusUnauthorized
|
||
case contains(lower, "forbidden", "permission", "权限", "禁止"):
|
||
return http.StatusForbidden
|
||
case contains(lower, "invalid", "required", "must", "cannot be empty", "不能为空",
|
||
"格式", "参数", "密码不正确", "incorrect", "wrong", "too short", "too long",
|
||
"已失效", "expired", "验证码不正确", "不能与"):
|
||
return http.StatusBadRequest
|
||
case contains(lower, "locked", "too many", "账号已被锁定", "rate limit"):
|
||
return http.StatusTooManyRequests
|
||
default:
|
||
return http.StatusInternalServerError
|
||
}
|
||
}
|
||
|
||
// contains 检查 s 是否包含 keywords 中的任意一个
|
||
func contains(s string, keywords ...string) bool {
|
||
for _, kw := range keywords {
|
||
if strings.Contains(s, kw) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|