feat!: sensitive actions need a fresh passkey touch (elevation windows), plus login page hint cleanup [CI]

This commit is contained in:
justme committed 2026-10-07 22:29:24 +08:00
1 parent 1379b8c7cb
commit ff45ad086e
27 files changed
+1305 -58

No files matched your search

+2 -13
View File
@@ -7,6 +7,7 @@ import { conditionalSupported, passkeySupported } from '@shared/api/webauthn'
import { describeError, Field, Segmented, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
import { useAuth } from './auth'
import { BackendUpdateNotice } from './BackendUpdateNotice'
import { ElevationBar } from './ElevationBar'
import { PasskeyNotice } from './PasskeyNotice'
import { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents'
@@ -81,8 +82,6 @@ function Login() {
}
}
const conditional = useRef<AbortController | null>(null) // 正在挂着的自动填充请求:点按钮之前要先取消,浏览器同一时间只允许一个 WebAuthn 请求
// 自动填充是否可用:unknown 还在检测,yes 已经挂上(点用户名框会列出已保存的 passkey),no 浏览器不支持
const [autofill, setAutofill] = useState<'unknown' | 'yes' | 'no'>('unknown')
const retryOnFocus = useRef(false) // 服务器拒绝了自动填充选的 passkey:等用户再点输入框时才重新开始,不自己循环
const usePasskey = async () => {
setBusy(true)
@@ -110,7 +109,6 @@ function Login() {
let expired = false // 只有「挑战到期」才重新开始;组件卸载、依赖变化时的取消不能再触发重来(否则会形成循环)
void conditionalSupported().then(async (ok) => {
if (abort.signal.aborted) return
setAutofill(ok ? 'yes' : 'no')
if (!ok) return
timer = setTimeout(() => {
expired = true
@@ -194,7 +192,6 @@ function Login() {
) : null}
{usingPasskey ? (
<>
<p className="muted">用指纹、人脸或安全密钥登录,不用输用户名和密码。点下面的按钮,浏览器会让你选一个这个站点的 Passkey。</p>
{error ? <div className="notice bad">{error}</div> : null}
<button className="btn primary" type="button" disabled={busy} onClick={() => void usePasskey()} autoFocus>
<KeyRound size={16} />
@@ -214,15 +211,6 @@ function Login() {
<Field label="用户名">
<input className="input" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete={autofillActive ? 'username webauthn' : 'username'} autoFocus={!adding} required onFocus={retry} onClick={retry} />
</Field>
{canPasskey ? (
<p className="faint" style={{ margin: '-6px 0 0', fontSize: 12 }}>
{autofill === 'yes'
? '点一下用户名框:如果浏览器里已经保存了这个站点的 Passkey,会列在下拉里,选一个就直接登录。还没有的话,先用密码登录,到「系统 → 用户」添加。'
: autofill === 'no'
? '这个浏览器不支持从输入框里选 Passkey,请切到上面的「Passkey」。'
: ''}
</p>
) : null}
<Field label="密码">
<input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
</Field>
@@ -273,6 +261,7 @@ export function App() {
</div>
))}
<div className="spacer" />
<ElevationBar />
<SourceButton variant="status" />
<div className="me">
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
+77
View File
@@ -0,0 +1,77 @@
import { ShieldCheck } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { api, ApiError, setElevationHandler } from '@shared/api/client'
import type { ElevationDto, Page } from '@shared/api/types'
import { ELEVATED_EVENT, elevate, passkeySupported, type ElevationClass } from '@shared/api/webauthn'
import { useAuth } from './auth'
const NAMES: Record<string, string> = { enroll: '添加节点', revoke: '吊销', credential: '改凭证' }
function remaining(until: string, now: number): string {
const seconds = Math.max(0, Math.round((Date.parse(until) - now) / 1000))
return seconds >= 60 ? `${Math.ceil(seconds / 60)} 分钟` : `${seconds} 秒`
}
/**
* 敏感操作要先触碰 passkey:这里注册全局的处理函数(API 返回 `elevation_required` 时自动弹出触碰,成功后重试原请求),
* 并在侧栏显示当前会话开着的提权窗口和剩余时间,可以提前结束。
*/
export function ElevationBar() {
const { me } = useAuth()
const [windows, setWindows] = useState<ElevationDto[]>([])
const [now, setNow] = useState(() => Date.now())
const signedIn = !!me?.user_id
const refresh = useCallback(() => {
if (!signedIn) return
api
.get<Page<ElevationDto>>('/api/admin/auth/elevation')
.then((page) => setWindows(page.items))
.catch(() => undefined)
}, [signedIn])
useEffect(() => {
if (!signedIn || !passkeySupported()) return undefined
setElevationHandler(async (kind) => {
try {
return await elevate(kind as ElevationClass)
} catch (e) {
if (e instanceof ApiError && e.status === 400) throw new Error('先添加一个 Passkey 才能做这个操作(系统 → 用户 → 我的 Passkey)')
throw e
}
})
refresh()
window.addEventListener(ELEVATED_EVENT, refresh)
const tick = setInterval(() => setNow(Date.now()), 1000)
return () => {
setElevationHandler(null)
window.removeEventListener(ELEVATED_EVENT, refresh)
clearInterval(tick)
}
}, [signedIn, refresh])
const open = windows.filter((w) => Date.parse(w.expires_at) > now)
if (open.length === 0) return null
return (
<div className="notice" style={{ margin: '0 0 8px', padding: '8px 10px' }}>
<div className="row" style={{ gap: 6 }}>
<ShieldCheck size={16} />
<b>已用 Passkey 确认</b>
<button
type="button"
className="btn small text right"
onClick={() => {
api.del('/api/admin/auth/elevation').then(() => setWindows([])).catch(() => undefined)
}}
>
结束
</button>
</div>
{open.map((w) => (
<div key={w.class} className="faint" style={{ fontSize: 12 }}>
{NAMES[w.class] ?? w.class}:还剩 {remaining(w.expires_at, now)}
</div>
))}
</div>
)
}
+22
View File
@@ -36,7 +36,29 @@ export function withQuery(path: string, query?: Query): string {
return text ? `${path}?${text}` : path
}
/**
* 敏感操作(加节点、吊销、改凭证)需要先触碰 passkey 开一个提权窗口:后端返回 403 `elevation_required`(detail 是窗口种类)时,
* 调用已注册的处理函数(弹出 passkey 触碰),成功后把原请求重试一次。
*/
let elevationHandler: ((elevation: string) => Promise<boolean>) | null = null
export function setElevationHandler(handler: ((elevation: string) => Promise<boolean>) | null) {
elevationHandler = handler
}
async function request<T>(method: string, path: string, body?: unknown, init?: RequestInit): Promise<T> {
try {
return await send<T>(method, path, body, init)
} catch (e) {
if (e instanceof ApiError && e.code === 'elevation_required' && elevationHandler) {
if (await elevationHandler(e.detail)) return send<T>(method, path, body, init)
throw new ApiError(403, e.code, '这个操作需要先用 Passkey 确认,已取消')
}
throw e
}
}
async function send<T>(method: string, path: string, body?: unknown, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
if (body !== undefined) headers['Content-Type'] = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'
if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken
+234
View File
@@ -448,6 +448,42 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/auth/elevation": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** The elevation windows this session has open (sensitive actions need one) */
get: operations["auth_elevation_list"];
put?: never;
/** Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s) */
post: operations["auth_elevate"];
/** Close all elevation windows of this session */
delete: operations["auth_elevation_end"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/elevation/options": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** A challenge for opening an elevation window with a passkey touch */
post: operations["auth_elevation_options"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/me": {
parameters: {
query?: never;
@@ -1370,6 +1406,21 @@ export interface components {
first: boolean;
last: boolean;
};
ElevationAssertionRequest: {
class: string;
credential_id: string;
authenticator_data: string;
client_data_json: string;
signature: string;
};
ElevationDto: {
class: string;
/** Format: date-time */
expires_at: unknown;
};
ElevationRequest: {
class: string;
};
EventDto: {
/** Format: int64 */
id: number | string;
@@ -1668,6 +1719,10 @@ export interface components {
items: components["schemas"]["AuditDto"][];
next_cursor: null | string;
};
PageDtoOfElevationDto: {
items: components["schemas"]["ElevationDto"][];
next_cursor: null | string;
};
PageDtoOfEventDto: {
items: components["schemas"]["EventDto"][];
next_cursor: null | string;
@@ -1779,6 +1834,7 @@ export interface components {
rp_id: string;
/** Format: int32 */
timeout_ms: number | string;
allow_credentials?: null | string[];
};
PatchProbeRequest: {
name: null | string;
@@ -3293,6 +3349,184 @@ export interface operations {
};
};
};
auth_elevation_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageDtoOfElevationDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_elevate: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ElevationAssertionRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ElevationDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Too Many Requests */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_elevation_end: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_elevation_options: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ElevationRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PasskeyRequestOptionsDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Too Many Requests */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_me: {
parameters: {
query?: never;
+1
View File
@@ -41,6 +41,7 @@ export type ApiTokenDto = Narrow<S['ApiTokenDto']>
export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']>
export type CapabilityCatalogDto = Narrow<S['CapabilityCatalogDto']>
export type PasskeyDto = Narrow<S['PasskeyDto']>
export type ElevationDto = Narrow<S['ElevationDto']>
export type PasskeyCreationOptionsDto = Narrow<S['PasskeyCreationOptionsDto']>
export type PasskeyRequestOptionsDto = Narrow<S['PasskeyRequestOptionsDto']>
export type MeDto = Narrow<S['MeDto']>
+39
View File
@@ -58,6 +58,45 @@ export async function signInWithPasskey(opts: { conditional?: boolean; signal?:
})
}
/** 提权窗口的种类:加节点、吊销和删除、改凭证(用一次就没有)。 */
export type ElevationClass = 'enroll' | 'revoke' | 'credential'
export const ELEVATED_EVENT = 'moshiro:elevated'
/**
* 用已有的 passkey 触碰一次,给当前会话开一个提权窗口。用户取消返回 false;没有 passkey 等服务端拒绝的情况抛 ApiError。
* 浏览器只会让用户选自己的 passkey(服务端给了 allow_credentials)。
*/
export async function elevate(elevation: ElevationClass): Promise<boolean> {
const options = await api.post<PasskeyRequestOptionsDto>('/api/admin/auth/elevation/options', { class: elevation })
let credential: PublicKeyCredential | null
try {
credential = (await navigator.credentials.get({
publicKey: {
challenge: toBytes(options.challenge),
rpId: options.rp_id,
timeout: options.timeout_ms,
userVerification: 'required',
allowCredentials: (options.allow_credentials ?? []).map((id) => ({ type: 'public-key' as const, id: toBytes(id) })),
},
})) as PublicKeyCredential | null
} catch (e) {
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) return false
throw e
}
if (!credential) return false
const response = credential.response as AuthenticatorAssertionResponse
await api.post('/api/admin/auth/elevation', {
class: elevation,
credential_id: credential.id,
authenticator_data: toText(response.authenticatorData),
client_data_json: toText(response.clientDataJSON),
signature: toText(response.signature),
})
window.dispatchEvent(new Event(ELEVATED_EVENT))
return true
}
/** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */
export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise<PasskeyDto> {
const options = await api.post<PasskeyCreationOptionsDto>('/api/admin/auth/passkeys/options')