feat(auth): 将iframe通信从用户名改为令牌验证

This commit is contained in:
JiaJun
2026-04-21 17:00:32 +08:00
parent 195cba9d6e
commit a071d07a7c
9 changed files with 126 additions and 67 deletions

View File

@@ -143,20 +143,25 @@
<div class="layout"> <div class="layout">
<aside class="panel"> <aside class="panel">
<h1>PlayX Host Test</h1> <h1>PlayX Host Test</h1>
<p>Use this page to test iframe embedding and the <code>PLAYX_READY</code> / <code>IFRAME_CONTEXT</code> handshake flow.</p> <p>Use this page to test iframe embedding and the <code>IFRAME_READY</code> / <code>IFRAME_CONTEXT</code> handshake flow.</p>
<label> <label>
Username Username
<input id="username" value="+60777777777" /> <input id="username" value="+60333333333" />
</label>
<label>
Token
<input id="token" value="" readonly />
</label> </label>
<label> <label>
Language Language
<input id="language" value="zh-CN" /> <input id="language" value="zh-my" />
</label> </label>
<div class="actions"> <div class="actions">
<button id="send" class="primary" type="button">Send Context</button> <button id="send" class="primary" type="button">Fetch Token &amp; Send Context</button>
<button id="reload" class="secondary" type="button">Reload Iframe</button> <button id="reload" class="secondary" type="button">Reload Iframe</button>
</div> </div>
@@ -170,8 +175,8 @@
<script> <script>
const frame = document.getElementById('playx-frame') const frame = document.getElementById('playx-frame')
const previewFrame = document.getElementById('preview-frame')
const usernameInput = document.getElementById('username') const usernameInput = document.getElementById('username')
const tokenInput = document.getElementById('token')
const languageInput = document.getElementById('language') const languageInput = document.getElementById('language')
const sendButton = document.getElementById('send') const sendButton = document.getElementById('send')
const reloadButton = document.getElementById('reload') const reloadButton = document.getElementById('reload')
@@ -182,6 +187,9 @@
const heightLogDelay = 300 const heightLogDelay = 300
let pendingHeightLog = null let pendingHeightLog = null
let heightLogTimer = null let heightLogTimer = null
let currentTokenUsername = ''
let autoContextSent = false
let sendContextInFlight = false
function appendLog(message, data) { function appendLog(message, data) {
const stamp = new Date().toLocaleTimeString() const stamp = new Date().toLocaleTimeString()
@@ -200,7 +208,7 @@
} }
heightLogTimer = window.setTimeout(() => { heightLogTimer = window.setTimeout(() => {
appendLog('Received PLAYX_HEIGHT from iframe', pendingHeightLog) appendLog('Received IFRAME_HEIGHT from iframe', pendingHeightLog)
pendingHeightLog = null pendingHeightLog = null
heightLogTimer = null heightLogTimer = null
}, heightLogDelay) }, heightLogDelay)
@@ -210,35 +218,106 @@
return { return {
type: 'IFRAME_CONTEXT', type: 'IFRAME_CONTEXT',
payload: { payload: {
username: usernameInput.value.trim(), token: tokenInput.value.trim(),
language: languageInput.value.trim(), language: languageInput.value.trim(),
}, },
} }
} }
function sendContext() { async function ensureToken() {
const username = usernameInput.value.trim()
if (!username) {
throw new Error('Username is required.')
}
const existingToken = tokenInput.value.trim()
if (existingToken && currentTokenUsername === username) {
return existingToken
}
const response = await fetch(`/api/v1/temLogin?username=${encodeURIComponent(username)}`, {
method: 'GET',
})
if (!response.ok) {
throw new Error(`temLogin request failed: ${response.status}`)
}
const payload = await response.json()
const token = payload?.data?.userInfo?.token?.trim()
console.log('[test-host] temLogin response:', payload)
if (!token) {
throw new Error(`temLogin response missing token: ${JSON.stringify(payload)}`)
}
tokenInput.value = token
currentTokenUsername = username
appendLog('Fetched token from temLogin', {
username,
tokenPreview: `${token.slice(0, 12)}...`,
})
return token
}
async function sendContext() {
if (sendContextInFlight) {
return
}
sendContextInFlight = true
sendButton.disabled = true
try {
await ensureToken()
} catch (error) {
appendLog('Failed to fetch token', {
message: error instanceof Error ? error.message : String(error),
})
sendButton.disabled = false
sendContextInFlight = false
return
}
const message = buildPayload() const message = buildPayload()
console.log('[test-host] postMessage payload:', message)
frame.contentWindow?.postMessage(message, iframeOrigin) frame.contentWindow?.postMessage(message, iframeOrigin)
appendLog('Sent IFRAME_CONTEXT to iframe', {
language: message.payload.language,
tokenPreview: `${message.payload.token.slice(0, 12)}...`,
})
sendButton.disabled = false
sendContextInFlight = false
} }
frame.addEventListener('load', () => { frame.addEventListener('load', () => {
autoContextSent = false
}) })
sendButton.addEventListener('click', () => { sendButton.addEventListener('click', async () => {
sendContext() await sendContext()
}) })
reloadButton.addEventListener('click', () => { reloadButton.addEventListener('click', () => {
autoContextSent = false
frame.contentWindow?.location.reload() frame.contentWindow?.location.reload()
}) })
window.addEventListener('message', (event) => { window.addEventListener('message', (event) => {
if (event.source === frame.contentWindow && event.data?.type === 'PLAYX_READY') { if (event.source === frame.contentWindow && event.data?.type === 'IFRAME_READY') {
sendContext() appendLog('Received IFRAME_READY from iframe')
if (autoContextSent) {
return
}
autoContextSent = true
void sendContext()
return return
} }
if (event.source === frame.contentWindow && event.data?.type === 'PLAYX_HEIGHT') { if (event.source === frame.contentWindow && event.data?.type === 'IFRAME_HEIGHT') {
const height = Number(event.data?.payload?.height) const height = Number(event.data?.payload?.height)
if (!Number.isFinite(height) || height <= 0) { if (!Number.isFinite(height) || height <= 0) {

View File

@@ -40,12 +40,6 @@ function App() {
})() })()
}, [language, queryClient]) }, [language, queryClient])
// 开发/测试阶段如需跳过 iframe 传参,可设置:
// VITE_BYPASS_IFRAME_CONTEXT=true
// 项目会直接使用测试数据初始化:
// username: +60777777777
// language: zh
// useEffect(() => { // useEffect(() => {
// const handleMessage = (event: MessageEvent<HostContextMessage>) => { // const handleMessage = (event: MessageEvent<HostContextMessage>) => {
// const message = event.data // const message = event.data
@@ -54,15 +48,15 @@ function App() {
// return // return
// } // }
// //
// const {language, username} = message.payload // const {language, token} = message.payload
//
// if (typeof username === 'string' && username.trim()) {
//
// }
// //
// if (typeof language === 'string' && language.trim()) { // if (typeof language === 'string' && language.trim()) {
// // language 由 zustand 存储,供请求头直接读取 // // language 由 zustand 存储,供请求头直接读取
// } // }
//
// if (typeof token === 'string' && token.trim()) {
// // token 由宿主机通过 postMessage 传入,供鉴权和请求头直接读取
// }
// } // }
// //
// window.addEventListener('message', handleMessage) // window.addEventListener('message', handleMessage)
@@ -77,8 +71,8 @@ function App() {
// { // {
// type: 'IFRAME_CONTEXT', // type: 'IFRAME_CONTEXT',
// payload: { // payload: {
// username: '+60777777777', // token: 'token',
// language: 'zh-CN', // language: 'zh-my',
// }, // },
// }, // },
// window.location.origin // window.location.origin
@@ -95,7 +89,7 @@ function App() {
// const IFRAME_ORIGIN = 'https://your-iframe-app.example.com' // const IFRAME_ORIGIN = 'https://your-iframe-app.example.com'
// //
// window.addEventListener('message', (event) => { // window.addEventListener('message', (event) => {
// if (event.source !== iframe.contentWindow || event.data?.type !== 'PLAYX_READY') { // if (event.source !== iframe.contentWindow || event.data?.type !== 'IFRAME_READY') {
// return // return
// } // }
// //
@@ -103,8 +97,8 @@ function App() {
// { // {
// type: 'IFRAME_CONTEXT', // type: 'IFRAME_CONTEXT',
// payload: { // payload: {
// username: '+60777777777', // token: 'token',
// language: 'zh-CN', // language: 'zh-my',
// }, // },
// }, // },
// IFRAME_ORIGIN // IFRAME_ORIGIN

View File

@@ -1,7 +1,7 @@
import {useQuery, useQueryClient} from '@tanstack/react-query' import {useQuery, useQueryClient} from '@tanstack/react-query'
import {type PropsWithChildren, useEffect, useRef, useState} from 'react' import {type PropsWithChildren, useEffect, useRef, useState} from 'react'
import {login, validateToken} from '@/api/auth.ts' import {validateToken} from '@/api/auth.ts'
import {userAssets} from '@/api/user.ts' import {userAssets} from '@/api/user.ts'
import {useTranslation} from 'react-i18next' import {useTranslation} from 'react-i18next'
import {normalizeLanguage} from '@/lib/i18n' import {normalizeLanguage} from '@/lib/i18n'
@@ -14,30 +14,20 @@ const HOST_HEIGHT_MESSAGE = 'IFRAME_HEIGHT'
const HOST_READY_INTERVAL = 500 const HOST_READY_INTERVAL = 500
const HOST_READY_RETRY_LIMIT = 20 const HOST_READY_RETRY_LIMIT = 20
const HOST_HEIGHT_REPORT_INTERVAL = 250 const HOST_HEIGHT_REPORT_INTERVAL = 250
const TEST_BOOTSTRAP_ENABLED = import.meta.env.VITE_BYPASS_IFRAME_CONTEXT === 'true'
const TEST_BOOTSTRAP_USERNAME = '+60777777777'
const TEST_BOOTSTRAP_LANGUAGE = 'zh'
export function AuthGuide({children}: PropsWithChildren) { export function AuthGuide({children}: PropsWithChildren) {
const {t} = useTranslation() const {t} = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [username, setUsername] = useState(TEST_BOOTSTRAP_ENABLED ? TEST_BOOTSTRAP_USERNAME : '') const [hostToken, setHostToken] = useState('')
const setUserInfo = useUserStore((state) => state.setUserInfo) const setUserInfo = useUserStore((state) => state.setUserInfo)
const setAuthInfo = useUserStore((state) => state.setAuthInfo) const setAuthInfo = useUserStore((state) => state.setAuthInfo)
const setAssetsInfo = useUserStore((state) => state.setAssetsInfo) const setAssetsInfo = useUserStore((state) => state.setAssetsInfo)
const setLanguage = useUserStore((state) => state.setLanguage) const setLanguage = useUserStore((state) => state.setLanguage)
const clearUserInfo = useUserStore((state) => state.clearUserInfo) const clearUserInfo = useUserStore((state) => state.clearUserInfo)
const hasHostContextRef = useRef(false) const hasHostContextRef = useRef(false)
const activeUsernameRef = useRef('') const activeTokenRef = useRef('')
useEffect(() => { useEffect(() => {
if (TEST_BOOTSTRAP_ENABLED) {
hasHostContextRef.current = true
activeUsernameRef.current = TEST_BOOTSTRAP_USERNAME
setLanguage(TEST_BOOTSTRAP_LANGUAGE)
return
}
const isEmbedded = window.parent !== window const isEmbedded = window.parent !== window
const notifyParentReady = () => { const notifyParentReady = () => {
@@ -59,17 +49,17 @@ export function AuthGuide({children}: PropsWithChildren) {
return return
} }
const {language, username: nextUsername} = message.payload const {language, token} = message.payload
if (typeof nextUsername === 'string' && nextUsername.trim()) { if (typeof token === 'string' && token.trim()) {
const normalizedUsername = nextUsername.trim() const normalizedToken = token.trim()
if (activeUsernameRef.current && activeUsernameRef.current !== normalizedUsername) { if (activeTokenRef.current && activeTokenRef.current !== normalizedToken) {
clearUserInfo() clearUserInfo()
queryClient.removeQueries({queryKey: ['auth-bootstrap']}) queryClient.removeQueries({queryKey: ['auth-bootstrap']})
} }
hasHostContextRef.current = true hasHostContextRef.current = true
activeUsernameRef.current = normalizedUsername activeTokenRef.current = normalizedToken
setUsername(normalizedUsername) setHostToken(normalizedToken)
} }
if (typeof language === 'string' && language.trim()) { if (typeof language === 'string' && language.trim()) {
@@ -106,19 +96,17 @@ export function AuthGuide({children}: PropsWithChildren) {
}, [clearUserInfo, queryClient, setLanguage]) }, [clearUserInfo, queryClient, setLanguage])
const authBootstrapQuery = useQuery({ const authBootstrapQuery = useQuery({
queryKey: queryKeys.authBootstrap(username), queryKey: queryKeys.authBootstrap(hostToken),
enabled: Boolean(username), enabled: Boolean(hostToken),
queryFn: async () => { queryFn: async () => {
const loginResponse = await login({username}) const validateResponse = await validateToken(hostToken)
const userInfo = loginResponse.data.userInfo
const validateResponse = await validateToken(userInfo.token)
const authInfo = validateResponse.data const authInfo = validateResponse.data
const assetsResponse = await userAssets({ const assetsResponse = await userAssets({
session_id: authInfo.session_id, session_id: authInfo.session_id,
}) })
return { return {
userInfo, userInfo: {token: hostToken},
authInfo, authInfo,
assetsInfo: assetsResponse.data, assetsInfo: assetsResponse.data,
} }
@@ -235,10 +223,10 @@ export function AuthGuide({children}: PropsWithChildren) {
} }
}, []) }, [])
if (!username || authBootstrapQuery.isPending) { if (!hostToken || authBootstrapQuery.isPending) {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center text-[14px] text-white/68"> <div className="flex min-h-screen items-center justify-center bg-[#08070E] px-6 text-center text-[14px] text-white/68">
{!username && !TEST_BOOTSTRAP_ENABLED ? t('auth.waitingForHostContext') : t('auth.loadingAccountData')} {!hostToken ? t('auth.waitingForHostContext') : t('auth.loadingAccountData')}
</div> </div>
) )
} }

View File

@@ -16,7 +16,7 @@ const en = {
malay: 'Malay', malay: 'Malay',
}, },
auth: { auth: {
waitingForHostContext: 'Waiting for host context...', waitingForHostContext: 'Waiting for verification...',
loadingAccountData: 'Loading account data...', loadingAccountData: 'Loading account data...',
authenticationFailed: 'Authentication failed', authenticationFailed: 'Authentication failed',
refreshAndTryAgain: 'Please refresh and try again.', refreshAndTryAgain: 'Please refresh and try again.',

View File

@@ -16,7 +16,7 @@ const ms = {
malay: 'Bahasa Melayu', malay: 'Bahasa Melayu',
}, },
auth: { auth: {
waitingForHostContext: 'Menunggu konteks hos...', waitingForHostContext: 'Menunggu pengesahan...',
loadingAccountData: 'Sedang memuatkan data akaun...', loadingAccountData: 'Sedang memuatkan data akaun...',
authenticationFailed: 'Pengesahan gagal', authenticationFailed: 'Pengesahan gagal',
refreshAndTryAgain: 'Sila muat semula dan cuba lagi.', refreshAndTryAgain: 'Sila muat semula dan cuba lagi.',

View File

@@ -16,7 +16,7 @@ const zh = {
malay: '马来文', malay: '马来文',
}, },
auth: { auth: {
waitingForHostContext: '等待宿主上下文...', waitingForHostContext: '等待验证...',
loadingAccountData: '账户数据加载中...', loadingAccountData: '账户数据加载中...',
authenticationFailed: '鉴权失败', authenticationFailed: '鉴权失败',
refreshAndTryAgain: '请刷新后重试。', refreshAndTryAgain: '请刷新后重试。',

View File

@@ -4,13 +4,13 @@ export type LoginParams = {
} }
export type LoginUserInfo = { export type LoginUserInfo = {
id: number
username: string
nickname: string
playx_user_id: string
token: string token: string
refresh_token: string id?: number
expires_in: number username?: string
nickname?: string
playx_user_id?: string
refresh_token?: string
expires_in?: number
} }
export type LoginData = { export type LoginData = {

View File

@@ -9,7 +9,6 @@ export type HostContextMessage = {
payload?: { payload?: {
token?: string token?: string
language?: string language?: string
username?: string
} }
} }

1
src/vite-env.d.ts vendored
View File

@@ -1,7 +1,6 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_BYPASS_IFRAME_CONTEXT?: string
readonly VITE_API_BASE_URL?: string readonly VITE_API_BASE_URL?: string
readonly VITE_API_ORIGIN?: string readonly VITE_API_ORIGIN?: string
} }