diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go
index 0ccf47e4..b4607b5e 100644
--- a/backend/internal/handler/auth_linuxdo_oauth.go
+++ b/backend/internal/handler/auth_linuxdo_oauth.go
@@ -211,8 +211,22 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
email = linuxDoSyntheticEmail(subject)
}
- tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username)
+ // 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
+ tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
if err != nil {
+ if errors.Is(err, service.ErrOAuthInvitationRequired) {
+ pendingToken, tokenErr := h.authService.CreatePendingOAuthToken(email, username)
+ if tokenErr != nil {
+ redirectOAuthError(c, frontendCallback, "login_failed", "service_error", "")
+ return
+ }
+ fragment := url.Values{}
+ fragment.Set("error", "invitation_required")
+ fragment.Set("pending_oauth_token", pendingToken)
+ fragment.Set("redirect", redirectTo)
+ redirectWithFragment(c, frontendCallback, fragment)
+ return
+ }
// 避免把内部细节泄露给客户端;给前端保留结构化原因与提示信息即可。
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
return
@@ -227,6 +241,45 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
redirectWithFragment(c, frontendCallback, fragment)
}
+type completeLinuxDoOAuthRequest struct {
+ PendingOAuthToken string `json:"pending_oauth_token" binding:"required"`
+ InvitationCode string `json:"invitation_code" binding:"required"`
+}
+
+// CompleteLinuxDoOAuthRegistration completes a pending OAuth registration by validating
+// the invitation code and creating the user account.
+// POST /api/v1/auth/oauth/linuxdo/complete-registration
+func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
+ var req completeLinuxDoOAuthRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
+ return
+ }
+
+ email, username, err := h.authService.VerifyPendingOAuthToken(req.PendingOAuthToken)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "INVALID_TOKEN", "message": "invalid or expired registration token"})
+ return
+ }
+
+ tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
+ if err != nil {
+ statusCode := http.StatusBadRequest
+ c.JSON(statusCode, gin.H{
+ "error": infraerrors.Reason(err),
+ "message": infraerrors.Message(err),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "access_token": tokenPair.AccessToken,
+ "refresh_token": tokenPair.RefreshToken,
+ "expires_in": tokenPair.ExpiresIn,
+ "token_type": "Bearer",
+ })
+}
+
func (h *AuthHandler) getLinuxDoOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
if h != nil && h.settingSvc != nil {
return h.settingSvc.GetLinuxDoConnectOAuthConfig(ctx)
diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go
index c168820c..0efc9560 100644
--- a/backend/internal/server/routes/auth.go
+++ b/backend/internal/server/routes/auth.go
@@ -61,6 +61,12 @@ func RegisterAuthRoutes(
}), h.Auth.ResetPassword)
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
+ auth.POST("/oauth/linuxdo/complete-registration",
+ rateLimiter.LimitWithOptions("oauth-linuxdo-complete", 10, time.Minute, middleware.RateLimitOptions{
+ FailureMode: middleware.RateLimitFailClose,
+ }),
+ h.Auth.CompleteLinuxDoOAuthRegistration,
+ )
}
// 公开设置(无需认证)
diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go
index 6a17c83f..f6d40f29 100644
--- a/backend/internal/service/auth_service.go
+++ b/backend/internal/service/auth_service.go
@@ -21,24 +21,25 @@ import (
)
var (
- ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
- ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
- ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
- ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
- ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
- ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
- ErrAccessTokenExpired = infraerrors.Unauthorized("ACCESS_TOKEN_EXPIRED", "access token has expired")
- ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
- ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
- ErrRefreshTokenInvalid = infraerrors.Unauthorized("REFRESH_TOKEN_INVALID", "invalid refresh token")
- ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
- ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
- ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
- ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed")
- ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
- ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
- ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
- ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code")
+ ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
+ ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
+ ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
+ ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
+ ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
+ ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
+ ErrAccessTokenExpired = infraerrors.Unauthorized("ACCESS_TOKEN_EXPIRED", "access token has expired")
+ ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
+ ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
+ ErrRefreshTokenInvalid = infraerrors.Unauthorized("REFRESH_TOKEN_INVALID", "invalid refresh token")
+ ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
+ ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
+ ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
+ ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed")
+ ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
+ ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
+ ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
+ ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code")
+ ErrOAuthInvitationRequired = infraerrors.Forbidden("OAUTH_INVITATION_REQUIRED", "invitation code required to complete oauth registration")
)
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
@@ -523,9 +524,10 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
return token, user, nil
}
-// LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair
-// 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token
-func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username string) (*TokenPair, *User, error) {
+// LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair。
+// 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token。
+// invitationCode 仅在邀请码注册模式下新用户注册时使用;已有账号登录时忽略。
+func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode string) (*TokenPair, *User, error) {
// 检查 refreshTokenCache 是否可用
if s.refreshTokenCache == nil {
return nil, nil, errors.New("refresh token cache not configured")
@@ -552,6 +554,22 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
return nil, nil, ErrRegDisabled
}
+ // 检查是否需要邀请码
+ var invitationRedeemCode *RedeemCode
+ if s.settingService != nil && s.settingService.IsInvitationCodeEnabled(ctx) {
+ if invitationCode == "" {
+ return nil, nil, ErrOAuthInvitationRequired
+ }
+ redeemCode, err := s.redeemRepo.GetByCode(ctx, invitationCode)
+ if err != nil {
+ return nil, nil, ErrInvitationCodeInvalid
+ }
+ if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused {
+ return nil, nil, ErrInvitationCodeInvalid
+ }
+ invitationRedeemCode = redeemCode
+ }
+
randomPassword, err := randomHexString(32)
if err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to generate random password for oauth signup: %v", err)
@@ -593,6 +611,11 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
} else {
user = newUser
s.assignDefaultSubscriptions(ctx, user.ID)
+ if invitationRedeemCode != nil {
+ if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil {
+ logger.LegacyPrintf("service.auth", "[Auth] Failed to mark invitation code as used for oauth user %d: %v", user.ID, err)
+ }
+ }
}
} else {
logger.LegacyPrintf("service.auth", "[Auth] Database error during oauth login: %v", err)
@@ -618,6 +641,55 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
return tokenPair, user, nil
}
+// pendingOAuthTokenTTL is the validity period for pending OAuth tokens.
+const pendingOAuthTokenTTL = 10 * time.Minute
+
+type pendingOAuthClaims struct {
+ Email string `json:"email"`
+ Username string `json:"username"`
+ jwt.RegisteredClaims
+}
+
+// CreatePendingOAuthToken generates a short-lived JWT that carries the OAuth identity
+// while waiting for the user to supply an invitation code.
+func (s *AuthService) CreatePendingOAuthToken(email, username string) (string, error) {
+ now := time.Now()
+ claims := &pendingOAuthClaims{
+ Email: email,
+ Username: username,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(now.Add(pendingOAuthTokenTTL)),
+ IssuedAt: jwt.NewNumericDate(now),
+ NotBefore: jwt.NewNumericDate(now),
+ },
+ }
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return token.SignedString([]byte(s.cfg.JWT.Secret))
+}
+
+// VerifyPendingOAuthToken validates a pending OAuth token and returns the embedded identity.
+// Returns ErrInvalidToken when the token is invalid or expired.
+func (s *AuthService) VerifyPendingOAuthToken(tokenStr string) (email, username string, err error) {
+ if len(tokenStr) > maxTokenLength {
+ return "", "", ErrInvalidToken
+ }
+ parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
+ token, parseErr := parser.ParseWithClaims(tokenStr, &pendingOAuthClaims{}, func(t *jwt.Token) (any, error) {
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+ }
+ return []byte(s.cfg.JWT.Secret), nil
+ })
+ if parseErr != nil {
+ return "", "", ErrInvalidToken
+ }
+ claims, ok := token.Claims.(*pendingOAuthClaims)
+ if !ok || !token.Valid {
+ return "", "", ErrInvalidToken
+ }
+ return claims.Email, claims.Username, nil
+}
+
func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int64) {
if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 {
return
diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts
index e196e234..c5e1f35d 100644
--- a/frontend/src/api/auth.ts
+++ b/frontend/src/api/auth.ts
@@ -335,6 +335,28 @@ export async function resetPassword(request: ResetPasswordRequest): Promise
+ {{ t('auth.linuxdo.invitationRequired') }} +
++ {{ invitationError }} +
+