mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 08:22:34 +00:00
Merge pull request #4920 from alexj11324/feat/passkey-auth
feat: add passkey authentication
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { get, post, patch, remove, credentialGet, credentialCreate } = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
credentialGet: vi.fn(),
|
||||
credentialCreate: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
get,
|
||||
post,
|
||||
patch,
|
||||
delete: remove
|
||||
}
|
||||
}))
|
||||
|
||||
import { passkeyAPI } from '@/api/passkey'
|
||||
|
||||
class FakePublicKeyCredential {
|
||||
id = 'credential-id'
|
||||
rawId = Uint8Array.from([1, 2, 3]).buffer
|
||||
type = 'public-key'
|
||||
authenticatorAttachment = 'platform'
|
||||
response: Record<string, unknown> = {
|
||||
authenticatorData: Uint8Array.from([4, 5]).buffer,
|
||||
clientDataJSON: Uint8Array.from([6, 7]).buffer,
|
||||
signature: Uint8Array.from([8, 9]).buffer,
|
||||
userHandle: Uint8Array.from([10, 11]).buffer
|
||||
}
|
||||
|
||||
getClientExtensionResults(): AuthenticationExtensionsClientOutputs {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRegistrationCredential extends FakePublicKeyCredential {
|
||||
constructor() {
|
||||
super()
|
||||
this.response = {
|
||||
attestationObject: Uint8Array.from([12, 13]).buffer,
|
||||
clientDataJSON: Uint8Array.from([6, 7]).buffer,
|
||||
getTransports: () => ['internal']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('passkey api', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset()
|
||||
post.mockReset()
|
||||
patch.mockReset()
|
||||
remove.mockReset()
|
||||
credentialGet.mockReset()
|
||||
credentialCreate.mockReset()
|
||||
|
||||
vi.stubGlobal('PublicKeyCredential', FakePublicKeyCredential)
|
||||
Object.defineProperty(window, 'PublicKeyCredential', {
|
||||
configurable: true,
|
||||
value: FakePublicKeyCredential
|
||||
})
|
||||
Object.defineProperty(navigator, 'credentials', {
|
||||
configurable: true,
|
||||
value: { get: credentialGet, create: credentialCreate }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('converts assertion options and response bytes to WebAuthn JSON', async () => {
|
||||
post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
session_token: 'one-time-session',
|
||||
options: {
|
||||
publicKey: {
|
||||
challenge: 'AQID',
|
||||
rpId: 'sub2api.example.com',
|
||||
userVerification: 'required'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'access',
|
||||
token_type: 'Bearer',
|
||||
user: { id: 1 }
|
||||
}
|
||||
})
|
||||
credentialGet.mockResolvedValue(new FakePublicKeyCredential())
|
||||
|
||||
await passkeyAPI.login()
|
||||
|
||||
const request = credentialGet.mock.calls[0][0] as CredentialRequestOptions
|
||||
expect(Array.from(new Uint8Array(request.publicKey!.challenge))).toEqual([1, 2, 3])
|
||||
expect(request.publicKey!.userVerification).toBe('required')
|
||||
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/auth/passkey/login/finish', {
|
||||
session_token: 'one-time-session',
|
||||
credential: {
|
||||
id: 'credential-id',
|
||||
rawId: 'AQID',
|
||||
type: 'public-key',
|
||||
authenticatorAttachment: 'platform',
|
||||
clientExtensionResults: {},
|
||||
response: {
|
||||
authenticatorData: 'BAU',
|
||||
clientDataJSON: 'Bgc',
|
||||
signature: 'CAk',
|
||||
userHandle: 'Cgs'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the account password when beginning registration', async () => {
|
||||
post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
session_token: 'register-session',
|
||||
options: {
|
||||
publicKey: {
|
||||
challenge: 'AQID',
|
||||
user: { id: 'BAU', name: 'user@example.com', displayName: 'user' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { id: 3, name: 'Laptop', created_at: '2026-07-28T00:00:00Z', backup: false }
|
||||
})
|
||||
credentialCreate.mockResolvedValue(new FakeRegistrationCredential())
|
||||
|
||||
await passkeyAPI.register('Laptop', 'hunter2')
|
||||
|
||||
expect(post).toHaveBeenNthCalledWith(1, '/user/passkeys/register/begin', {
|
||||
password: 'hunter2'
|
||||
})
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/user/passkeys/register/finish', {
|
||||
session_token: 'register-session',
|
||||
name: 'Laptop',
|
||||
credential: expect.objectContaining({
|
||||
response: {
|
||||
attestationObject: 'DA0',
|
||||
clientDataJSON: 'Bgc',
|
||||
transports: ['internal']
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the account password when revoking a credential', async () => {
|
||||
remove.mockResolvedValue({ data: null })
|
||||
|
||||
await passkeyAPI.remove(12, 'hunter2')
|
||||
|
||||
expect(remove).toHaveBeenCalledWith('/user/passkeys/12', {
|
||||
data: { password: 'hunter2' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -366,6 +366,10 @@ export interface SystemSettings {
|
||||
invitation_code_enabled: boolean;
|
||||
totp_enabled: boolean; // TOTP 双因素认证
|
||||
totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置
|
||||
passkey_enabled: boolean;
|
||||
passkey_configured: boolean;
|
||||
passkey_rp_id: string;
|
||||
passkey_rp_origins: string[];
|
||||
session_binding_enabled: boolean; // 会话 IP/UA 绑定
|
||||
step_up_enabled: boolean; // 敏感操作 step-up 2FA
|
||||
audit_log_retention_days: number; // 审计日志保留天数
|
||||
@@ -674,6 +678,7 @@ export interface UpdateSettingsRequest {
|
||||
frontend_url?: string;
|
||||
invitation_code_enabled?: boolean;
|
||||
totp_enabled?: boolean; // TOTP 双因素认证
|
||||
passkey_enabled?: boolean;
|
||||
session_binding_enabled?: boolean; // 会话 IP/UA 绑定
|
||||
step_up_enabled?: boolean; // 敏感操作 step-up 2FA
|
||||
audit_log_retention_days?: number; // 审计日志保留天数
|
||||
|
||||
@@ -19,6 +19,7 @@ export { userGroupsAPI } from './groups'
|
||||
export { userChannelsAPI } from './channels'
|
||||
export * as batchImageAPI from './batchImage'
|
||||
export { totpAPI } from './totp'
|
||||
export { passkeyAPI, type PasskeyCredentialSummary } from './passkey'
|
||||
export { default as announcementsAPI } from './announcements'
|
||||
export { channelMonitorUserAPI } from './channelMonitor'
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { apiClient } from './client'
|
||||
import type { AuthResponse } from '@/types'
|
||||
|
||||
export interface PasskeyCredentialSummary {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
backup: boolean
|
||||
}
|
||||
|
||||
interface CeremonyOptionsResponse {
|
||||
session_token: string
|
||||
options: {
|
||||
publicKey: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function requirePasskeySupport(): void {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error('Passkeys are not supported by this browser')
|
||||
}
|
||||
}
|
||||
|
||||
function base64URLToBuffer(value: string): ArrayBuffer {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
const binary = atob(padded)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function bufferToBase64URL(value: ArrayBuffer | null): string | null {
|
||||
if (value === null) return null
|
||||
const bytes = new Uint8Array(value)
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function creationOptionsFromJSON(
|
||||
value: Record<string, unknown>
|
||||
): PublicKeyCredentialCreationOptions {
|
||||
const options = { ...value } as Record<string, unknown>
|
||||
options.challenge = base64URLToBuffer(String(options.challenge))
|
||||
|
||||
const user = { ...(options.user as Record<string, unknown>) }
|
||||
user.id = base64URLToBuffer(String(user.id))
|
||||
options.user = user
|
||||
|
||||
if (Array.isArray(options.excludeCredentials)) {
|
||||
options.excludeCredentials = options.excludeCredentials.map((descriptor) => ({
|
||||
...(descriptor as Record<string, unknown>),
|
||||
id: base64URLToBuffer(String((descriptor as Record<string, unknown>).id))
|
||||
}))
|
||||
}
|
||||
return options as unknown as PublicKeyCredentialCreationOptions
|
||||
}
|
||||
|
||||
function requestOptionsFromJSON(
|
||||
value: Record<string, unknown>
|
||||
): PublicKeyCredentialRequestOptions {
|
||||
const options = { ...value } as Record<string, unknown>
|
||||
options.challenge = base64URLToBuffer(String(options.challenge))
|
||||
if (Array.isArray(options.allowCredentials)) {
|
||||
options.allowCredentials = options.allowCredentials.map((descriptor) => ({
|
||||
...(descriptor as Record<string, unknown>),
|
||||
id: base64URLToBuffer(String((descriptor as Record<string, unknown>).id))
|
||||
}))
|
||||
}
|
||||
return options as unknown as PublicKeyCredentialRequestOptions
|
||||
}
|
||||
|
||||
function serializeRegistrationCredential(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
response: {
|
||||
attestationObject: bufferToBase64URL(response.attestationObject),
|
||||
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
|
||||
transports: typeof response.getTransports === 'function' ? response.getTransports() : []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeAssertionCredential(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
response: {
|
||||
authenticatorData: bufferToBase64URL(response.authenticatorData),
|
||||
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
|
||||
signature: bufferToBase64URL(response.signature),
|
||||
userHandle: bufferToBase64URL(response.userHandle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function login(): Promise<AuthResponse> {
|
||||
requirePasskeySupport()
|
||||
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
|
||||
'/auth/passkey/login/begin'
|
||||
)
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: requestOptionsFromJSON(begin.options.publicKey)
|
||||
})
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('Passkey sign-in was cancelled')
|
||||
}
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/passkey/login/finish', {
|
||||
session_token: begin.session_token,
|
||||
credential: serializeAssertionCredential(credential)
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
async function register(name: string, password: string): Promise<PasskeyCredentialSummary> {
|
||||
requirePasskeySupport()
|
||||
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
|
||||
'/user/passkeys/register/begin',
|
||||
{ password }
|
||||
)
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: creationOptionsFromJSON(begin.options.publicKey)
|
||||
})
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('Passkey creation was cancelled')
|
||||
}
|
||||
const { data } = await apiClient.post<PasskeyCredentialSummary>(
|
||||
'/user/passkeys/register/finish',
|
||||
{
|
||||
session_token: begin.session_token,
|
||||
name,
|
||||
credential: serializeRegistrationCredential(credential)
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
async function list(): Promise<PasskeyCredentialSummary[]> {
|
||||
const { data } = await apiClient.get<PasskeyCredentialSummary[]>('/user/passkeys')
|
||||
return data
|
||||
}
|
||||
|
||||
async function rename(id: number, name: string): Promise<void> {
|
||||
await apiClient.patch(`/user/passkeys/${id}`, { name })
|
||||
}
|
||||
|
||||
async function remove(id: number, password: string): Promise<void> {
|
||||
await apiClient.delete(`/user/passkeys/${id}`, { data: { password } })
|
||||
}
|
||||
|
||||
export const passkeyAPI = {
|
||||
isSupported: () => Boolean(window.PublicKeyCredential && navigator.credentials),
|
||||
login,
|
||||
register,
|
||||
list,
|
||||
rename,
|
||||
remove
|
||||
}
|
||||
Reference in New Issue
Block a user