feat(admin): журналы входов и изменений с IP/UA и сессиями
Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip; SSO handoff и revoke refresh-сессий; улучшены audit-карточки. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)`)
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user