From 260fda19b3961ca9d7ce1e4afec6af68474159d9 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Tue, 30 Jun 2026 01:11:10 +0800 Subject: [PATCH] feat: fix OAuth email completion flow --- .../internal/handler/auth_linuxdo_oauth.go | 13 ++ .../handler/auth_linuxdo_oauth_test.go | 121 +++++++++++++++++- .../server/middleware/backend_mode_guard.go | 2 + .../middleware/backend_mode_guard_test.go | 12 ++ frontend/src/api/__tests__/client.spec.ts | 13 ++ frontend/src/api/url.ts | 20 ++- frontend/src/views/auth/OAuthCallbackView.vue | 5 +- 7 files changed, 177 insertions(+), 9 deletions(-) diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index fb4a30ece1..92720495b9 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -383,6 +383,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) { upstreamClaims, compatEmail, compatEmailUser, + emailVerificationRequired, forceEmailOnSignup, ); err != nil { redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "") @@ -433,6 +434,7 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession( upstreamClaims map[string]any, compatEmail string, compatEmailUser *dbent.User, + emailVerificationRequired bool, forceEmailOnSignup bool, ) error { suggestionEmail := strings.TrimSpace(suggestedEmail) @@ -467,6 +469,17 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession( if forceEmailOnSignup && compatEmailUser == nil { completionResponse["choice_reason"] = "force_email_on_signup" } + if (emailVerificationRequired || forceEmailOnSignup) && compatEmailUser == nil { + completionResponse["step"] = "create_account_required" + completionResponse["email_binding_required"] = true + completionResponse["force_email_on_signup"] = true + if emailVerificationRequired { + completionResponse["choice_reason"] = "email_verification_required" + } + delete(completionResponse, "email") + delete(completionResponse, "resolved_email") + resolvedChoiceEmail = "" + } var targetUserID *int64 if compatEmailUser != nil && compatEmailUser.ID > 0 { diff --git a/backend/internal/handler/auth_linuxdo_oauth_test.go b/backend/internal/handler/auth_linuxdo_oauth_test.go index 20a56bc8ad..1de9a132a4 100644 --- a/backend/internal/handler/auth_linuxdo_oauth_test.go +++ b/backend/internal/handler/auth_linuxdo_oauth_test.go @@ -631,6 +631,108 @@ func TestLinuxDoOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite require.Equal(t, "third_party_signup", completion["choice_reason"]) } +func TestLinuxDoOAuthCallbackEmailVerificationCompletesWithBoundEmail(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":"email-verify-123","username":"linuxdo_email","name":"Email Verify","avatar_url":"https://cdn.example/email.png"}`)) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + handler, client := newLinuxDoOAuthHandlerAndClientWithEmailVerification(t, false, "fresh@example.com", "246810", 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-email&state=state-email", nil) + req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-email")) + req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard")) + req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-email")) + req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin)) + req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-email")) + c.Request = req + + handler.LinuxDoOAuthCallback(c) + + require.Equal(t, http.StatusFound, recorder.Code) + require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location")) + sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName) + require.NotNil(t, sessionCookie) + + ctx := context.Background() + session, err := client.PendingAuthSession.Query(). + Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))). + Only(ctx) + require.NoError(t, err) + require.Equal(t, oauthIntentLogin, session.Intent) + require.Nil(t, session.TargetUserID) + require.Empty(t, session.ResolvedEmail) + require.Equal(t, "linuxdo-email-verify-123@linuxdo-connect.invalid", session.UpstreamIdentityClaims["email"]) + + completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any) + require.True(t, ok) + require.Equal(t, "create_account_required", completion["step"]) + require.Equal(t, true, completion["email_binding_required"]) + require.Equal(t, true, completion["force_email_on_signup"]) + require.Equal(t, "email_verification_required", completion["choice_reason"]) + require.NotContains(t, completion, "email") + require.NotContains(t, completion, "resolved_email") + + createRecorder := httptest.NewRecorder() + createCtx, _ := gin.CreateTestContext(createRecorder) + body := bytes.NewBufferString(`{"email":"fresh@example.com","verify_code":"246810","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`) + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/create-account", body) + createReq.Header.Set("Content-Type", "application/json") + createReq.AddCookie(sessionCookie) + createReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-email")}) + createCtx.Request = createReq + + handler.CreatePendingOAuthAccount(createCtx) + + require.Equal(t, http.StatusOK, createRecorder.Code) + responseData := decodeJSONBody(t, createRecorder) + require.NotEmpty(t, responseData["access_token"]) + + userEntity, err := client.User.Query(). + Where(dbuser.EmailEQ("fresh@example.com")). + Only(ctx) + require.NoError(t, err) + require.Equal(t, "linuxdo", userEntity.SignupSource) + + identity, err := client.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ("linuxdo"), + authidentity.ProviderKeyEQ("linuxdo"), + authidentity.ProviderSubjectEQ("email-verify-123"), + ). + Only(ctx) + require.NoError(t, err) + require.Equal(t, userEntity.ID, identity.UserID) + + storedSession, err := client.PendingAuthSession.Get(ctx, session.ID) + require.NoError(t, err) + require.NotNil(t, storedSession.ConsumedAt) +} + func TestLinuxDoOAuthCallbackDirectlyLogsInNewUserWhenEmailVerificationDisabled(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -1103,6 +1205,24 @@ func newLinuxDoOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg c func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) (*AuthHandler, *dbent.Client) { t.Helper() handler, client := newOAuthPendingFlowTestHandler(t, invitationEnabled) + configureLinuxDoOAuthTestHandler(handler, oauthCfg) + return handler, client +} + +func newLinuxDoOAuthHandlerAndClientWithEmailVerification( + t *testing.T, + invitationEnabled bool, + email string, + code string, + oauthCfg config.LinuxDoConnectConfig, +) (*AuthHandler, *dbent.Client) { + t.Helper() + handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, invitationEnabled, email, code) + configureLinuxDoOAuthTestHandler(handler, oauthCfg) + return handler, client +} + +func configureLinuxDoOAuthTestHandler(handler *AuthHandler, oauthCfg config.LinuxDoConnectConfig) { handler.settingSvc = nil handler.cfg = &config.Config{ JWT: config.JWTConfig{ @@ -1113,5 +1233,4 @@ func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauth }, LinuxDo: oauthCfg, } - return handler, client } diff --git a/backend/internal/server/middleware/backend_mode_guard.go b/backend/internal/server/middleware/backend_mode_guard.go index 050e3bc669..15fa72a82b 100644 --- a/backend/internal/server/middleware/backend_mode_guard.go +++ b/backend/internal/server/middleware/backend_mode_guard.go @@ -43,6 +43,8 @@ func backendModeAllowsAuthPath(path string) bool { "/auth/oauth/github/callback", "/auth/oauth/google/callback", "/auth/oauth/dingtalk/callback", + "/auth/oauth/github/complete-registration", + "/auth/oauth/google/complete-registration", "/auth/oauth/linuxdo/complete-registration", "/auth/oauth/wechat/complete-registration", "/auth/oauth/oidc/complete-registration", diff --git a/backend/internal/server/middleware/backend_mode_guard_test.go b/backend/internal/server/middleware/backend_mode_guard_test.go index df2edde6a8..fbe405199e 100644 --- a/backend/internal/server/middleware/backend_mode_guard_test.go +++ b/backend/internal/server/middleware/backend_mode_guard_test.go @@ -258,6 +258,12 @@ func TestBackendModeAuthGuard(t *testing.T) { path: "/api/v1/auth/oauth/github/callback", wantStatus: http.StatusOK, }, + { + name: "enabled_allows_github_complete_registration", + enabled: "true", + path: "/api/v1/auth/oauth/github/complete-registration", + wantStatus: http.StatusOK, + }, { name: "enabled_blocks_google_oauth_start", enabled: "true", @@ -270,6 +276,12 @@ func TestBackendModeAuthGuard(t *testing.T) { path: "/api/v1/auth/oauth/google/callback", wantStatus: http.StatusOK, }, + { + name: "enabled_allows_google_complete_registration", + enabled: "true", + path: "/api/v1/auth/oauth/google/complete-registration", + wantStatus: http.StatusOK, + }, { name: "enabled_blocks_dingtalk_oauth_start", enabled: "true", diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index 8745df0292..a0a05410d4 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -20,11 +20,24 @@ describe('API Client', () => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllEnvs() }) // --- 请求拦截器 --- describe('请求拦截器', () => { + it('规范化相对 API base,避免在回调页拼出相对 v1 路径', async () => { + vi.resetModules() + vi.stubEnv('VITE_API_BASE_URL', 'api/v1') + + const mod = await import('@/api/client') + + expect(mod.apiClient.defaults.baseURL).toBe('/api/v1') + expect(mod.buildApiUrl('/auth/oauth/github/callback?code=abc')).toBe( + '/api/v1/auth/oauth/github/callback?code=abc' + ) + }) + it('自动附加 Authorization 头', async () => { localStorage.setItem('auth_token', 'my-jwt-token') diff --git a/frontend/src/api/url.ts b/frontend/src/api/url.ts index 97f93654b7..a2cde2561f 100644 --- a/frontend/src/api/url.ts +++ b/frontend/src/api/url.ts @@ -1,20 +1,30 @@ -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1' +const DEFAULT_API_BASE_URL = '/api/v1' +const API_BASE_URL = normalizeAPIBaseURL(import.meta.env.VITE_API_BASE_URL) function normalizePath(path: string): string { return path.startsWith('/') ? path : `/${path}` } +function normalizeAPIBaseURL(value: unknown): string { + const raw = String(value || DEFAULT_API_BASE_URL).trim() || DEFAULT_API_BASE_URL + const withoutTrailingSlash = raw.replace(/\/+$/, '') + if (/^[a-z][a-z\d+.-]*:\/\//i.test(withoutTrailingSlash) || withoutTrailingSlash.startsWith('//')) { + return withoutTrailingSlash + } + return normalizePath(withoutTrailingSlash) +} + export function getAPIBaseURL(): string { - return String(API_BASE_URL || '/api/v1') + return API_BASE_URL } export function buildApiUrl(path: string): string { const base = getAPIBaseURL().replace(/\/+$/, '') let suffix = normalizePath(path) - if (suffix === '/api/v1') { + if (suffix === DEFAULT_API_BASE_URL) { suffix = '' - } else if (suffix.startsWith('/api/v1/')) { - suffix = suffix.slice('/api/v1'.length) + } else if (suffix.startsWith(`${DEFAULT_API_BASE_URL}/`)) { + suffix = suffix.slice(DEFAULT_API_BASE_URL.length) } return `${base}${suffix}` } diff --git a/frontend/src/views/auth/OAuthCallbackView.vue b/frontend/src/views/auth/OAuthCallbackView.vue index 8ae6388009..7306b9ac62 100644 --- a/frontend/src/views/auth/OAuthCallbackView.vue +++ b/frontend/src/views/auth/OAuthCallbackView.vue @@ -152,6 +152,7 @@ import { useRoute, useRouter } from 'vue-router' import { useClipboard } from '@/composables/useClipboard' import { useAppStore, useAuthStore } from '@/stores' import { apiClient } from '@/api/client' +import { buildApiUrl } from '@/api/url' import { exchangePendingOAuthCompletion, persistOAuthTokenContext, @@ -256,8 +257,6 @@ function readPendingEmailOAuthProvider(): 'github' | 'google' | null { function redirectProviderCallbackToBackend(provider: 'github' | 'google'): void { if (typeof window === 'undefined') return - const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' - const normalized = apiBase.replace(/\/$/, '') const params = new URLSearchParams() for (const [key, value] of Object.entries(route.query)) { if (Array.isArray(value)) { @@ -269,7 +268,7 @@ function redirectProviderCallbackToBackend(provider: 'github' | 'google'): void } } const suffix = params.toString() ? `?${params.toString()}` : '' - window.location.href = `${normalized}/auth/oauth/${provider}/callback${suffix}` + window.location.href = buildApiUrl(`/auth/oauth/${provider}/callback${suffix}`) } async function finalizeTokenResponse(tokenResponse: OAuthTokenResponse, redirect: string) {