feat!: drop the backend-side elevation windows; password and passkey sessions share capabilities, passkeys guard what nodes verify; the trust page lists added and removed passkeys [CI]
This commit is contained in:
1 parent
f704c66dd9
commit
4106f831dc
34 files changed
+191
-1364
No files matched your search
@@ -7,9 +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 { PasskeyOnly } from './PasskeyOnly'
|
||||
import { AgentDetail } from './pages/AgentDetail'
|
||||
import { Agents } from './pages/Agents'
|
||||
import { Probes } from './pages/Probes'
|
||||
@@ -244,8 +242,6 @@ export function App() {
|
||||
}
|
||||
if (!me) return <Login />
|
||||
// 密码登录的会话没有任何能力:只给它添加 Passkey 的页面
|
||||
// 以数据源方式(会话令牌)连别的后端时 kind 是 token,所以只看登录方式
|
||||
if (me.auth_method === 'password') return <PasskeyOnly />
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
@@ -265,7 +261,6 @@ 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>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { KeyRound, LogOut } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { Page, PasskeyDto } from '@shared/api/types'
|
||||
import { passkeySupported } from '@shared/api/webauthn'
|
||||
import { useAuth } from './auth'
|
||||
import { PasskeyAddForm } from './pages/PasskeyAdd'
|
||||
|
||||
/**
|
||||
* 用密码登录的会话没有任何管理能力,只能做一件事:添加 Passkey。
|
||||
* 之后要用 Passkey 登录才能管理节点、令牌和设置——密码泄露最多只能让人给你的账号加一个 Passkey 的机会(还要你已有的 Passkey 批准第二个)。
|
||||
*/
|
||||
export function PasskeyOnly() {
|
||||
const { me, logout } = useAuth()
|
||||
const [count, setCount] = useState<number | null>(null)
|
||||
|
||||
const load = () => {
|
||||
api
|
||||
.get<Page<PasskeyDto>>('/api/admin/auth/passkeys')
|
||||
.then((list) => setCount(list.items.length))
|
||||
.catch(() => setCount(null))
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="card stack" style={{ maxWidth: 520 }}>
|
||||
<div>
|
||||
<h1>MoShiro</h1>
|
||||
<p className="muted">
|
||||
{me?.username},你是用密码登录的。
|
||||
</p>
|
||||
</div>
|
||||
<div className="notice">用密码登录的会话不能管理任何东西,只能添加 Passkey。添加之后退出,用 Passkey 重新登录。</div>
|
||||
{!passkeySupported() ? (
|
||||
<div className="notice warn">这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。</div>
|
||||
) : count === 0 || count === null ? (
|
||||
<div className="stack">
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<KeyRound size={16} />
|
||||
<b>添加你的第一个 Passkey</b>
|
||||
</div>
|
||||
<PasskeyAddForm compact onAdded={load} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="notice">你已经有 {count} 个 Passkey。退出后用 Passkey 登录即可。</div>
|
||||
)}
|
||||
<div>
|
||||
<button type="button" className="btn primary" onClick={() => void logout()}>
|
||||
<LogOut size={16} />
|
||||
退出,用 Passkey 登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,46 @@ import { signStatement } from '@shared/api/webauthn'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { CopyButton, ErrorNotice, Spinner, useAction, useToast } from '@shared/ui'
|
||||
|
||||
type Proposal = NonNullable<TrustStateDto['proposal']>
|
||||
|
||||
/**
|
||||
* 要签名的这一版相对当前生效的集合有什么变化。<b>签名之前必须看一眼</b>:往账号里加一把 passkey 再等人签名,是这个流程唯一的风险——
|
||||
* 签了就等于授权它成为操作员(节点会认它)。
|
||||
*/
|
||||
function Changes({ proposal }: { proposal: Proposal }) {
|
||||
const { added, removed, server_key_changed: serverChanged } = proposal
|
||||
if (added.length === 0 && removed.length === 0 && !serverChanged) return null
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
{added.length > 0 ? (
|
||||
<div>
|
||||
<b>新增的操作员 Passkey(签名后节点会认它):</b>
|
||||
<ul style={{ margin: '4px 0 0', paddingLeft: 20 }}>
|
||||
{added.map((a) => (
|
||||
<li key={a.id}>
|
||||
<b>{a.name}</b> <span className="faint">创建于 {new Date(a.created_at).toLocaleString()} · {a.id.slice(0, 12)}…</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{removed.length > 0 ? (
|
||||
<div>
|
||||
<b>移除的操作员 Passkey:</b>
|
||||
<ul style={{ margin: '4px 0 0', paddingLeft: 20 }}>
|
||||
{removed.map((id) => (
|
||||
<li key={id} className="faint">
|
||||
{id.slice(0, 12)}…
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{serverChanged ? <div>服务器身份密钥有变化。</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 信任根:你的 Passkey 是整个系统的根。节点安装时把下面这个指纹和属主(passkey)固定在本地,之后只认从它出发、由你的 Passkey 签过名的
|
||||
* 操作员集合,面板自己改不了,也签不出新的版本。
|
||||
@@ -41,10 +81,11 @@ export function Trust() {
|
||||
</div>
|
||||
<p className="muted">
|
||||
建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。
|
||||
这一步会把账号下已有的 Passkey 登记为操作员,并需要你触碰两次 Passkey(确认身份、给集合签名)。
|
||||
这一步会把下面列出的 Passkey 登记为操作员,需要你触碰一次 Passkey 给集合签名。
|
||||
</p>
|
||||
{state.proposal ? (
|
||||
<div>
|
||||
<div className="stack">
|
||||
<Changes proposal={state.proposal} />
|
||||
<button type="button" className="btn primary" disabled={busy} onClick={() => void sign()}>
|
||||
用 Passkey 建立信任根
|
||||
</button>
|
||||
@@ -89,8 +130,9 @@ export function Trust() {
|
||||
) : (
|
||||
<div className="notice warn stack">
|
||||
<div>
|
||||
账号下的 Passkey(或站点域名)和生效的操作员集合不一致,比如新添加或删除了 Passkey。需要用一把<b>现有操作员的 Passkey</b>签一个新版本,节点才会采用。
|
||||
账号下的 Passkey(或站点域名)和生效的操作员集合不一致。需要用一把<b>现有操作员的 Passkey</b>签一个新版本,节点才会采用。
|
||||
</div>
|
||||
{state.proposal ? <Changes proposal={state.proposal} /> : null}
|
||||
<div>
|
||||
<b>注意</b>:从集合里去掉一把 Passkey 之后,由它批准过的节点会被固定了信任根的对端拒绝。
|
||||
</div>
|
||||
|
||||
@@ -36,29 +36,7 @@ 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
|
||||
|
||||
+10
-234
@@ -448,42 +448,6 @@ 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;
|
||||
@@ -877,7 +841,7 @@ export interface paths {
|
||||
/** The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign */
|
||||
get: operations["trust_get"];
|
||||
put?: never;
|
||||
/** Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window) */
|
||||
/** Store the next operator set, signed with a passkey (the first one sets the genesis) */
|
||||
post: operations["trust_commit"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -1493,21 +1457,6 @@ 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;
|
||||
@@ -1807,10 +1756,6 @@ 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;
|
||||
@@ -1986,6 +1931,12 @@ export interface components {
|
||||
[key: string]: string[];
|
||||
};
|
||||
};
|
||||
ProposedCredentialDto: {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Format: date-time */
|
||||
created_at: unknown;
|
||||
};
|
||||
PublishReleaseRequest: {
|
||||
manifest: null | string;
|
||||
signature: null | string;
|
||||
@@ -2185,6 +2136,9 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
version: number | string;
|
||||
challenge: string;
|
||||
added: components["schemas"]["ProposedCredentialDto"][];
|
||||
removed: string[];
|
||||
server_key_changed: boolean;
|
||||
};
|
||||
TrustStateDto: {
|
||||
initialized: boolean;
|
||||
@@ -3470,184 +3424,6 @@ 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;
|
||||
|
||||
@@ -41,7 +41,6 @@ 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 TrustStateDto = Narrow<S['TrustStateDto']>
|
||||
export type SignedStatementDto = Narrow<S['SignedStatementDto']>
|
||||
export type PasskeyCreationOptionsDto = Narrow<S['PasskeyCreationOptionsDto']>
|
||||
|
||||
@@ -82,45 +82,6 @@ export async function signStatement(statement: string): Promise<SignedStatementD
|
||||
}
|
||||
}
|
||||
|
||||
/** 提权窗口的种类:加节点、吊销和删除、改凭证(用一次就没有)。 */
|
||||
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')
|
||||
|
||||
Reference in new issue
Block a user