From cc38bf06f8b60d37dbaa6ed170803795fda6f63d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 31 Jul 2026 03:12:39 +0700 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8B=20=D0=B2=D1=85=D0=BE=D0=B4=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=81?= =?UTF-8?q?=20IP/UA=20=D0=B8=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D1=8F=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip; SSO handoff и revoke refresh-сессий; улучшены audit-карточки. Co-authored-by: Cursor --- apps/api/src/app.ts | 1 + apps/api/src/lib/target-app.ts | 43 ++ apps/api/src/routes/admin.ts | 65 +++ apps/api/src/routes/audit.ts | 2 + apps/api/src/routes/auth.ts | 96 ++- apps/api/test/audit.test.ts | 140 +++++ apps/web/src/components/app-sidebar.tsx | 18 +- .../web/src/components/layout/site-header.tsx | 5 +- .../components/reui-kit/admin-users-grid.tsx | 20 + .../components/reui-kit/audit-log-helpers.ts | 21 + .../reui-kit/audit-log-timeline.tsx | 99 +++- .../components/reui-kit/user-audit-sheet.tsx | 199 ++++++- apps/web/src/lib/auth.ts | 16 + apps/web/src/queries/audit.ts | 11 + apps/web/src/queries/sessions.ts | 22 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_auth.admin.audit.tsx | 2 +- apps/web/src/routes/_auth.admin.logins.tsx | 547 ++++++++++++++++++ packages/db/src/audit-log.ts | 22 +- packages/db/src/index.ts | 16 + packages/db/src/schema/index.ts | 3 + packages/db/src/users.ts | 99 +++- packages/shared/src/contracts/audit.ts | 14 + packages/shared/src/contracts/auth.ts | 18 + 24 files changed, 1431 insertions(+), 69 deletions(-) create mode 100644 apps/api/src/lib/target-app.ts create mode 100644 apps/web/src/queries/sessions.ts create mode 100644 apps/web/src/routes/_auth.admin.logins.tsx diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c0abd89..14eed14 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -92,6 +92,7 @@ export async function buildApp(opts: { const app = Fastify({ logger: { level: config.logLevel }, + trustProxy: true, }) app.decorate('config', config) diff --git a/apps/api/src/lib/target-app.ts b/apps/api/src/lib/target-app.ts new file mode 100644 index 0000000..34b8303 --- /dev/null +++ b/apps/api/src/lib/target-app.ts @@ -0,0 +1,43 @@ +import type { AuditSourceApp } from '@authportal/shared' + +/** Resolve SSO target app from return_to URL host/path. */ +export function targetAppFromReturnTo( + returnTo: string | undefined | null, +): AuditSourceApp { + if (!returnTo) return 'portal' + let host = '' + let path = '' + try { + const u = new URL(returnTo) + host = u.hostname.toLowerCase() + path = u.pathname.toLowerCase() + } catch { + return 'portal' + } + const hay = `${host} ${path}` + if (/\bvps\b/.test(hay) || host.includes('vps')) return 'vps' + if ( + /\bcfdm\b/.test(hay) || + host.includes('cfdm') || + host.includes('domain') + ) { + return 'cfdm' + } + if (/\bbgp\b/.test(hay) || host.includes('bgp')) return 'bgp' + if ( + /\bfw\b/.test(hay) || + host.includes('firewall') || + host.includes('evofw') + ) { + return 'fw' + } + return 'portal' +} + +export function clientUserAgent( + headers: Record, +): string | null { + const ua = headers['user-agent'] + if (typeof ua === 'string' && ua.trim()) return ua.trim().slice(0, 512) + return null +} diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 8b39b24..4ffe374 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -8,7 +8,10 @@ import { getUserByEmail, getUserById, getUserPermissions, + listActiveSessions, listUsers, + revokeAllSessionsForUser, + revokeSessionById, setAppSwitcherConfig, setUserAccess, updateUser, @@ -46,6 +49,7 @@ function mapUser( apps, permissions, last_login_at: user.lastLoginAt ?? null, + last_login_ip: user.lastLoginIp ?? null, created_at: user.createdAt, updated_at: user.updatedAt, } @@ -296,4 +300,65 @@ export async function adminRoutes(app: FastifyInstance): Promise { }) 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 } + }, + ) } diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts index fbfd728..d393c4f 100644 --- a/apps/api/src/routes/audit.ts +++ b/apps/api/src/routes/audit.ts @@ -30,7 +30,9 @@ export async function auditAdminRoutes(app: FastifyInstance): Promise { action: q.action, severity: q.severity, userId: q.user_id, + actorEmail: q.actor_email, sourceApp: q.source_app, + kind: q.kind, limit: q.limit, }) }) diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index c7c0848..706fcf0 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -6,6 +6,7 @@ import { appsMetaFromSwitcher, loginRequestSchema, publicAppSwitcherConfig, + ssoAccessRequestSchema, } from '@authportal/shared' import { createRefreshSession, @@ -19,6 +20,7 @@ import { import { requireAuth } from '../plugins/auth-guards.js' import { issueAccessToken } from '../lib/issue-access-token.js' import { clientIp, safeAudit } from '../lib/audit.js' +import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js' const REFRESH_COOKIE = 'refresh_token' @@ -39,8 +41,10 @@ export async function authRoutes(app: FastifyInstance): Promise { }) } - const { email, password } = parsed.data + const { email, password, return_to: returnTo } = parsed.data const ip = clientIp(request) + const userAgent = clientUserAgent(request.headers) + const targetApp = targetAppFromReturnTo(returnTo) const user = getUserByEmail(app.db, email) if (!user || user.disabled) { safeAudit(app, { @@ -49,7 +53,12 @@ export async function authRoutes(app: FastifyInstance): Promise { actorEmail: email.toLowerCase(), targetType: 'session', summary: `Неудачный вход: ${email}`, - details: { reason: !user ? 'unknown_user' : 'disabled' }, + details: { + reason: !user ? 'unknown_user' : 'disabled', + user_agent: userAgent, + return_to: returnTo ?? null, + target_app: targetApp, + }, ip, }) return reply.status(401).send({ @@ -68,7 +77,12 @@ export async function authRoutes(app: FastifyInstance): Promise { targetType: 'session', targetId: user.id, summary: `Неудачный вход: ${user.email}`, - details: { reason: 'bad_password' }, + details: { + reason: 'bad_password', + user_agent: userAgent, + return_to: returnTo ?? null, + target_app: targetApp, + }, ip, }) return reply.status(401).send({ @@ -82,8 +96,11 @@ export async function authRoutes(app: FastifyInstance): Promise { const refreshExpires = new Date( Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000, ) - createRefreshSession(app.db, user.id, refreshRaw, refreshExpires) - touchLastLogin(app.db, user.id) + createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, { + ip, + userAgent, + }) + touchLastLogin(app.db, user.id, ip) reply.header( 'Set-Cookie', @@ -99,6 +116,11 @@ export async function authRoutes(app: FastifyInstance): Promise { targetType: 'session', targetId: user.id, summary: `Вход: ${user.email}`, + details: { + user_agent: userAgent, + return_to: returnTo ?? null, + target_app: targetApp, + }, ip, }) @@ -128,6 +150,51 @@ export async function authRoutes(app: FastifyInstance): Promise { }, ) + /** Record SSO handoff to a connected app (already authenticated). */ + app.post( + '/api/v1/auth/sso-access', + { + onRequest: requireAuth, + config: { rateLimit: { max: 60, timeWindow: '1 minute' } }, + }, + async (request, reply) => { + const parsed = ssoAccessRequestSchema.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 returnTo = parsed.data.return_to + const targetApp = targetAppFromReturnTo(returnTo) + const ip = clientIp(request) + const userAgent = clientUserAgent(request.headers) + safeAudit(app, { + action: 'auth.sso_handoff', + severity: 'info', + actorUserId: user.id, + actorEmail: user.email, + actorName: user.name, + targetType: 'session', + targetId: user.id, + summary: `SSO: ${user.email} → ${targetApp}`, + details: { + return_to: returnTo, + target_app: targetApp, + user_agent: userAgent, + }, + ip, + }) + return { ok: true, target_app: targetApp } + }, + ) + app.post('/api/v1/auth/logout', async (request, reply) => { const cookie = request.headers.cookie ?? '' const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`)) @@ -165,6 +232,7 @@ export async function authRoutes(app: FastifyInstance): Promise { targetType: 'session', targetId: actorUserId, summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход', + details: { user_agent: clientUserAgent(request.headers) }, ip: clientIp(request), }) @@ -218,15 +286,11 @@ export async function authRoutes(app: FastifyInstance): Promise { }, ) - app.get( - '/api/v1/catalog', - { onRequest: requireAuth }, - async () => { - const switcher = getAppSwitcherConfig(app.db) - return { - apps: appsMetaFromSwitcher(switcher), - permissions: PERMISSION_CATALOG, - } - }, - ) + app.get('/api/v1/catalog', { onRequest: requireAuth }, async () => { + const switcher = getAppSwitcherConfig(app.db) + return { + apps: appsMetaFromSwitcher(switcher), + permissions: PERMISSION_CATALOG, + } + }) } diff --git a/apps/api/test/audit.test.ts b/apps/api/test/audit.test.ts index a8bb235..ee1750e 100644 --- a/apps/api/test/audit.test.ts +++ b/apps/api/test/audit.test.ts @@ -183,4 +183,144 @@ describe('audit log API', () => { await app.close() }) + + it('records login with IP, UA and last_login_ip', async () => { + const app = await buildTestApp() + const login = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { + 'x-forwarded-for': '203.0.113.10', + 'user-agent': 'VitestBrowser/1.0', + }, + payload: { + email: 'admin@test.local', + password: 'adminpass', + return_to: 'https://vps.example.test/dashboard', + }, + }) + expect(login.statusCode).toBe(200) + const token = (login.json() as { access_token: string }).access_token + + const users = await app.inject({ + method: 'GET', + url: '/api/v1/admin/users', + headers: { authorization: `Bearer ${token}` }, + }) + const admin = ( + users.json() as { email: string; last_login_ip: string | null }[] + ).find((u) => u.email === 'admin@test.local') + expect(admin?.last_login_ip).toBe('203.0.113.10') + + const list = await app.inject({ + method: 'GET', + url: '/api/v1/admin/audit?kind=logins', + headers: { authorization: `Bearer ${token}` }, + }) + const entries = list.json() as { + action: string + ip: string | null + details: Record | null + }[] + const loginEvt = entries.find((e) => e.action === 'auth.login') + expect(loginEvt?.ip).toBe('203.0.113.10') + expect(loginEvt?.details?.user_agent).toBe('VitestBrowser/1.0') + expect(loginEvt?.details?.target_app).toBe('vps') + + await app.close() + }) + + it('records failed login with email and IP', async () => { + const app = await buildTestApp() + const fail = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'x-forwarded-for': '198.51.100.7' }, + payload: { email: 'nobody@test.local', password: 'wrong' }, + }) + expect(fail.statusCode).toBe(401) + + const token = await adminToken(app) + const list = await app.inject({ + method: 'GET', + url: '/api/v1/admin/audit?kind=logins&actor_email=nobody@test.local', + headers: { authorization: `Bearer ${token}` }, + }) + const entries = list.json() as { + action: string + actor_email: string | null + ip: string | null + }[] + expect(entries.some((e) => e.action === 'auth.login_failed')).toBe(true) + expect(entries[0]?.actor_email).toBe('nobody@test.local') + expect(entries[0]?.ip).toBe('198.51.100.7') + + await app.close() + }) + + it('records sso-access and separates kind=changes', async () => { + const app = await buildTestApp() + const token = await adminToken(app) + + const sso = await app.inject({ + method: 'POST', + url: '/api/v1/auth/sso-access', + headers: { authorization: `Bearer ${token}` }, + payload: { return_to: 'https://bgp.example.test/' }, + }) + expect(sso.statusCode).toBe(200) + expect((sso.json() as { target_app: string }).target_app).toBe('bgp') + + const logins = await app.inject({ + method: 'GET', + url: '/api/v1/admin/audit?kind=logins', + headers: { authorization: `Bearer ${token}` }, + }) + const loginEntries = logins.json() as { action: string }[] + expect(loginEntries.every((e) => e.action.startsWith('auth.'))).toBe(true) + expect(loginEntries.some((e) => e.action === 'auth.sso_handoff')).toBe(true) + + const changes = await app.inject({ + method: 'GET', + url: '/api/v1/admin/audit?kind=changes', + headers: { authorization: `Bearer ${token}` }, + }) + const changeEntries = changes.json() as { action: string }[] + expect(changeEntries.every((e) => !e.action.startsWith('auth.'))).toBe(true) + + await app.close() + }) + + it('lists and revokes refresh sessions', async () => { + const app = await buildTestApp() + const token = await adminToken(app) + + const sessions = await app.inject({ + method: 'GET', + url: '/api/v1/admin/sessions', + headers: { authorization: `Bearer ${token}` }, + }) + expect(sessions.statusCode).toBe(200) + const rows = sessions.json() as { id: string; user_id: string }[] + expect(rows.length).toBeGreaterThanOrEqual(1) + const sessionId = rows[0]!.id + + const revoke = await app.inject({ + method: 'DELETE', + url: `/api/v1/admin/sessions/${sessionId}`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(revoke.statusCode).toBe(200) + + const after = await app.inject({ + method: 'GET', + url: '/api/v1/admin/sessions', + headers: { authorization: `Bearer ${token}` }, + }) + expect( + (after.json() as { id: string }[]).some((s) => s.id === sessionId), + ).toBe(false) + + await app.close() + }) }) diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 9c938fd..615f219 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -4,6 +4,7 @@ import { AppWindowIcon, HistoryIcon, LayoutGridIcon, + LogInIcon, UsersIcon, } from 'lucide-react' import { AppSwitcher } from '@/components/app-switcher' @@ -66,7 +67,8 @@ export function AppSidebar() { isActive={ isActive(pathname, '/admin', false) && !pathname.startsWith('/admin/apps') && - !pathname.startsWith('/admin/audit') + !pathname.startsWith('/admin/audit') && + !pathname.startsWith('/admin/logins') } render={} > @@ -76,12 +78,22 @@ export function AppSidebar() { } + > + + Входы + + + + } > - Журнал + Изменения diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index 2265b7e..83de102 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -16,8 +16,11 @@ function breadcrumbs(pathname: string) { if (pathname.startsWith('/admin/apps')) { return [{ label: 'Ссылки приложений', href: '/admin/apps' }] } + if (pathname.startsWith('/admin/logins')) { + return [{ label: 'Журнал входов', href: '/admin/logins' }] + } if (pathname.startsWith('/admin/audit')) { - return [{ label: 'Журнал аудита', href: '/admin/audit' }] + return [{ label: 'Журнал изменений', href: '/admin/audit' }] } if (pathname.startsWith('/admin/users/')) { return [ diff --git a/apps/web/src/components/reui-kit/admin-users-grid.tsx b/apps/web/src/components/reui-kit/admin-users-grid.tsx index 9a0e5c2..dcc9bdf 100644 --- a/apps/web/src/components/reui-kit/admin-users-grid.tsx +++ b/apps/web/src/components/reui-kit/admin-users-grid.tsx @@ -656,6 +656,26 @@ function createAdminUserColumns(handlers: { skeleton: , }, }, + { + accessorKey: 'last_login_ip', + id: 'lastIp', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.last_login_ip ?? '—'} + + ), + size: 140, + enableSorting: true, + enableHiding: true, + enableResizing: true, + meta: { + headerTitle: 'Последний IP', + skeleton: , + }, + }, { id: 'actions', header: '', diff --git a/apps/web/src/components/reui-kit/audit-log-helpers.ts b/apps/web/src/components/reui-kit/audit-log-helpers.ts index b3f316e..ab94fbb 100644 --- a/apps/web/src/components/reui-kit/audit-log-helpers.ts +++ b/apps/web/src/components/reui-kit/audit-log-helpers.ts @@ -73,6 +73,11 @@ const ACTION_META: Record< filter: 'auth', }, 'auth.logout': { label: 'Выход', icon: LogOutIcon, filter: 'auth' }, + 'auth.sso_handoff': { + label: 'SSO', + icon: KeyRoundIcon, + filter: 'auth', + }, 'user.create': { label: 'Создание пользователя', icon: UserPlusIcon, filter: 'users' }, 'user.update': { label: 'Изменение пользователя', icon: UserCogIcon, filter: 'users' }, 'user.delete': { label: 'Удаление пользователя', icon: UserXIcon, filter: 'users' }, @@ -117,6 +122,22 @@ export function matchesSourceApp( return entry.source_app === source } +export function sourceAppLabel(app: AuditSourceApp | string): string { + return ( + SOURCE_APP_OPTIONS.find((o) => o.value === app)?.label ?? String(app) + ) +} + +export function detailString( + details: Record | null | undefined, + key: string, +): string | null { + if (!details) return null + const v = details[key] + if (typeof v === 'string' && v.trim()) return v + return null +} + export function matchesRange(entry: AuditLogEntry, range: AuditRange) { if (range === 'all') return true const ms = diff --git a/apps/web/src/components/reui-kit/audit-log-timeline.tsx b/apps/web/src/components/reui-kit/audit-log-timeline.tsx index 0fad3f7..94c5ed2 100644 --- a/apps/web/src/components/reui-kit/audit-log-timeline.tsx +++ b/apps/web/src/components/reui-kit/audit-log-timeline.tsx @@ -58,6 +58,7 @@ import { RANGE_OPTIONS, SOURCE_APP_OPTIONS, actionMeta, + detailString, exportAuditCsv, formatEventTime, groupByDay, @@ -68,6 +69,7 @@ import { severityDotClass, severityLabel, severityVariant, + sourceAppLabel, type AuditFilterId, type AuditRange, } from './audit-log-helpers' @@ -108,6 +110,10 @@ function EventRow({ const meta = actionMeta(event.action) const Icon = meta.icon const actorName = event.actor_name ?? event.actor_email ?? 'Система' + const userAgent = detailString(event.details, 'user_agent') + const targetApp = + detailString(event.details, 'target_app') ?? event.source_app + const reason = detailString(event.details, 'reason') return ( @@ -127,6 +133,9 @@ function EventRow({ /> {severityLabel[event.severity]} + + {sourceAppLabel(event.source_app)} + {formatEventTime(event.created_at)} @@ -149,16 +158,25 @@ function EventRow({ aria-label={`Подробности: ${meta.label}`} > -
+
{initials(event.actor_name, event.actor_email)} - - {actorName} - {event.summary ? ` — ${event.summary}` : null} + + {event.summary || actorName} + {event.ip ? ( + + {event.ip} + + ) : null} + {targetApp && targetApp !== 'portal' ? ( + + {sourceAppLabel(targetApp)} + + ) : null}
- - - {event.target_type - ? `${event.target_type}${event.target_id ? `: ${event.target_id}` : ''}` - : '—'} - - - + {event.actor_email ?? '—'} - + {event.ip ?? '—'} + + + {userAgent ?? '—'} + + + + + {sourceAppLabel(targetApp)} + + + {reason ? ( + + + {reason} + + + ) : null} {event.action} @@ -204,18 +235,34 @@ function EventRow({ {event.id.slice(0, 8)} - +
+ {userAgent ? ( + + ) : null} + +
@@ -272,7 +319,7 @@ export function AuditLogTimeline({ id="audit-log-title" className="text-xl font-semibold tracking-tight" > - Журнал аудита + Журнал изменений

{totalCount} событий · показано {visible.length} diff --git a/apps/web/src/components/reui-kit/user-audit-sheet.tsx b/apps/web/src/components/reui-kit/user-audit-sheet.tsx index 9ce1eab..4610786 100644 --- a/apps/web/src/components/reui-kit/user-audit-sheet.tsx +++ b/apps/web/src/components/reui-kit/user-audit-sheet.tsx @@ -1,17 +1,25 @@ /** - * User-scoped audit Sheet — chrome DNA solution-users-1 MemberDetailSheet. - * Preview: https://reui.io/preview/base/solution-users-1 + * User-scoped audit Sheet — chrome DNA solution-users-1 / solution-users-2. + * Preview: https://reui.io/preview/base/solution-users-1 · https://reui.io/preview/base/solution-users-2 * Timeline: https://reui.io/preview/base/solution-users-6 */ -import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { AdminUser } from '@authportal/shared' -import { XIcon } from 'lucide-react' +import { toast } from 'sonner' +import { Trash2Icon, XIcon } from 'lucide-react' import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline' +import { formatEventTime } from '@/components/reui-kit/audit-log-helpers' import { auditQueryOptions } from '@/queries/audit' -import { ApiError } from '@/lib/api-client' +import { + sessionsListQueryKey, + sessionsQueryOptions, +} from '@/queries/sessions' +import { api, ApiError } from '@/lib/api-client' import { Button } from '@authportal/ui/components/button' import { ScrollArea } from '@authportal/ui/components/scroll-area' import { Skeleton } from '@authportal/ui/components/skeleton' +import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Sheet, SheetClose, @@ -32,12 +40,71 @@ export function UserAuditSheet({ open: boolean onOpenChange: (open: boolean) => void }) { + const queryClient = useQueryClient() const userId = user?.id - const { data: entries = [], isLoading, error, refetch } = useQuery({ - ...auditQueryOptions({ userId, limit: 200 }), - enabled: open && Boolean(userId), + const email = user?.email + const [tab, setTab] = useState<'logins' | 'changes' | 'sessions'>('logins') + + const loginsQuery = useQuery({ + ...auditQueryOptions({ + userId, + actorEmail: email, + kind: 'logins', + limit: 200, + }), + enabled: open && Boolean(userId) && tab === 'logins', }) + const changesQuery = useQuery({ + ...auditQueryOptions({ userId, kind: 'changes', limit: 200 }), + enabled: open && Boolean(userId) && tab === 'changes', + }) + + const sessionsQuery = useQuery({ + ...sessionsQueryOptions({ userId }), + enabled: open && Boolean(userId) && tab === 'sessions', + }) + + const revokeAll = useMutation({ + mutationFn: () => + api.post<{ revoked: number }>( + `/api/v1/admin/users/${userId}/sessions/revoke-all`, + ), + onSuccess: async (data) => { + await queryClient.invalidateQueries({ + queryKey: sessionsListQueryKey(userId), + }) + toast.success(`Отозвано сессий: ${data.revoked}`) + }, + onError: (err) => { + toast.error( + err instanceof ApiError ? err.message : 'Не удалось отозвать сессии', + ) + }, + }) + + const revokeOne = useMutation({ + mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: sessionsListQueryKey(userId), + }) + toast.success('Сессия отозвана') + }, + onError: (err) => { + toast.error( + err instanceof ApiError ? err.message : 'Не удалось отозвать сессию', + ) + }, + }) + + const active = + tab === 'logins' + ? loginsQuery + : tab === 'changes' + ? changesQuery + : sessionsQuery + return ( - {user?.email ?? 'События пользователя (актор или цель)'} + {user?.email ?? 'События пользователя'} + {user?.last_login_ip + ? ` · последний IP ${user.last_login_ip}` + : ''} +

+ { + if (v === 'logins' || v === 'changes' || v === 'sessions') { + setTab(v) + } + }} + > + + + Входы + + + Изменения + + + Сессии + + + +
- {error ? ( + {active.error ? (

- {error instanceof ApiError - ? error.message + {active.error instanceof ApiError + ? active.error.message : 'Ошибка загрузки'}

- ) : isLoading ? ( + ) : active.isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
+ ) : tab === 'sessions' ? ( +
+
+ +
+ {(sessionsQuery.data ?? []).length === 0 ? ( +

+ Нет активных сессий +

+ ) : ( + (sessionsQuery.data ?? []).map((s) => ( +
+
+ + {s.ip ?? '—'} + + +
+

+ {s.user_agent ?? '—'} +

+

+ {formatEventTime(s.created_at)} · до{' '} + {formatEventTime(s.expires_at)} +

+
+ )) + )} +
) : ( diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index bbcd42a..7e73979 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -123,6 +123,22 @@ export async function ssoHandoffReturnTo(returnTo: string): Promise { const allowlist = await ensureReturnToAllowlist() if (!isReturnToAllowed(returnTo, allowlist)) return false const issued = await reissueAccessToken() + try { + const token = getToken() + if (token) { + void fetch('/api/v1/auth/sso-access', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ return_to: returnTo }), + keepalive: true, + }).catch(() => undefined) + } + } catch { + /* best-effort audit */ + } window.location.href = buildSsoRedirectUrl( returnTo, issued.access_token, diff --git a/apps/web/src/queries/audit.ts b/apps/web/src/queries/audit.ts index feffcd3..2a3cf17 100644 --- a/apps/web/src/queries/audit.ts +++ b/apps/web/src/queries/audit.ts @@ -1,5 +1,6 @@ import { queryOptions } from '@tanstack/react-query' import type { + AuditKind, AuditLogEntry, AuditSettings, AuditSourceApp, @@ -11,29 +12,39 @@ export const auditSettingsQueryKey = ['admin', 'audit', 'settings'] as const export function auditListQueryKey(opts?: { userId?: string + actorEmail?: string sourceApp?: AuditSourceApp | 'all' + kind?: AuditKind }) { return [ ...auditQueryKey, 'list', opts?.userId ?? null, + opts?.actorEmail ?? null, opts?.sourceApp ?? 'all', + opts?.kind ?? null, ] as const } export function auditQueryOptions(opts?: { userId?: string + actorEmail?: string sourceApp?: AuditSourceApp + kind?: AuditKind limit?: number }) { const params = new URLSearchParams() params.set('limit', String(opts?.limit ?? 200)) if (opts?.userId) params.set('user_id', opts.userId) + if (opts?.actorEmail) params.set('actor_email', opts.actorEmail) if (opts?.sourceApp) params.set('source_app', opts.sourceApp) + if (opts?.kind) params.set('kind', opts.kind) return queryOptions({ queryKey: auditListQueryKey({ userId: opts?.userId, + actorEmail: opts?.actorEmail, sourceApp: opts?.sourceApp ?? 'all', + kind: opts?.kind, }), queryFn: () => api.get(`/api/v1/admin/audit?${params.toString()}`), diff --git a/apps/web/src/queries/sessions.ts b/apps/web/src/queries/sessions.ts new file mode 100644 index 0000000..6ac62e1 --- /dev/null +++ b/apps/web/src/queries/sessions.ts @@ -0,0 +1,22 @@ +import { queryOptions } from '@tanstack/react-query' +import type { AdminSession } from '@authportal/shared' +import { api } from '@/lib/api-client' + +export const sessionsQueryKey = ['admin', 'sessions'] as const + +export function sessionsListQueryKey(userId?: string) { + return [...sessionsQueryKey, 'list', userId ?? null] as const +} + +export function sessionsQueryOptions(opts?: { userId?: string }) { + const params = new URLSearchParams() + if (opts?.userId) params.set('user_id', opts.userId) + const qs = params.toString() + return queryOptions({ + queryKey: sessionsListQueryKey(opts?.userId), + queryFn: () => + api.get( + `/api/v1/admin/sessions${qs ? `?${qs}` : ''}`, + ), + }) +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 1eab6f7..a3b83ed 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as AuthAppsRouteImport } from './routes/_auth.apps' import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index' import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps' import { Route as AuthAdminAuditRouteImport } from './routes/_auth.admin.audit' +import { Route as AuthAdminLoginsRouteImport } from './routes/_auth.admin.logins' import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId' const IndexRoute = IndexRouteImport.update({ @@ -58,6 +59,11 @@ const AuthAdminAuditRoute = AuthAdminAuditRouteImport.update({ path: '/audit', getParentRoute: () => AuthAdminRoute, } as any) +const AuthAdminLoginsRoute = AuthAdminLoginsRouteImport.update({ + id: '/logins', + path: '/logins', + getParentRoute: () => AuthAdminRoute, +} as any) const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({ id: '/users/$userId', path: '/users/$userId', @@ -71,6 +77,7 @@ export interface FileRoutesByFullPath { '/apps': typeof AuthAppsRoute '/admin/apps': typeof AuthAdminAppsRoute '/admin/audit': typeof AuthAdminAuditRoute + '/admin/logins': typeof AuthAdminLoginsRoute '/admin/': typeof AuthAdminIndexRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute } @@ -80,6 +87,7 @@ export interface FileRoutesByTo { '/apps': typeof AuthAppsRoute '/admin/apps': typeof AuthAdminAppsRoute '/admin/audit': typeof AuthAdminAuditRoute + '/admin/logins': typeof AuthAdminLoginsRoute '/admin': typeof AuthAdminIndexRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute } @@ -92,6 +100,7 @@ export interface FileRoutesById { '/_auth/apps': typeof AuthAppsRoute '/_auth/admin/apps': typeof AuthAdminAppsRoute '/_auth/admin/audit': typeof AuthAdminAuditRoute + '/_auth/admin/logins': typeof AuthAdminLoginsRoute '/_auth/admin/': typeof AuthAdminIndexRoute '/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute } @@ -104,6 +113,7 @@ export interface FileRouteTypes { | '/apps' | '/admin/apps' | '/admin/audit' + | '/admin/logins' | '/admin/' | '/admin/users/$userId' fileRoutesByTo: FileRoutesByTo @@ -113,6 +123,7 @@ export interface FileRouteTypes { | '/apps' | '/admin/apps' | '/admin/audit' + | '/admin/logins' | '/admin' | '/admin/users/$userId' id: @@ -124,6 +135,7 @@ export interface FileRouteTypes { | '/_auth/apps' | '/_auth/admin/apps' | '/_auth/admin/audit' + | '/_auth/admin/logins' | '/_auth/admin/' | '/_auth/admin/users/$userId' fileRoutesById: FileRoutesById @@ -192,6 +204,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthAdminAuditRouteImport parentRoute: typeof AuthAdminRoute } + '/_auth/admin/logins': { + id: '/_auth/admin/logins' + path: '/logins' + fullPath: '/admin/logins' + preLoaderRoute: typeof AuthAdminLoginsRouteImport + parentRoute: typeof AuthAdminRoute + } '/_auth/admin/users/$userId': { id: '/_auth/admin/users/$userId' path: '/users/$userId' @@ -205,6 +224,7 @@ declare module '@tanstack/react-router' { interface AuthAdminRouteChildren { AuthAdminAppsRoute: typeof AuthAdminAppsRoute AuthAdminAuditRoute: typeof AuthAdminAuditRoute + AuthAdminLoginsRoute: typeof AuthAdminLoginsRoute AuthAdminIndexRoute: typeof AuthAdminIndexRoute AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute } @@ -212,6 +232,7 @@ interface AuthAdminRouteChildren { const AuthAdminRouteChildren: AuthAdminRouteChildren = { AuthAdminAppsRoute: AuthAdminAppsRoute, AuthAdminAuditRoute: AuthAdminAuditRoute, + AuthAdminLoginsRoute: AuthAdminLoginsRoute, AuthAdminIndexRoute: AuthAdminIndexRoute, AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute, } diff --git a/apps/web/src/routes/_auth.admin.audit.tsx b/apps/web/src/routes/_auth.admin.audit.tsx index 0d1aa47..59ddb45 100644 --- a/apps/web/src/routes/_auth.admin.audit.tsx +++ b/apps/web/src/routes/_auth.admin.audit.tsx @@ -30,7 +30,7 @@ function AdminAuditPage() { isLoading, error, refetch, - } = useQuery(auditQueryOptions()) + } = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 })) const { data: settings } = useQuery(auditSettingsQueryOptions) const saveMutation = useMutation({ diff --git a/apps/web/src/routes/_auth.admin.logins.tsx b/apps/web/src/routes/_auth.admin.logins.tsx new file mode 100644 index 0000000..801316d --- /dev/null +++ b/apps/web/src/routes/_auth.admin.logins.tsx @@ -0,0 +1,547 @@ +/** + * Admin logins journal — history DataGrid + active sessions. + * Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/stats-12 + * Sessions DNA: https://reui.io/preview/base/solution-users-2 + */ +import { useMemo, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type ColumnDef, + type PaginationState, + type SortingState, + useReactTable, +} from '@tanstack/react-table' +import { toast } from 'sonner' +import type { AdminSession, AuditLogEntry } from '@authportal/shared' +import { + KeyRoundIcon, + LogInIcon, + ShieldAlertIcon, + Trash2Icon, +} from 'lucide-react' +import { PageShell } from '@/components/page-shell' +import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' +import { + actionMeta, + detailString, + formatEventTime, + matchesRange, + sourceAppLabel, + type AuditRange, +} from '@/components/reui-kit/audit-log-helpers' +import { Badge } from '@/components/reui/badge' +import { DataGrid } from '@/components/reui/data-grid/data-grid' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' +import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' +import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { + Frame, + FrameFooter, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { auditQueryOptions } from '@/queries/audit' +import { + sessionsListQueryKey, + sessionsQueryOptions, +} from '@/queries/sessions' +import { api, ApiError } from '@/lib/api-client' +import { Button } from '@authportal/ui/components/button' +import { Skeleton } from '@authportal/ui/components/skeleton' +import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@authportal/ui/components/select' +import { RANGE_OPTIONS } from '@/components/reui-kit/audit-log-helpers' + +export const Route = createFileRoute('/_auth/admin/logins')({ + component: AdminLoginsPage, +}) + +type ResultFilter = 'all' | 'success' | 'failed' + +function resultVariant(action: string) { + if (action === 'auth.login_failed') return 'warning-outline' as const + if (action === 'auth.sso_handoff') return 'info-outline' as const + return 'success-outline' as const +} + +function AdminLoginsPage() { + const queryClient = useQueryClient() + const [tab, setTab] = useState<'history' | 'sessions'>('history') + const [resultFilter, setResultFilter] = useState('all') + const [range, setRange] = useState('7d') + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 20, + }) + const [sorting, setSorting] = useState([ + { id: 'created_at', desc: true }, + ]) + + const { + data: entries = [], + isLoading, + error, + refetch, + } = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 })) + + const { + data: sessions = [], + isLoading: sessionsLoading, + error: sessionsError, + refetch: refetchSessions, + } = useQuery(sessionsQueryOptions()) + + const revokeMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: sessionsListQueryKey() }) + toast.success('Сессия отозвана') + }, + onError: (err) => { + toast.error( + err instanceof ApiError ? err.message : 'Не удалось отозвать сессию', + ) + }, + }) + + const filtered = useMemo(() => { + return entries.filter((e) => { + if (!matchesRange(e, range)) return false + if (resultFilter === 'success') { + return e.action === 'auth.login' || e.action === 'auth.sso_handoff' + } + if (resultFilter === 'failed') return e.action === 'auth.login_failed' + return true + }) + }, [entries, range, resultFilter]) + + const kpi = useMemo(() => { + const day = entries.filter((e) => matchesRange(e, '24h')) + const ok = day.filter( + (e) => e.action === 'auth.login' || e.action === 'auth.sso_handoff', + ).length + const fail = day.filter((e) => e.action === 'auth.login_failed').length + const ips = new Set( + day.map((e) => e.ip).filter((ip): ip is string => Boolean(ip)), + ) + return { ok, fail, ips: ips.size, sessions: sessions.length } + }, [entries, sessions.length]) + + const historyColumns = useMemo[]>( + () => [ + { + accessorKey: 'created_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatEventTime(row.original.created_at)} + + ), + size: 140, + }, + { + id: 'result', + accessorFn: (r) => r.action, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {actionMeta(row.original.action).label} + + ), + size: 140, + }, + { + accessorKey: 'actor_email', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.actor_email ?? '—'} + + ), + size: 200, + }, + { + accessorKey: 'ip', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.ip ?? '—'} + + ), + size: 130, + }, + { + id: 'ua', + accessorFn: (r) => detailString(r.details, 'user_agent') ?? '', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const ua = detailString(row.original.details, 'user_agent') + return ( + + {ua ?? '—'} + + ) + }, + size: 220, + }, + { + id: 'app', + accessorFn: (r) => + detailString(r.details, 'target_app') ?? r.source_app, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const app = + detailString(row.original.details, 'target_app') ?? + row.original.source_app + return ( + + {sourceAppLabel(app)} + + ) + }, + size: 120, + }, + { + id: 'reason', + accessorFn: (r) => detailString(r.details, 'reason') ?? '', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {detailString(row.original.details, 'reason') ?? '—'} + + ), + size: 120, + }, + ], + [], + ) + + const historyTable = useReactTable({ + data: filtered, + columns: historyColumns, + state: { pagination, sorting }, + onPaginationChange: setPagination, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + const sessionColumns = useMemo[]>( + () => [ + { + accessorKey: 'email', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + {row.original.name} + + + {row.original.email} + +
+ ), + size: 200, + }, + { + accessorKey: 'ip', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.ip ?? '—'} + + ), + size: 130, + }, + { + accessorKey: 'user_agent', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.user_agent ?? '—'} + + ), + size: 240, + }, + { + accessorKey: 'created_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatEventTime(row.original.created_at)} + + ), + size: 140, + }, + { + accessorKey: 'expires_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatEventTime(row.original.expires_at)} + + ), + size: 140, + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( + + ), + size: 120, + }, + ], + [revokeMutation], + ) + + const [sessionPagination, setSessionPagination] = useState({ + pageIndex: 0, + pageSize: 20, + }) + const sessionsTable = useReactTable({ + data: sessions, + columns: sessionColumns, + state: { pagination: sessionPagination }, + onPaginationChange: setSessionPagination, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + return ( + +
+ , + iconClassName: 'text-success', + hint: 'Вход и SSO', + }, + { + id: 'fail', + label: 'Неудачи (24ч)', + value: kpi.fail, + icon: , + iconClassName: 'text-warning', + variant: kpi.fail > 0 ? 'warning' : 'default', + }, + { + id: 'ips', + label: 'Уникальные IP (24ч)', + value: kpi.ips, + icon: , + }, + { + id: 'sessions', + label: 'Активные сессии', + value: kpi.sessions, + icon: , + onSelect: () => setTab('sessions'), + }, + ]} + /> + + { + if (v === 'history' || v === 'sessions') setTab(v) + }} + > + + + История + + + Активные сессии + + {sessions.length} + + + + + + {tab === 'history' ? ( +
+
+ { + if (v === 'all' || v === 'success' || v === 'failed') { + setResultFilter(v) + } + }} + > + + + Все + + + Успешные + + + Неудачи + + + + +
+ + {error ? ( +
+

+ {error instanceof ApiError ? error.message : 'Ошибка загрузки'} +

+ +
+ ) : isLoading ? ( + + ) : ( + + + + Журнал входов + + + + + + + + + + + + )} +
+ ) : sessionsError ? ( +
+

+ {sessionsError instanceof ApiError + ? sessionsError.message + : 'Ошибка загрузки'} +

+ +
+ ) : sessionsLoading ? ( + + ) : ( + + + + Активные сессии + + + + + + + + + + + + )} +
+
+ ) +} diff --git a/packages/db/src/audit-log.ts b/packages/db/src/audit-log.ts index 2706da5..71da18f 100644 --- a/packages/db/src/audit-log.ts +++ b/packages/db/src/audit-log.ts @@ -1,7 +1,8 @@ -import { and, desc, eq, lt, or, sql } from 'drizzle-orm' +import { and, desc, eq, like, lt, notLike, or, sql } from 'drizzle-orm' import { randomUUID } from 'node:crypto' import { DEFAULT_AUDIT_RETENTION_DAYS, + type AuditKind, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, @@ -99,7 +100,9 @@ export function listAudit( action?: string severity?: AuditSeverity userId?: string + actorEmail?: string sourceApp?: AuditSourceApp + kind?: AuditKind limit?: number } = {}, ): AuditLogEntry[] { @@ -108,7 +111,22 @@ export function listAudit( if (opts.action) conditions.push(eq(auditLog.action, opts.action)) if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity)) if (opts.sourceApp) conditions.push(eq(auditLog.sourceApp, opts.sourceApp)) - if (opts.userId) { + if (opts.kind === 'logins') { + conditions.push(like(auditLog.action, 'auth.%')) + } else if (opts.kind === 'changes') { + conditions.push(notLike(auditLog.action, 'auth.%')) + } + if (opts.actorEmail && opts.userId) { + conditions.push( + or( + eq(auditLog.actorEmail, opts.actorEmail.toLowerCase()), + eq(auditLog.actorUserId, opts.userId), + eq(auditLog.targetId, opts.userId), + )!, + ) + } else if (opts.actorEmail) { + conditions.push(eq(auditLog.actorEmail, opts.actorEmail.toLowerCase())) + } else if (opts.userId) { conditions.push( or( eq(auditLog.actorUserId, opts.userId), diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index f8eac75..608b42c 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -38,6 +38,7 @@ export function migrateSchema(sqlite: Sqlite): void { is_admin INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, last_login_at TEXT, + last_login_ip TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -58,6 +59,8 @@ export function migrateSchema(sqlite: Sqlite): void { token_hash TEXT NOT NULL, expires_at TEXT NOT NULL, revoked_at TEXT, + ip TEXT, + user_agent TEXT, created_at TEXT NOT NULL ); @@ -110,6 +113,19 @@ export function migrateSchema(sqlite: Sqlite): void { if (!userCols.some((c) => c.name === 'last_login_at')) { sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_at TEXT`) } + if (!userCols.some((c) => c.name === 'last_login_ip')) { + sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_ip TEXT`) + } + + const sessionCols = sqlite + .prepare(`PRAGMA table_info(refresh_sessions)`) + .all() as Array<{ name: string }> + if (!sessionCols.some((c) => c.name === 'ip')) { + sqlite.exec(`ALTER TABLE refresh_sessions ADD COLUMN ip TEXT`) + } + if (!sessionCols.some((c) => c.name === 'user_agent')) { + sqlite.exec(`ALTER TABLE refresh_sessions ADD COLUMN user_agent TEXT`) + } const auditCols = sqlite .prepare(`PRAGMA table_info(audit_log)`) diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 05ba5b7..d38732a 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -8,6 +8,7 @@ export const users = sqliteTable('users', { isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), disabled: integer('disabled', { mode: 'boolean' }).notNull().default(false), lastLoginAt: text('last_login_at'), + lastLoginIp: text('last_login_ip'), createdAt: text('created_at').notNull(), updatedAt: text('updated_at').notNull(), }) @@ -34,6 +35,8 @@ export const refreshSessions = sqliteTable('refresh_sessions', { tokenHash: text('token_hash').notNull(), expiresAt: text('expires_at').notNull(), revokedAt: text('revoked_at'), + ip: text('ip'), + userAgent: text('user_agent'), createdAt: text('created_at').notNull(), }) diff --git a/packages/db/src/users.ts b/packages/db/src/users.ts index 3a876a7..85b072a 100644 --- a/packages/db/src/users.ts +++ b/packages/db/src/users.ts @@ -130,10 +130,18 @@ export function setUserAccess( .run() } -export function touchLastLogin(db: AppDb, userId: string): void { +export function touchLastLogin( + db: AppDb, + userId: string, + ip?: string | null, +): void { const now = new Date().toISOString() db.update(users) - .set({ lastLoginAt: now, updatedAt: now }) + .set({ + lastLoginAt: now, + lastLoginIp: ip ?? null, + updatedAt: now, + }) .where(eq(users.id, userId)) .run() } @@ -143,17 +151,22 @@ export function createRefreshSession( userId: string, rawToken: string, expiresAt: Date, -): void { + meta?: { ip?: string | null; userAgent?: string | null }, +): string { + const id = randomUUID() db.insert(refreshSessions) .values({ - id: randomUUID(), + id, userId, tokenHash: hashToken(rawToken), expiresAt: expiresAt.toISOString(), revokedAt: null, + ip: meta?.ip ?? null, + userAgent: meta?.userAgent ?? null, createdAt: new Date().toISOString(), }) .run() + return id } export function revokeRefreshSession(db: AppDb, rawToken: string): void { @@ -163,3 +176,81 @@ export function revokeRefreshSession(db: AppDb, rawToken: string): void { .where(eq(refreshSessions.tokenHash, hashToken(rawToken))) .run() } + +export type ActiveSessionRow = { + id: string + userId: string + email: string + name: string + ip: string | null + userAgent: string | null + createdAt: string + expiresAt: string +} + +export function listActiveSessions( + db: AppDb, + opts: { userId?: string } = {}, +): ActiveSessionRow[] { + const now = new Date().toISOString() + const rows = db + .select({ + id: refreshSessions.id, + userId: refreshSessions.userId, + email: users.email, + name: users.name, + ip: refreshSessions.ip, + userAgent: refreshSessions.userAgent, + createdAt: refreshSessions.createdAt, + expiresAt: refreshSessions.expiresAt, + revokedAt: refreshSessions.revokedAt, + }) + .from(refreshSessions) + .innerJoin(users, eq(refreshSessions.userId, users.id)) + .all() + + return rows + .filter((r) => { + if (r.revokedAt) return false + if (r.expiresAt < now) return false + if (opts.userId && r.userId !== opts.userId) return false + return true + }) + .map((r) => ({ + id: r.id, + userId: r.userId, + email: r.email, + name: r.name, + ip: r.ip, + userAgent: r.userAgent, + createdAt: r.createdAt, + expiresAt: r.expiresAt, + })) + .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) +} + +export function revokeSessionById(db: AppDb, sessionId: string): boolean { + const row = db + .select() + .from(refreshSessions) + .where(eq(refreshSessions.id, sessionId)) + .get() + if (!row || row.revokedAt) return false + db.update(refreshSessions) + .set({ revokedAt: new Date().toISOString() }) + .where(eq(refreshSessions.id, sessionId)) + .run() + return true +} + +export function revokeAllSessionsForUser(db: AppDb, userId: string): number { + const now = new Date().toISOString() + const active = listActiveSessions(db, { userId }) + for (const s of active) { + db.update(refreshSessions) + .set({ revokedAt: now }) + .where(eq(refreshSessions.id, s.id)) + .run() + } + return active.length +} diff --git a/packages/shared/src/contracts/audit.ts b/packages/shared/src/contracts/audit.ts index d122b1c..0a3071e 100644 --- a/packages/shared/src/contracts/audit.ts +++ b/packages/shared/src/contracts/audit.ts @@ -29,6 +29,7 @@ export const AUDIT_ACTIONS = [ 'auth.login', 'auth.login_failed', 'auth.logout', + 'auth.sso_handoff', 'user.create', 'user.update', 'user.delete', @@ -40,6 +41,17 @@ export const AUDIT_ACTIONS = [ export type AuditAction = (typeof AUDIT_ACTIONS)[number] export const auditActionSchema = z.enum(AUDIT_ACTIONS) +export const AUDIT_KINDS = ['logins', 'changes'] as const +export type AuditKind = (typeof AUDIT_KINDS)[number] +export const auditKindSchema = z.enum(AUDIT_KINDS) + +export const AUTH_AUDIT_ACTIONS = [ + 'auth.login', + 'auth.login_failed', + 'auth.logout', + 'auth.sso_handoff', +] as const + export const auditLogEntrySchema = z.object({ id: z.string(), event_id: z.string().nullable(), @@ -62,7 +74,9 @@ export const auditListQuerySchema = z.object({ action: z.string().optional(), severity: auditSeveritySchema.optional(), user_id: z.string().optional(), + actor_email: z.string().email().optional(), source_app: auditSourceAppSchema.optional(), + kind: auditKindSchema.optional(), limit: z.coerce.number().int().min(1).max(500).default(200), }) export type AuditListQuery = z.infer diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index cb90f7f..9fed67e 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -371,11 +371,29 @@ export const adminUserSchema = z.object({ apps: z.array(appIdSchema), permissions: z.array(z.string()), last_login_at: z.string().nullable(), + last_login_ip: z.string().nullable(), created_at: z.string(), updated_at: z.string(), }) export type AdminUser = z.infer +export const adminSessionSchema = z.object({ + id: z.string(), + user_id: z.string(), + email: z.string().email(), + name: z.string(), + ip: z.string().nullable(), + user_agent: z.string().nullable(), + created_at: z.string(), + expires_at: z.string(), +}) +export type AdminSession = z.infer + +export const ssoAccessRequestSchema = z.object({ + return_to: z.string().url(), +}) +export type SsoAccessRequest = z.infer + export const createUserRequestSchema = z.object({ email: z.string().email(), name: z.string().min(1),