Commit 0e076eb8 by jianan

feat(auth): /auth/ticket 票据落地页——票桥接力建会话,零自证进工作台(referrer-portal-api D11)

- AuthTicketView:consume 一次性票(裸 axios 避开 401 拦截器)→ storeUser 注入会话 → 跳 redirect
  (redirect 参数限站内路径,防开放重定向)
- oidc.ts adoptTicketSession:casdoor token 经 oidc-client-ts 公开面 UserManager.storeUser
  装载进内存 userStore,与 PKCE 回调产物同构,token 仍不落 localStorage
- 路由与认证守卫放行 auth-ticket;i18n 补 ticket 三键(zh-Hans/zh-Hant)
- PKCE 路径保留:无票直开/浏览器直达照旧(D8 兜底);vue-tsc 无新增错误
parent ff73e600
......@@ -110,6 +110,9 @@ export default {
'auth.callbackTitle': '认证回调',
'auth.callbackDesc': '登录成功,正在跳转…',
'auth.callbackError': '登录失败或授权已失效,请重试。',
'auth.ticketTitle': '免登进入工作台',
'auth.ticketDesc': '正在通过票据建立登录态…',
'auth.ticketError': '登录票据无效或已过期,请重新登录。',
'auth.login': '登录',
'auth.retry': '重试',
'auth.logout': '退出',
......
......@@ -110,6 +110,9 @@ export default {
'auth.callbackTitle': '認證回調',
'auth.callbackDesc': '登入成功,正在跳轉…',
'auth.callbackError': '登入失敗或授權已失效,請重試。',
'auth.ticketTitle': '免登進入工作台',
'auth.ticketDesc': '正在通過票據建立登入態…',
'auth.ticketError': '登入票據無效或已過期,請重新登入。',
'auth.login': '登入',
'auth.retry': '重試',
'auth.logout': '退出',
......
......@@ -14,17 +14,19 @@ const router = createRouter({
{ path: '/pipeline', name: 'pipeline', component: () => import('@/views/PipelineView.vue') },
{ path: '/customers', name: 'customers', component: () => import('@/views/CustomersView.vue') },
{ path: '/auth/callback', name: 'auth-callback', component: () => import('@/views/AuthCallbackView.vue') },
{ path: '/auth/ticket', name: 'auth-ticket', component: () => import('@/views/AuthTicketView.vue') },
{ path: '/logged-out', name: 'logged-out', component: () => import('@/views/LoggedOutView.vue') },
// 兜底:未登记路径回首页。缺这条时守卫放行后无匹配组件,<RouterView> 直接空白
{ path: '/:pathMatch(.*)*', redirect: '/' }
]
})
// 认证守卫:除回调/已退出页外,无有效登录态即发起 OIDC 授权(当前路径随授权 state 携带,回跳后还原)。
// 认证守卫:除回调/票据落地/已退出页外,无有效登录态即发起 OIDC 授权(当前路径随授权 state 携带,回跳后还原)。
// mock-only(本地生产核对)下整条 OIDC 链路停用,守卫直接放行。
router.beforeEach(async (to) => {
if (isMockOnly) return true
if (to.name === 'auth-callback' || to.name === 'logged-out') return true
// auth-ticket:票即凭证,登录态由页内 consume 建立;此时跳 PKCE 会毁掉接力
if (to.name === 'auth-callback' || to.name === 'auth-ticket' || to.name === 'logged-out') return true
const user = await userManager.getUser()
if (user && !user.expired) return true
......
......@@ -5,7 +5,8 @@ import {
userManager,
signinRedirect,
handleRedirectCallback,
signoutRedirect
signoutRedirect,
adoptTicketSession
} from '@/utils/oidc'
import { isMockOnly } from '@/utils/devMock'
......@@ -55,6 +56,16 @@ export const useAuthStore = defineStore('auth', () => {
return returnPath
}
/**
* 票桥接力(design D11):票据消费换来的 Casdoor token 直接建会话,
* 与 handleCallback 产物同构(内存 userStore + store 内展示身份)。
*/
async function adoptSession(accessToken: string): Promise<void> {
// 无条件以票内主体覆盖会话:接力者才是当前自然人,保留旧会话会错绑身份
const adopted = await adoptTicketSession(accessToken)
user.value = toAuthUser(adopted)
}
/** 登出:跳转 IdP 结束 SSO 会话,回 /logged-out。返回 false 表示 IdP 不可达。 */
async function logout(): Promise<boolean> {
try {
......@@ -66,5 +77,5 @@ export const useAuthStore = defineStore('auth', () => {
}
}
return { user, init, login, handleCallback, logout }
return { user, init, login, handleCallback, adoptSession, logout }
})
import { UserManager, WebStorageStateStore } from 'oidc-client-ts'
import { User, UserManager, WebStorageStateStore } from 'oidc-client-ts'
import type { IdTokenClaims } from 'oidc-client-ts'
/**
* 内存存储:登录后的 User(含 access_token)只存于此,不写入 localStorage/sessionStorage。
......@@ -66,3 +67,41 @@ export async function handleRedirectCallback(): Promise<string> {
export function signoutRedirect(): Promise<void> {
return userManager.signoutRedirect()
}
/** 解析 JWT payload(UTF-8 安全,profile 里的中文姓名等 claim 直存于 payload) */
export function decodeJwtPayload(token: string): Record<string, unknown> {
const payload = token.split('.')[1]
if (!payload) throw new Error('无效的 JWT:缺少 payload 段')
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const bin = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, '='))
const json = decodeURIComponent(
Array.from(bin, (c) => '%' + c.charCodeAt(0).toString(16).padStart(2, '0')).join('')
)
return JSON.parse(json) as Record<string, unknown>
}
/**
* 票桥接力(referrer-portal-api design D11):consume 换来的 Casdoor access token
* 经 oidc-client-ts 公开的 `UserManager.storeUser()` 装载进内存 userStore——
* 此后 http 拦截器的 `userManager.getUser()` 与 PKCE 回调产物完全同构,业务侧零特判。
* 与 D8 同纪律:token 只进内存(MemoryStore),刷新/关页即失,重进需再过票桥。
*/
export async function adoptTicketSession(accessToken: string): Promise<User> {
const payload = decodeJwtPayload(accessToken)
// Casdoor JWT 必备 claims 齐全才可当 IdTokenClaims 用;缺则视为非法令牌
if (typeof payload.sub !== 'string' || typeof payload.exp !== 'number') {
throw new Error('令牌缺少 sub/exp,不是合法的 Casdoor access token')
}
const user = new User({
access_token: accessToken,
token_type: 'Bearer',
scope: typeof payload.scope === 'string' ? payload.scope : 'openid profile phone',
profile: payload as IdTokenClaims,
expires_at: payload.exp
})
if (user.expired) {
throw new Error('令牌已过期,请重新进入')
}
await userManager.storeUser(user)
return user
}
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import axios from 'axios'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const error = ref(false)
/** 只接受站内绝对路径,防 open redirect(ticket URL 可被外部粘贴传播) */
function safeRedirect(raw: unknown): string {
const p = typeof raw === 'string' && raw ? raw : '/'
return p.startsWith('/') && !p.startsWith('//') ? p : '/'
}
onMounted(async () => {
const ticket = typeof route.query.ticket === 'string' ? route.query.ticket : ''
if (!ticket) {
// 无票直开(收藏/误访):不建会话,回落正常入口——PKCE 登录照旧
await router.replace('/')
return
}
try {
// 裸 axios:不经 http.ts 的 Bearer 注入与 401 静默重登拦截器,
// 本页 401 属预期分支(票无效),必须由本页自己处理而不是跳 OIDC
const res = await axios.post(
`/csf/api/c/sso/ticket/consume?ticket=${encodeURIComponent(ticket)}`
)
const data = res.data?.code === 200 ? res.data.data : null
if (!data || !data.token) {
throw new Error('票据消费失败')
}
await auth.adoptSession(String(data.token))
await router.replace(safeRedirect(route.query.redirect))
} catch (e) {
console.error('[auth-ticket] 票据消费失败:', e)
error.value = true
}
})
function retryLogin() {
// 票失效(>30s / 已消费):走工作台自身 OIDC 登录兜底
void auth.login('/')
}
</script>
<template>
<div class="py-16 text-center">
<template v-if="!error">
<h1 class="text-lg font-semibold">{{ t('auth.ticketTitle') }}</h1>
<p class="mt-2 text-sm text-slate-500">{{ t('auth.ticketDesc') }}</p>
</template>
<template v-else>
<h1 class="text-lg font-semibold">{{ t('auth.ticketTitle') }}</h1>
<p class="mt-2 text-sm text-slate-500">{{ t('auth.ticketError') }}</p>
<button
class="mt-4 rounded-lg bg-brand-600 px-4 py-2 text-sm text-white"
@click="retryLogin"
>
{{ t('auth.login') }}
</button>
</template>
</div>
</template>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment