mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(auth): 修复 Linux DO 登录误进入邮箱验证
This commit is contained in:
@@ -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: ''
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user