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: '登录失败' }
}