feat(admin): журналы входов и изменений с IP/UA и сессиями
Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip; SSO handoff и revoke refresh-сессий; улучшены audit-карточки. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
8b1203f5e9
commit
cc38bf06f8
24 files changed
+1431
-69
No files matched your search
@@ -92,6 +92,7 @@ export async function buildApp(opts: {
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: config.logLevel },
|
||||
trustProxy: true,
|
||||
})
|
||||
|
||||
app.decorate('config', config)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user