⚠️ 应用类型约束:当前实现仅支持「钉钉登录-企业内部应用」(DingTalk 开放平台
internal_app 类型)。第三方个人应用、第三方企业应用类型暂不支持——OAuth 流程
相同但 corp 校验、跨企业行为不同。backend 通过 DingTalkAppKind 校验对非
internal_app 类型 fail-closed(硬约束)。
钉钉 OAuth 登录主链
- 4 步 OAuth 链:ExchangeCodeForUserToken / GetUnionIdByUserToken /
GetUserIdByUnionId / GetStaffInfoByUserId;app token 缓存
- pending session 机制持久化 OAuth 中间态;cookie-only token 持久化
- 三种分流:bind_login_required / email_completion / choose_account_action
- corp_restriction_policy 支持 none + internal_only;stale "whitelist" 在
加载层与写入层均静默 coerce 为 none + slog.Warn
- bypass_registration 开关:企业内部模式豁免全局 REGISTRATION_DISABLED
- isReservedEmail / signup_source / canUnbindProvider / OAuth pending flow
等横切点支持 dingtalk provider
- migration 136:4 表 CHECK 约束加入 'dingtalk' provider 值
internal_only 模式同步企业邮箱/姓名/部门到用户属性
- SyncCorpEmail / SyncDisplayName / SyncDept 三个独立开关 + 对应
SyncXxxAttrKey 目标属性 key(默认 dingtalk_email / dingtalk_name /
dingtalk_department);非 internal_only policy 在写入层与加载层均
coerce 为 false,admin handler 与 setting_service 双层兜底
- 同步语义:首次注册写 users.username(昵称优先 → 企业姓名 fallback),
之后每次登录刷新 3 个属性;空值也写入以覆盖旧值
- 邮箱三级 fallback:org_email > email > extension["企业邮箱"]
(钉钉自定义字段 JSON)
- 部门路径递归向上拼接,跳过 dept_id=1 选首个真实子部门,剥离根组织名
- GetUnionIdByUserToken 同时返回 OIDC /contact/users/me 的 nick 字段;
新增 GetDeptInfo 调用 OAPI /topapi/v2/department/get
- AuthHandler 注入 UserAttributeService;OAuth pending flow 在
createPendingOAuthAccount / bindPendingOAuthLogin 分别派发到
AfterRegistration(syncUsername=true)/ AfterLogin
- migration 137 seed dingtalk_email/name/department 三个用户属性定义
附带修复(同集成路径暴露的两个 OAuth 注册回归)
- LoginOrRegisterOAuthWithTokenPair 新建用户分支用 inferLegacySignupSource
覆写 caller 显式传入的 signupSource,导致 dingtalk/linuxdo/oidc/wechat
渠道授权按 email 渠道读取;改为只在 caller 未显式传入时回退邮箱推断
- mergeProviderDefaultGrantSettings 把 parse fallback 默认值
(Concurrency=5 / Balance=0) 当作"未配置"哨兵,admin 显式设 5 时被误判
退回全局默认(复现:全局默认 1 + 渠道默认并发 5 + grant_on_signup → 新
用户实际 concurrency=1);去掉哨兵,admin 任何 >=0 值都覆盖 globalDefaults
前端
- DingTalk Login / Callback / EmailCompletion / ChoiceAccount / Error
视图;router + auth API client
- admin SettingsView:corp policy radio(none / internal_only)+ bypass
注册开关 + i18n;internal_only 下展示三同步开关 + 目标 attr key 下拉
(拉取 user attribute definitions),展示 fieldEmail /
qyapi_get_department_list 钉钉权限申请提示
- Profile:S1 主动绑定 / S5 解绑钉钉按钮 + 合成邮箱防自锁
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
747 lines
22 KiB
Go
747 lines
22 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// AuthHandler handles authentication-related requests
|
||
type AuthHandler struct {
|
||
cfg *config.Config
|
||
authService *service.AuthService
|
||
userService *service.UserService
|
||
settingSvc *service.SettingService
|
||
promoService *service.PromoService
|
||
redeemService *service.RedeemService
|
||
totpService *service.TotpService
|
||
userAttributeService *service.UserAttributeService
|
||
|
||
dingTalkClientInstance *DingTalkClient
|
||
dingTalkClientMu sync.Mutex
|
||
}
|
||
|
||
// NewAuthHandler creates a new AuthHandler
|
||
func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService, redeemService *service.RedeemService, totpService *service.TotpService, userAttributeService *service.UserAttributeService) *AuthHandler {
|
||
return &AuthHandler{
|
||
cfg: cfg,
|
||
authService: authService,
|
||
userService: userService,
|
||
settingSvc: settingService,
|
||
promoService: promoService,
|
||
redeemService: redeemService,
|
||
totpService: totpService,
|
||
userAttributeService: userAttributeService,
|
||
}
|
||
}
|
||
|
||
// RegisterRequest represents the registration request payload
|
||
type RegisterRequest struct {
|
||
Email string `json:"email" binding:"required,email"`
|
||
Password string `json:"password" binding:"required,min=6"`
|
||
VerifyCode string `json:"verify_code"`
|
||
TurnstileToken string `json:"turnstile_token"`
|
||
PromoCode string `json:"promo_code"` // 注册优惠码
|
||
InvitationCode string `json:"invitation_code"` // 邀请码
|
||
AffCode string `json:"aff_code"` // 邀请返利码
|
||
}
|
||
|
||
// SendVerifyCodeRequest 发送验证码请求
|
||
type SendVerifyCodeRequest struct {
|
||
Email string `json:"email" binding:"required,email"`
|
||
TurnstileToken string `json:"turnstile_token"`
|
||
}
|
||
|
||
// SendVerifyCodeResponse 发送验证码响应
|
||
type SendVerifyCodeResponse struct {
|
||
Message string `json:"message"`
|
||
Countdown int `json:"countdown"` // 倒计时秒数
|
||
}
|
||
|
||
// LoginRequest represents the login request payload
|
||
type LoginRequest struct {
|
||
Email string `json:"email" binding:"required,email"`
|
||
Password string `json:"password" binding:"required"`
|
||
TurnstileToken string `json:"turnstile_token"`
|
||
}
|
||
|
||
// AuthResponse 认证响应格式(匹配前端期望)
|
||
type AuthResponse struct {
|
||
AccessToken string `json:"access_token"`
|
||
RefreshToken string `json:"refresh_token,omitempty"` // 新增:Refresh Token
|
||
ExpiresIn int `json:"expires_in,omitempty"` // 新增:Access Token有效期(秒)
|
||
TokenType string `json:"token_type"`
|
||
User *dto.User `json:"user"`
|
||
}
|
||
|
||
func ensureLoginUserActive(user *service.User) error {
|
||
if user == nil {
|
||
return infraerrors.Unauthorized("INVALID_USER", "user not found")
|
||
}
|
||
if !user.IsActive() {
|
||
return service.ErrUserNotActive
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// respondWithTokenPair 生成 Token 对并返回认证响应
|
||
// 如果 Token 对生成失败,回退到只返回 Access Token(向后兼容)
|
||
func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) {
|
||
if err := ensureLoginUserActive(user); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
tokenPair, err := h.authService.GenerateTokenPair(c.Request.Context(), user, "")
|
||
if err != nil {
|
||
slog.Error("failed to generate token pair", "error", err, "user_id", user.ID)
|
||
// 回退到只返回Access Token
|
||
token, tokenErr := h.authService.GenerateToken(user)
|
||
if tokenErr != nil {
|
||
response.InternalError(c, "Failed to generate token")
|
||
return
|
||
}
|
||
response.Success(c, AuthResponse{
|
||
AccessToken: token,
|
||
TokenType: "Bearer",
|
||
User: dto.UserFromService(user),
|
||
})
|
||
return
|
||
}
|
||
response.Success(c, AuthResponse{
|
||
AccessToken: tokenPair.AccessToken,
|
||
RefreshToken: tokenPair.RefreshToken,
|
||
ExpiresIn: tokenPair.ExpiresIn,
|
||
TokenType: "Bearer",
|
||
User: dto.UserFromService(user),
|
||
})
|
||
}
|
||
|
||
func (h *AuthHandler) ensureBackendModeAllowsUser(ctx context.Context, user *service.User) error {
|
||
if user == nil {
|
||
return infraerrors.Unauthorized("INVALID_USER", "user not found")
|
||
}
|
||
if h == nil || !h.isBackendModeEnabled(ctx) || user.IsAdmin() {
|
||
return nil
|
||
}
|
||
return infraerrors.Forbidden("BACKEND_MODE_ADMIN_ONLY", "Backend mode is active. Only admin login is allowed.")
|
||
}
|
||
|
||
func (h *AuthHandler) ensureBackendModeAllowsNewUserLogin(ctx context.Context) error {
|
||
if h == nil || !h.isBackendModeEnabled(ctx) {
|
||
return nil
|
||
}
|
||
return infraerrors.Forbidden("BACKEND_MODE_ADMIN_ONLY", "Backend mode is active. Only admin login is allowed.")
|
||
}
|
||
|
||
func (h *AuthHandler) isBackendModeEnabled(ctx context.Context) bool {
|
||
if h == nil || h.settingSvc == nil {
|
||
return false
|
||
}
|
||
settings, err := h.settingSvc.GetPublicSettings(ctx)
|
||
if err == nil && settings != nil {
|
||
return settings.BackendModeEnabled
|
||
}
|
||
return h.settingSvc.IsBackendModeEnabled(ctx)
|
||
}
|
||
|
||
// Register handles user registration
|
||
// POST /api/v1/auth/register
|
||
func (h *AuthHandler) Register(c *gin.Context) {
|
||
var req RegisterRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// Turnstile 验证(邮箱验证码注册场景避免重复校验一次性 token)
|
||
if err := h.authService.VerifyTurnstileForRegister(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c), req.VerifyCode); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
_, user, err := h.authService.RegisterWithVerification(
|
||
c.Request.Context(),
|
||
req.Email,
|
||
req.Password,
|
||
req.VerifyCode,
|
||
req.PromoCode,
|
||
req.InvitationCode,
|
||
req.AffCode,
|
||
)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
h.respondWithTokenPair(c, user)
|
||
}
|
||
|
||
// SendVerifyCode 发送邮箱验证码
|
||
// POST /api/v1/auth/send-verify-code
|
||
func (h *AuthHandler) SendVerifyCode(c *gin.Context) {
|
||
var req SendVerifyCodeRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// Turnstile 验证
|
||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
result, err := h.authService.SendVerifyCodeAsync(c.Request.Context(), req.Email)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, SendVerifyCodeResponse{
|
||
Message: "Verification code sent successfully",
|
||
Countdown: result.Countdown,
|
||
})
|
||
}
|
||
|
||
// Login handles user login
|
||
// POST /api/v1/auth/login
|
||
func (h *AuthHandler) Login(c *gin.Context) {
|
||
var req LoginRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// Turnstile 验证
|
||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
token, user, err := h.authService.Login(c.Request.Context(), req.Email, req.Password)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
_ = token // token 由 authService.Login 返回但此处由 respondWithTokenPair 重新生成
|
||
|
||
if err := h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
// Check if TOTP 2FA is enabled for this user
|
||
if h.totpService != nil && h.settingSvc.IsTotpEnabled(c.Request.Context()) && user.TotpEnabled {
|
||
// Create a temporary login session for 2FA
|
||
tempToken, err := h.totpService.CreateLoginSession(c.Request.Context(), user.ID, user.Email)
|
||
if err != nil {
|
||
response.InternalError(c, "Failed to create 2FA session")
|
||
return
|
||
}
|
||
|
||
response.Success(c, TotpLoginResponse{
|
||
Requires2FA: true,
|
||
TempToken: tempToken,
|
||
UserEmailMasked: service.MaskEmail(user.Email),
|
||
})
|
||
return
|
||
}
|
||
|
||
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
||
|
||
h.respondWithTokenPair(c, user)
|
||
}
|
||
|
||
// TotpLoginResponse represents the response when 2FA is required
|
||
type TotpLoginResponse struct {
|
||
Requires2FA bool `json:"requires_2fa"`
|
||
TempToken string `json:"temp_token,omitempty"`
|
||
UserEmailMasked string `json:"user_email_masked,omitempty"`
|
||
}
|
||
|
||
// Login2FARequest represents the 2FA login request
|
||
type Login2FARequest struct {
|
||
TempToken string `json:"temp_token" binding:"required"`
|
||
TotpCode string `json:"totp_code" binding:"required,len=6"`
|
||
}
|
||
|
||
// Login2FA completes the login with 2FA verification
|
||
// POST /api/v1/auth/login/2fa
|
||
func (h *AuthHandler) Login2FA(c *gin.Context) {
|
||
var req Login2FARequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
slog.Debug("login_2fa_request",
|
||
"temp_token_len", len(req.TempToken),
|
||
"totp_code_len", len(req.TotpCode))
|
||
|
||
// Get the login session
|
||
session, err := h.totpService.GetLoginSession(c.Request.Context(), req.TempToken)
|
||
if err != nil || session == nil {
|
||
tokenPrefix := ""
|
||
if len(req.TempToken) >= 8 {
|
||
tokenPrefix = req.TempToken[:8]
|
||
}
|
||
slog.Debug("login_2fa_session_invalid",
|
||
"temp_token_prefix", tokenPrefix,
|
||
"error", err)
|
||
response.BadRequest(c, "Invalid or expired 2FA session")
|
||
return
|
||
}
|
||
|
||
slog.Debug("login_2fa_session_found",
|
||
"user_id", session.UserID,
|
||
"email", session.Email)
|
||
|
||
// Verify the TOTP code
|
||
if err := h.totpService.VerifyCode(c.Request.Context(), session.UserID, req.TotpCode); err != nil {
|
||
slog.Debug("login_2fa_verify_failed",
|
||
"user_id", session.UserID,
|
||
"error", err)
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
// Get the user (before session deletion so we can check backend mode)
|
||
user, err := h.userService.GetByID(c.Request.Context(), session.UserID)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
if err := ensureLoginUserActive(user); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
if err := h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
if session.PendingOAuthBind != nil {
|
||
pendingSvc, err := h.pendingIdentityService()
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
pendingSession, err := pendingSvc.GetBrowserSession(
|
||
c.Request.Context(),
|
||
session.PendingOAuthBind.PendingSessionToken,
|
||
session.PendingOAuthBind.BrowserSessionKey,
|
||
)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
decision, err := h.ensurePendingOAuthAdoptionDecision(c, pendingSession.ID, oauthAdoptionDecisionRequest{})
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
if err := applyPendingOAuthBinding(
|
||
c.Request.Context(),
|
||
h.entClient(),
|
||
h.authService,
|
||
h.userService,
|
||
pendingSession,
|
||
decision,
|
||
&user.ID,
|
||
true,
|
||
true,
|
||
); err != nil {
|
||
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to bind pending oauth identity").WithCause(err))
|
||
return
|
||
}
|
||
if _, err := pendingSvc.ConsumeBrowserSession(
|
||
c.Request.Context(),
|
||
pendingSession.SessionToken,
|
||
pendingSession.BrowserSessionKey,
|
||
); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
secureCookie := isRequestHTTPS(c)
|
||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
||
|
||
user, err = h.userService.GetByID(c.Request.Context(), session.UserID)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Delete the login session (only after all checks pass)
|
||
_ = h.totpService.DeleteLoginSession(c.Request.Context(), req.TempToken)
|
||
|
||
if session.PendingOAuthBind == nil {
|
||
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
||
}
|
||
|
||
h.respondWithTokenPair(c, user)
|
||
}
|
||
|
||
// GetCurrentUser handles getting current authenticated user
|
||
// GET /api/v1/auth/me
|
||
func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
|
||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||
if !ok {
|
||
response.Unauthorized(c, "User not authenticated")
|
||
return
|
||
}
|
||
|
||
user, err := h.userService.GetByID(c.Request.Context(), subject.UserID)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
identities, err := h.userService.GetProfileIdentitySummaries(c.Request.Context(), subject.UserID, user)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
type UserResponse struct {
|
||
userProfileResponse
|
||
RunMode string `json:"run_mode"`
|
||
}
|
||
|
||
runMode := config.RunModeStandard
|
||
if h.cfg != nil {
|
||
runMode = h.cfg.RunMode
|
||
}
|
||
|
||
response.Success(c, UserResponse{
|
||
userProfileResponse: userProfileResponseFromService(user, identities),
|
||
RunMode: runMode,
|
||
})
|
||
}
|
||
|
||
// ValidatePromoCodeRequest 验证优惠码请求
|
||
type ValidatePromoCodeRequest struct {
|
||
Code string `json:"code" binding:"required"`
|
||
}
|
||
|
||
// ValidatePromoCodeResponse 验证优惠码响应
|
||
type ValidatePromoCodeResponse struct {
|
||
Valid bool `json:"valid"`
|
||
BonusAmount float64 `json:"bonus_amount,omitempty"`
|
||
ErrorCode string `json:"error_code,omitempty"`
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
// ValidatePromoCode 验证优惠码(公开接口,注册前调用)
|
||
// POST /api/v1/auth/validate-promo-code
|
||
func (h *AuthHandler) ValidatePromoCode(c *gin.Context) {
|
||
// 检查优惠码功能是否启用
|
||
if h.settingSvc != nil && !h.settingSvc.IsPromoCodeEnabled(c.Request.Context()) {
|
||
response.Success(c, ValidatePromoCodeResponse{
|
||
Valid: false,
|
||
ErrorCode: "PROMO_CODE_DISABLED",
|
||
})
|
||
return
|
||
}
|
||
|
||
var req ValidatePromoCodeRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
promoCode, err := h.promoService.ValidatePromoCode(c.Request.Context(), req.Code)
|
||
if err != nil {
|
||
// 根据错误类型返回对应的错误码
|
||
errorCode := "PROMO_CODE_INVALID"
|
||
switch err {
|
||
case service.ErrPromoCodeNotFound:
|
||
errorCode = "PROMO_CODE_NOT_FOUND"
|
||
case service.ErrPromoCodeExpired:
|
||
errorCode = "PROMO_CODE_EXPIRED"
|
||
case service.ErrPromoCodeDisabled:
|
||
errorCode = "PROMO_CODE_DISABLED"
|
||
case service.ErrPromoCodeMaxUsed:
|
||
errorCode = "PROMO_CODE_MAX_USED"
|
||
case service.ErrPromoCodeAlreadyUsed:
|
||
errorCode = "PROMO_CODE_ALREADY_USED"
|
||
}
|
||
|
||
response.Success(c, ValidatePromoCodeResponse{
|
||
Valid: false,
|
||
ErrorCode: errorCode,
|
||
})
|
||
return
|
||
}
|
||
|
||
if promoCode == nil {
|
||
response.Success(c, ValidatePromoCodeResponse{
|
||
Valid: false,
|
||
ErrorCode: "PROMO_CODE_INVALID",
|
||
})
|
||
return
|
||
}
|
||
|
||
response.Success(c, ValidatePromoCodeResponse{
|
||
Valid: true,
|
||
BonusAmount: promoCode.BonusAmount,
|
||
})
|
||
}
|
||
|
||
// ValidateInvitationCodeRequest 验证邀请码请求
|
||
type ValidateInvitationCodeRequest struct {
|
||
Code string `json:"code" binding:"required"`
|
||
}
|
||
|
||
// ValidateInvitationCodeResponse 验证邀请码响应
|
||
type ValidateInvitationCodeResponse struct {
|
||
Valid bool `json:"valid"`
|
||
ErrorCode string `json:"error_code,omitempty"`
|
||
}
|
||
|
||
// ValidateInvitationCode 验证邀请码(公开接口,注册前调用)
|
||
// POST /api/v1/auth/validate-invitation-code
|
||
func (h *AuthHandler) ValidateInvitationCode(c *gin.Context) {
|
||
// 检查邀请码功能是否启用
|
||
if h.settingSvc == nil || !h.settingSvc.IsInvitationCodeEnabled(c.Request.Context()) {
|
||
response.Success(c, ValidateInvitationCodeResponse{
|
||
Valid: false,
|
||
ErrorCode: "INVITATION_CODE_DISABLED",
|
||
})
|
||
return
|
||
}
|
||
|
||
var req ValidateInvitationCodeRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 验证邀请码
|
||
redeemCode, err := h.redeemService.GetByCode(c.Request.Context(), req.Code)
|
||
if err != nil {
|
||
response.Success(c, ValidateInvitationCodeResponse{
|
||
Valid: false,
|
||
ErrorCode: "INVITATION_CODE_NOT_FOUND",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 检查类型和状态
|
||
if redeemCode.Type != service.RedeemTypeInvitation {
|
||
response.Success(c, ValidateInvitationCodeResponse{
|
||
Valid: false,
|
||
ErrorCode: "INVITATION_CODE_INVALID",
|
||
})
|
||
return
|
||
}
|
||
|
||
if redeemCode.Status != service.StatusUnused {
|
||
response.Success(c, ValidateInvitationCodeResponse{
|
||
Valid: false,
|
||
ErrorCode: "INVITATION_CODE_USED",
|
||
})
|
||
return
|
||
}
|
||
|
||
response.Success(c, ValidateInvitationCodeResponse{
|
||
Valid: true,
|
||
})
|
||
}
|
||
|
||
// ForgotPasswordRequest 忘记密码请求
|
||
type ForgotPasswordRequest struct {
|
||
Email string `json:"email" binding:"required,email"`
|
||
TurnstileToken string `json:"turnstile_token"`
|
||
}
|
||
|
||
// ForgotPasswordResponse 忘记密码响应
|
||
type ForgotPasswordResponse struct {
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// ForgotPassword 请求密码重置
|
||
// POST /api/v1/auth/forgot-password
|
||
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
|
||
var req ForgotPasswordRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// Turnstile 验证
|
||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
frontendBaseURL := strings.TrimSpace(h.settingSvc.GetFrontendURL(c.Request.Context()))
|
||
if frontendBaseURL == "" {
|
||
slog.Error("frontend_url not configured in settings or config; cannot build password reset link")
|
||
response.InternalError(c, "Password reset is not configured")
|
||
return
|
||
}
|
||
|
||
// Request password reset (async)
|
||
// Note: This returns success even if email doesn't exist (to prevent enumeration)
|
||
if err := h.authService.RequestPasswordResetAsync(c.Request.Context(), req.Email, frontendBaseURL); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, ForgotPasswordResponse{
|
||
Message: "If your email is registered, you will receive a password reset link shortly.",
|
||
})
|
||
}
|
||
|
||
// ResetPasswordRequest 重置密码请求
|
||
type ResetPasswordRequest struct {
|
||
Email string `json:"email" binding:"required,email"`
|
||
Token string `json:"token" binding:"required"`
|
||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||
}
|
||
|
||
// ResetPasswordResponse 重置密码响应
|
||
type ResetPasswordResponse struct {
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// ResetPassword 重置密码
|
||
// POST /api/v1/auth/reset-password
|
||
func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||
var req ResetPasswordRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// Reset password
|
||
if err := h.authService.ResetPassword(c.Request.Context(), req.Email, req.Token, req.NewPassword); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, ResetPasswordResponse{
|
||
Message: "Your password has been reset successfully. You can now log in with your new password.",
|
||
})
|
||
}
|
||
|
||
// ==================== Token Refresh Endpoints ====================
|
||
|
||
// RefreshTokenRequest 刷新Token请求
|
||
type RefreshTokenRequest struct {
|
||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||
}
|
||
|
||
// RefreshTokenResponse 刷新Token响应
|
||
type RefreshTokenResponse struct {
|
||
AccessToken string `json:"access_token"`
|
||
RefreshToken string `json:"refresh_token"`
|
||
ExpiresIn int `json:"expires_in"` // Access Token有效期(秒)
|
||
TokenType string `json:"token_type"`
|
||
}
|
||
|
||
// RefreshToken 刷新Token
|
||
// POST /api/v1/auth/refresh
|
||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||
var req RefreshTokenRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
result, err := h.authService.RefreshTokenPair(c.Request.Context(), req.RefreshToken)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
// Backend mode: block non-admin token refresh
|
||
if h.settingSvc.IsBackendModeEnabled(c.Request.Context()) && result.UserRole != "admin" {
|
||
response.Forbidden(c, "Backend mode is active. Only admin login is allowed.")
|
||
return
|
||
}
|
||
|
||
response.Success(c, RefreshTokenResponse{
|
||
AccessToken: result.AccessToken,
|
||
RefreshToken: result.RefreshToken,
|
||
ExpiresIn: result.ExpiresIn,
|
||
TokenType: "Bearer",
|
||
})
|
||
}
|
||
|
||
// LogoutRequest 登出请求
|
||
type LogoutRequest struct {
|
||
RefreshToken string `json:"refresh_token,omitempty"` // 可选:撤销指定的Refresh Token
|
||
}
|
||
|
||
// LogoutResponse 登出响应
|
||
type LogoutResponse struct {
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// Logout 用户登出
|
||
// POST /api/v1/auth/logout
|
||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||
var req LogoutRequest
|
||
// 允许空请求体(向后兼容)
|
||
_ = c.ShouldBindJSON(&req)
|
||
|
||
// 如果提供了Refresh Token,撤销它
|
||
if req.RefreshToken != "" {
|
||
if err := h.authService.RevokeRefreshToken(c.Request.Context(), req.RefreshToken); err != nil {
|
||
slog.Debug("failed to revoke refresh token", "error", err)
|
||
// 不影响登出流程
|
||
}
|
||
}
|
||
h.consumePendingOAuthSessionOnLogout(c)
|
||
clearOAuthLogoutCookies(c)
|
||
|
||
response.Success(c, LogoutResponse{
|
||
Message: "Logged out successfully",
|
||
})
|
||
}
|
||
|
||
// RevokeAllSessionsResponse 撤销所有会话响应
|
||
type RevokeAllSessionsResponse struct {
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// RevokeAllSessions 撤销当前用户的所有会话
|
||
// POST /api/v1/auth/revoke-all-sessions
|
||
func (h *AuthHandler) RevokeAllSessions(c *gin.Context) {
|
||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||
if !ok {
|
||
response.Unauthorized(c, "User not authenticated")
|
||
return
|
||
}
|
||
|
||
if err := h.authService.RevokeAllUserTokens(c.Request.Context(), subject.UserID); err != nil {
|
||
slog.Error("failed to revoke all sessions", "user_id", subject.UserID, "error", err)
|
||
response.InternalError(c, "Failed to revoke sessions")
|
||
return
|
||
}
|
||
|
||
response.Success(c, RevokeAllSessionsResponse{
|
||
Message: "All sessions have been revoked. Please log in again.",
|
||
})
|
||
}
|