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
+20 -2
View File
@@ -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),
+16
View File
@@ -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)`)
+3
View File
@@ -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(),
})
+95 -4
View File
@@ -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
}
+14
View File
@@ -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<typeof auditListQuerySchema>
+18
View File
@@ -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<typeof adminUserSchema>
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<typeof adminSessionSchema>
export const ssoAccessRequestSchema = z.object({
return_to: z.string().url(),
})
export type SsoAccessRequest = z.infer<typeof ssoAccessRequestSchema>
export const createUserRequestSchema = z.object({
email: z.string().email(),
name: z.string().min(1),