fix issue #836 linux.do注册无需邀请码

This commit is contained in:
Elysia
2026-03-09 00:35:34 +08:00
parent ee7d061001
commit c069b3b1e8
7 changed files with 269 additions and 25 deletions

View File

@@ -211,8 +211,22 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
email = linuxDoSyntheticEmail(subject) 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 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)) redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
return return
@@ -227,6 +241,45 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
redirectWithFragment(c, frontendCallback, fragment) 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) { func (h *AuthHandler) getLinuxDoOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
if h != nil && h.settingSvc != nil { if h != nil && h.settingSvc != nil {
return h.settingSvc.GetLinuxDoConnectOAuthConfig(ctx) return h.settingSvc.GetLinuxDoConnectOAuthConfig(ctx)

View File

@@ -61,6 +61,12 @@ func RegisterAuthRoutes(
}), h.Auth.ResetPassword) }), h.Auth.ResetPassword)
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart) auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback) 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,
)
} }
// 公开设置(无需认证) // 公开设置(无需认证)

View File

@@ -21,24 +21,25 @@ import (
) )
var ( var (
ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password") ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active") ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists") ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved") ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token") ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired") ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
ErrAccessTokenExpired = infraerrors.Unauthorized("ACCESS_TOKEN_EXPIRED", "access token has expired") ErrAccessTokenExpired = infraerrors.Unauthorized("ACCESS_TOKEN_EXPIRED", "access token has expired")
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large") ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked") ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
ErrRefreshTokenInvalid = infraerrors.Unauthorized("REFRESH_TOKEN_INVALID", "invalid refresh token") ErrRefreshTokenInvalid = infraerrors.Unauthorized("REFRESH_TOKEN_INVALID", "invalid refresh token")
ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired") ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused") ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed") ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed")
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required") ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code") 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 触发解析时的异常内存分配。 // maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
@@ -523,9 +524,10 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
return token, user, nil return token, user, nil
} }
// LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair // LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair
// 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token // 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token
func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username string) (*TokenPair, *User, error) { // invitationCode 仅在邀请码注册模式下新用户注册时使用;已有账号登录时忽略。
func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode string) (*TokenPair, *User, error) {
// 检查 refreshTokenCache 是否可用 // 检查 refreshTokenCache 是否可用
if s.refreshTokenCache == nil { if s.refreshTokenCache == nil {
return nil, nil, errors.New("refresh token cache not configured") 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 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) randomPassword, err := randomHexString(32)
if err != nil { if err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to generate random password for oauth signup: %v", err) 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 { } else {
user = newUser user = newUser
s.assignDefaultSubscriptions(ctx, user.ID) 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 { } else {
logger.LegacyPrintf("service.auth", "[Auth] Database error during oauth login: %v", err) 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 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) { func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int64) {
if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 { if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 {
return return

View File

@@ -335,6 +335,28 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
return data return data
} }
/**
* Complete LinuxDo OAuth registration by supplying an invitation code
* @param pendingOAuthToken - Short-lived JWT from the OAuth callback
* @param invitationCode - Invitation code entered by the user
* @returns Token pair on success
*/
export async function completeLinuxDoOAuthRegistration(
pendingOAuthToken: string,
invitationCode: string
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
const { data } = await apiClient.post<{
access_token: string
refresh_token: string
expires_in: number
token_type: string
}>('/auth/oauth/linuxdo/complete-registration', {
pending_oauth_token: pendingOAuthToken,
invitation_code: invitationCode
})
return data
}
export const authAPI = { export const authAPI = {
login, login,
login2FA, login2FA,
@@ -357,7 +379,8 @@ export const authAPI = {
forgotPassword, forgotPassword,
resetPassword, resetPassword,
refreshToken, refreshToken,
revokeAllSessions revokeAllSessions,
completeLinuxDoOAuthRegistration
} }
export default authAPI export default authAPI

View File

@@ -433,7 +433,12 @@ export default {
callbackProcessing: 'Completing login, please wait...', callbackProcessing: 'Completing login, please wait...',
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.', callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
callbackMissingToken: 'Missing login token, please try again.', callbackMissingToken: 'Missing login token, please try again.',
backToLogin: 'Back to Login' backToLogin: 'Back to Login',
invitationRequired: 'This Linux.do account is not yet registered. The site requires an invitation code — please enter one to complete registration.',
invalidPendingToken: 'The registration token has expired. Please sign in with Linux.do again.',
completeRegistration: 'Complete Registration',
completing: 'Completing registration…',
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
}, },
oauth: { oauth: {
code: 'Code', code: 'Code',

View File

@@ -432,7 +432,12 @@ export default {
callbackProcessing: '正在验证登录信息,请稍候...', callbackProcessing: '正在验证登录信息,请稍候...',
callbackHint: '如果页面未自动跳转,请返回登录页重试。', callbackHint: '如果页面未自动跳转,请返回登录页重试。',
callbackMissingToken: '登录信息缺失,请返回重试。', callbackMissingToken: '登录信息缺失,请返回重试。',
backToLogin: '返回登录' backToLogin: '返回登录',
invitationRequired: '该 Linux.do 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
invalidPendingToken: '注册凭证已失效,请重新使用 Linux.do 登录。',
completeRegistration: '完成注册',
completing: '正在完成注册...',
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
}, },
oauth: { oauth: {
code: '授权码', code: '授权码',

View File

@@ -10,6 +10,36 @@
</p> </p>
</div> </div>
<transition name="fade">
<div v-if="needsInvitation" class="space-y-4">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('auth.linuxdo.invitationRequired') }}
</p>
<div>
<input
v-model="invitationCode"
type="text"
class="input w-full"
:placeholder="t('auth.invitationCodePlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleSubmitInvitation"
/>
</div>
<transition name="fade">
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
{{ invitationError }}
</p>
</transition>
<button
class="btn btn-primary w-full"
:disabled="isSubmitting || !invitationCode.trim()"
@click="handleSubmitInvitation"
>
{{ isSubmitting ? t('auth.linuxdo.completing') : t('auth.linuxdo.completeRegistration') }}
</button>
</div>
</transition>
<transition name="fade"> <transition name="fade">
<div <div
v-if="errorMessage" v-if="errorMessage"
@@ -41,6 +71,7 @@ import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout' import { AuthLayout } from '@/components/layout'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import { useAuthStore, useAppStore } from '@/stores' import { useAuthStore, useAppStore } from '@/stores'
import { completeLinuxDoOAuthRegistration } from '@/api/auth'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -52,6 +83,14 @@ const appStore = useAppStore()
const isProcessing = ref(true) const isProcessing = ref(true)
const errorMessage = ref('') const errorMessage = ref('')
// Invitation code flow state
const needsInvitation = ref(false)
const pendingOAuthToken = ref('')
const invitationCode = ref('')
const isSubmitting = ref(false)
const invitationError = ref('')
const redirectTo = ref('/dashboard')
function parseFragmentParams(): URLSearchParams { function parseFragmentParams(): URLSearchParams {
const raw = typeof window !== 'undefined' ? window.location.hash : '' const raw = typeof window !== 'undefined' ? window.location.hash : ''
const hash = raw.startsWith('#') ? raw.slice(1) : raw const hash = raw.startsWith('#') ? raw.slice(1) : raw
@@ -67,6 +106,34 @@ function sanitizeRedirectPath(path: string | null | undefined): string {
return path return path
} }
async function handleSubmitInvitation() {
invitationError.value = ''
if (!invitationCode.value.trim()) return
isSubmitting.value = true
try {
const tokenData = await completeLinuxDoOAuthRegistration(
pendingOAuthToken.value,
invitationCode.value.trim()
)
if (tokenData.refresh_token) {
localStorage.setItem('refresh_token', tokenData.refresh_token)
}
if (tokenData.expires_in) {
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
}
await authStore.setToken(tokenData.access_token)
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
invitationError.value =
err.response?.data?.message || err.message || t('auth.linuxdo.completeRegistrationFailed')
} finally {
isSubmitting.value = false
}
}
onMounted(async () => { onMounted(async () => {
const params = parseFragmentParams() const params = parseFragmentParams()
@@ -80,6 +147,19 @@ onMounted(async () => {
const errorDesc = params.get('error_description') || params.get('error_message') || '' const errorDesc = params.get('error_description') || params.get('error_message') || ''
if (error) { if (error) {
if (error === 'invitation_required') {
pendingOAuthToken.value = params.get('pending_oauth_token') || ''
redirectTo.value = sanitizeRedirectPath(params.get('redirect'))
if (!pendingOAuthToken.value) {
errorMessage.value = t('auth.linuxdo.invalidPendingToken')
appStore.showError(errorMessage.value)
isProcessing.value = false
return
}
needsInvitation.value = true
isProcessing.value = false
return
}
errorMessage.value = errorDesc || error errorMessage.value = errorDesc || error
appStore.showError(errorMessage.value) appStore.showError(errorMessage.value)
isProcessing.value = false isProcessing.value = false