feat(auth): добавить вход по passkey
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 47s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 1m50s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 47s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 1m50s
Альтернатива паролю на портале; SSO приложений без изменений. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
"@fastify/sensible": "^6.0.3",
|
"@fastify/sensible": "^6.0.3",
|
||||||
"@fastify/static": "^8.2.0",
|
"@fastify/static": "^8.2.0",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"fastify": "^5.4.0",
|
"fastify": "^5.4.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"jose": "^6.2.8",
|
"jose": "^6.2.8",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { adminRoutes } from './routes/admin.js'
|
|||||||
import { auditAdminRoutes } from './routes/audit.js'
|
import { auditAdminRoutes } from './routes/audit.js'
|
||||||
import { auditIngestRoutes } from './routes/ingest-audit.js'
|
import { auditIngestRoutes } from './routes/ingest-audit.js'
|
||||||
import { oidcRoutes } from './routes/oidc.js'
|
import { oidcRoutes } from './routes/oidc.js'
|
||||||
|
import { webauthnRoutes } from './routes/webauthn.js'
|
||||||
import { startAuditRetentionJob } from './services/audit-retention.js'
|
import { startAuditRetentionJob } from './services/audit-retention.js'
|
||||||
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
|
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
|
||||||
|
|
||||||
@@ -129,6 +130,7 @@ export async function buildApp(opts: {
|
|||||||
await app.register(auditAdminRoutes)
|
await app.register(auditAdminRoutes)
|
||||||
await app.register(auditIngestRoutes)
|
await app.register(auditIngestRoutes)
|
||||||
await app.register(oidcRoutes)
|
await app.register(oidcRoutes)
|
||||||
|
await app.register(webauthnRoutes)
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
const stopRetention = startAuditRetentionJob(app)
|
const stopRetention = startAuditRetentionJob(app)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export const configSchema = z.object({
|
|||||||
logLevel: z.string().default('info'),
|
logLevel: z.string().default('info'),
|
||||||
isProd: z.boolean(),
|
isProd: z.boolean(),
|
||||||
auditIngestSecret: z.string().min(8).optional(),
|
auditIngestSecret: z.string().min(8).optional(),
|
||||||
|
webauthnRpID: z.string().min(1),
|
||||||
|
webauthnRpName: z.string().min(1).default('Auth Portal'),
|
||||||
|
webauthnOrigins: z.array(z.string().url()).min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema>
|
export type AppConfig = z.infer<typeof configSchema>
|
||||||
@@ -35,6 +38,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||||||
env.AUDIT_INGEST_SECRET ??
|
env.AUDIT_INGEST_SECRET ??
|
||||||
(isProd ? undefined : 'dev-audit-ingest-secret')
|
(isProd ? undefined : 'dev-audit-ingest-secret')
|
||||||
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
|
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
|
||||||
|
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
|
||||||
|
|
||||||
return configSchema.parse({
|
return configSchema.parse({
|
||||||
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
||||||
@@ -56,9 +60,43 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||||||
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
|
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
|
||||||
isProd,
|
isProd,
|
||||||
auditIngestSecret,
|
auditIngestSecret,
|
||||||
|
webauthnRpID: rpID,
|
||||||
|
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
|
||||||
|
webauthnOrigins: origins,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function webauthnFromIssuer(
|
||||||
|
issuer: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
isProd: boolean,
|
||||||
|
): { rpID: string; origins: string[] } {
|
||||||
|
let issuerUrl: URL
|
||||||
|
try {
|
||||||
|
issuerUrl = new URL(issuer)
|
||||||
|
} catch {
|
||||||
|
issuerUrl = new URL('https://auth.shnt.top')
|
||||||
|
}
|
||||||
|
const rpID = (env.WEBAUTHN_RP_ID || issuerUrl.hostname).trim()
|
||||||
|
const origins = new Set<string>()
|
||||||
|
origins.add(issuerUrl.origin)
|
||||||
|
const extra = env.WEBAUTHN_ORIGINS ?? ''
|
||||||
|
for (const raw of extra.split(',')) {
|
||||||
|
const value = raw.trim().replace(/\/$/, '')
|
||||||
|
if (!value) continue
|
||||||
|
try {
|
||||||
|
origins.add(new URL(value).origin)
|
||||||
|
} catch {
|
||||||
|
/* skip invalid */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isProd) {
|
||||||
|
origins.add('http://localhost:5173')
|
||||||
|
origins.add('http://localhost:8080')
|
||||||
|
}
|
||||||
|
return { rpID, origins: [...origins] }
|
||||||
|
}
|
||||||
|
|
||||||
export function oidcIssuerFromConfig(config: AppConfig): string {
|
export function oidcIssuerFromConfig(config: AppConfig): string {
|
||||||
return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '')
|
return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from 'fastify'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import {
|
||||||
|
createRefreshSession,
|
||||||
|
touchLastLogin,
|
||||||
|
type UserRow,
|
||||||
|
} from '@authportal/db'
|
||||||
|
import { issueAccessToken } from './issue-access-token.js'
|
||||||
|
import { safeAudit } from './audit.js'
|
||||||
|
import { targetAppFromReturnTo } from './target-app.js'
|
||||||
|
|
||||||
|
export const REFRESH_COOKIE = 'refresh_token'
|
||||||
|
|
||||||
|
export function completeLogin(
|
||||||
|
app: FastifyInstance,
|
||||||
|
reply: FastifyReply,
|
||||||
|
user: UserRow,
|
||||||
|
opts: {
|
||||||
|
method: 'password' | 'passkey'
|
||||||
|
returnTo?: string
|
||||||
|
ip: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const body = issueAccessToken(app, user)
|
||||||
|
const refreshRaw = randomBytes(32).toString('hex')
|
||||||
|
const refreshExpires = new Date(
|
||||||
|
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
|
||||||
|
)
|
||||||
|
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, {
|
||||||
|
ip: opts.ip,
|
||||||
|
userAgent: opts.userAgent,
|
||||||
|
})
|
||||||
|
touchLastLogin(app.db, user.id, opts.ip)
|
||||||
|
|
||||||
|
reply.header(
|
||||||
|
'Set-Cookie',
|
||||||
|
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const targetApp = targetAppFromReturnTo(opts.returnTo)
|
||||||
|
const isPasskey = opts.method === 'passkey'
|
||||||
|
safeAudit(app, {
|
||||||
|
action: isPasskey ? 'auth.passkey_login' : 'auth.login',
|
||||||
|
severity: 'info',
|
||||||
|
actorUserId: user.id,
|
||||||
|
actorEmail: user.email,
|
||||||
|
actorName: user.name,
|
||||||
|
targetType: 'session',
|
||||||
|
targetId: user.id,
|
||||||
|
summary: isPasskey
|
||||||
|
? `Вход (passkey): ${user.email}`
|
||||||
|
: `Вход: ${user.email}`,
|
||||||
|
details: {
|
||||||
|
method: opts.method,
|
||||||
|
user_agent: opts.userAgent,
|
||||||
|
return_to: opts.returnTo ?? null,
|
||||||
|
target_app: targetApp,
|
||||||
|
},
|
||||||
|
ip: opts.ip,
|
||||||
|
})
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'
|
||||||
|
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'
|
||||||
|
|
||||||
|
export function webauthnRelyingParty(app: FastifyInstance): {
|
||||||
|
rpID: string
|
||||||
|
rpName: string
|
||||||
|
origins: string[]
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
rpID: app.config.webauthnRpID,
|
||||||
|
rpName: app.config.webauthnRpName,
|
||||||
|
origins: app.config.webauthnOrigins,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userIdToBytes(userId: string): Uint8Array {
|
||||||
|
return isoUint8Array.fromUTF8String(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodePublicKey(publicKey: Uint8Array): string {
|
||||||
|
return isoBase64URL.fromBuffer(publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodePublicKey(stored: string): Uint8Array {
|
||||||
|
return isoBase64URL.toBuffer(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asTransports(
|
||||||
|
values: string[],
|
||||||
|
): AuthenticatorTransportFuture[] | undefined {
|
||||||
|
if (values.length === 0) return undefined
|
||||||
|
return values as AuthenticatorTransportFuture[]
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
listOidcClients,
|
listOidcClients,
|
||||||
parseJsonStringArray,
|
parseJsonStringArray,
|
||||||
updateOidcClient,
|
updateOidcClient,
|
||||||
|
countWebauthnCredentials,
|
||||||
|
deleteWebauthnCredentialsForUser,
|
||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
import {
|
import {
|
||||||
APP_IDS,
|
APP_IDS,
|
||||||
@@ -62,6 +64,7 @@ function mapUser(
|
|||||||
permissions,
|
permissions,
|
||||||
last_login_at: user.lastLoginAt ?? null,
|
last_login_at: user.lastLoginAt ?? null,
|
||||||
last_login_ip: user.lastLoginIp ?? null,
|
last_login_ip: user.lastLoginIp ?? null,
|
||||||
|
passkey_count: countWebauthnCredentials(db, user.id),
|
||||||
created_at: user.createdAt,
|
created_at: user.createdAt,
|
||||||
updated_at: user.updatedAt,
|
updated_at: user.updatedAt,
|
||||||
}
|
}
|
||||||
@@ -217,6 +220,30 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/admin/users/:id/passkeys',
|
||||||
|
async (request, reply) => {
|
||||||
|
const existing = getUserById(app.db, request.params.id)
|
||||||
|
if (!existing) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const removed = deleteWebauthnCredentialsForUser(app.db, existing.id)
|
||||||
|
safeAudit(app, {
|
||||||
|
action: 'admin.passkey_reset',
|
||||||
|
severity: 'warning',
|
||||||
|
...actorFromRequest(request),
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: existing.id,
|
||||||
|
summary: `Сброшены passkeys: ${existing.email} (${removed})`,
|
||||||
|
details: { removed },
|
||||||
|
ip: clientIp(request),
|
||||||
|
})
|
||||||
|
return { ok: true, removed, user: mapUser(app.db, existing) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>(
|
app.delete<{ Params: { id: string } }>(
|
||||||
'/api/v1/admin/users/:id',
|
'/api/v1/admin/users/:id',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance } from 'fastify'
|
||||||
import { verify } from '@node-rs/argon2'
|
import { verify } from '@node-rs/argon2'
|
||||||
import { randomBytes } from 'node:crypto'
|
|
||||||
import {
|
import {
|
||||||
PERMISSION_CATALOG,
|
PERMISSION_CATALOG,
|
||||||
appsMetaFromSwitcher,
|
appsMetaFromSwitcher,
|
||||||
@@ -9,26 +8,24 @@ import {
|
|||||||
ssoAccessRequestSchema,
|
ssoAccessRequestSchema,
|
||||||
} from '@authportal/shared'
|
} from '@authportal/shared'
|
||||||
import {
|
import {
|
||||||
createRefreshSession,
|
|
||||||
getAppSwitcherConfig,
|
getAppSwitcherConfig,
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
getUserById,
|
getUserById,
|
||||||
listUsers,
|
listUsers,
|
||||||
revokeRefreshSession,
|
revokeRefreshSession,
|
||||||
touchLastLogin,
|
|
||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
import { requireAuth } from '../plugins/auth-guards.js'
|
import { requireAuth } from '../plugins/auth-guards.js'
|
||||||
import { issueAccessToken } from '../lib/issue-access-token.js'
|
import { issueAccessToken } from '../lib/issue-access-token.js'
|
||||||
|
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.js'
|
||||||
import { clientIp, safeAudit } from '../lib/audit.js'
|
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||||
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||||
|
|
||||||
const REFRESH_COOKIE = 'refresh_token'
|
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
|
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
|
||||||
app.get('/api/v1/auth/config', async () => ({
|
app.get('/api/v1/auth/config', async () => ({
|
||||||
return_to_allowlist: app.config.returnToAllowlist,
|
return_to_allowlist: app.config.returnToAllowlist,
|
||||||
issuer: app.config.issuer,
|
issuer: app.config.issuer,
|
||||||
|
webauthn: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
app.post('/api/v1/auth/login', {
|
app.post('/api/v1/auth/login', {
|
||||||
@@ -90,41 +87,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = issueAccessToken(app, user)
|
return completeLogin(app, reply, user, {
|
||||||
|
method: 'password',
|
||||||
const refreshRaw = randomBytes(32).toString('hex')
|
returnTo,
|
||||||
const refreshExpires = new Date(
|
|
||||||
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
|
|
||||||
)
|
|
||||||
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, {
|
|
||||||
ip,
|
ip,
|
||||||
userAgent,
|
userAgent,
|
||||||
})
|
})
|
||||||
touchLastLogin(app.db, user.id, ip)
|
|
||||||
|
|
||||||
reply.header(
|
|
||||||
'Set-Cookie',
|
|
||||||
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
safeAudit(app, {
|
|
||||||
action: 'auth.login',
|
|
||||||
severity: 'info',
|
|
||||||
actorUserId: user.id,
|
|
||||||
actorEmail: user.email,
|
|
||||||
actorName: user.name,
|
|
||||||
targetType: 'session',
|
|
||||||
targetId: user.id,
|
|
||||||
summary: `Вход: ${user.email}`,
|
|
||||||
details: {
|
|
||||||
user_agent: userAgent,
|
|
||||||
return_to: returnTo ?? null,
|
|
||||||
target_app: targetApp,
|
|
||||||
},
|
|
||||||
ip,
|
|
||||||
})
|
|
||||||
|
|
||||||
return body
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import {
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
type AuthenticationResponseJSON,
|
||||||
|
type RegistrationResponseJSON,
|
||||||
|
} from '@simplewebauthn/server'
|
||||||
|
import {
|
||||||
|
consumeWebauthnChallenge,
|
||||||
|
createWebauthnChallenge,
|
||||||
|
createWebauthnCredential,
|
||||||
|
deleteWebauthnCredential,
|
||||||
|
getUserById,
|
||||||
|
getWebauthnCredentialByCredentialId,
|
||||||
|
getWebauthnCredentialById,
|
||||||
|
listWebauthnCredentials,
|
||||||
|
parseTransportsJson,
|
||||||
|
updateWebauthnCredentialName,
|
||||||
|
touchWebauthnCredential,
|
||||||
|
type WebauthnCredentialRow,
|
||||||
|
} from '@authportal/db'
|
||||||
|
import {
|
||||||
|
patchPasskeyRequestSchema,
|
||||||
|
webauthnVerifyRequestSchema,
|
||||||
|
type PasskeyCredential,
|
||||||
|
} from '@authportal/shared'
|
||||||
|
import { requireAuth } from '../plugins/auth-guards.js'
|
||||||
|
import { completeLogin } from '../lib/complete-login.js'
|
||||||
|
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||||
|
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||||
|
import {
|
||||||
|
asTransports,
|
||||||
|
decodePublicKey,
|
||||||
|
encodePublicKey,
|
||||||
|
userIdToBytes,
|
||||||
|
webauthnRelyingParty,
|
||||||
|
} from '../lib/webauthn.js'
|
||||||
|
|
||||||
|
const LOGIN_RATE = { max: 20, timeWindow: '1 minute' } as const
|
||||||
|
|
||||||
|
function toPasskeyDto(row: WebauthnCredentialRow): PasskeyCredential {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
created_at: row.createdAt,
|
||||||
|
last_used_at: row.lastUsedAt ?? null,
|
||||||
|
device_type: row.deviceType ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPasskeyName(userAgent: string | null): string {
|
||||||
|
const date = new Date().toLocaleDateString('ru-RU')
|
||||||
|
if (!userAgent) return `Passkey ${date}`
|
||||||
|
if (/iPhone|iPad|Macintosh/i.test(userAgent)) return `Apple ${date}`
|
||||||
|
if (/Windows/i.test(userAgent)) return `Windows Hello ${date}`
|
||||||
|
if (/Android/i.test(userAgent)) return `Android ${date}`
|
||||||
|
return `Passkey ${date}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRegistrationResponse(
|
||||||
|
raw: unknown,
|
||||||
|
): RegistrationResponseJSON | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return raw as RegistrationResponseJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
function asAuthenticationResponse(
|
||||||
|
raw: unknown,
|
||||||
|
): AuthenticationResponseJSON | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return raw as AuthenticationResponseJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function webauthnRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post(
|
||||||
|
'/api/v1/webauthn/register/options',
|
||||||
|
{
|
||||||
|
onRequest: requireAuth,
|
||||||
|
config: { rateLimit: LOGIN_RATE },
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const auth = request.authUser!
|
||||||
|
const user = getUserById(app.db, auth.id)
|
||||||
|
if (!user || user.disabled) {
|
||||||
|
return reply.status(401).send({
|
||||||
|
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const rp = webauthnRelyingParty(app)
|
||||||
|
const existing = listWebauthnCredentials(app.db, user.id)
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: rp.rpName,
|
||||||
|
rpID: rp.rpID,
|
||||||
|
userName: user.email,
|
||||||
|
userDisplayName: user.name,
|
||||||
|
userID: userIdToBytes(user.id),
|
||||||
|
attestationType: 'none',
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'preferred',
|
||||||
|
userVerification: 'preferred',
|
||||||
|
},
|
||||||
|
excludeCredentials: existing.map((cred) => ({
|
||||||
|
id: cred.credentialId,
|
||||||
|
transports: asTransports(parseTransportsJson(cred.transportsJson)),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
const row = createWebauthnChallenge(app.db, {
|
||||||
|
purpose: 'register',
|
||||||
|
challenge: options.challenge,
|
||||||
|
userId: user.id,
|
||||||
|
})
|
||||||
|
return { challenge_id: row.id, options }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/v1/webauthn/register',
|
||||||
|
{
|
||||||
|
onRequest: requireAuth,
|
||||||
|
config: { rateLimit: LOGIN_RATE },
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const auth = request.authUser!
|
||||||
|
const user = getUserById(app.db, auth.id)
|
||||||
|
if (!user || user.disabled) {
|
||||||
|
return reply.status(401).send({
|
||||||
|
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const response = asRegistrationResponse(parsed.data.response)
|
||||||
|
if (!response) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректный ответ passkey' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const challenge = consumeWebauthnChallenge(
|
||||||
|
app.db,
|
||||||
|
parsed.data.challenge_id,
|
||||||
|
'register',
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
if (!challenge) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'Срок действия challenge истёк, повторите регистрацию',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const rp = webauthnRelyingParty(app)
|
||||||
|
let verification
|
||||||
|
try {
|
||||||
|
verification = await verifyRegistrationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: challenge.challenge,
|
||||||
|
expectedOrigin: rp.origins,
|
||||||
|
expectedRPID: rp.rpID,
|
||||||
|
requireUserVerification: true,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err }, 'webauthn register verify failed')
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: 'VALIDATION_ERROR', message: 'Не удалось проверить passkey' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: 'VALIDATION_ERROR', message: 'Passkey не подтверждён' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const info = verification.registrationInfo
|
||||||
|
const duplicate = getWebauthnCredentialByCredentialId(
|
||||||
|
app.db,
|
||||||
|
info.credential.id,
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
return reply.status(409).send({
|
||||||
|
error: { code: 'CONFLICT', message: 'Этот passkey уже зарегистрирован' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const name =
|
||||||
|
parsed.data.name?.trim() ||
|
||||||
|
defaultPasskeyName(clientUserAgent(request.headers))
|
||||||
|
const row = createWebauthnCredential(app.db, {
|
||||||
|
userId: user.id,
|
||||||
|
credentialId: info.credential.id,
|
||||||
|
publicKey: encodePublicKey(info.credential.publicKey),
|
||||||
|
counter: info.credential.counter,
|
||||||
|
deviceType: info.credentialDeviceType,
|
||||||
|
backedUp: info.credentialBackedUp,
|
||||||
|
transports: info.credential.transports,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
safeAudit(app, {
|
||||||
|
action: 'auth.passkey_register',
|
||||||
|
severity: 'info',
|
||||||
|
actorUserId: user.id,
|
||||||
|
actorEmail: user.email,
|
||||||
|
actorName: user.name,
|
||||||
|
targetType: 'credential',
|
||||||
|
targetId: row.id,
|
||||||
|
summary: `Passkey добавлен: ${user.email}`,
|
||||||
|
details: { name: row.name, device_type: row.deviceType },
|
||||||
|
ip: clientIp(request),
|
||||||
|
})
|
||||||
|
return toPasskeyDto(row)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/v1/webauthn/credentials',
|
||||||
|
{ onRequest: requireAuth },
|
||||||
|
async (request) => {
|
||||||
|
const auth = request.authUser!
|
||||||
|
return listWebauthnCredentials(app.db, auth.id).map(toPasskeyDto)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.patch<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/webauthn/credentials/:id',
|
||||||
|
{ onRequest: requireAuth },
|
||||||
|
async (request, reply) => {
|
||||||
|
const parsed = patchPasskeyRequestSchema.safeParse(request.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const auth = request.authUser!
|
||||||
|
const row = updateWebauthnCredentialName(
|
||||||
|
app.db,
|
||||||
|
request.params.id,
|
||||||
|
auth.id,
|
||||||
|
parsed.data.name.trim(),
|
||||||
|
)
|
||||||
|
if (!row) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return toPasskeyDto(row)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/webauthn/credentials/:id',
|
||||||
|
{ onRequest: requireAuth },
|
||||||
|
async (request, reply) => {
|
||||||
|
const auth = request.authUser!
|
||||||
|
const existing = getWebauthnCredentialById(app.db, request.params.id)
|
||||||
|
if (!existing || existing.userId !== auth.id) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
deleteWebauthnCredential(app.db, request.params.id, auth.id)
|
||||||
|
const user = getUserById(app.db, auth.id)
|
||||||
|
safeAudit(app, {
|
||||||
|
action: 'auth.passkey_delete',
|
||||||
|
severity: 'warning',
|
||||||
|
actorUserId: auth.id,
|
||||||
|
actorEmail: auth.email,
|
||||||
|
actorName: auth.name,
|
||||||
|
targetType: 'credential',
|
||||||
|
targetId: request.params.id,
|
||||||
|
summary: `Passkey удалён: ${user?.email ?? auth.email}`,
|
||||||
|
details: { name: existing.name },
|
||||||
|
ip: clientIp(request),
|
||||||
|
})
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/v1/webauthn/login/options',
|
||||||
|
{ config: { rateLimit: LOGIN_RATE } },
|
||||||
|
async () => {
|
||||||
|
const rp = webauthnRelyingParty(app)
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: rp.rpID,
|
||||||
|
userVerification: 'preferred',
|
||||||
|
})
|
||||||
|
const row = createWebauthnChallenge(app.db, {
|
||||||
|
purpose: 'authenticate',
|
||||||
|
challenge: options.challenge,
|
||||||
|
})
|
||||||
|
return { challenge_id: row.id, options }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/v1/webauthn/login',
|
||||||
|
{ config: { rateLimit: LOGIN_RATE } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const ip = clientIp(request)
|
||||||
|
const userAgent = clientUserAgent(request.headers)
|
||||||
|
const returnTo = parsed.data.return_to
|
||||||
|
const fail = (reason: string) => {
|
||||||
|
safeAudit(app, {
|
||||||
|
action: 'auth.passkey_login_failed',
|
||||||
|
severity: 'warning',
|
||||||
|
targetType: 'session',
|
||||||
|
summary: 'Неудачный вход по passkey',
|
||||||
|
details: {
|
||||||
|
reason,
|
||||||
|
user_agent: userAgent,
|
||||||
|
return_to: returnTo ?? null,
|
||||||
|
target_app: targetAppFromReturnTo(returnTo),
|
||||||
|
},
|
||||||
|
ip,
|
||||||
|
})
|
||||||
|
return reply.status(401).send({
|
||||||
|
error: { code: 'UNAUTHORIZED', message: 'Не удалось войти с passkey' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = asAuthenticationResponse(parsed.data.response)
|
||||||
|
if (!response) return fail('bad_response')
|
||||||
|
|
||||||
|
const challenge = consumeWebauthnChallenge(
|
||||||
|
app.db,
|
||||||
|
parsed.data.challenge_id,
|
||||||
|
'authenticate',
|
||||||
|
)
|
||||||
|
if (!challenge) return fail('expired_challenge')
|
||||||
|
|
||||||
|
const cred = getWebauthnCredentialByCredentialId(app.db, response.id)
|
||||||
|
if (!cred) return fail('unknown_credential')
|
||||||
|
|
||||||
|
const user = getUserById(app.db, cred.userId)
|
||||||
|
if (!user || user.disabled) return fail(user ? 'disabled' : 'unknown_user')
|
||||||
|
|
||||||
|
const rp = webauthnRelyingParty(app)
|
||||||
|
let verification
|
||||||
|
try {
|
||||||
|
verification = await verifyAuthenticationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: challenge.challenge,
|
||||||
|
expectedOrigin: rp.origins,
|
||||||
|
expectedRPID: rp.rpID,
|
||||||
|
requireUserVerification: true,
|
||||||
|
credential: {
|
||||||
|
id: cred.credentialId,
|
||||||
|
publicKey: decodePublicKey(cred.publicKey),
|
||||||
|
counter: cred.counter,
|
||||||
|
transports: asTransports(parseTransportsJson(cred.transportsJson)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err }, 'webauthn login verify failed')
|
||||||
|
return fail('verify_error')
|
||||||
|
}
|
||||||
|
if (!verification.verified) return fail('not_verified')
|
||||||
|
|
||||||
|
touchWebauthnCredential(
|
||||||
|
app.db,
|
||||||
|
cred.id,
|
||||||
|
verification.authenticationInfo.newCounter,
|
||||||
|
)
|
||||||
|
return completeLogin(app, reply, user, {
|
||||||
|
method: 'passkey',
|
||||||
|
returnTo,
|
||||||
|
ip,
|
||||||
|
userAgent,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildApp } from '../src/app.js'
|
||||||
|
import { loadConfig } from '../src/config.js'
|
||||||
|
import { createWebauthnCredential } from '@authportal/db'
|
||||||
|
|
||||||
|
async function buildTestApp() {
|
||||||
|
const config = loadConfig({
|
||||||
|
...process.env,
|
||||||
|
JWT_SECRET: 'test-secret-at-least-8',
|
||||||
|
ADMIN_EMAIL: '[email protected]',
|
||||||
|
ADMIN_PASSWORD: 'adminpass',
|
||||||
|
DATABASE_URL: 'sqlite::memory:',
|
||||||
|
ISSUER: 'https://auth.test.local',
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
})
|
||||||
|
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminToken(app: Awaited<ReturnType<typeof buildTestApp>>) {
|
||||||
|
const login = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
payload: { email: '[email protected]', password: 'adminpass' },
|
||||||
|
})
|
||||||
|
expect(login.statusCode).toBe(200)
|
||||||
|
return (login.json() as { access_token: string }).access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('webauthn / passkeys', () => {
|
||||||
|
it('exposes webauthn flag on auth config', async () => {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(res.json()).toMatchObject({ webauthn: true })
|
||||||
|
expect(app.config.webauthnRpID).toBe('auth.test.local')
|
||||||
|
expect(app.config.webauthnOrigins).toContain('https://auth.test.local')
|
||||||
|
expect(app.config.webauthnOrigins).toContain('http://localhost:5173')
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires JWT for register options', async () => {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
const denied = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/webauthn/register/options',
|
||||||
|
})
|
||||||
|
expect(denied.statusCode).toBe(401)
|
||||||
|
|
||||||
|
const token = await adminToken(app)
|
||||||
|
const ok = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/webauthn/register/options',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(ok.statusCode).toBe(200)
|
||||||
|
const body = ok.json() as {
|
||||||
|
challenge_id: string
|
||||||
|
options: { challenge: string; rp: { id: string } }
|
||||||
|
}
|
||||||
|
expect(body.challenge_id).toBeTruthy()
|
||||||
|
expect(body.options.challenge).toBeTruthy()
|
||||||
|
expect(body.options.rp.id).toBe('auth.test.local')
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows public login options and rejects a bogus assertion', async () => {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
const options = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/webauthn/login/options',
|
||||||
|
})
|
||||||
|
expect(options.statusCode).toBe(200)
|
||||||
|
const body = options.json() as { challenge_id: string; options: unknown }
|
||||||
|
expect(body.challenge_id).toBeTruthy()
|
||||||
|
|
||||||
|
const login = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/webauthn/login',
|
||||||
|
payload: {
|
||||||
|
challenge_id: body.challenge_id,
|
||||||
|
response: { id: 'not-a-credential', type: 'public-key' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(login.statusCode).toBe(401)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists, deletes own credentials and reports passkey_count', async () => {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
const token = await adminToken(app)
|
||||||
|
const me = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth/me',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
const userId = (me.json() as { id: string }).id
|
||||||
|
|
||||||
|
createWebauthnCredential(app.db, {
|
||||||
|
userId,
|
||||||
|
credentialId: 'dGVzdC1jcmVkLWlk',
|
||||||
|
publicKey: 'dGVzdC1wdWJrZXk',
|
||||||
|
counter: 0,
|
||||||
|
name: 'Test key',
|
||||||
|
})
|
||||||
|
|
||||||
|
const listed = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/webauthn/credentials',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(listed.statusCode).toBe(200)
|
||||||
|
const creds = listed.json() as { id: string; name: string }[]
|
||||||
|
expect(creds).toHaveLength(1)
|
||||||
|
expect(creds[0]?.name).toBe('Test key')
|
||||||
|
|
||||||
|
const users = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/admin/users',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
const admin = (
|
||||||
|
users.json() as { email: string; passkey_count: number }[]
|
||||||
|
).find((u) => u.email === '[email protected]')
|
||||||
|
expect(admin?.passkey_count).toBe(1)
|
||||||
|
|
||||||
|
const renamed = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { name: 'Laptop' },
|
||||||
|
})
|
||||||
|
expect(renamed.statusCode).toBe(200)
|
||||||
|
expect((renamed.json() as { name: string }).name).toBe('Laptop')
|
||||||
|
|
||||||
|
const deleted = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(deleted.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const empty = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/webauthn/credentials',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(empty.json()).toEqual([])
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets admin reset a user passkeys', async () => {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
const token = await adminToken(app)
|
||||||
|
const me = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/auth/me',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
const userId = (me.json() as { id: string }).id
|
||||||
|
createWebauthnCredential(app.db, {
|
||||||
|
userId,
|
||||||
|
credentialId: 'cmVzZXQta2V5',
|
||||||
|
publicKey: 'cHVia2V5',
|
||||||
|
counter: 1,
|
||||||
|
name: 'To reset',
|
||||||
|
})
|
||||||
|
|
||||||
|
const reset = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/v1/admin/users/${userId}/passkeys`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(reset.statusCode).toBe(200)
|
||||||
|
expect(reset.json()).toMatchObject({ ok: true, removed: 1 })
|
||||||
|
|
||||||
|
const listed = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/webauthn/credentials',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(listed.json()).toEqual([])
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-router": "^1.170.15",
|
"@tanstack/react-router": "^1.170.15",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Link, useRouterState } from '@tanstack/react-router'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
AppWindowIcon,
|
AppWindowIcon,
|
||||||
|
FingerprintIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
@@ -53,6 +54,16 @@ export function AppSidebar() {
|
|||||||
<span>Приложения</span>
|
<span>Приложения</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip="Безопасность"
|
||||||
|
isActive={isActive(pathname, '/account', true)}
|
||||||
|
render={<Link to="/account" />}
|
||||||
|
>
|
||||||
|
<FingerprintIcon className="size-4" />
|
||||||
|
<span>Безопасность</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
|
FingerprintIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
MonitorIcon,
|
MonitorIcon,
|
||||||
MoonIcon,
|
MoonIcon,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
SunIcon,
|
SunIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
|
|
||||||
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
||||||
@@ -109,6 +111,7 @@ export function NavUser() {
|
|||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
const { data: me } = useQuery(meQueryOptions)
|
const { data: me } = useQuery(meQueryOptions)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const name = me?.name?.trim() || 'Пользователь'
|
const name = me?.name?.trim() || 'Пользователь'
|
||||||
const email = me?.email?.trim() || ''
|
const email = me?.email?.trim() || ''
|
||||||
@@ -175,6 +178,12 @@ export function NavUser() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => void navigate({ to: '/account' })}
|
||||||
|
>
|
||||||
|
<FingerprintIcon aria-hidden />
|
||||||
|
Безопасность
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||||
<PaletteIcon aria-hidden />
|
<PaletteIcon aria-hidden />
|
||||||
Тема
|
Тема
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
import { EyeIcon, EyeOffIcon, FingerprintIcon } from 'lucide-react'
|
||||||
import { buildSsoRedirectUrl, isPortalOidcAuthorizeUrl, isReturnToAllowed } from '@authportal/shared'
|
import {
|
||||||
|
browserSupportsWebAuthn,
|
||||||
|
browserSupportsWebAuthnAutofill,
|
||||||
|
startAuthentication,
|
||||||
|
WebAuthnAbortService,
|
||||||
|
} from '@simplewebauthn/browser'
|
||||||
|
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser'
|
||||||
|
import {
|
||||||
|
buildSsoRedirectUrl,
|
||||||
|
isPortalOidcAuthorizeUrl,
|
||||||
|
isReturnToAllowed,
|
||||||
|
type LoginResponse,
|
||||||
|
} from '@authportal/shared'
|
||||||
import { Button } from '@authportal/ui/components/button'
|
import { Button } from '@authportal/ui/components/button'
|
||||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||||
import { Input } from '@authportal/ui/components/input'
|
import { Input } from '@authportal/ui/components/input'
|
||||||
@@ -12,6 +24,7 @@ import {
|
|||||||
InputGroupButton,
|
InputGroupButton,
|
||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
} from '@authportal/ui/components/input-group'
|
} from '@authportal/ui/components/input-group'
|
||||||
|
import { Separator } from '@authportal/ui/components/separator'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
@@ -20,6 +33,7 @@ import {
|
|||||||
import { ensureAuthConfig, setToken } from '@/lib/auth'
|
import { ensureAuthConfig, setToken } from '@/lib/auth'
|
||||||
import { ApiError } from '@/lib/api-client'
|
import { ApiError } from '@/lib/api-client'
|
||||||
import { login, meQueryKey } from '@/queries/auth'
|
import { login, meQueryKey } from '@/queries/auth'
|
||||||
|
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
|
||||||
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
|
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
|
||||||
|
|
||||||
export function PortalLoginForm() {
|
export function PortalLoginForm() {
|
||||||
@@ -29,42 +43,110 @@ export function PortalLoginForm() {
|
|||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [pending, setPending] = useState(false)
|
const [pending, setPending] = useState(false)
|
||||||
|
const [passkeySupported, setPasskeySupported] = useState(false)
|
||||||
|
|
||||||
|
async function applySession(res: LoginResponse) {
|
||||||
|
setToken(res.access_token)
|
||||||
|
queryClient.setQueryData(meQueryKey, res.user)
|
||||||
|
|
||||||
|
const returnTo = search.return_to
|
||||||
|
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
|
||||||
|
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
|
||||||
|
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
|
||||||
|
window.location.href = returnTo
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.location.href = buildSsoRedirectUrl(
|
||||||
|
returnTo,
|
||||||
|
res.access_token,
|
||||||
|
res.expires_at,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.user.is_admin) {
|
||||||
|
await navigate({ to: '/admin' })
|
||||||
|
} else {
|
||||||
|
await navigate({ to: '/apps' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPasskeyLogin() {
|
||||||
|
const { challenge_id, options } = await webauthnLoginOptions()
|
||||||
|
const assertion = await startAuthentication({
|
||||||
|
optionsJSON: options as unknown as PublicKeyCredentialRequestOptionsJSON,
|
||||||
|
})
|
||||||
|
const res = await webauthnLogin(challenge_id, assertion, search.return_to)
|
||||||
|
await applySession(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!browserSupportsWebAuthn()) return
|
||||||
|
setPasskeySupported(true)
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function startConditional() {
|
||||||
|
if (!(await browserSupportsWebAuthnAutofill())) return
|
||||||
|
try {
|
||||||
|
const { challenge_id, options } = await webauthnLoginOptions()
|
||||||
|
if (cancelled) return
|
||||||
|
const assertion = await startAuthentication({
|
||||||
|
optionsJSON:
|
||||||
|
options as unknown as PublicKeyCredentialRequestOptionsJSON,
|
||||||
|
useBrowserAutofill: true,
|
||||||
|
})
|
||||||
|
if (cancelled) return
|
||||||
|
setPending(true)
|
||||||
|
setError(null)
|
||||||
|
const res = await webauthnLogin(
|
||||||
|
challenge_id,
|
||||||
|
assertion,
|
||||||
|
search.return_to,
|
||||||
|
)
|
||||||
|
await applySession(res)
|
||||||
|
} catch {
|
||||||
|
/* abort / unsupported / user dismissed */
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void startConditional()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
WebAuthnAbortService.cancelCeremony()
|
||||||
|
}
|
||||||
|
// Login page mount only — return_to is stable for the visit.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
WebAuthnAbortService.cancelCeremony()
|
||||||
setError(null)
|
setError(null)
|
||||||
setPending(true)
|
setPending(true)
|
||||||
const form = new FormData(event.currentTarget)
|
const form = new FormData(event.currentTarget)
|
||||||
const email = String(form.get('email') ?? '')
|
const email = String(form.get('email') ?? '')
|
||||||
const password = String(form.get('password') ?? '')
|
const password = String(form.get('password') ?? '')
|
||||||
const returnTo = search.return_to
|
|
||||||
try {
|
try {
|
||||||
const res = await login(email, password, returnTo)
|
const res = await login(email, password, search.return_to)
|
||||||
setToken(res.access_token)
|
await applySession(res)
|
||||||
queryClient.setQueryData(meQueryKey, res.user)
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Не удалось войти')
|
||||||
|
} finally {
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
|
async function handlePasskeyClick() {
|
||||||
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
|
WebAuthnAbortService.cancelCeremony()
|
||||||
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
|
setError(null)
|
||||||
window.location.href = returnTo
|
setPending(true)
|
||||||
return
|
try {
|
||||||
}
|
await runPasskeyLogin()
|
||||||
window.location.href = buildSsoRedirectUrl(
|
|
||||||
returnTo,
|
|
||||||
res.access_token,
|
|
||||||
res.expires_at,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.user.is_admin) {
|
|
||||||
await navigate({ to: '/admin' })
|
|
||||||
} else {
|
|
||||||
await navigate({ to: '/apps' })
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof ApiError ? err.message : 'Не удалось войти',
|
err instanceof ApiError ? err.message : 'Не удалось войти с passkey',
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false)
|
setPending(false)
|
||||||
@@ -101,7 +183,7 @@ export function PortalLoginForm() {
|
|||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="username"
|
autoComplete="username webauthn"
|
||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
className="bg-background"
|
className="bg-background"
|
||||||
required
|
required
|
||||||
@@ -137,6 +219,26 @@ export function PortalLoginForm() {
|
|||||||
{pending ? 'Вход…' : 'Войти'}
|
{pending ? 'Вход…' : 'Войти'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{passkeySupported ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
<span className="text-muted-foreground text-xs">или</span>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void handlePasskeyClick()}
|
||||||
|
>
|
||||||
|
<FingerprintIcon aria-hidden="true" />
|
||||||
|
Войти с passkey
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CircleDotIcon,
|
CircleDotIcon,
|
||||||
FilterIcon,
|
FilterIcon,
|
||||||
FunnelXIcon,
|
FunnelXIcon,
|
||||||
|
FingerprintIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
@@ -115,6 +116,7 @@ export interface AdminUsersGridProps {
|
|||||||
onOpenAccess: (user: AdminUser) => void
|
onOpenAccess: (user: AdminUser) => void
|
||||||
onDeactivate: (user: AdminUser) => void
|
onDeactivate: (user: AdminUser) => void
|
||||||
onDelete: (user: AdminUser) => void
|
onDelete: (user: AdminUser) => void
|
||||||
|
onResetPasskeys: (user: AdminUser) => void
|
||||||
onBulkSetRole: (userIds: string[], isAdmin: boolean) => void
|
onBulkSetRole: (userIds: string[], isAdmin: boolean) => void
|
||||||
onBulkDeactivate: (userIds: string[]) => void
|
onBulkDeactivate: (userIds: string[]) => void
|
||||||
}
|
}
|
||||||
@@ -403,14 +405,17 @@ function ActionsCell({
|
|||||||
onOpenAccess,
|
onOpenAccess,
|
||||||
onDeactivate,
|
onDeactivate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onResetPasskeys,
|
||||||
}: {
|
}: {
|
||||||
row: Row<AdminUser>
|
row: Row<AdminUser>
|
||||||
onOpenAudit: (user: AdminUser) => void
|
onOpenAudit: (user: AdminUser) => void
|
||||||
onOpenAccess: (user: AdminUser) => void
|
onOpenAccess: (user: AdminUser) => void
|
||||||
onDeactivate: (user: AdminUser) => void
|
onDeactivate: (user: AdminUser) => void
|
||||||
onDelete: (user: AdminUser) => void
|
onDelete: (user: AdminUser) => void
|
||||||
|
onResetPasskeys: (user: AdminUser) => void
|
||||||
}) {
|
}) {
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [resetOpen, setResetOpen] = useState(false)
|
||||||
const user = row.original
|
const user = row.original
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -444,6 +449,12 @@ function ActionsCell({
|
|||||||
Отключить
|
Отключить
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{(user.passkey_count ?? 0) > 0 ? (
|
||||||
|
<DropdownMenuItem onClick={() => setResetOpen(true)}>
|
||||||
|
<FingerprintIcon className="size-4" aria-hidden="true" />
|
||||||
|
Сбросить passkeys
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -456,6 +467,31 @@ function ActionsCell({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
|
||||||
|
<AlertDialogContent size="sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Сбросить passkeys?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Все ключи пользователя{' '}
|
||||||
|
<span className="text-foreground font-medium">{user.email}</span>{' '}
|
||||||
|
будут удалены. Вход останется по паролю.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setResetOpen(false)
|
||||||
|
onResetPasskeys(user)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
<AlertDialogContent size="sm">
|
<AlertDialogContent size="sm">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -489,6 +525,7 @@ function createAdminUserColumns(handlers: {
|
|||||||
onOpenAccess: (user: AdminUser) => void
|
onOpenAccess: (user: AdminUser) => void
|
||||||
onDeactivate: (user: AdminUser) => void
|
onDeactivate: (user: AdminUser) => void
|
||||||
onDelete: (user: AdminUser) => void
|
onDelete: (user: AdminUser) => void
|
||||||
|
onResetPasskeys: (user: AdminUser) => void
|
||||||
}): ColumnDef<AdminUser>[] {
|
}): ColumnDef<AdminUser>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -614,15 +651,22 @@ function createAdminUserColumns(handlers: {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'twoFactor',
|
id: 'twoFactor',
|
||||||
|
accessorFn: (row) => (row.passkey_count ?? 0) > 0,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader title="2FA" visibility column={column} />
|
<DataGridColumnHeader title="2FA" visibility column={column} />
|
||||||
),
|
),
|
||||||
cell: () => (
|
cell: ({ row }) =>
|
||||||
<Badge variant="destructive-outline">
|
(row.original.passkey_count ?? 0) > 0 ? (
|
||||||
<TriangleAlertIcon aria-hidden="true" />
|
<Badge variant="success-outline">
|
||||||
Выкл
|
<FingerprintIcon aria-hidden="true" />
|
||||||
</Badge>
|
Passkey
|
||||||
),
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="destructive-outline">
|
||||||
|
<TriangleAlertIcon aria-hidden="true" />
|
||||||
|
Выкл
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
size: 100,
|
size: 100,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: true,
|
enableHiding: true,
|
||||||
@@ -686,6 +730,7 @@ function createAdminUserColumns(handlers: {
|
|||||||
onOpenAccess={handlers.onOpenAccess}
|
onOpenAccess={handlers.onOpenAccess}
|
||||||
onDeactivate={handlers.onDeactivate}
|
onDeactivate={handlers.onDeactivate}
|
||||||
onDelete={handlers.onDelete}
|
onDelete={handlers.onDelete}
|
||||||
|
onResetPasskeys={handlers.onResetPasskeys}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
size: 60,
|
size: 60,
|
||||||
@@ -710,6 +755,7 @@ export function AdminUsersGrid({
|
|||||||
onOpenAccess,
|
onOpenAccess,
|
||||||
onDeactivate,
|
onDeactivate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onResetPasskeys,
|
||||||
onBulkSetRole,
|
onBulkSetRole,
|
||||||
onBulkDeactivate,
|
onBulkDeactivate,
|
||||||
}: AdminUsersGridProps) {
|
}: AdminUsersGridProps) {
|
||||||
@@ -848,8 +894,9 @@ export function AdminUsersGrid({
|
|||||||
onOpenAccess,
|
onOpenAccess,
|
||||||
onDeactivate,
|
onDeactivate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onResetPasskeys,
|
||||||
}),
|
}),
|
||||||
[onOpenAudit, onOpenAccess, onDeactivate, onDelete],
|
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ export { UserAuditSheet } from './user-audit-sheet'
|
|||||||
export { CreateUserSheet } from './create-user-sheet'
|
export { CreateUserSheet } from './create-user-sheet'
|
||||||
export { CreateOidcClientSheet } from './create-oidc-client-sheet'
|
export { CreateOidcClientSheet } from './create-oidc-client-sheet'
|
||||||
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
|
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
|
||||||
|
export { PasskeySettingsPanel } from './passkey-settings-panel'
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Passkey management — DNA settings-16 / settings-2 / settings-10.
|
||||||
|
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-10
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { FingerprintIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
startRegistration,
|
||||||
|
browserSupportsWebAuthn,
|
||||||
|
} from '@simplewebauthn/browser'
|
||||||
|
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
|
||||||
|
import type { PasskeyCredential } from '@authportal/shared'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Button } from '@authportal/ui/components/button'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@authportal/ui/components/item'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@authportal/ui/components/alert-dialog'
|
||||||
|
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { ApiError } from '@/lib/api-client'
|
||||||
|
import {
|
||||||
|
deletePasskey,
|
||||||
|
passkeysQueryKey,
|
||||||
|
passkeysQueryOptions,
|
||||||
|
webauthnRegister,
|
||||||
|
webauthnRegisterOptions,
|
||||||
|
} from '@/queries/webauthn'
|
||||||
|
|
||||||
|
function formatWhen(iso: string | null) {
|
||||||
|
if (!iso) return 'ещё не использовался'
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasskeySettingsPanel() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data: passkeys = [], isLoading } = useQuery(passkeysQueryOptions)
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<PasskeyCredential | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const supported = browserSupportsWebAuthn()
|
||||||
|
|
||||||
|
const registerMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { challenge_id, options } = await webauthnRegisterOptions()
|
||||||
|
const attResp = await startRegistration({
|
||||||
|
optionsJSON:
|
||||||
|
options as unknown as PublicKeyCredentialCreationOptionsJSON,
|
||||||
|
})
|
||||||
|
return webauthnRegister(challenge_id, attResp)
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
|
||||||
|
toast.success('Passkey добавлен')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof ApiError ? err.message : 'Не удалось добавить passkey',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deletePasskey(id),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
|
||||||
|
toast.success('Passkey удалён')
|
||||||
|
setPendingDelete(null)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof ApiError ? err.message : 'Не удалось удалить passkey',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Frame className="w-full">
|
||||||
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<FrameTitle>Passkeys</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Вход без пароля: Windows Hello, Face ID, ключ безопасности
|
||||||
|
</FrameDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!supported || registerMutation.isPending}
|
||||||
|
onClick={() => registerMutation.mutate()}
|
||||||
|
>
|
||||||
|
<PlusIcon aria-hidden="true" />
|
||||||
|
{registerMutation.isPending ? 'Ожидание…' : 'Добавить passkey'}
|
||||||
|
</Button>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-2">
|
||||||
|
{!supported ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Этот браузер не поддерживает WebAuthn.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-16 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-16 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
) : passkeys.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Ключи не зарегистрированы. Добавьте passkey, чтобы входить без
|
||||||
|
пароля.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
passkeys.map((item) => (
|
||||||
|
<Item key={item.id} variant="outline" className="items-start">
|
||||||
|
<ItemMedia variant="icon">
|
||||||
|
<FingerprintIcon className="size-4" aria-hidden="true" />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle className="flex flex-wrap items-center gap-2">
|
||||||
|
{item.name}
|
||||||
|
{item.device_type === 'multiDevice' ? (
|
||||||
|
<Badge variant="info-outline" size="sm">
|
||||||
|
Синхронизируется
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription>
|
||||||
|
Добавлен {formatWhen(item.created_at)} · вход{' '}
|
||||||
|
{formatWhen(item.last_used_at)}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Удалить ${item.name}`}
|
||||||
|
onClick={() => setPendingDelete(item)}
|
||||||
|
>
|
||||||
|
<Trash2Icon aria-hidden="true" />
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(pendingDelete)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPendingDelete(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent size="sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Удалить passkey?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{pendingDelete
|
||||||
|
? `«${pendingDelete.name}» больше нельзя будет использовать для входа.`
|
||||||
|
: null}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingDelete) deleteMutation.mutate(pendingDelete.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import type {
|
||||||
|
LoginResponse,
|
||||||
|
PasskeyCredential,
|
||||||
|
WebauthnOptionsResponse,
|
||||||
|
} from '@authportal/shared'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const passkeysQueryKey = ['webauthn', 'credentials'] as const
|
||||||
|
|
||||||
|
export const passkeysQueryOptions = queryOptions({
|
||||||
|
queryKey: passkeysQueryKey,
|
||||||
|
queryFn: () => api.get<PasskeyCredential[]>('/api/v1/webauthn/credentials'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function webauthnLoginOptions() {
|
||||||
|
return api.post<WebauthnOptionsResponse>('/api/v1/webauthn/login/options')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webauthnLogin(
|
||||||
|
challengeId: string,
|
||||||
|
response: unknown,
|
||||||
|
returnTo?: string,
|
||||||
|
) {
|
||||||
|
return api.post<LoginResponse>('/api/v1/webauthn/login', {
|
||||||
|
challenge_id: challengeId,
|
||||||
|
response,
|
||||||
|
...(returnTo ? { return_to: returnTo } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webauthnRegisterOptions() {
|
||||||
|
return api.post<WebauthnOptionsResponse>('/api/v1/webauthn/register/options')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function webauthnRegister(
|
||||||
|
challengeId: string,
|
||||||
|
response: unknown,
|
||||||
|
name?: string,
|
||||||
|
) {
|
||||||
|
return api.post<PasskeyCredential>('/api/v1/webauthn/register', {
|
||||||
|
challenge_id: challengeId,
|
||||||
|
response,
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renamePasskey(id: string, name: string) {
|
||||||
|
return api.patch<PasskeyCredential>(`/api/v1/webauthn/credentials/${id}`, {
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePasskey(id: string) {
|
||||||
|
return api.delete<{ ok: boolean }>(`/api/v1/webauthn/credentials/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminResetPasskeys(userId: string) {
|
||||||
|
return api.delete<{
|
||||||
|
ok: boolean
|
||||||
|
removed: number
|
||||||
|
}>(`/api/v1/admin/users/${userId}/passkeys`)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AuthRouteImport } from './routes/_auth'
|
import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
import { Route as LogoutRouteImport } from './routes/logout'
|
import { Route as LogoutRouteImport } from './routes/logout'
|
||||||
|
import { Route as AuthAccountRouteImport } from './routes/_auth.account'
|
||||||
import { Route as AuthAdminRouteImport } from './routes/_auth.admin'
|
import { Route as AuthAdminRouteImport } from './routes/_auth.admin'
|
||||||
import { Route as AuthAppsRouteImport } from './routes/_auth.apps'
|
import { Route as AuthAppsRouteImport } from './routes/_auth.apps'
|
||||||
import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
|
import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
|
||||||
@@ -35,6 +36,11 @@ const LogoutRoute = LogoutRouteImport.update({
|
|||||||
path: '/logout',
|
path: '/logout',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthAccountRoute = AuthAccountRouteImport.update({
|
||||||
|
id: '/account',
|
||||||
|
path: '/account',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
const AuthAdminRoute = AuthAdminRouteImport.update({
|
const AuthAdminRoute = AuthAdminRouteImport.update({
|
||||||
id: '/admin',
|
id: '/admin',
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
@@ -79,6 +85,7 @@ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
|
'/account': typeof AuthAccountRoute
|
||||||
'/admin': typeof AuthAdminRouteWithChildren
|
'/admin': typeof AuthAdminRouteWithChildren
|
||||||
'/apps': typeof AuthAppsRoute
|
'/apps': typeof AuthAppsRoute
|
||||||
'/admin/apps': typeof AuthAdminAppsRoute
|
'/admin/apps': typeof AuthAdminAppsRoute
|
||||||
@@ -91,6 +98,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
|
'/account': typeof AuthAccountRoute
|
||||||
'/apps': typeof AuthAppsRoute
|
'/apps': typeof AuthAppsRoute
|
||||||
'/admin/apps': typeof AuthAdminAppsRoute
|
'/admin/apps': typeof AuthAdminAppsRoute
|
||||||
'/admin/audit': typeof AuthAdminAuditRoute
|
'/admin/audit': typeof AuthAdminAuditRoute
|
||||||
@@ -104,6 +112,7 @@ export interface FileRoutesById {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/_auth': typeof AuthRouteWithChildren
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
|
'/_auth/account': typeof AuthAccountRoute
|
||||||
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
||||||
'/_auth/apps': typeof AuthAppsRoute
|
'/_auth/apps': typeof AuthAppsRoute
|
||||||
'/_auth/admin/apps': typeof AuthAdminAppsRoute
|
'/_auth/admin/apps': typeof AuthAdminAppsRoute
|
||||||
@@ -118,6 +127,7 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/logout'
|
| '/logout'
|
||||||
|
| '/account'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/apps'
|
| '/apps'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
@@ -130,6 +140,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/logout'
|
| '/logout'
|
||||||
|
| '/account'
|
||||||
| '/apps'
|
| '/apps'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
| '/admin/audit'
|
| '/admin/audit'
|
||||||
@@ -142,6 +153,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/_auth'
|
| '/_auth'
|
||||||
| '/logout'
|
| '/logout'
|
||||||
|
| '/_auth/account'
|
||||||
| '/_auth/admin'
|
| '/_auth/admin'
|
||||||
| '/_auth/apps'
|
| '/_auth/apps'
|
||||||
| '/_auth/admin/apps'
|
| '/_auth/admin/apps'
|
||||||
@@ -181,6 +193,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LogoutRouteImport
|
preLoaderRoute: typeof LogoutRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/_auth/account': {
|
||||||
|
id: '/_auth/account'
|
||||||
|
path: '/account'
|
||||||
|
fullPath: '/account'
|
||||||
|
preLoaderRoute: typeof AuthAccountRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
'/_auth/admin': {
|
'/_auth/admin': {
|
||||||
id: '/_auth/admin'
|
id: '/_auth/admin'
|
||||||
path: '/admin'
|
path: '/admin'
|
||||||
@@ -263,11 +282,13 @@ const AuthAdminRouteWithChildren = AuthAdminRoute._addFileChildren(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface AuthRouteChildren {
|
interface AuthRouteChildren {
|
||||||
|
AuthAccountRoute: typeof AuthAccountRoute
|
||||||
AuthAdminRoute: typeof AuthAdminRouteWithChildren
|
AuthAdminRoute: typeof AuthAdminRouteWithChildren
|
||||||
AuthAppsRoute: typeof AuthAppsRoute
|
AuthAppsRoute: typeof AuthAppsRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteChildren: AuthRouteChildren = {
|
const AuthRouteChildren: AuthRouteChildren = {
|
||||||
|
AuthAccountRoute: AuthAccountRoute,
|
||||||
AuthAdminRoute: AuthAdminRouteWithChildren,
|
AuthAdminRoute: AuthAdminRouteWithChildren,
|
||||||
AuthAppsRoute: AuthAppsRoute,
|
AuthAppsRoute: AuthAppsRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Account security — passkeys.
|
||||||
|
* Preview: https://reui.io/preview/base/settings-10 · https://reui.io/preview/base/settings-16
|
||||||
|
*/
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { PasskeySettingsPanel } from '@/components/reui-kit/passkey-settings-panel'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/account')({
|
||||||
|
component: AccountPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AccountPage() {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Безопасность</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Passkey как альтернатива паролю. Пароль остаётся запасным входом.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PasskeySettingsPanel />
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
|||||||
import { UserAuditSheet } from '@/components/reui-kit/user-audit-sheet'
|
import { UserAuditSheet } from '@/components/reui-kit/user-audit-sheet'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
|
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
|
||||||
|
import { adminResetPasskeys } from '@/queries/webauthn'
|
||||||
import { Button } from '@authportal/ui/components/button'
|
import { Button } from '@authportal/ui/components/button'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/admin/')({
|
export const Route = createFileRoute('/_auth/admin/')({
|
||||||
@@ -104,6 +105,29 @@ function AdminUsersPage() {
|
|||||||
[deleteMutation],
|
[deleteMutation],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const resetPasskeysMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => adminResetPasskeys(id),
|
||||||
|
onSuccess: async (_data, id) => {
|
||||||
|
await invalidateUsers()
|
||||||
|
const user = users.find((u) => u.id === id)
|
||||||
|
toast.success('Passkeys сброшены', {
|
||||||
|
description: user?.email,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof ApiError ? err.message : 'Не удалось сбросить passkeys',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleResetPasskeys = useCallback(
|
||||||
|
(user: AdminUser) => {
|
||||||
|
resetPasskeysMutation.mutate(user.id)
|
||||||
|
},
|
||||||
|
[resetPasskeysMutation],
|
||||||
|
)
|
||||||
|
|
||||||
const handleBulkSetRole = useCallback(
|
const handleBulkSetRole = useCallback(
|
||||||
(userIds: string[], isAdmin: boolean) => {
|
(userIds: string[], isAdmin: boolean) => {
|
||||||
Promise.all(
|
Promise.all(
|
||||||
@@ -170,6 +194,7 @@ function AdminUsersPage() {
|
|||||||
onOpenAccess={(user) => setAccessUserId(user.id)}
|
onOpenAccess={(user) => setAccessUserId(user.id)}
|
||||||
onDeactivate={handleDeactivate}
|
onDeactivate={handleDeactivate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onResetPasskeys={handleResetPasskeys}
|
||||||
onBulkSetRole={handleBulkSetRole}
|
onBulkSetRole={handleBulkSetRole}
|
||||||
onBulkDeactivate={handleBulkDeactivate}
|
onBulkDeactivate={handleBulkDeactivate}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -27,3 +27,7 @@ ADMIN_NAME=Admin
|
|||||||
RETURN_TO_ALLOWLIST=.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top
|
RETURN_TO_ALLOWLIST=.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
# WebAuthn / passkeys (defaults from ISSUER hostname + origin)
|
||||||
|
# WEBAUTHN_RP_ID=auth.shnt.top
|
||||||
|
# WEBAUTHN_ORIGINS=https://auth.shnt.top
|
||||||
|
# WEBAUTHN_RP_NAME=Auth Portal
|
||||||
|
|||||||
@@ -182,6 +182,36 @@ export function migrateSchema(sqlite: Sqlite): void {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_client ON oidc_auth_codes(client_id);
|
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_client ON oidc_auth_codes(client_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_user ON oidc_auth_codes(user_id);
|
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_user ON oidc_auth_codes(user_id);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
credential_id TEXT NOT NULL UNIQUE,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
counter INTEGER NOT NULL DEFAULT 0,
|
||||||
|
device_type TEXT,
|
||||||
|
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transports_json TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
purpose TEXT NOT NULL,
|
||||||
|
challenge TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
|
||||||
|
ON webauthn_credentials(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
|
||||||
|
ON webauthn_challenges(expires_at);
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function healthCheck(sqlite: Sqlite): void {
|
export function healthCheck(sqlite: Sqlite): void {
|
||||||
@@ -193,3 +223,4 @@ export * from './users.js'
|
|||||||
export * from './settings.js'
|
export * from './settings.js'
|
||||||
export * from './audit-log.js'
|
export * from './audit-log.js'
|
||||||
export * from './oidc.js'
|
export * from './oidc.js'
|
||||||
|
export * from './webauthn.js'
|
||||||
|
|||||||
@@ -101,3 +101,28 @@ export const oidcSigningKeys = sqliteTable('oidc_signing_keys', {
|
|||||||
active: integer('active', { mode: 'boolean' }).notNull().default(true),
|
active: integer('active', { mode: 'boolean' }).notNull().default(true),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const webauthnCredentials = sqliteTable('webauthn_credentials', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
credentialId: text('credential_id').notNull().unique(),
|
||||||
|
publicKey: text('public_key').notNull(),
|
||||||
|
counter: integer('counter').notNull().default(0),
|
||||||
|
deviceType: text('device_type'),
|
||||||
|
backedUp: integer('backed_up', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
transportsJson: text('transports_json'),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
lastUsedAt: text('last_used_at'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const webauthnChallenges = sqliteTable('webauthn_challenges', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
purpose: text('purpose').notNull(),
|
||||||
|
challenge: text('challenge').notNull(),
|
||||||
|
expiresAt: text('expires_at').notNull(),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { and, eq, lt } from 'drizzle-orm'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type { AppDb } from './index.js'
|
||||||
|
import { webauthnChallenges, webauthnCredentials } from './schema/index.js'
|
||||||
|
|
||||||
|
export type WebauthnCredentialRow = typeof webauthnCredentials.$inferSelect
|
||||||
|
export type WebauthnChallengeRow = typeof webauthnChallenges.$inferSelect
|
||||||
|
export type WebauthnChallengePurpose = 'register' | 'authenticate'
|
||||||
|
|
||||||
|
const CHALLENGE_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
export function purgeExpiredWebauthnChallenges(db: AppDb): void {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
db.delete(webauthnChallenges)
|
||||||
|
.where(lt(webauthnChallenges.expiresAt, now))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWebauthnChallenge(
|
||||||
|
db: AppDb,
|
||||||
|
input: {
|
||||||
|
purpose: WebauthnChallengePurpose
|
||||||
|
challenge: string
|
||||||
|
userId?: string | null
|
||||||
|
},
|
||||||
|
): WebauthnChallengeRow {
|
||||||
|
purgeExpiredWebauthnChallenges(db)
|
||||||
|
const now = new Date()
|
||||||
|
const id = randomUUID()
|
||||||
|
db.insert(webauthnChallenges)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
userId: input.userId ?? null,
|
||||||
|
purpose: input.purpose,
|
||||||
|
challenge: input.challenge,
|
||||||
|
expiresAt: new Date(now.getTime() + CHALLENGE_TTL_MS).toISOString(),
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
return getWebauthnChallengeById(db, id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebauthnChallengeById(
|
||||||
|
db: AppDb,
|
||||||
|
id: string,
|
||||||
|
): WebauthnChallengeRow | undefined {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(webauthnChallenges)
|
||||||
|
.where(eq(webauthnChallenges.id, id))
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeWebauthnChallenge(
|
||||||
|
db: AppDb,
|
||||||
|
id: string,
|
||||||
|
purpose: WebauthnChallengePurpose,
|
||||||
|
userId?: string | null,
|
||||||
|
): WebauthnChallengeRow | undefined {
|
||||||
|
const row = getWebauthnChallengeById(db, id)
|
||||||
|
if (!row) return undefined
|
||||||
|
if (row.purpose !== purpose) return undefined
|
||||||
|
if (row.expiresAt < new Date().toISOString()) {
|
||||||
|
db.delete(webauthnChallenges).where(eq(webauthnChallenges.id, id)).run()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (userId != null && row.userId && row.userId !== userId) return undefined
|
||||||
|
db.delete(webauthnChallenges).where(eq(webauthnChallenges.id, id)).run()
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWebauthnCredentials(
|
||||||
|
db: AppDb,
|
||||||
|
userId: string,
|
||||||
|
): WebauthnCredentialRow[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(webauthnCredentials)
|
||||||
|
.where(eq(webauthnCredentials.userId, userId))
|
||||||
|
.all()
|
||||||
|
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countWebauthnCredentials(db: AppDb, userId: string): number {
|
||||||
|
return listWebauthnCredentials(db, userId).length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebauthnCredentialById(
|
||||||
|
db: AppDb,
|
||||||
|
id: string,
|
||||||
|
): WebauthnCredentialRow | undefined {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(webauthnCredentials)
|
||||||
|
.where(eq(webauthnCredentials.id, id))
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebauthnCredentialByCredentialId(
|
||||||
|
db: AppDb,
|
||||||
|
credentialId: string,
|
||||||
|
): WebauthnCredentialRow | undefined {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(webauthnCredentials)
|
||||||
|
.where(eq(webauthnCredentials.credentialId, credentialId))
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWebauthnCredential(
|
||||||
|
db: AppDb,
|
||||||
|
input: {
|
||||||
|
userId: string
|
||||||
|
credentialId: string
|
||||||
|
publicKey: string
|
||||||
|
counter: number
|
||||||
|
deviceType?: string | null
|
||||||
|
backedUp?: boolean
|
||||||
|
transports?: string[]
|
||||||
|
name: string
|
||||||
|
},
|
||||||
|
): WebauthnCredentialRow {
|
||||||
|
const id = randomUUID()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
db.insert(webauthnCredentials)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
userId: input.userId,
|
||||||
|
credentialId: input.credentialId,
|
||||||
|
publicKey: input.publicKey,
|
||||||
|
counter: input.counter,
|
||||||
|
deviceType: input.deviceType ?? null,
|
||||||
|
backedUp: input.backedUp ?? false,
|
||||||
|
transportsJson: input.transports
|
||||||
|
? JSON.stringify(input.transports)
|
||||||
|
: null,
|
||||||
|
name: input.name,
|
||||||
|
createdAt: now,
|
||||||
|
lastUsedAt: null,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
return getWebauthnCredentialById(db, id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWebauthnCredentialName(
|
||||||
|
db: AppDb,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
name: string,
|
||||||
|
): WebauthnCredentialRow | undefined {
|
||||||
|
const existing = getWebauthnCredentialById(db, id)
|
||||||
|
if (!existing || existing.userId !== userId) return undefined
|
||||||
|
db.update(webauthnCredentials)
|
||||||
|
.set({ name })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(webauthnCredentials.id, id),
|
||||||
|
eq(webauthnCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
return getWebauthnCredentialById(db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function touchWebauthnCredential(
|
||||||
|
db: AppDb,
|
||||||
|
id: string,
|
||||||
|
counter: number,
|
||||||
|
): void {
|
||||||
|
db.update(webauthnCredentials)
|
||||||
|
.set({
|
||||||
|
counter,
|
||||||
|
lastUsedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(webauthnCredentials.id, id))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWebauthnCredential(
|
||||||
|
db: AppDb,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): boolean {
|
||||||
|
const result = db
|
||||||
|
.delete(webauthnCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(webauthnCredentials.id, id),
|
||||||
|
eq(webauthnCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWebauthnCredentialsForUser(
|
||||||
|
db: AppDb,
|
||||||
|
userId: string,
|
||||||
|
): number {
|
||||||
|
const result = db
|
||||||
|
.delete(webauthnCredentials)
|
||||||
|
.where(eq(webauthnCredentials.userId, userId))
|
||||||
|
.run()
|
||||||
|
return result.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTransportsJson(raw: string | null): string[] {
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(v)) return []
|
||||||
|
return v.filter((x): x is string => typeof x === 'string')
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -400,11 +400,42 @@ export const adminUserSchema = z.object({
|
|||||||
permissions: z.array(z.string()),
|
permissions: z.array(z.string()),
|
||||||
last_login_at: z.string().nullable(),
|
last_login_at: z.string().nullable(),
|
||||||
last_login_ip: z.string().nullable(),
|
last_login_ip: z.string().nullable(),
|
||||||
|
passkey_count: z.number().int().nonnegative().default(0),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
})
|
})
|
||||||
export type AdminUser = z.infer<typeof adminUserSchema>
|
export type AdminUser = z.infer<typeof adminUserSchema>
|
||||||
|
|
||||||
|
export const passkeyCredentialSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
created_at: z.string(),
|
||||||
|
last_used_at: z.string().nullable(),
|
||||||
|
device_type: z.string().nullable(),
|
||||||
|
})
|
||||||
|
export type PasskeyCredential = z.infer<typeof passkeyCredentialSchema>
|
||||||
|
|
||||||
|
export const webauthnOptionsResponseSchema = z.object({
|
||||||
|
challenge_id: z.string(),
|
||||||
|
options: z.record(z.string(), z.unknown()),
|
||||||
|
})
|
||||||
|
export type WebauthnOptionsResponse = z.infer<
|
||||||
|
typeof webauthnOptionsResponseSchema
|
||||||
|
>
|
||||||
|
|
||||||
|
export const webauthnVerifyRequestSchema = z.object({
|
||||||
|
challenge_id: z.string().min(1),
|
||||||
|
response: z.unknown(),
|
||||||
|
return_to: z.string().url().optional(),
|
||||||
|
name: z.string().min(1).max(80).optional(),
|
||||||
|
})
|
||||||
|
export type WebauthnVerifyRequest = z.infer<typeof webauthnVerifyRequestSchema>
|
||||||
|
|
||||||
|
export const patchPasskeyRequestSchema = z.object({
|
||||||
|
name: z.string().min(1).max(80),
|
||||||
|
})
|
||||||
|
export type PatchPasskeyRequest = z.infer<typeof patchPasskeyRequestSchema>
|
||||||
|
|
||||||
export const adminSessionSchema = z.object({
|
export const adminSessionSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
user_id: z.string(),
|
user_id: z.string(),
|
||||||
|
|||||||
Generated
+217
@@ -72,6 +72,9 @@ importers:
|
|||||||
'@node-rs/argon2':
|
'@node-rs/argon2':
|
||||||
specifier: ^2.0.2
|
specifier: ^2.0.2
|
||||||
version: 2.0.2
|
version: 2.0.2
|
||||||
|
'@simplewebauthn/server':
|
||||||
|
specifier: ^13.3.2
|
||||||
|
version: 13.3.2
|
||||||
fastify:
|
fastify:
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.10.0
|
version: 5.10.0
|
||||||
@@ -127,6 +130,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.4.0([email protected]([email protected]))
|
version: 5.4.0([email protected]([email protected]))
|
||||||
|
'@simplewebauthn/browser':
|
||||||
|
specifier: ^13.3.0
|
||||||
|
version: 13.3.0
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.3.1
|
specifier: ^4.3.1
|
||||||
version: 4.3.3([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
version: 4.3.3([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
||||||
@@ -1250,6 +1256,9 @@ packages:
|
|||||||
'@floating-ui/[email protected]':
|
'@floating-ui/[email protected]':
|
||||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||||
|
|
||||||
|
'@hexagon/[email protected]':
|
||||||
|
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
||||||
|
|
||||||
'@hookform/[email protected]':
|
'@hookform/[email protected]':
|
||||||
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1298,6 +1307,9 @@ packages:
|
|||||||
'@keyv/[email protected]':
|
'@keyv/[email protected]':
|
||||||
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
||||||
|
|
||||||
|
'@levischuck/[email protected]':
|
||||||
|
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
|
||||||
|
|
||||||
'@lukeed/[email protected]':
|
'@lukeed/[email protected]':
|
||||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1471,6 +1483,46 @@ packages:
|
|||||||
'@oxc-project/[email protected]':
|
'@oxc-project/[email protected]':
|
||||||
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
|
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-hiRJvr5ydif9fbTA7czZw1OfgzYDhu5gXNzhDfS3wSXzWYoSS/BHY+Wu2c36CNbn3mI6FoVLf0yHvQy0D4rZWw==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-N3POfw5RA7efAliAATiudtmvKQqukVEOzrMQuqQY/us2EPKczMy2WiecLt1SX6s3b0OwcFaPUXGF6uIYlBUTbg==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-E9zYmC5mk7eiDKqQAOsZGrJ7mUCIDC0031s4Nsl7dj1Za5EBKcHVqY+1vD/a4xbk480PGqvi455W2b4FeHRJ8Q==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-4xmeZiZ46VI2qGbiZPzdv6p9IhMtjWdbwZf3VCgN8OAVcued60ej5Ki2FF94srVH4Ot/h45Co7T5C/fpQXP4rA==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-Nzwoj+fRr1XB9CQuc4AanUuvQ3OIXAY+ngZIYP+eZUqd+Sonj+ZHTnrg+egQuUJ64/iMi9HFPN04MokGrFTK0w==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-ecGZpkY6Lq5bSgTFU+LS74WjawzDgjWHcjFMVNPH/1C0j53Xi9dmLcPMdmZyk8gdsd2RKeV1fP9EbLB6W6zZ1g==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-yjWVrQEmPp2y9lWjLLE28BRHbt7wYdwWvwXjFgNuekP9mDD/ofz31muVI8qOg2as7XZs1bZIZGPPnJ/0osTClQ==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-t7m3e9p/Gf9YoMM+hLsHUqB+NhOlifiOkENAIG4RV2BFWVGTATVzbXoR8L0EgNWpiriPaeIjBCS5B9PTtkaxQw==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-SOux4+jikCnOwoJvpBp/grOqzFmJPnNSwe3sAg1Bn93YdmCiDtvolZifJIhiRq0UWMTnLRPj9/ZCMAc2W9VsMQ==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-v5Oa6p7hCT3ONqYHQyTFQYcycD06eMhHbr0m7evpvPQSpWJIq8GgbdnvA9bVGTUlOx6KOeDrX7Z0W4s/yboNTg==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-HE+ejy9dX9JP3yLL6CGYoWym4Cted1lGXH7DxsbNDGiq8KabwVR7mzYidwsyNZ/m0hNWjiS+dzSwrsgMB/OIaQ==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@pinojs/[email protected]':
|
'@pinojs/[email protected]':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
@@ -1754,6 +1806,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==}
|
resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@simplewebauthn/[email protected]':
|
||||||
|
resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==}
|
||||||
|
|
||||||
|
'@simplewebauthn/[email protected]':
|
||||||
|
resolution: {integrity: sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@sindresorhus/[email protected]':
|
'@sindresorhus/[email protected]':
|
||||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2258,6 +2317,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4094,6 +4157,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==}
|
||||||
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
@@ -4171,6 +4241,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==}
|
resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -4572,6 +4645,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -4599,6 +4675,10 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
||||||
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||||
|
|
||||||
@@ -5774,6 +5854,8 @@ snapshots:
|
|||||||
|
|
||||||
'@floating-ui/[email protected]': {}
|
'@floating-ui/[email protected]': {}
|
||||||
|
|
||||||
|
'@hexagon/[email protected]': {}
|
||||||
|
|
||||||
'@hookform/[email protected]([email protected]([email protected]))':
|
'@hookform/[email protected]([email protected]([email protected]))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/utils': 0.3.0
|
'@standard-schema/utils': 0.3.0
|
||||||
@@ -5818,6 +5900,8 @@ snapshots:
|
|||||||
|
|
||||||
'@keyv/[email protected]': {}
|
'@keyv/[email protected]': {}
|
||||||
|
|
||||||
|
'@levischuck/[email protected]': {}
|
||||||
|
|
||||||
'@lukeed/[email protected]': {}
|
'@lukeed/[email protected]': {}
|
||||||
|
|
||||||
'@markwylde/[email protected]':
|
'@markwylde/[email protected]':
|
||||||
@@ -5990,6 +6074,106 @@ snapshots:
|
|||||||
|
|
||||||
'@oxc-project/[email protected]': {}
|
'@oxc-project/[email protected]': {}
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
'@peculiar/asn1-x509': 2.9.3
|
||||||
|
'@peculiar/asn1-x509-attr': 2.9.3
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
'@peculiar/asn1-x509': 2.9.3
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
'@peculiar/asn1-x509': 2.9.3
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-cms': 2.9.3
|
||||||
|
'@peculiar/asn1-pkcs8': 2.9.3
|
||||||
|
'@peculiar/asn1-rsa': 2.9.3
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
'@peculiar/asn1-x509': 2.9.3
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-cms': 2.9.3
|
||||||
|
'@peculiar/asn1-pfx': 2.9.3
|
||||||
|
'@peculiar/asn1-pkcs8': 2.9.3
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
'@peculiar/asn1-x509': 2.9.3
|
||||||
|
'@peculiar/asn1-x509-attr': 2.9.3
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
'@peculiar/asn1-x509': 2.9.3
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/utils': 2.0.3
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
'@peculiar/asn1-x509': 2.9.3
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
'@peculiar/utils': 2.0.3
|
||||||
|
asn1js: 3.0.10
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@peculiar/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@peculiar/asn1-cms': 2.9.3
|
||||||
|
'@peculiar/asn1-csr': 2.9.3
|
||||||
|
'@peculiar/asn1-ecc': 2.9.3
|
||||||
|
'@peculiar/asn1-pkcs9': 2.9.3
|
||||||
|
'@peculiar/asn1-rsa': 2.9.3
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
'@peculiar/asn1-x509': 2.9.3
|
||||||
|
pvtsutils: 1.3.6
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
tsyringe: 4.10.0
|
||||||
|
|
||||||
'@pinojs/[email protected]': {}
|
'@pinojs/[email protected]': {}
|
||||||
|
|
||||||
'@pnpm/[email protected]': {}
|
'@pnpm/[email protected]': {}
|
||||||
@@ -6229,6 +6413,19 @@ snapshots:
|
|||||||
|
|
||||||
'@simple-libs/[email protected]': {}
|
'@simple-libs/[email protected]': {}
|
||||||
|
|
||||||
|
'@simplewebauthn/[email protected]': {}
|
||||||
|
|
||||||
|
'@simplewebauthn/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@hexagon/base64': 1.1.28
|
||||||
|
'@levischuck/tiny-cbor': 0.2.11
|
||||||
|
'@peculiar/asn1-android': 2.9.3
|
||||||
|
'@peculiar/asn1-ecc': 2.9.3
|
||||||
|
'@peculiar/asn1-rsa': 2.9.3
|
||||||
|
'@peculiar/asn1-schema': 2.9.3
|
||||||
|
'@peculiar/asn1-x509': 2.9.3
|
||||||
|
'@peculiar/x509': 1.14.3
|
||||||
|
|
||||||
'@sindresorhus/[email protected]': {}
|
'@sindresorhus/[email protected]': {}
|
||||||
|
|
||||||
'@sindresorhus/[email protected]': {}
|
'@sindresorhus/[email protected]': {}
|
||||||
@@ -6786,6 +6983,12 @@ snapshots:
|
|||||||
minimalistic-assert: 1.0.1
|
minimalistic-assert: 1.0.1
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
pvtsutils: 1.3.6
|
||||||
|
pvutils: 1.2.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -8453,6 +8656,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -8537,6 +8746,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@pnpm/npm-conf': 3.0.3
|
'@pnpm/npm-conf': 3.0.3
|
||||||
@@ -8955,6 +9166,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected])([email protected])([email protected])([email protected]):
|
[email protected]([email protected])([email protected])([email protected])([email protected]):
|
||||||
@@ -8991,6 +9204,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
tslib: 1.14.1
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|||||||
Reference in New Issue
Block a user