This commit is contained in:
2026-09-02 20:49:13 +08:00
parent 4ee8ac3ee7
commit a7c36771e4
4 changed files with 41 additions and 22 deletions
+31 -11
View File
@@ -1,32 +1,52 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue' import { onBeforeUnmount, onMounted } from 'vue'
import { useAuthStore } from '@/modules/auth/public'
let readyInterval: number | null = null let readyInterval: number | null = null
let expectedAdminOrigin = ''
let impersonationExchangePending = false
function configuredImpersonationOrigin() {
const queryOrigin = new URLSearchParams(window.location.search).get('impersonationOrigin')
if (queryOrigin) {
try { return new URL(queryOrigin).origin } catch { return '' }
}
return String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim() || window.location.origin
}
onMounted(() => { onMounted(() => {
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim() expectedAdminOrigin = configuredImpersonationOrigin()
const targetOrigin = configuredAdminOrigin || window.location.origin if (!window.opener || !expectedAdminOrigin) return
const announceReady = () => window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-ready' }, targetOrigin) window.addEventListener('message', acceptImpersonationTicket)
const announceReady = () => window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-ready' }, expectedAdminOrigin)
announceReady() announceReady()
readyInterval = window.setInterval(announceReady, 300) readyInterval = window.setInterval(announceReady, 300)
window.setTimeout(() => { window.setTimeout(() => {
if (readyInterval !== null) window.clearInterval(readyInterval) if (readyInterval !== null) window.clearInterval(readyInterval)
readyInterval = null readyInterval = null
}, 10000) }, 10000)
window.addEventListener('message', stopReadyAnnouncement)
}) })
function stopReadyAnnouncement(event: MessageEvent<unknown>) { async function acceptImpersonationTicket(event: MessageEvent<unknown>) {
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim() const message = event.data as { source?: string; type?: string; code?: string } | null
const expectedAdminOrigin = configuredAdminOrigin || window.location.origin if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || !message.code || impersonationExchangePending) return
const message = event.data as { source?: string; type?: string } | null impersonationExchangePending = true
if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || readyInterval === null) return if (readyInterval !== null) window.clearInterval(readyInterval)
window.clearInterval(readyInterval)
readyInterval = null readyInterval = null
const auth = useAuthStore()
try {
await auth.acceptImpersonationCode(message.code)
window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-complete' }, expectedAdminOrigin)
window.location.replace('/')
} catch {
window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-failed' }, expectedAdminOrigin)
window.location.replace('/login?impersonation=failed')
}
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (readyInterval !== null) window.clearInterval(readyInterval) if (readyInterval !== null) window.clearInterval(readyInterval)
window.removeEventListener('message', stopReadyAnnouncement) window.removeEventListener('message', acceptImpersonationTicket)
}) })
</script> </script>
-9
View File
@@ -223,15 +223,6 @@ export const useAuthStore = defineStore('client-auth', () => {
} }
window.addEventListener('cc-client-session-expired', clearSession) window.addEventListener('cc-client-session-expired', clearSession)
window.addEventListener('message', (event: MessageEvent<unknown>) => {
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim()
const expectedAdminOrigin = configuredAdminOrigin || window.location.origin
const message = event.data as { source?: string; type?: string; code?: string } | null
if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || !message.code) return
void acceptImpersonationCode(message.code)
.then(() => window.location.replace('/'))
.catch(() => window.location.replace('/login?impersonation=failed'))
})
window.addEventListener('storage', (event) => { window.addEventListener('storage', (event) => {
if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession() if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession()
}) })
+8 -1
View File
@@ -13,6 +13,8 @@ const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url)
const sessionSource = readFileSync(new URL('../../../api/session.ts', import.meta.url), 'utf8') const sessionSource = readFileSync(new URL('../../../api/session.ts', import.meta.url), 'utf8')
const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8') const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
const mainSource = readFileSync(new URL('../../../main.ts', import.meta.url), 'utf8') const mainSource = readFileSync(new URL('../../../main.ts', import.meta.url), 'utf8')
const appSource = readFileSync(new URL('../../../App.vue', import.meta.url), 'utf8')
const routerSource = readFileSync(new URL('../../../router/index.ts', import.meta.url), 'utf8')
const stylesSource = readFileSync(new URL('../../../styles/index.css', import.meta.url), 'utf8') const stylesSource = readFileSync(new URL('../../../styles/index.css', import.meta.url), 'utf8')
test('uses one prototype-inspired responsive shell for login and registration', () => { test('uses one prototype-inspired responsive shell for login and registration', () => {
@@ -105,7 +107,12 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
test('handles impersonation exchange as public authentication and exposes failures', () => { test('handles impersonation exchange as public authentication and exposes failures', () => {
assert.match(httpSource, /publicAuthPaths = \[[^\]]*'\/auth\/impersonations\/exchange'/) assert.match(httpSource, /publicAuthPaths = \[[^\]]*'\/auth\/impersonations\/exchange'/)
assert.match(authStoreSource, /\/login\?impersonation=failed/) assert.match(appSource, /impersonationOrigin/)
assert.match(appSource, /await auth\.acceptImpersonationCode\(message\.code\)/)
assert.match(appSource, /type: 'impersonation-complete'/)
assert.match(appSource, /type: 'impersonation-failed'/)
assert.doesNotMatch(authStoreSource, /message\.type !== 'impersonation-ticket'/)
assert.match(routerSource, /isImpersonationHandoff[\s\S]*!isImpersonationHandoff/)
assert.match(loginSource, /route\.query\.impersonation === 'failed'/) assert.match(loginSource, /route\.query\.impersonation === 'failed'/)
assert.match(loginSource, /代登录失败或授权已失效,请返回管理后台重新发起/) assert.match(loginSource, /代登录失败或授权已失效,请返回管理后台重新发起/)
}) })
+2 -1
View File
@@ -32,7 +32,8 @@ router.beforeEach(async (to) => {
if (to.meta.requiresAuth && !auth.isAuthenticated) { if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
} }
if ((to.name === 'login' || to.name === 'register') && auth.isAuthenticated) { const isImpersonationHandoff = to.name === 'login' && typeof to.query.impersonationOrigin === 'string'
if ((to.name === 'login' || to.name === 'register') && auth.isAuthenticated && !isImpersonationHandoff) {
return getFirstAccessiblePath(auth.permissionCodes) return getFirstAccessiblePath(auth.permissionCodes)
} }
const permission = typeof to.meta.permission === 'string' ? to.meta.permission : undefined const permission = typeof to.meta.permission === 'string' ? to.meta.permission : undefined