feat!: sensitive actions need a fresh passkey touch (elevation windows), plus login page hint cleanup [CI]
This commit is contained in:
1 parent
1379b8c7cb
commit
ff45ad086e
27 files changed
+1305
-58
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']>
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user