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">
<aside class="panel">
<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>
Username
<input id="username" value="+60777777777" />
<input id="username" value="+60333333333" />
</label>
<label>
Token
<input id="token" value="" readonly />
</label>
<label>
Language
<input id="language" value="zh-CN" />
<input id="language" value="zh-my" />
</label>
<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>
</div>
@@ -170,8 +175,8 @@
<script>
const frame = document.getElementById('playx-frame')
const previewFrame = document.getElementById('preview-frame')
const usernameInput = document.getElementById('username')
const tokenInput = document.getElementById('token')
const languageInput = document.getElementById('language')
const sendButton = document.getElementById('send')
const reloadButton = document.getElementById('reload')
@@ -182,6 +187,9 @@
const heightLogDelay = 300
let pendingHeightLog = null
let heightLogTimer = null
let currentTokenUsername = ''
let autoContextSent = false
let sendContextInFlight = false
function appendLog(message, data) {
const stamp = new Date().toLocaleTimeString()
@@ -200,7 +208,7 @@
}
heightLogTimer = window.setTimeout(() => {
appendLog('Received PLAYX_HEIGHT from iframe', pendingHeightLog)
appendLog('Received IFRAME_HEIGHT from iframe', pendingHeightLog)
pendingHeightLog = null
heightLogTimer = null
}, heightLogDelay)
@@ -210,35 +218,106 @@
return {
type: 'IFRAME_CONTEXT',
payload: {
username: usernameInput.value.trim(),
token: tokenInput.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()
console.log('[test-host] postMessage payload:', message)
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', () => {
autoContextSent = false
})
sendButton.addEventListener('click', () => {
sendContext()
sendButton.addEventListener('click', async () => {
await sendContext()
})
reloadButton.addEventListener('click', () => {
autoContextSent = false
frame.contentWindow?.location.reload()
})
window.addEventListener('message', (event) => {
if (event.source === frame.contentWindow && event.data?.type === 'PLAYX_READY') {
sendContext()
if (event.source === frame.contentWindow && event.data?.type === 'IFRAME_READY') {
appendLog('Received IFRAME_READY from iframe')
if (autoContextSent) {
return
}
autoContextSent = true
void sendContext()
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)
if (!Number.isFinite(height) || height <= 0) {

View File

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

View File

@@ -1,7 +1,7 @@
import {useQuery, useQueryClient} from '@tanstack/react-query'
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 {useTranslation} from 'react-i18next'
import {normalizeLanguage} from '@/lib/i18n'
@@ -14,30 +14,20 @@ const HOST_HEIGHT_MESSAGE = 'IFRAME_HEIGHT'
const HOST_READY_INTERVAL = 500
const HOST_READY_RETRY_LIMIT = 20
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) {
const {t} = useTranslation()
const queryClient = useQueryClient()
const [username, setUsername] = useState(TEST_BOOTSTRAP_ENABLED ? TEST_BOOTSTRAP_USERNAME : '')
const [hostToken, setHostToken] = useState('')
const setUserInfo = useUserStore((state) => state.setUserInfo)
const setAuthInfo = useUserStore((state) => state.setAuthInfo)
const setAssetsInfo = useUserStore((state) => state.setAssetsInfo)
const setLanguage = useUserStore((state) => state.setLanguage)
const clearUserInfo = useUserStore((state) => state.clearUserInfo)
const hasHostContextRef = useRef(false)
const activeUsernameRef = useRef('')
const activeTokenRef = useRef('')
useEffect(() => {
if (TEST_BOOTSTRAP_ENABLED) {
hasHostContextRef.current = true
activeUsernameRef.current = TEST_BOOTSTRAP_USERNAME
setLanguage(TEST_BOOTSTRAP_LANGUAGE)
return
}
const isEmbedded = window.parent !== window
const notifyParentReady = () => {
@@ -59,17 +49,17 @@ export function AuthGuide({children}: PropsWithChildren) {
return
}
const {language, username: nextUsername} = message.payload
const {language, token} = message.payload
if (typeof nextUsername === 'string' && nextUsername.trim()) {
const normalizedUsername = nextUsername.trim()
if (activeUsernameRef.current && activeUsernameRef.current !== normalizedUsername) {
if (typeof token === 'string' && token.trim()) {
const normalizedToken = token.trim()
if (activeTokenRef.current && activeTokenRef.current !== normalizedToken) {
clearUserInfo()
queryClient.removeQueries({queryKey: ['auth-bootstrap']})
}
hasHostContextRef.current = true
activeUsernameRef.current = normalizedUsername
setUsername(normalizedUsername)
activeTokenRef.current = normalizedToken
setHostToken(normalizedToken)
}
if (typeof language === 'string' && language.trim()) {
@@ -106,19 +96,17 @@ export function AuthGuide({children}: PropsWithChildren) {
}, [clearUserInfo, queryClient, setLanguage])
const authBootstrapQuery = useQuery({
queryKey: queryKeys.authBootstrap(username),
enabled: Boolean(username),
queryKey: queryKeys.authBootstrap(hostToken),
enabled: Boolean(hostToken),
queryFn: async () => {
const loginResponse = await login({username})
const userInfo = loginResponse.data.userInfo
const validateResponse = await validateToken(userInfo.token)
const validateResponse = await validateToken(hostToken)
const authInfo = validateResponse.data
const assetsResponse = await userAssets({
session_id: authInfo.session_id,
})
return {
userInfo,
userInfo: {token: hostToken},
authInfo,
assetsInfo: assetsResponse.data,
}
@@ -235,10 +223,10 @@ export function AuthGuide({children}: PropsWithChildren) {
}
}, [])
if (!username || authBootstrapQuery.isPending) {
if (!hostToken || authBootstrapQuery.isPending) {
return (
<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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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