diff --git a/frontend_nuxt/pages/discord-callback.vue b/frontend_nuxt/pages/discord-callback.vue index 22fb2e91d..ce1998a37 100644 --- a/frontend_nuxt/pages/discord-callback.vue +++ b/frontend_nuxt/pages/discord-callback.vue @@ -1,3 +1,4 @@ + @@ -8,9 +9,30 @@ import { discordExchange } from '~/utils/discord' onMounted(async () => { const url = new URL(window.location.href) - const code = url.searchParams.get('code') - const state = url.searchParams.get('state') - const result = await discordExchange(code, state, '') + const code = url.searchParams.get('code') || '' + const stateStr = url.searchParams.get('state') || '' + + // 从 state 解析 invite_token;兜底支持 query ?invite_token= + let inviteToken = '' + if (stateStr) { + try { + const s = new URLSearchParams(stateStr) + inviteToken = s.get('invite_token') || s.get('invitetoken') || '' + } catch {} + } + // if (!inviteToken) { + // inviteToken = + // url.searchParams.get('invite_token') || + // url.searchParams.get('invitetoken') || + // '' + // } + + if (!code) { + navigateTo('/login', { replace: true }) + return + } + + const result = await discordExchange(code, inviteToken, '') if (result.needReason) { navigateTo(`/signup-reason?token=${result.token}`, { replace: true }) diff --git a/frontend_nuxt/pages/github-callback.vue b/frontend_nuxt/pages/github-callback.vue index ebce82546..9d1138b99 100644 --- a/frontend_nuxt/pages/github-callback.vue +++ b/frontend_nuxt/pages/github-callback.vue @@ -1,3 +1,4 @@ + @@ -8,9 +9,31 @@ import { githubExchange } from '~/utils/github' onMounted(async () => { const url = new URL(window.location.href) - const code = url.searchParams.get('code') - const state = url.searchParams.get('state') - const result = await githubExchange(code, state, '') + const code = url.searchParams.get('code') || '' + const state = url.searchParams.get('state') || '' + + // 从 state 中解析 invite_token(githubAuthorize 已把它放进 state) + let inviteToken = '' + if (state) { + try { + const s = new URLSearchParams(state) + inviteToken = s.get('invite_token') || s.get('invitetoken') || '' + } catch {} + } + // 兜底:也支持直接跟在回调URL的查询参数上 + // if (!inviteToken) { + // inviteToken = + // url.searchParams.get('invite_token') || + // url.searchParams.get('invitetoken') || + // '' + // } + + if (!code) { + navigateTo('/login', { replace: true }) + return + } + + const result = await githubExchange(code, inviteToken, '') if (result.needReason) { navigateTo(`/signup-reason?token=${result.token}`, { replace: true }) diff --git a/frontend_nuxt/pages/google-callback.vue b/frontend_nuxt/pages/google-callback.vue index 303b9404c..f2609a5e9 100644 --- a/frontend_nuxt/pages/google-callback.vue +++ b/frontend_nuxt/pages/google-callback.vue @@ -9,6 +9,21 @@ import { googleAuthWithToken } from '~/utils/google' onMounted(async () => { const hash = new URLSearchParams(window.location.hash.substring(1)) const idToken = hash.get('id_token') + + // 优先从 state 中解析 + let inviteToken = '' + const stateStr = hash.get('state') || '' + if (stateStr) { + const state = new URLSearchParams(stateStr) + inviteToken = state.get('invite_token') || '' + } + + // 兜底:如果之前把 invite_token 放在回调 URL 的查询参数中 + // if (!inviteToken) { + // const query = new URLSearchParams(window.location.search) + // inviteToken = query.get('invite_token') || '' + // } + if (idToken) { await googleAuthWithToken( idToken, @@ -18,6 +33,7 @@ onMounted(async () => { (token) => { navigateTo(`/signup-reason?token=${token}`, { replace: true }) }, + { inviteToken }, ) } else { navigateTo('/login', { replace: true }) diff --git a/frontend_nuxt/utils/discord.js b/frontend_nuxt/utils/discord.js index e49fd1974..04353f59c 100644 --- a/frontend_nuxt/utils/discord.js +++ b/frontend_nuxt/utils/discord.js @@ -2,7 +2,7 @@ import { toast } from '../main' import { setToken, loadCurrentUser } from './auth' import { registerPush } from './push' -export function discordAuthorize(state = '') { +export function discordAuthorize(inviteToken = '') { const config = useRuntimeConfig() const WEBSITE_BASE_URL = config.public.websiteBaseUrl const DISCORD_CLIENT_ID = config.public.discordClientId @@ -10,62 +10,60 @@ export function discordAuthorize(state = '') { toast.error('Discord 登录不可用') return } + const redirectUri = `${WEBSITE_BASE_URL}/discord-callback` - const url = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20email&state=${state}` + // 用 state 明文携带 invite_token(仅用于回传,不再透传给后端) + const state = new URLSearchParams({ invite_token: inviteToken }).toString() + + const url = + `https://discord.com/api/oauth2/authorize` + + `?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&response_type=code` + + `&scope=${encodeURIComponent('identify email')}` + + `&state=${encodeURIComponent(state)}` + window.location.href = url } -export async function discordExchange(code, state, reason) { +export async function discordExchange(code, inviteToken = '', reason = '') { try { const config = useRuntimeConfig() const API_BASE_URL = config.public.apiBaseUrl + + const payload = { + code, + redirectUri: `${window.location.origin}/discord-callback`, + reason, + } + if (inviteToken) payload.inviteToken = inviteToken // 明文传给后端 + const res = await fetch(`${API_BASE_URL}/api/auth/discord`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - code, - redirectUri: `${window.location.origin}/discord-callback`, - reason, - state, - }), + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload), }) const data = await res.json() + if (res.ok && data.token) { setToken(data.token) await loadCurrentUser() toast.success('登录成功') - registerPush() - return { - success: true, - needReason: false, - } + registerPush?.() + return { success: true, needReason: false } } else if (data.reason_code === 'NOT_APPROVED') { toast.info('当前为注册审核模式,请填写注册理由') - return { - success: false, - needReason: true, - token: data.token, - } + return { success: false, needReason: true, token: data.token } } else if (data.reason_code === 'IS_APPROVING') { toast.info('您的注册理由正在审批中') - return { - success: true, - needReason: false, - } + return { success: true, needReason: false } } else { toast.error(data.error || '登录失败') - return { - success: false, - needReason: false, - error: data.error || '登录失败', - } + return { success: false, needReason: false, error: data.error || '登录失败' } } } catch (e) { + console.error(e) toast.error('登录失败') - return { - success: false, - needReason: false, - error: '登录失败', - } + return { success: false, needReason: false, error: '登录失败' } } } diff --git a/frontend_nuxt/utils/github.js b/frontend_nuxt/utils/github.js index 96679370a..8ecc31ee5 100644 --- a/frontend_nuxt/utils/github.js +++ b/frontend_nuxt/utils/github.js @@ -2,7 +2,7 @@ import { toast } from '../main' import { setToken, loadCurrentUser } from './auth' import { registerPush } from './push' -export function githubAuthorize(state = '') { +export function githubAuthorize(inviteToken = '') { const config = useRuntimeConfig() const WEBSITE_BASE_URL = config.public.websiteBaseUrl const GITHUB_CLIENT_ID = config.public.githubClientId @@ -10,62 +10,58 @@ export function githubAuthorize(state = '') { toast.error('GitHub 登录不可用') return } + const redirectUri = `${WEBSITE_BASE_URL}/github-callback` - const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${state}` + const state = new URLSearchParams({ invite_token: inviteToken }).toString() + + const url = + `https://github.com/login/oauth/authorize` + + `?client_id=${encodeURIComponent(GITHUB_CLIENT_ID)}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=${encodeURIComponent('user:email')}` + + `&state=${encodeURIComponent(state)}` + window.location.href = url } -export async function githubExchange(code, state, reason) { +export async function githubExchange(code, inviteToken = '', reason = '') { try { const config = useRuntimeConfig() const API_BASE_URL = config.public.apiBaseUrl + + const payload = { + code, + redirectUri: `${window.location.origin}/github-callback`, + reason, + } + if (inviteToken) payload.inviteToken = inviteToken + const res = await fetch(`${API_BASE_URL}/api/auth/github`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - code, - redirectUri: `${window.location.origin}/github-callback`, - reason, - state, - }), + body: JSON.stringify(payload), }) const data = await res.json() + if (res.ok && data.token) { setToken(data.token) await loadCurrentUser() toast.success('登录成功') - registerPush() - return { - success: true, - needReason: false, - } + registerPush?.() + return { success: true, needReason: false } } else if (data.reason_code === 'NOT_APPROVED') { toast.info('当前为注册审核模式,请填写注册理由') - return { - success: false, - needReason: true, - token: data.token, - } + return { success: false, needReason: true, token: data.token } } else if (data.reason_code === 'IS_APPROVING') { toast.info('您的注册理由正在审批中') - return { - success: true, - needReason: false, - } + return { success: true, needReason: false } } else { toast.error(data.error || '登录失败') - return { - success: false, - needReason: false, - error: data.error || '登录失败', - } + return { success: false, needReason: false, error: data.error || '登录失败' } } } catch (e) { + console.error(e) toast.error('登录失败') - return { - success: false, - needReason: false, - error: '登录失败', - } + return { success: false, needReason: false, error: '登录失败' } } } diff --git a/frontend_nuxt/utils/google.js b/frontend_nuxt/utils/google.js index 668ab6941..8288bb111 100644 --- a/frontend_nuxt/utils/google.js +++ b/frontend_nuxt/utils/google.js @@ -21,44 +21,85 @@ export async function googleGetIdToken() { }) } -export function googleAuthorize() { +export function googleAuthorize(inviteToken = '') { const config = useRuntimeConfig() const GOOGLE_CLIENT_ID = config.public.googleClientId const WEBSITE_BASE_URL = config.public.websiteBaseUrl + if (!GOOGLE_CLIENT_ID) { toast.error('Google 登录不可用, 请检查网络设置与VPN') return } + const redirectUri = `${WEBSITE_BASE_URL}/google-callback` - const nonce = Math.random().toString(36).substring(2) - const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=id_token&scope=openid%20email%20profile&nonce=${nonce}` + const nonce = Math.random().toString(36).slice(2) + + // 明文放在 state(推荐;Google 会原样回传) + const state = new URLSearchParams({ invite_token: inviteToken }).toString() + + const url = + `https://accounts.google.com/o/oauth2/v2/auth` + + `?client_id=${encodeURIComponent(GOOGLE_CLIENT_ID)}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&response_type=id_token` + + `&scope=${encodeURIComponent('openid email profile')}` + + `&nonce=${encodeURIComponent(nonce)}` + + `&state=${encodeURIComponent(state)}` + window.location.href = url } -export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) { +export async function googleAuthWithToken( + idToken, + redirect_success, + redirect_not_approved, + options = {}, // { inviteToken?: string } +) { try { + if (!idToken) { + toast.error('缺少 id_token') + return + } + const config = useRuntimeConfig() const API_BASE_URL = config.public.apiBaseUrl + + const payload = { idToken } + if (options && options.inviteToken) { + payload.inviteToken = options.inviteToken + } + const res = await fetch(`${API_BASE_URL}/api/auth/google`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ idToken }), + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload), }) - const data = await res.json() - if (res.ok && data.token) { + + const data = await res.json().catch(() => ({})) + + if (res.ok && data && data.token) { setToken(data.token) await loadCurrentUser() toast.success('登录成功') - registerPush() - if (redirect_success) redirect_success() - } else if (data.reason_code === 'NOT_APPROVED') { - toast.info('当前为注册审核模式,请填写注册理由') - if (redirect_not_approved) redirect_not_approved(data.token) - } else if (data.reason_code === 'IS_APPROVING') { - toast.info('您的注册理由正在审批中') - if (redirect_success) redirect_success() + registerPush?.() + if (typeof redirect_success === 'function') redirect_success() + return } + + if (data && data.reason_code === 'NOT_APPROVED') { + toast.info('当前为注册审核模式,请填写注册理由') + if (typeof redirect_not_approved === 'function') redirect_not_approved(data.token) + return + } + + if (data && data.reason_code === 'IS_APPROVING') { + toast.info('您的注册理由正在审批中') + if (typeof redirect_success === 'function') redirect_success() + return + } + toast.error(data?.message || '登录失败') } catch (e) { + console.error(e) toast.error('登录失败') } } diff --git a/frontend_nuxt/utils/twitter.js b/frontend_nuxt/utils/twitter.js index 30d140b78..2762d3e5e 100644 --- a/frontend_nuxt/utils/twitter.js +++ b/frontend_nuxt/utils/twitter.js @@ -20,7 +20,8 @@ async function generateCodeChallenge(codeVerifier) { .replace(/=+$/, '') } -export async function twitterAuthorize(state = '') { +// 邀请码明文放入 state;同时生成 csrf 放入 state 并在回调校验 +export async function twitterAuthorize(inviteToken = '') { const config = useRuntimeConfig() const WEBSITE_BASE_URL = config.public.websiteBaseUrl const TWITTER_CLIENT_ID = config.public.twitterClientId @@ -28,17 +29,30 @@ export async function twitterAuthorize(state = '') { toast.error('Twitter 登录不可用') return } - if (state === '') { - state = Math.random().toString(36).substring(2, 15) - } + const redirectUri = `${WEBSITE_BASE_URL}/twitter-callback` + + // PKCE const codeVerifier = generateCodeVerifier() sessionStorage.setItem('twitter_code_verifier', codeVerifier) const codeChallenge = await generateCodeChallenge(codeVerifier) + + // CSRF + 邀请码一起放入 state + const csrf = Math.random().toString(36).slice(2) + sessionStorage.setItem('twitter_csrf_state', csrf) + const state = new URLSearchParams({ + csrf, + invite_token: inviteToken || '', + }).toString() + const url = - `https://x.com/i/oauth2/authorize?response_type=code&client_id=${TWITTER_CLIENT_ID}` + - `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=tweet.read%20users.read` + - `&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256` + `https://x.com/i/oauth2/authorize?response_type=code&client_id=${encodeURIComponent(TWITTER_CLIENT_ID)}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=${encodeURIComponent('tweet.read users.read')}` + + `&state=${encodeURIComponent(state)}` + + `&code_challenge=${encodeURIComponent(codeChallenge)}` + + `&code_challenge_method=S256` + window.location.href = url } @@ -46,8 +60,29 @@ export async function twitterExchange(code, state, reason) { try { const config = useRuntimeConfig() const API_BASE_URL = config.public.apiBaseUrl + + // 取出并清理 PKCE/CSRF const codeVerifier = sessionStorage.getItem('twitter_code_verifier') sessionStorage.removeItem('twitter_code_verifier') + + const savedCsrf = sessionStorage.getItem('twitter_csrf_state') + sessionStorage.removeItem('twitter_csrf_state') + + // 从 state 解析 csrf 与 invite_token + let parsedCsrf = '' + let inviteToken = '' + try { + const sp = new URLSearchParams(state || '') + parsedCsrf = sp.get('csrf') || '' + inviteToken = sp.get('invite_token') || sp.get('invitetoken') || '' + } catch {} + + // 简单 CSRF 校验(存在才校验,避免误杀老会话) + if (savedCsrf && parsedCsrf && savedCsrf !== parsedCsrf) { + toast.error('登录状态校验失败,请重试') + return { success: false, needReason: false, error: 'state mismatch' } + } + const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -57,8 +92,10 @@ export async function twitterExchange(code, state, reason) { reason, state, codeVerifier, + inviteToken, }), }) + const data = await res.json() if (res.ok && data.token) { setToken(data.token) @@ -77,6 +114,7 @@ export async function twitterExchange(code, state, reason) { return { success: false, needReason: false, error: data.error || '登录失败' } } } catch (e) { + console.error(e) toast.error('登录失败') return { success: false, needReason: false, error: '登录失败' } }