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]>
531 lines
16 KiB
TypeScript
531 lines
16 KiB
TypeScript
import type { FastifyInstance } from 'fastify'
|
|
import { hash } from '@node-rs/argon2'
|
|
import {
|
|
createUser,
|
|
deleteUser,
|
|
getAppSwitcherConfig,
|
|
getUserApps,
|
|
getUserByEmail,
|
|
getUserById,
|
|
getUserPermissions,
|
|
listActiveSessions,
|
|
listUsers,
|
|
revokeAllSessionsForUser,
|
|
revokeSessionById,
|
|
setAppSwitcherConfig,
|
|
setUserAccess,
|
|
updateUser,
|
|
createOidcClient,
|
|
deleteOidcClient,
|
|
generateOidcClientSecret,
|
|
getOidcClientById,
|
|
listOidcClients,
|
|
parseJsonStringArray,
|
|
updateOidcClient,
|
|
countWebauthnCredentials,
|
|
deleteWebauthnCredentialsForUser,
|
|
} from '@authportal/db'
|
|
import {
|
|
APP_IDS,
|
|
OIDC_SCOPES,
|
|
allPermissionKeys,
|
|
appSwitcherConfigSchema,
|
|
createOidcClientRequestSchema,
|
|
createUserRequestSchema,
|
|
normalizePermissionKeys,
|
|
patchOidcClientRequestSchema,
|
|
patchUserRequestSchema,
|
|
putUserAccessRequestSchema,
|
|
type AdminUser,
|
|
type AppId,
|
|
type OidcClientPublic,
|
|
} from '@authportal/shared'
|
|
import { requireAdmin } from '../plugins/auth-guards.js'
|
|
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
|
|
import { oidcIssuerFromConfig } from '../config.js'
|
|
|
|
const allowedPermissions = new Set(allPermissionKeys())
|
|
|
|
function mapUser(
|
|
db: FastifyInstance['db'],
|
|
user: NonNullable<ReturnType<typeof getUserById>>,
|
|
): AdminUser {
|
|
const apps = getUserApps(db, user.id).filter((a): a is AppId =>
|
|
(APP_IDS as readonly string[]).includes(a),
|
|
)
|
|
const permissions = normalizePermissionKeys(getUserPermissions(db, user.id))
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
is_admin: user.isAdmin,
|
|
disabled: user.disabled,
|
|
apps,
|
|
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,
|
|
}
|
|
}
|
|
|
|
function validateAccess(apps: string[], permissions: string[]): string | null {
|
|
for (const app of apps) {
|
|
if (!(APP_IDS as readonly string[]).includes(app)) {
|
|
return `Неизвестное приложение: ${app}`
|
|
}
|
|
}
|
|
for (const p of permissions) {
|
|
if (!allowedPermissions.has(p)) {
|
|
return `Неизвестное право: ${p}`
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function mapOidcClient(
|
|
row: NonNullable<ReturnType<typeof getOidcClientById>>,
|
|
): OidcClientPublic {
|
|
return {
|
|
id: row.id,
|
|
client_id: row.clientId,
|
|
name: row.name,
|
|
redirect_uris: parseJsonStringArray(row.redirectUrisJson),
|
|
scopes: parseJsonStringArray(row.scopesJson),
|
|
enabled: row.enabled,
|
|
created_at: row.createdAt,
|
|
updated_at: row.updatedAt,
|
|
}
|
|
}
|
|
|
|
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|
app.addHook('onRequest', async (request, reply) => {
|
|
if (!request.url.startsWith('/api/v1/admin')) return
|
|
await requireAdmin(request, reply)
|
|
})
|
|
|
|
app.get('/api/v1/admin/users', async () => {
|
|
return listUsers(app.db).map((u) => mapUser(app.db, u))
|
|
})
|
|
|
|
app.get<{ Params: { id: string } }>(
|
|
'/api/v1/admin/users/:id',
|
|
async (request, reply) => {
|
|
const user = getUserById(app.db, request.params.id)
|
|
if (!user) {
|
|
return reply.status(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
|
})
|
|
}
|
|
return mapUser(app.db, user)
|
|
},
|
|
)
|
|
|
|
app.post('/api/v1/admin/users', async (request, reply) => {
|
|
const parsed = createUserRequestSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({
|
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
|
})
|
|
}
|
|
const data = parsed.data
|
|
const permissions = normalizePermissionKeys(data.permissions)
|
|
if (getUserByEmail(app.db, data.email)) {
|
|
return reply.status(409).send({
|
|
error: { code: 'CONFLICT', message: 'Email уже занят' },
|
|
})
|
|
}
|
|
const accessError = validateAccess(data.apps, permissions)
|
|
if (accessError) {
|
|
return reply.status(400).send({
|
|
error: { code: 'VALIDATION_ERROR', message: accessError },
|
|
})
|
|
}
|
|
|
|
const passwordHash = await hash(data.password)
|
|
const user = createUser(app.db, {
|
|
email: data.email,
|
|
name: data.name,
|
|
passwordHash,
|
|
isAdmin: data.is_admin,
|
|
})
|
|
setUserAccess(app.db, user.id, data.apps, permissions)
|
|
const mapped = mapUser(app.db, getUserById(app.db, user.id)!)
|
|
safeAudit(app, {
|
|
action: 'user.create',
|
|
severity: 'info',
|
|
...actorFromRequest(request),
|
|
targetType: 'user',
|
|
targetId: user.id,
|
|
summary: `Создан пользователь ${mapped.email}`,
|
|
details: {
|
|
is_admin: mapped.is_admin,
|
|
apps: mapped.apps,
|
|
},
|
|
ip: clientIp(request),
|
|
})
|
|
return reply.status(201).send(mapped)
|
|
})
|
|
|
|
app.patch<{ Params: { id: string } }>(
|
|
'/api/v1/admin/users/:id',
|
|
async (request, reply) => {
|
|
const parsed = patchUserRequestSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({
|
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
|
})
|
|
}
|
|
const existing = getUserById(app.db, request.params.id)
|
|
if (!existing) {
|
|
return reply.status(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
|
})
|
|
}
|
|
if (parsed.data.email && parsed.data.email !== existing.email) {
|
|
const clash = getUserByEmail(app.db, parsed.data.email)
|
|
if (clash) {
|
|
return reply.status(409).send({
|
|
error: { code: 'CONFLICT', message: 'Email уже занят' },
|
|
})
|
|
}
|
|
}
|
|
|
|
const passwordHash = parsed.data.password
|
|
? await hash(parsed.data.password)
|
|
: undefined
|
|
const updated = updateUser(app.db, request.params.id, {
|
|
email: parsed.data.email,
|
|
name: parsed.data.name,
|
|
passwordHash,
|
|
isAdmin: parsed.data.is_admin,
|
|
disabled: parsed.data.disabled,
|
|
})
|
|
const mapped = mapUser(app.db, updated!)
|
|
safeAudit(app, {
|
|
action: 'user.update',
|
|
severity: parsed.data.disabled ? 'warning' : 'info',
|
|
...actorFromRequest(request),
|
|
targetType: 'user',
|
|
targetId: mapped.id,
|
|
summary: `Изменён пользователь ${mapped.email}`,
|
|
details: {
|
|
...parsed.data,
|
|
password: parsed.data.password ? '[changed]' : undefined,
|
|
},
|
|
ip: clientIp(request),
|
|
})
|
|
return mapped
|
|
},
|
|
)
|
|
|
|
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) => {
|
|
if (request.authUser?.id === request.params.id) {
|
|
return reply.status(400).send({
|
|
error: {
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Нельзя удалить себя',
|
|
},
|
|
})
|
|
}
|
|
const existing = getUserById(app.db, request.params.id)
|
|
if (!existing) {
|
|
return reply.status(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
|
})
|
|
}
|
|
const ok = deleteUser(app.db, request.params.id)
|
|
if (!ok) {
|
|
return reply.status(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
|
})
|
|
}
|
|
safeAudit(app, {
|
|
action: 'user.delete',
|
|
severity: 'critical',
|
|
...actorFromRequest(request),
|
|
targetType: 'user',
|
|
targetId: existing.id,
|
|
summary: `Удалён пользователь ${existing.email}`,
|
|
details: { email: existing.email, name: existing.name },
|
|
ip: clientIp(request),
|
|
})
|
|
return reply.status(204).send()
|
|
},
|
|
)
|
|
|
|
app.put<{ Params: { id: string } }>(
|
|
'/api/v1/admin/users/:id/access',
|
|
async (request, reply) => {
|
|
const parsed = putUserAccessRequestSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({
|
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
|
})
|
|
}
|
|
const existing = getUserById(app.db, request.params.id)
|
|
if (!existing) {
|
|
return reply.status(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
|
})
|
|
}
|
|
const permissions = normalizePermissionKeys(parsed.data.permissions)
|
|
const accessError = validateAccess(parsed.data.apps, permissions)
|
|
if (accessError) {
|
|
return reply.status(400).send({
|
|
error: { code: 'VALIDATION_ERROR', message: accessError },
|
|
})
|
|
}
|
|
setUserAccess(
|
|
app.db,
|
|
request.params.id,
|
|
parsed.data.apps,
|
|
permissions,
|
|
)
|
|
const mapped = mapUser(app.db, getUserById(app.db, request.params.id)!)
|
|
safeAudit(app, {
|
|
action: 'user.access_update',
|
|
severity: 'info',
|
|
...actorFromRequest(request),
|
|
targetType: 'user',
|
|
targetId: mapped.id,
|
|
summary: `Обновлены права: ${mapped.email}`,
|
|
details: {
|
|
apps: mapped.apps,
|
|
permissions_count: mapped.permissions.length,
|
|
},
|
|
ip: clientIp(request),
|
|
})
|
|
return mapped
|
|
},
|
|
)
|
|
|
|
app.get('/api/v1/admin/app-switcher', async () =>
|
|
getAppSwitcherConfig(app.db),
|
|
)
|
|
|
|
app.put('/api/v1/admin/app-switcher', async (request, reply) => {
|
|
const parsed = appSwitcherConfigSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({
|
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
|
})
|
|
}
|
|
const result = setAppSwitcherConfig(app.db, parsed.data)
|
|
safeAudit(app, {
|
|
action: 'app_switcher.update',
|
|
severity: 'info',
|
|
...actorFromRequest(request),
|
|
targetType: 'settings',
|
|
targetId: 'app_switcher',
|
|
summary: 'Обновлены ссылки App Switcher',
|
|
details: {
|
|
menuLabel: result.menuLabel,
|
|
apps: result.apps.map((a) => a.id),
|
|
},
|
|
ip: clientIp(request),
|
|
})
|
|
return result
|
|
})
|
|
|
|
app.get('/api/v1/admin/sessions', async (request) => {
|
|
const userId = (request.query as { user_id?: string }).user_id
|
|
return listActiveSessions(app.db, { userId }).map((s) => ({
|
|
id: s.id,
|
|
user_id: s.userId,
|
|
email: s.email,
|
|
name: s.name,
|
|
ip: s.ip,
|
|
user_agent: s.userAgent,
|
|
created_at: s.createdAt,
|
|
expires_at: s.expiresAt,
|
|
}))
|
|
})
|
|
|
|
app.delete<{ Params: { id: string } }>(
|
|
'/api/v1/admin/sessions/:id',
|
|
async (request, reply) => {
|
|
const ok = revokeSessionById(app.db, request.params.id)
|
|
if (!ok) {
|
|
return reply.status(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Сессия не найдена' },
|
|
})
|
|
}
|
|
safeAudit(app, {
|
|
action: 'auth.logout',
|
|
severity: 'warning',
|
|
...actorFromRequest(request),
|
|
targetType: 'session',
|
|
targetId: request.params.id,
|
|
summary: `Админ отозвал сессию ${request.params.id.slice(0, 8)}`,
|
|
details: { source: 'admin_revoke' },
|
|
ip: clientIp(request),
|
|
})
|
|
return { ok: true }
|
|
},
|
|
)
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/v1/admin/users/:id/sessions/revoke-all',
|
|
async (request, reply) => {
|
|
const user = getUserById(app.db, request.params.id)
|
|
if (!user) {
|
|
return reply.status(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
|
})
|
|
}
|
|
const revoked = revokeAllSessionsForUser(app.db, user.id)
|
|
safeAudit(app, {
|
|
action: 'auth.logout',
|
|
severity: 'warning',
|
|
...actorFromRequest(request),
|
|
targetType: 'user',
|
|
targetId: user.id,
|
|
summary: `Отозваны все сессии: ${user.email} (${revoked})`,
|
|
details: { revoked, source: 'admin_revoke_all' },
|
|
ip: clientIp(request),
|
|
})
|
|
return { revoked }
|
|
},
|
|
)
|
|
|
|
app.get('/api/v1/admin/oidc/meta', async () => {
|
|
const issuer = oidcIssuerFromConfig(app.config)
|
|
return {
|
|
issuer,
|
|
discovery_url: `${issuer}/.well-known/openid-configuration`,
|
|
jwks_url: `${issuer}/.well-known/jwks.json`,
|
|
scopes: [...OIDC_SCOPES],
|
|
}
|
|
})
|
|
|
|
app.get('/api/v1/admin/oidc/clients', async () =>
|
|
listOidcClients(app.db).map(mapOidcClient),
|
|
)
|
|
|
|
app.post('/api/v1/admin/oidc/clients', async (request, reply) => {
|
|
const parsed = createOidcClientRequestSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({
|
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
|
})
|
|
}
|
|
const secret = generateOidcClientSecret()
|
|
const secretHash = await hash(secret)
|
|
const row = createOidcClient(app.db, {
|
|
name: parsed.data.name,
|
|
clientSecretHash: secretHash,
|
|
redirectUris: parsed.data.redirect_uris,
|
|
scopes: parsed.data.scopes,
|
|
enabled: parsed.data.enabled,
|
|
})
|
|
const mapped = mapOidcClient(row)
|
|
safeAudit(app, {
|
|
action: 'app_switcher.update',
|
|
severity: 'info',
|
|
...actorFromRequest(request),
|
|
targetType: 'settings',
|
|
targetId: row.id,
|
|
summary: `OIDC client создан: ${row.name}`,
|
|
details: { client_id: row.clientId },
|
|
ip: clientIp(request),
|
|
})
|
|
return { ...mapped, client_secret: secret }
|
|
})
|
|
|
|
app.get<{ Params: { id: string } }>(
|
|
'/api/v1/admin/oidc/clients/:id',
|
|
async (request, reply) => {
|
|
const row = getOidcClientById(app.db, request.params.id)
|
|
if (!row) {
|
|
return reply.status(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
|
})
|
|
}
|
|
return mapOidcClient(row)
|
|
},
|
|
)
|
|
|
|
app.patch<{ Params: { id: string } }>(
|
|
'/api/v1/admin/oidc/clients/:id',
|
|
async (request, reply) => {
|
|
const parsed = patchOidcClientRequestSchema.safeParse(request.body)
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({
|
|
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
|
})
|
|
}
|
|
const row = updateOidcClient(app.db, request.params.id, {
|
|
name: parsed.data.name,
|
|
redirectUris: parsed.data.redirect_uris,
|
|
scopes: parsed.data.scopes,
|
|
enabled: parsed.data.enabled,
|
|
})
|
|
if (!row) {
|
|
return reply.status(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
|
})
|
|
}
|
|
return mapOidcClient(row)
|
|
},
|
|
)
|
|
|
|
app.delete<{ Params: { id: string } }>(
|
|
'/api/v1/admin/oidc/clients/:id',
|
|
async (request, reply) => {
|
|
const ok = deleteOidcClient(app.db, request.params.id)
|
|
if (!ok) {
|
|
return reply.status(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
|
})
|
|
}
|
|
return { ok: true }
|
|
},
|
|
)
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/v1/admin/oidc/clients/:id/rotate-secret',
|
|
async (request, reply) => {
|
|
const existing = getOidcClientById(app.db, request.params.id)
|
|
if (!existing) {
|
|
return reply.status(404).send({
|
|
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
|
})
|
|
}
|
|
const secret = generateOidcClientSecret()
|
|
const secretHash = await hash(secret)
|
|
const row = updateOidcClient(app.db, request.params.id, {
|
|
clientSecretHash: secretHash,
|
|
})!
|
|
return { ...mapOidcClient(row), client_secret: secret }
|
|
},
|
|
)
|
|
}
|