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/static": "^8.2.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"fastify": "^5.4.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"jose": "^6.2.8",
|
||||
|
||||
@@ -31,6 +31,7 @@ import { adminRoutes } from './routes/admin.js'
|
||||
import { auditAdminRoutes } from './routes/audit.js'
|
||||
import { auditIngestRoutes } from './routes/ingest-audit.js'
|
||||
import { oidcRoutes } from './routes/oidc.js'
|
||||
import { webauthnRoutes } from './routes/webauthn.js'
|
||||
import { startAuditRetentionJob } from './services/audit-retention.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(auditIngestRoutes)
|
||||
await app.register(oidcRoutes)
|
||||
await app.register(webauthnRoutes)
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const stopRetention = startAuditRetentionJob(app)
|
||||
|
||||
@@ -24,6 +24,9 @@ export const configSchema = z.object({
|
||||
logLevel: z.string().default('info'),
|
||||
isProd: z.boolean(),
|
||||
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>
|
||||
@@ -35,6 +38,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
env.AUDIT_INGEST_SECRET ??
|
||||
(isProd ? undefined : 'dev-audit-ingest-secret')
|
||||
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
|
||||
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
|
||||
|
||||
return configSchema.parse({
|
||||
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) ||
|
||||
isProd,
|
||||
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 {
|
||||
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,
|
||||
parseJsonStringArray,
|
||||
updateOidcClient,
|
||||
countWebauthnCredentials,
|
||||
deleteWebauthnCredentialsForUser,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
APP_IDS,
|
||||
@@ -62,6 +64,7 @@ function mapUser(
|
||||
permissions,
|
||||
last_login_at: user.lastLoginAt ?? null,
|
||||
last_login_ip: user.lastLoginIp ?? null,
|
||||
passkey_count: countWebauthnCredentials(db, user.id),
|
||||
created_at: user.createdAt,
|
||||
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 } }>(
|
||||
'/api/v1/admin/users/:id',
|
||||
async (request, reply) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { verify } from '@node-rs/argon2'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
PERMISSION_CATALOG,
|
||||
appsMetaFromSwitcher,
|
||||
@@ -9,26 +8,24 @@ import {
|
||||
ssoAccessRequestSchema,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
createRefreshSession,
|
||||
getAppSwitcherConfig,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
listUsers,
|
||||
revokeRefreshSession,
|
||||
touchLastLogin,
|
||||
} from '@authportal/db'
|
||||
import { requireAuth } from '../plugins/auth-guards.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 { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token'
|
||||
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
|
||||
app.get('/api/v1/auth/config', async () => ({
|
||||
return_to_allowlist: app.config.returnToAllowlist,
|
||||
issuer: app.config.issuer,
|
||||
webauthn: true,
|
||||
}))
|
||||
|
||||
app.post('/api/v1/auth/login', {
|
||||
@@ -90,41 +87,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
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, {
|
||||
return completeLogin(app, reply, user, {
|
||||
method: 'password',
|
||||
returnTo,
|
||||
ip,
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user