feat(admin): журналы входов и изменений с IP/UA и сессиями
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m57s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip;
SSO handoff и revoke refresh-сессий; улучшены audit-карточки.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-31 03:12:39 +07:00
co-authored by Cursor
parent 8b1203f5e9
commit cc38bf06f8
24 changed files with 1431 additions and 69 deletions
+1
View File
@@ -92,6 +92,7 @@ export async function buildApp(opts: {
const app = Fastify({
logger: { level: config.logLevel },
trustProxy: true,
})
app.decorate('config', config)
+43
View File
@@ -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, string | string[] | undefined>,
): string | null {
const ua = headers['user-agent']
if (typeof ua === 'string' && ua.trim()) return ua.trim().slice(0, 512)
return null
}
+65
View File
@@ -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<void> {
})
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 }
},
)
}
+2
View File
@@ -30,7 +30,9 @@ export async function auditAdminRoutes(app: FastifyInstance): Promise<void> {
action: q.action,
severity: q.severity,
userId: q.user_id,
actorEmail: q.actor_email,
sourceApp: q.source_app,
kind: q.kind,
limit: q.limit,
})
})
+80 -16
View File
@@ -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<void> {
})
}
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
},
)
/** 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<void> {
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<void> {
},
)
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,
}
})
}
+140
View File
@@ -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: '[email protected]',
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 === '[email protected]')
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<string, unknown> | 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: '[email protected]', 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&[email protected]',
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('[email protected]')
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()
})
})