fix(auth): 修复 Linux DO 登录误进入邮箱验证

This commit is contained in:
wsxfs
2026-06-06 13:05:07 +08:00
parent 6f18f74030
commit aea2950b18
4 changed files with 211 additions and 17 deletions
+71 -1
View File
@@ -322,6 +322,47 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
emailVerificationRequired := h != nil && h.authService != nil && h.authService.IsEmailVerifyEnabled(c.Request.Context())
forceEmailOnSignup := h.isForceEmailOnThirdPartySignup(c.Request.Context())
if compatEmailUser == nil && !emailVerificationRequired && !forceEmailOnSignup {
if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "", "", "linuxdo")
if err == nil {
if err := applyPendingOAuthBinding(
c.Request.Context(),
h.entClient(),
h.authService,
h.userService,
&dbent.PendingAuthSession{
Intent: oauthIntentLogin,
ProviderType: identityKey.ProviderType,
ProviderKey: identityKey.ProviderKey,
ProviderSubject: identityKey.ProviderSubject,
ResolvedEmail: email,
UpstreamIdentityClaims: upstreamClaims,
},
nil,
&user.ID,
true,
false,
); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to bind oauth identity", "")
return
}
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
redirectOAuthTokenPair(c, frontendCallback, tokenPair, redirectTo)
return
}
if !errors.Is(err, service.ErrOAuthInvitationRequired) {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
}
if err := h.createLinuxDoOAuthChoicePendingSession(
c,
identityKey,
@@ -332,7 +373,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
upstreamClaims,
compatEmail,
compatEmailUser,
h.isForceEmailOnThirdPartySignup(c.Request.Context()),
forceEmailOnSignup,
); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
return
@@ -744,6 +785,35 @@ func redirectOAuthError(c *gin.Context, frontendCallback string, code string, me
redirectWithFragment(c, frontendCallback, fragment)
}
func redirectOAuthTokenPair(c *gin.Context, frontendCallback string, tokenPair *service.TokenPair, redirectTo string) {
fragment := url.Values{}
if tokenPair != nil {
fragment.Set("access_token", truncateFragmentValue(tokenPair.AccessToken))
fragment.Set("refresh_token", truncateFragmentValue(tokenPair.RefreshToken))
fragment.Set("expires_in", strconv.Itoa(tokenPair.ExpiresIn))
fragment.Set("token_type", "Bearer")
}
if redirect := strings.TrimSpace(redirectTo); redirect != "" {
originalRedirect := redirect
for range 2 {
decoded, err := url.QueryUnescape(redirect)
if err != nil || decoded == redirect {
break
}
redirect = decoded
}
if redirect != originalRedirect {
if sanitized := sanitizeFrontendRedirectPath(redirect); sanitized != "" {
redirect = sanitized
} else {
redirect = originalRedirect
}
}
fragment.Set("redirect", truncateFragmentValue(redirect))
}
redirectWithFragment(c, frontendCallback, fragment)
}
func redirectWithFragment(c *gin.Context, frontendCallback string, fragment url.Values) {
u, err := url.Parse(frontendCallback)
if err != nil {
@@ -241,8 +241,20 @@ func TestLinuxDoOAuthCallbackAllowsMissingVerifierWhenPKCEDisabled(t *testing.T)
handler.LinuxDoOAuthCallback(c)
require.Equal(t, http.StatusFound, recorder.Code)
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
require.NotNil(t, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName))
location := recorder.Header().Get("Location")
require.Contains(t, location, "/auth/linuxdo/callback#")
require.Contains(t, location, "access_token=")
requireCookieCleared(t, recorder, oauthPendingSessionCookieName)
identity, err := client.AuthIdentity.Query().
Where(
authidentity.ProviderTypeEQ("linuxdo"),
authidentity.ProviderKeyEQ("linuxdo"),
authidentity.ProviderSubjectEQ("compat-subject"),
).
Only(context.Background())
require.NoError(t, err)
require.Positive(t, identity.UserID)
}
func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
@@ -619,6 +631,82 @@ func TestLinuxDoOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite
require.Equal(t, "third_party_signup", completion["choice_reason"])
}
func TestLinuxDoOAuthCallbackDirectlyLogsInNewUserWhenEmailVerificationDisabled(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
case "/userinfo":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"direct-123","username":"linuxdo_direct","name":"Direct Login","avatar_url":"https://cdn.example/direct.png"}`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{
Enabled: true,
ClientID: "linuxdo-client",
ClientSecret: "linuxdo-secret",
AuthorizeURL: upstream.URL + "/authorize",
TokenURL: upstream.URL + "/token",
UserInfoURL: upstream.URL + "/userinfo",
Scopes: "read",
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
FrontendRedirectURL: "/auth/linuxdo/callback",
TokenAuthMethod: "client_secret_post",
UsePKCE: true,
})
t.Cleanup(func() { _ = client.Close() })
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-direct&state=state-direct", nil)
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-direct"))
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-direct"))
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-direct"))
c.Request = req
handler.LinuxDoOAuthCallback(c)
require.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
require.Contains(t, location, "/auth/linuxdo/callback#")
require.Contains(t, location, "access_token=")
require.Contains(t, location, "refresh_token=")
fragmentValues := parseOAuthRedirectFragment(t, location)
require.Equal(t, "/dashboard", fragmentValues.Get("redirect"))
requireCookieCleared(t, recorder, oauthPendingSessionCookieName)
requireCookieCleared(t, recorder, oauthPendingBrowserCookieName)
ctx := context.Background()
userEntity, err := client.User.Query().
Where(dbuser.EmailEQ("linuxdo-direct-123@linuxdo-connect.invalid")).
Only(ctx)
require.NoError(t, err)
require.Equal(t, "linuxdo_direct", userEntity.Username)
require.Equal(t, "linuxdo", userEntity.SignupSource)
identity, err := client.AuthIdentity.Query().
Where(
authidentity.ProviderTypeEQ("linuxdo"),
authidentity.ProviderKeyEQ("linuxdo"),
authidentity.ProviderSubjectEQ("direct-123"),
).
Only(ctx)
require.NoError(t, err)
require.Equal(t, userEntity.ID, identity.UserID)
require.Equal(t, "https://cdn.example/direct.png", identity.Metadata["suggested_avatar_url"])
sessionCount, err := client.PendingAuthSession.Query().Count(ctx)
require.NoError(t, err)
require.Zero(t, sessionCount)
}
func TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCurrentUser(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -16,7 +16,7 @@
:placeholder="t('auth.passwordPlaceholder')"
:disabled="isSubmitting"
/>
<div v-if="turnstileEnabled && turnstileSiteKey" class="space-y-2">
<div v-if="emailVerifyEnabled && turnstileEnabled && turnstileSiteKey" class="space-y-2">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
@@ -25,17 +25,17 @@
@error="onTurnstileError"
/>
</div>
<div class="flex gap-3">
<input
v-model="verifyCode"
:data-testid="`${testIdPrefix}-create-account-verify-code`"
type="text"
<div v-if="emailVerifyEnabled" class="flex gap-3">
<input
v-model="verifyCode"
:data-testid="`${testIdPrefix}-create-account-verify-code`"
type="text"
inputmode="numeric"
maxlength="6"
class="input min-w-0 flex-1"
placeholder="123456"
:disabled="isSubmitting"
/>
maxlength="6"
class="input min-w-0 flex-1"
placeholder="123456"
:disabled="isSubmitting"
/>
<button
:data-testid="`${testIdPrefix}-create-account-send-code`"
type="button"
@@ -52,10 +52,10 @@
}}
</button>
</div>
<p v-if="sendCodeSuccess" class="text-sm text-green-600 dark:text-green-400">
<p v-if="emailVerifyEnabled && sendCodeSuccess" class="text-sm text-green-600 dark:text-green-400">
{{ t('auth.codeSentSuccess') }}
</p>
<p v-else class="text-xs text-gray-500 dark:text-dark-400">
<p v-else-if="emailVerifyEnabled" class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.verificationCodeHint') }}
</p>
<input
@@ -125,6 +125,7 @@ const sendCodeError = ref('')
const sendCodeSuccess = ref(false)
const countdown = ref(0)
const invitationCodeEnabled = ref(false)
const emailVerifyEnabled = ref(true)
const turnstileEnabled = ref(false)
const turnstileSiteKey = ref('')
const turnstileToken = ref('')
@@ -247,7 +248,7 @@ function handleSubmit() {
emit('submit', {
email: trimmedEmail,
password: password.value,
verifyCode: verifyCode.value.trim(),
verifyCode: emailVerifyEnabled.value ? verifyCode.value.trim() : '',
invitationCode: invitationCode.value.trim() || undefined
})
}
@@ -260,10 +261,12 @@ onMounted(async () => {
try {
const settings = await getPublicSettings()
invitationCodeEnabled.value = settings.invitation_code_enabled === true
emailVerifyEnabled.value = settings.email_verify_enabled !== false
turnstileEnabled.value = settings.turnstile_enabled === true
turnstileSiteKey.value = settings.turnstile_site_key || ''
} catch {
invitationCodeEnabled.value = false
emailVerifyEnabled.value = true
turnstileEnabled.value = false
turnstileSiteKey.value = ''
}
@@ -85,9 +85,42 @@ describe('PendingOAuthCreateAccountForm', () => {
expect(wrapper.text()).toContain('auth.alreadyHaveAccount')
})
it('hides email verification controls when public settings disable email verification', async () => {
getPublicSettings.mockResolvedValue({
email_verify_enabled: false,
turnstile_enabled: false,
turnstile_site_key: ''
})
const wrapper = mount(PendingOAuthCreateAccountForm, {
props: {
testIdPrefix: 'linuxdo',
initialEmail: 'prefill@example.com',
isSubmitting: false
}
})
await flushPromises()
await wrapper.get('[data-testid="linuxdo-create-account-password"]').setValue('secret-123')
await wrapper.get('form').trigger('submit.prevent')
expect(wrapper.find('[data-testid="linuxdo-create-account-verify-code"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="linuxdo-create-account-send-code"]').exists()).toBe(false)
expect(wrapper.emitted('submit')).toEqual([
[
{
email: 'prefill@example.com',
password: 'secret-123',
verifyCode: ''
}
]
])
})
it('shows and emits invitation code when invitation-only signup is enabled', async () => {
getPublicSettings.mockResolvedValue({
invitation_code_enabled: true,
email_verify_enabled: true,
turnstile_enabled: false,
turnstile_site_key: ''
})