Skip to content

Commit 32ad0ab

Browse files
Lokins577claude
andcommitted
feat: Prism OIDC 登录链路
M1 完整实现,仅配置值待 Prism 侧提供。 - 授权码 + PKCE,state/nonce 存 KV 且一次性消费 - ID token 本地 RS256 验签:硬编码只接受 RS256,拒绝 alg=none - JWKS 按 kid 缓存,未命中时强制刷新一次以适配密钥轮换 - 会话 KV key 用 SHA-256(token),省掉一个需要保管的 secret - 身份组静默复查:会话超 15 分钟用 refresh token 拉新 ID token 重新映射 role_key,降级到 guest 时立即踢掉全部会话 - 非 NSUK 团队成员允许登录但 role 为 guest,前端引导去邀请注册页 - Prism 未配置时登录入口优雅降级,不报 500 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2161b2d commit 32ad0ab

16 files changed

Lines changed: 1189 additions & 152 deletions

File tree

app/components/AppHeader.vue

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script setup lang="ts">
2+
const { user, configured, roleLabel, login, logout } = useAuth()
3+
24
const nav = [
35
{ label: '首页', to: '/' },
46
{ label: '创意工坊', to: '/workshop' },
@@ -11,9 +13,7 @@ const isActive = (to: string) => (to === '/' ? route.path === '/' : route.path.s
1113
</script>
1214

1315
<template>
14-
<header
15-
class="sticky top-0 z-50 border-b border-(--ui-border) bg-(--ui-bg)/80 backdrop-blur"
16-
>
16+
<header class="sticky top-0 z-50 border-b border-(--ui-border) bg-(--ui-bg)/80 backdrop-blur">
1717
<div class="mx-auto flex h-14 max-w-5xl items-center gap-6 px-6">
1818
<NuxtLink to="/" class="font-semibold tracking-tight">NSUK</NuxtLink>
1919

@@ -29,9 +29,19 @@ const isActive = (to: string) => (to === '/' ? route.path === '/' : route.path.s
2929
</NuxtLink>
3030
</nav>
3131

32-
<div class="ml-auto">
33-
<!-- TODO(D组): 接入 Prism 登录态后替换为用户菜单 -->
34-
<UButton to="/account" size="sm" variant="soft">账号</UButton>
32+
<div class="ml-auto flex items-center gap-3">
33+
<template v-if="user">
34+
<NuxtLink to="/account" class="flex items-center gap-2 text-sm">
35+
<span>{{ user.displayName }}</span>
36+
<UBadge v-if="user.level > 0" size="sm" variant="subtle">{{ roleLabel }}</UBadge>
37+
</NuxtLink>
38+
<UButton size="sm" variant="ghost" @click="logout">登出</UButton>
39+
</template>
40+
41+
<UButton v-else-if="configured" size="sm" variant="soft" @click="login()">登录</UButton>
42+
43+
<!-- Prism 未配置时不显示可点的登录按钮,避免点了什么也不发生 -->
44+
<span v-else class="text-sm text-(--ui-text-dimmed)">登录未配置</span>
3545
</div>
3646
</div>
3747
</header>

app/composables/useAuth.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
interface SessionUser {
2+
sub: string
3+
username: string
4+
displayName: string
5+
roleKey: string
6+
level: number
7+
permissions: string[]
8+
}
9+
10+
interface MeResponse {
11+
ok: boolean
12+
user: SessionUser | null
13+
/** Prism 尚未配置时为 false,登录入口应降级而不是报错 */
14+
configured: boolean
15+
/** 团队邀请链接注册页,用于引导非成员 */
16+
joinUrl: string
17+
}
18+
19+
const ROLE_LABEL: Record<string, string> = {
20+
guest: '游客',
21+
sponsor: '赞助者',
22+
staff: '客服',
23+
developer: '开发者',
24+
admin: '管理员',
25+
}
26+
27+
export function useAuth() {
28+
const state = useState<MeResponse | null>('auth:me', () => null)
29+
30+
async function load() {
31+
const data = await $fetch<MeResponse>('/api/auth/me').catch(() => null)
32+
state.value = data
33+
return data
34+
}
35+
36+
const user = computed(() => state.value?.user ?? null)
37+
const configured = computed(() => state.value?.configured ?? false)
38+
const joinUrl = computed(() => state.value?.joinUrl ?? '')
39+
const level = computed(() => user.value?.level ?? 0)
40+
const roleLabel = computed(() => ROLE_LABEL[user.value?.roleKey ?? 'guest'] ?? '游客')
41+
42+
/** 登录后是否还需要走 NSUK 团队注册。level 0 即尚未拿到任何身份组 */
43+
const needsJoin = computed(() => !!user.value && level.value === 0)
44+
45+
function login(redirect?: string) {
46+
const target = redirect ?? useRoute().fullPath
47+
return navigateTo(`/api/auth/login?redirect=${encodeURIComponent(target)}`, {
48+
external: true,
49+
})
50+
}
51+
52+
async function logout() {
53+
await $fetch('/api/auth/logout', { method: 'POST' }).catch(() => null)
54+
state.value = null
55+
await navigateTo('/')
56+
reloadNuxtApp()
57+
}
58+
59+
return { state, user, configured, joinUrl, level, roleLabel, needsJoin, load, login, logout }
60+
}

app/layouts/default.vue

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
<script setup lang="ts">
2+
const { state, load } = useAuth()
3+
4+
// SSR 期间取一次登录态,客户端复用,避免每个页面各查一遍
5+
if (!state.value) {
6+
const { data } = await useAsyncData('auth:me', () => load())
7+
if (data.value) state.value = data.value
8+
}
9+
</script>
10+
111
<template>
212
<div class="flex min-h-screen flex-col">
313
<AppHeader />

app/pages/account.vue

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,85 @@
11
<script setup lang="ts">
22
useHead({ title: '账号 · NSUK' })
3+
4+
const { user, configured, joinUrl, needsJoin, roleLabel, login } = useAuth()
5+
const route = useRoute()
6+
7+
/** 回调失败时会带 ?error= 回来 */
8+
const loginError = computed(() => {
9+
const e = route.query.error
10+
return typeof e === 'string' ? e : ''
11+
})
12+
13+
const ERROR_TEXT: Record<string, string> = {
14+
invalid_state: '登录状态已过期,请重新登录。',
15+
missing_code: '授权未完成。',
16+
login_failed: '登录失败,请稍后重试。',
17+
access_denied: '你取消了授权。',
18+
}
319
</script>
420

521
<template>
622
<div class="mx-auto max-w-3xl px-6 py-12">
723
<h1 class="text-2xl font-bold">账号</h1>
8-
<p class="mt-2 text-sm text-(--ui-text-muted)">
9-
本站账号由 Prism 提供,模组令牌与设备绑定也在这里管理。
10-
</p>
11-
12-
<div
13-
class="mt-8 rounded-(--ui-radius) border border-dashed border-(--ui-border) p-8 text-sm text-(--ui-text-dimmed)"
14-
>
15-
登录链路开发中(M1,等待 Prism 侧适配完成)。
16-
</div>
24+
25+
<UAlert
26+
v-if="loginError"
27+
class="mt-6"
28+
color="error"
29+
variant="subtle"
30+
:description="ERROR_TEXT[loginError] ?? `登录失败:${loginError}`"
31+
/>
32+
33+
<!-- 未登录 -->
34+
<template v-if="!user">
35+
<p class="mt-2 text-sm text-(--ui-text-muted)">
36+
本站账号由 Prism 提供,模组令牌与设备绑定也在这里管理。
37+
</p>
38+
<UButton v-if="configured" class="mt-6" @click="login('/account')">使用 Prism 登录</UButton>
39+
<div
40+
v-else
41+
class="mt-6 rounded-(--ui-radius) border border-dashed border-(--ui-border) p-8 text-sm text-(--ui-text-dimmed)"
42+
>
43+
登录尚未配置(等待 Prism 侧提供应用凭据)。
44+
</div>
45+
</template>
46+
47+
<!-- 已登录 -->
48+
<template v-else>
49+
<div class="mt-6 flex items-center gap-3">
50+
<span class="text-lg">{{ user.displayName }}</span>
51+
<UBadge variant="subtle">{{ roleLabel }}</UBadge>
52+
</div>
53+
<p class="mt-1 text-sm text-(--ui-text-dimmed)">@{{ user.username }}</p>
54+
55+
<!--
56+
已登录但没有任何身份组:说明还没通过 NSUK 通道加入团队。
57+
引导去邀请链接注册页,带 continue 回跳。
58+
-->
59+
<UAlert
60+
v-if="needsJoin"
61+
class="mt-6"
62+
color="warning"
63+
variant="subtle"
64+
title="尚未加入 NSUK"
65+
description="你的 Prism 账号还不属于 NSUK 团队,暂时只能浏览公开内容。"
66+
>
67+
<template #actions>
68+
<UButton v-if="joinUrl" :to="joinUrl" external size="sm">前往加入</UButton>
69+
</template>
70+
</UAlert>
71+
72+
<section class="mt-10">
73+
<h2 class="text-lg font-semibold">模组授权</h2>
74+
<p class="mt-1 text-sm text-(--ui-text-muted)">
75+
赞助者及以上可生成令牌,填入模组后即可使用。一个账号同时只能绑定一台设备。
76+
</p>
77+
<div
78+
class="mt-4 rounded-(--ui-radius) border border-dashed border-(--ui-border) p-8 text-sm text-(--ui-text-dimmed)"
79+
>
80+
开发中(M4)。
81+
</div>
82+
</section>
83+
</template>
1784
</div>
1885
</template>

nuxt.config.ts

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,38 +18,12 @@ export default defineNuxtConfig({
1818
preset: 'cloudflare_module',
1919
},
2020

21-
// 服务端配置由 wrangler 的 [vars] / secret 注入,此处只声明形状与默认值。
22-
// 命名对应环境变量 NUXT_PRISM_ISSUER、NUXT_PUBLIC_SITE_URL 等。
21+
// 服务端配置全部由 Hono 直接从 Worker env 读取(见 server/hono/lib/config.ts),
22+
// 不走 runtimeConfig —— 后者依赖 unctx 异步上下文,在 Hono handler 里取不稳定。
23+
// 这里只保留前端需要的公开值。
2324
runtimeConfig: {
24-
// ─── Prism OIDC(D 组,待 Prism 侧适配完成后填充)───
25-
prism: {
26-
issuer: '',
27-
clientId: '',
28-
clientSecret: '',
29-
// NSUK 团队 ID,用于读取 groups_in_team_<id> claim
30-
teamId: '',
31-
// 团队邀请链接注册入口
32-
joinUrl: '',
33-
},
34-
35-
// ─── 模组授权(F 组)───
36-
mod: {
37-
// License 签名私钥(PKCS#8 PEM),wrangler secret put NUXT_MOD_LICENSE_PRIVATE_KEY
38-
licensePrivateKey: '',
39-
// License 有效期(小时)。内测 24,正式 168
40-
licenseTtlHours: 24,
41-
// 设备换绑冷却(小时)
42-
rebindCooldownHours: 24,
43-
},
44-
45-
// ─── Prism webhook 接收 ───
46-
webhook: {
47-
// 自定义 header 中的共享密钥(Prism 的 audit webhook 无 HMAC 签名)
48-
secret: '',
49-
},
50-
5125
public: {
52-
siteUrl: 'https://nsuk.example',
26+
siteUrl: '',
5327
},
5428
},
5529

server/hono/app.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ const app = new Hono<AppBindings>().basePath('/api')
1313
function maskSecrets(input: unknown, env: Env | undefined): string {
1414
let out = String(input ?? '')
1515
const keys: (keyof Env)[] = [
16-
'NUXT_PRISM_CLIENT_SECRET',
17-
'NUXT_MOD_LICENSE_PRIVATE_KEY',
18-
'NUXT_WEBHOOK_SECRET',
16+
'PRISM_CLIENT_SECRET',
17+
'MOD_LICENSE_PRIVATE_KEY',
18+
'WEBHOOK_SECRET',
1919
]
2020
for (const k of keys) {
2121
const v = env?.[k]

server/hono/lib/config.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// 配置读取。
2+
//
3+
// Hono 直接从 Worker 的 env 读,不走 Nitro 的 runtimeConfig —— 后者依赖
4+
// unctx 的异步上下文,在 Hono handler 里取不稳定,而 env 本来就是每个
5+
// 请求都带着的。变量与 secret 都在 wrangler.toml 里声明。
6+
import type { Env } from '../types'
7+
8+
export interface PrismConfig {
9+
issuer: string
10+
clientId: string
11+
clientSecret: string
12+
teamId: string
13+
joinUrl: string
14+
}
15+
16+
export class ConfigError extends Error {
17+
constructor(key: string) {
18+
super(`缺少配置:${key}`)
19+
this.name = 'ConfigError'
20+
}
21+
}
22+
23+
function required(env: Env, key: keyof Env): string {
24+
const v = env[key]
25+
if (typeof v !== 'string' || v === '') throw new ConfigError(String(key))
26+
return v
27+
}
28+
29+
export function prismConfig(env: Env): PrismConfig {
30+
return {
31+
issuer: required(env, 'PRISM_ISSUER').replace(/\/+$/, ''),
32+
clientId: required(env, 'PRISM_CLIENT_ID'),
33+
clientSecret: required(env, 'PRISM_CLIENT_SECRET'),
34+
teamId: required(env, 'PRISM_TEAM_ID'),
35+
joinUrl: env.PRISM_JOIN_URL ?? '',
36+
}
37+
}
38+
39+
/** 配置是否齐备。用于在未接入 Prism 时让登录入口优雅降级而不是 500 */
40+
export function isPrismConfigured(env: Env): boolean {
41+
try {
42+
prismConfig(env)
43+
return true
44+
} catch {
45+
return false
46+
}
47+
}
48+
49+
export function siteUrl(env: Env): string {
50+
return (env.SITE_URL || 'http://localhost:3000').replace(/\/+$/, '')
51+
}
52+
53+
/** cookie 是否加 Secure。本地 http 开发时必须关掉,否则浏览器不会存 */
54+
export function cookieSecure(env: Env): boolean {
55+
return siteUrl(env).startsWith('https://')
56+
}

server/hono/lib/crypto.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// 基础密码学工具。全部基于 WebCrypto,Workers 原生支持,无第三方依赖。
2+
3+
const encoder = new TextEncoder()
4+
const decoder = new TextDecoder()
5+
6+
export function base64UrlEncode(bytes: ArrayBuffer | Uint8Array): string {
7+
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
8+
let binary = ''
9+
for (const b of arr) binary += String.fromCharCode(b)
10+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
11+
}
12+
13+
// 显式基于 ArrayBuffer 构造:WebCrypto 的 BufferSource 不接受
14+
// Uint8Array<ArrayBufferLike>(可能是 SharedArrayBuffer)。
15+
export function base64UrlDecode(input: string): Uint8Array<ArrayBuffer> {
16+
const padded = input.replace(/-/g, '+').replace(/_/g, '/')
17+
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
18+
const out = new Uint8Array(new ArrayBuffer(binary.length))
19+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
20+
return out
21+
}
22+
23+
/** 同上,供需要传给 WebCrypto 的文本使用 */
24+
export function encodeUtf8(input: string): Uint8Array<ArrayBuffer> {
25+
const src = encoder.encode(input)
26+
const out = new Uint8Array(new ArrayBuffer(src.length))
27+
out.set(src)
28+
return out
29+
}
30+
31+
export function base64UrlDecodeText(input: string): string {
32+
return decoder.decode(base64UrlDecode(input))
33+
}
34+
35+
/** 密码学随机串,用于 state / nonce / code_verifier / 会话令牌 */
36+
export function randomToken(bytes = 32): string {
37+
const buf = new Uint8Array(bytes)
38+
crypto.getRandomValues(buf)
39+
return base64UrlEncode(buf)
40+
}
41+
42+
export async function sha256(input: string): Promise<ArrayBuffer> {
43+
return crypto.subtle.digest('SHA-256', encoder.encode(input))
44+
}
45+
46+
export async function sha256Base64Url(input: string): Promise<string> {
47+
return base64UrlEncode(await sha256(input))
48+
}
49+
50+
/**
51+
* 常数时间比较。用于 webhook 共享密钥等场景 ——
52+
* 普通的 === 会在第一个不同字节处返回,泄露前缀信息。
53+
*/
54+
export function timingSafeEqual(a: string, b: string): boolean {
55+
const ab = encoder.encode(a)
56+
const bb = encoder.encode(b)
57+
if (ab.length !== bb.length) return false
58+
let diff = 0
59+
for (let i = 0; i < ab.length; i++) diff |= ab[i]! ^ bb[i]!
60+
return diff === 0
61+
}

0 commit comments

Comments
 (0)