mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-08-31 01:13:06 +08:00
feat: fix OAuth email completion flow
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
+15
-5
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user