feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI]
This commit is contained in:
1 parent
ddb6608e32
commit
6385c1b9a3
31 files changed
+2296
-42
No files matched your search
@@ -327,6 +327,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/auth/passkey/options": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** A challenge for signing in with a passkey (discoverable credential, no username needed) */
|
||||
post: operations["auth_passkey_options"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/auth/passkey/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Sign in with a passkey assertion; same response as the password sign-in */
|
||||
post: operations["auth_passkey_login"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/releases": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -362,6 +396,58 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/auth/passkeys": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** The signed-in user's passkeys */
|
||||
get: operations["auth_passkeys_list"];
|
||||
put?: never;
|
||||
/** Register a passkey from a WebAuthn attestation (ES256, user verification required) */
|
||||
post: operations["auth_passkeys_register"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/auth/passkeys/options": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** A challenge for registering a new passkey (the user must be signed in) */
|
||||
post: operations["auth_passkeys_options"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/auth/passkeys/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Remove one of your own passkeys */
|
||||
delete: operations["auth_passkeys_delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/auth/me": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1574,6 +1660,10 @@ export interface components {
|
||||
items: components["schemas"]["NodeInfoDto"][];
|
||||
next_cursor: null | string;
|
||||
};
|
||||
PageDtoOfPasskeyDto: {
|
||||
items: components["schemas"]["PasskeyDto"][];
|
||||
next_cursor: null | string;
|
||||
};
|
||||
PageDtoOfPlanDto: {
|
||||
items: components["schemas"]["PlanDto"][];
|
||||
next_cursor: null | string;
|
||||
@@ -1622,6 +1712,38 @@ export interface components {
|
||||
values: null | string[];
|
||||
description: string;
|
||||
};
|
||||
PasskeyCreationOptionsDto: {
|
||||
challenge: string;
|
||||
rp_id: string;
|
||||
rp_name: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
exclude_credentials: string[];
|
||||
/** Format: int32 */
|
||||
timeout_ms: number | string;
|
||||
};
|
||||
PasskeyDto: {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Format: date-time */
|
||||
created_at: unknown;
|
||||
/** Format: date-time */
|
||||
last_used_at: unknown;
|
||||
transports: string[];
|
||||
};
|
||||
PasskeyLoginRequest: {
|
||||
credential_id: string;
|
||||
authenticator_data: string;
|
||||
client_data_json: string;
|
||||
signature: string;
|
||||
return_session: null | boolean;
|
||||
};
|
||||
PasskeyRequestOptionsDto: {
|
||||
challenge: string;
|
||||
rp_id: string;
|
||||
/** Format: int32 */
|
||||
timeout_ms: number | string;
|
||||
};
|
||||
PatchProbeRequest: {
|
||||
name: null | string;
|
||||
};
|
||||
@@ -1718,6 +1840,13 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
queue_length: number | string;
|
||||
};
|
||||
RegisterPasskeyRequest: {
|
||||
name: string;
|
||||
credential_id: string;
|
||||
attestation_object: string;
|
||||
client_data_json: string;
|
||||
transports: null | string[];
|
||||
};
|
||||
RejectedDto: {
|
||||
id: string;
|
||||
reason: string;
|
||||
@@ -2744,6 +2873,86 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
auth_passkey_options: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PasskeyRequestOptionsDto"];
|
||||
};
|
||||
};
|
||||
/** @description Too Many Requests */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
auth_passkey_login: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PasskeyLoginRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LoginResponse"];
|
||||
};
|
||||
};
|
||||
/** @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 Too Many Requests */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
releases_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2871,6 +3080,182 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
auth_passkeys_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"]["PageDtoOfPasskeyDto"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
auth_passkeys_register: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RegisterPasskeyRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PasskeyDto"];
|
||||
};
|
||||
};
|
||||
/** @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 Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
auth_passkeys_options: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PasskeyCreationOptionsDto"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
auth_passkeys_delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
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"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
auth_me: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -40,6 +40,9 @@ export type CreatedJoinTokenDto = Narrow<S['CreatedJoinTokenDto']>
|
||||
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 PasskeyCreationOptionsDto = Narrow<S['PasskeyCreationOptionsDto']>
|
||||
export type PasskeyRequestOptionsDto = Narrow<S['PasskeyRequestOptionsDto']>
|
||||
export type MeDto = Narrow<S['MeDto']>
|
||||
export type SettingDto = Narrow<S['SettingDto']>
|
||||
export type UserDto = Narrow<S['UserDto']>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { api } from './client'
|
||||
import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } from './types'
|
||||
|
||||
/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */
|
||||
export async function conditionalSupported(): Promise<boolean> {
|
||||
try {
|
||||
return passkeySupported() && (await PublicKeyCredential.isConditionalMediationAvailable?.()) === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* passkey 保存在哪里:浏览器和系统决定具体用谁的密码管理器,网站只能给提示。
|
||||
* `device`:此设备或浏览器账户(Chrome 里是 Google 密码管理工具,也可以是 Windows Hello、iCloud 钥匙串);
|
||||
* `external`:安全密钥,或者用手机扫码。
|
||||
*/
|
||||
export type PasskeyPlace = 'device' | 'external'
|
||||
|
||||
/** 浏览器是否支持 WebAuthn(passkey)。 */
|
||||
export function passkeySupported(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials
|
||||
}
|
||||
|
||||
function toBytes(text: string): Uint8Array<ArrayBuffer> {
|
||||
const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=')
|
||||
const binary = atob(padded)
|
||||
const out = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
|
||||
return out
|
||||
}
|
||||
|
||||
function toText(buffer: ArrayBuffer): string {
|
||||
let binary = ''
|
||||
for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 passkey 登录(可发现凭证:不需要用户名)。服务端设了会话 Cookie;返回登录响应。
|
||||
* passkey 绑定在后端的域名上:只有和后端同站的页面能用,所以其他后端(跨域)不显示这个入口。
|
||||
*/
|
||||
export async function signInWithPasskey(opts: { conditional?: boolean; signal?: AbortSignal } = {}): Promise<LoginResponse> {
|
||||
const options = await api.post<PasskeyRequestOptionsDto>('/api/admin/auth/passkey/options')
|
||||
const credential = (await navigator.credentials.get({
|
||||
// conditional:不弹窗,浏览器在用户名输入框的自动填充里列出已保存的 passkey(Chrome 里就是 Google 密码管理工具里的)
|
||||
mediation: opts.conditional ? 'conditional' : undefined,
|
||||
signal: opts.signal,
|
||||
publicKey: { challenge: toBytes(options.challenge), rpId: options.rp_id, timeout: opts.conditional ? undefined : options.timeout_ms, userVerification: 'required' },
|
||||
})) as PublicKeyCredential | null
|
||||
if (!credential) throw new Error('没有选择 passkey')
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
return api.post<LoginResponse>('/api/admin/auth/passkey/login', {
|
||||
credential_id: credential.id,
|
||||
authenticator_data: toText(response.authenticatorData),
|
||||
client_data_json: toText(response.clientDataJSON),
|
||||
signature: toText(response.signature),
|
||||
})
|
||||
}
|
||||
|
||||
/** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */
|
||||
export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise<PasskeyDto> {
|
||||
const options = await api.post<PasskeyCreationOptionsDto>('/api/admin/auth/passkeys/options')
|
||||
const credential = (await navigator.credentials.create({
|
||||
publicKey: {
|
||||
challenge: toBytes(options.challenge),
|
||||
rp: { id: options.rp_id, name: options.rp_name },
|
||||
user: { id: toBytes(options.user_id), name: options.user_name, displayName: options.user_name },
|
||||
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
|
||||
excludeCredentials: options.exclude_credentials.map((id) => ({ type: 'public-key' as const, id: toBytes(id) })),
|
||||
authenticatorSelection: { authenticatorAttachment: place === 'device' ? 'platform' : 'cross-platform', residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
|
||||
attestation: 'none',
|
||||
timeout: options.timeout_ms,
|
||||
// 给浏览器的提示(Chrome 128+):优先用设备自带的(含 Google 密码管理工具),或者安全密钥和手机
|
||||
hints: place === 'device' ? ['client-device'] : ['security-key', 'hybrid'],
|
||||
} as PublicKeyCredentialCreationOptions,
|
||||
})) as PublicKeyCredential | null
|
||||
if (!credential) throw new Error('没有创建 passkey')
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
return api.post<PasskeyDto>('/api/admin/auth/passkeys', {
|
||||
name,
|
||||
credential_id: credential.id,
|
||||
attestation_object: toText(response.attestationObject),
|
||||
client_data_json: toText(response.clientDataJSON),
|
||||
transports: response.getTransports?.() ?? [],
|
||||
})
|
||||
}
|
||||
@@ -504,6 +504,15 @@ textarea {
|
||||
box-shadow: 0 4px 16px color-mix(in srgb, var(--app-shadow), transparent 75%);
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toast.bad {
|
||||
background: var(--app-bad);
|
||||
color: var(--app-card);
|
||||
|
||||
+14
-5
@@ -104,24 +104,33 @@ interface ToastItem {
|
||||
id: number
|
||||
text: string
|
||||
bad: boolean
|
||||
title?: string
|
||||
}
|
||||
|
||||
const ToastContext = createContext<(text: string, bad?: boolean) => void>(() => undefined)
|
||||
/** 提示的可选项:`title` 是加粗的标题行,`ms` 是停留的毫秒数(缺省成功 3 秒、失败 6 秒)。点一下提示可以提前关掉。 */
|
||||
export interface ToastOptions {
|
||||
title?: string
|
||||
ms?: number
|
||||
}
|
||||
|
||||
const ToastContext = createContext<(text: string, bad?: boolean, options?: ToastOptions) => void>(() => undefined)
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<ToastItem[]>([])
|
||||
const nextId = useRef(1)
|
||||
const push = useCallback((text: string, bad = false) => {
|
||||
const push = useCallback((text: string, bad = false, options?: ToastOptions) => {
|
||||
const id = nextId.current++
|
||||
setItems((list) => [...list, { id, text, bad }])
|
||||
window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), bad ? 6000 : 3000)
|
||||
setItems((list) => [...list, { id, text, bad, title: options?.title }])
|
||||
window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), options?.ms ?? (bad ? 6000 : 3000))
|
||||
}, [])
|
||||
const dismiss = (id: number) => setItems((list) => list.filter((t) => t.id !== id))
|
||||
return (
|
||||
<ToastContext.Provider value={push}>
|
||||
{children}
|
||||
<div className="toasts" aria-live="polite">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`toast ${t.bad ? 'bad' : ''}`}>
|
||||
<div key={t.id} className={`toast ${t.bad ? 'bad' : ''}`} role="status" onClick={() => dismiss(t.id)} title="点一下关闭">
|
||||
{t.title ? <div className="toast-title">{t.title}</div> : null}
|
||||
{t.text}
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user