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]>
389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
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,
|
|
})
|
|
},
|
|
)
|
|
}
|