Audit logs

This commit is contained in:
Evert Prants 2024-06-10 20:20:25 +03:00
parent af9c70f169
commit 63f54c3338
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
28 changed files with 708 additions and 63 deletions

View File

@ -8,12 +8,18 @@
$: firstPage = pageNum === 1; $: firstPage = pageNum === 1;
$: lastPage = pageNum === meta.pageCount; $: lastPage = pageNum === meta.pageCount;
$: pageButtons = Array.from({ length: meta.pageCount }, (_, i) => i + 1); $: pageButtons = Array.from({ length: meta.pageCount }, (_, i) => i + 1);
const makePageUrl = (params: URLSearchParams, pageNumber: number) => {
const searchParams = new URLSearchParams(params);
searchParams.set('page', String(pageNumber));
return searchParams.toString();
};
</script> </script>
<nav class="pager"> <nav class="pager">
<a <a
class="page-button page-prev {firstPage ? 'disabled' : ''}" class="page-button page-prev {firstPage ? 'disabled' : ''}"
href={`?page=${pageNum - 1}`} href={`?${makePageUrl($page.url.searchParams, pageNum - 1)}`}
tabindex={firstPage ? -1 : 0} tabindex={firstPage ? -1 : 0}
aria-label={$t('common.previous')} aria-label={$t('common.previous')}
aria-disabled={firstPage}>&lt;</a aria-disabled={firstPage}>&lt;</a
@ -23,7 +29,7 @@
{@const active = buttonNumber === pageNum} {@const active = buttonNumber === pageNum}
<a <a
class="page-button page-link {active ? 'disabled' : ''}" class="page-button page-link {active ? 'disabled' : ''}"
href={`?page=${buttonNumber}`} href={`?${makePageUrl($page.url.searchParams, buttonNumber)}`}
tabindex={active ? -1 : 0} tabindex={active ? -1 : 0}
aria-label={`${$t('common.page')} ${buttonNumber}`} aria-label={`${$t('common.page')} ${buttonNumber}`}
aria-disabled={active}>{buttonNumber}</a aria-disabled={active}>{buttonNumber}</a
@ -33,7 +39,7 @@
<a <a
class="page-button page-prev {lastPage ? 'disabled' : ''}" class="page-button page-prev {lastPage ? 'disabled' : ''}"
tabindex={lastPage ? -1 : 0} tabindex={lastPage ? -1 : 0}
href={`?page=${pageNum + 1}`} href={`?${makePageUrl($page.url.searchParams, pageNum + 1)}`}
aria-label={$t('common.next')} aria-label={$t('common.next')}
aria-disabled={lastPage}>&gt;</a aria-disabled={lastPage}>&gt;</a
> >

View File

@ -0,0 +1,84 @@
<script lang="ts">
import { t } from '$lib/i18n';
import type { PageData } from '../../../routes/ssoadmin/audit/$types';
import Button from '../Button.svelte';
import Icon from '../icons/Icon.svelte';
import AdminDateTime from './AdminDateTime.svelte';
export let audit: PageData['list'][0];
let expanded = false;
</script>
<div class="audit-log{expanded ? ' expanded' : ''}{audit.flagged ? ' flagged' : ''}">
<div class="audit-log-title">
<span class="audit-action">{audit.action}</span>
<span class="audit-stamp"><AdminDateTime date={audit.created_at} /></span>
<Button on:click={() => (expanded = !expanded)}><Icon icon="ChevronDown" /></Button>
</div>
{#if expanded}
<dl>
<dt>{$t('admin.audit.action')}</dt>
<dd>{audit.action}</dd>
{#if audit.user}
<dt>{$t('admin.audit.user')}</dt>
<dd>{audit.user.uuid} ({audit.user.name})</dd>
{/if}
{#if audit.ip}
<dt>{$t('admin.audit.ip')}</dt>
<dd>{audit.ip}</dd>
{/if}
{#if audit.ua}
<dt>{$t('admin.audit.ua')}</dt>
<dd>{audit.ua}</dd>
{/if}
<dt>{$t('admin.audit.createdAt')}</dt>
<dd><AdminDateTime date={audit.created_at} /></dd>
{#if audit.content}
<dt>{$t('admin.audit.comment')}</dt>
<dd><pre>{audit.content}</pre></dd>
{/if}
</dl>
{/if}
</div>
<style>
.audit-log {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 8px;
background-color: var(--ina-card-background);
border-radius: 8px;
box-shadow: var(--ina-card-shadow);
flex-grow: 1;
&.flagged {
background-color: var(--in-error-color);
}
& > dl {
margin: 0;
& > dt {
font-weight: 600;
margin-top: 0.5rem;
}
& > dd > pre {
margin: 0;
}
}
}
.audit-log-title {
display: flex;
align-items: center;
justify-content: space-between;
flex-grow: 1;
}
.audit-log.expanded :global(svg) {
transform: rotate(180deg);
}
</style>

View File

@ -1,11 +1,9 @@
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import type { PageData } from '../../../routes/ssoadmin/oauth2/$types'; import type { PageData } from '../../../routes/ssoadmin/oauth2/$types';
import AdminDateTime from './AdminDateTime.svelte';
export let client: PageData['list'][0]; export let client: PageData['list'][0];
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeStyle: 'medium' });
const formatDate = dateFormat.format.bind(null);
</script> </script>
<div class="client"> <div class="client">
@ -37,7 +35,7 @@
<dd>{$t(`common.bool.${Boolean(client.confidential)}`)}</dd> <dd>{$t(`common.bool.${Boolean(client.confidential)}`)}</dd>
<dt>{$t('admin.oauth2.created')}</dt> <dt>{$t('admin.oauth2.created')}</dt>
<dd>{formatDate(client.created_at)}</dd> <dd><AdminDateTime date={client.created_at} /></dd>
<dt>{$t('admin.oauth2.owner')}</dt> <dt>{$t('admin.oauth2.owner')}</dt>
<dd> <dd>

View File

@ -0,0 +1,10 @@
<script lang="ts">
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeStyle: 'medium' });
const formatDate = dateFormat.format.bind(null);
export let date: Date;
$: dateStr = formatDate(date);
</script>
{dateStr}

View File

@ -1,11 +1,9 @@
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import AdminDateTime from './AdminDateTime.svelte';
import type { PageData } from '../../../routes/ssoadmin/users/$types'; import type { PageData } from '../../../routes/ssoadmin/users/$types';
export let user: PageData['list'][0]; export let user: PageData['list'][0];
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeStyle: 'medium' });
const formatDate = dateFormat.format.bind(null);
</script> </script>
<div class="user"> <div class="user">
@ -33,7 +31,7 @@
<dd>{$t(`common.bool.${Boolean(user.activated)}`)}</dd> <dd>{$t(`common.bool.${Boolean(user.activated)}`)}</dd>
<dt>{$t('admin.users.registered')}</dt> <dt>{$t('admin.users.registered')}</dt>
<dd>{formatDate(user.created_at)}</dd> <dd><AdminDateTime date={user.created_at} /></dd>
</dl> </dl>
</div> </div>
</div> </div>

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"
><path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z" /></svg
>

After

Width:  |  Height:  |  Size: 141 B

View File

@ -133,5 +133,14 @@
"noFile": "Please upload a file first.", "noFile": "Please upload a file first.",
"tooManyTimes": "You are doing that too much, please, slow down!" "tooManyTimes": "You are doing that too much, please, slow down!"
} }
},
"audit": {
"title": "Audit logs",
"action": "Action",
"comment": "Comment",
"user": "Actor",
"ip": "IP address",
"ua": "User agent",
"createdAt": "Created at"
} }
} }

View File

@ -0,0 +1,162 @@
import { SQL, count, desc, eq, inArray, or, sql } from 'drizzle-orm';
import { DB, auditLog, user, type AuditLog, type NewAuditLog, type User } from '../drizzle';
import {
AuditAction,
type AuditListItem,
type AuditSearchClause,
type MinimalRequestEvent
} from './types';
import type { Paginated, PaginationMeta } from '$lib/types';
const AUTOFLAG = [
AuditAction.MALICIOUS_REQUEST,
AuditAction.THROTTLE,
AuditAction.DEACTIVATION_REQUEST,
AuditAction.DATA_DOWNLOAD_REQUEST
];
export class Audit {
public static async insert(
action: AuditAction,
comment?: string,
user?: User,
ip?: string,
userAgent?: string
) {
const newAuditLog: NewAuditLog = {
action,
content: comment,
actorId: user?.id,
actor_ip: ip,
actor_ua: userAgent,
flagged: Number(AUTOFLAG.includes(action))
};
// TODO: send flagged to administrator
await DB.drizzle.insert(auditLog).values(newAuditLog);
}
public static async insertRequest(
action: AuditAction,
{ request, getClientAddress }: MinimalRequestEvent,
user?: User,
comment?: string
) {
const ip = getClientAddress();
const userAgent = request.headers.get('user-agent') || undefined;
return Audit.insert(action, comment, user, ip, userAgent);
}
public static async auditCount(search: AuditSearchClause) {
const [{ rowCount }] = await DB.drizzle
.select({ rowCount: count(auditLog.id).mapWith(Number) })
.from(auditLog)
.leftJoin(user, eq(user.id, auditLog.actorId))
.where(Audit.getAuditWhere(search));
return rowCount;
}
public static async searchAudit(
search: AuditSearchClause & {
limit?: number;
offset?: number;
}
) {
const limit = search.limit || 20;
const rowCount = await Audit.auditCount(search);
const auditSubquery = DB.drizzle
.select({ id: auditLog.id })
.from(auditLog)
.leftJoin(user, eq(user.id, auditLog.actorId))
.limit(limit)
.offset(search.offset || 0)
.where(Audit.getAuditWhere(search))
.orderBy(desc(auditLog.created_at))
.as('auditSubquery');
const junkList = await DB.drizzle
.select({
audit_log: auditLog,
user: user
})
.from(auditSubquery)
.innerJoin(auditLog, eq(auditLog.id, auditSubquery.id))
.leftJoin(user, eq(user.id, auditLog.actorId));
const list = Audit.mapAuditRows(junkList);
const meta: PaginationMeta = {
rowCount,
pageSize: limit,
pageCount: Math.ceil(rowCount / limit)
};
return { list, meta } satisfies Paginated<AuditListItem>;
}
private static getAuditWhere(search: AuditSearchClause) {
const selectList: SQL<unknown>[] = [];
if (search.actions) {
const actions = Array.isArray(search.actions)
? search.actions
: (search.actions as string).split(',');
selectList.push(inArray(auditLog.action, actions));
}
if (search.content) {
selectList.push(sql`lower(${auditLog.content}) LIKE ${`%${search.content.toLowerCase()}%`}`);
}
if (search.ip) {
selectList.push(sql`lower(${auditLog.actor_ip}) LIKE ${`%${search.ip.toLowerCase()}%`}`);
}
if (search.ua) {
selectList.push(sql`lower(${auditLog.actor_ua}) LIKE ${`%${search.ua.toLowerCase()}%`}`);
}
if (search.user) {
selectList.push(eq(user.uuid, search.user));
}
if (search.flagged) {
selectList.push(eq(auditLog.flagged, 1));
}
return or(...selectList);
}
private static mapAuditRows(
rows: {
audit_log: AuditLog;
user?: User | null;
}[]
) {
return rows.reduce<AuditListItem[]>((accum, entry) => {
let existingEntry = accum.find((e) => e.id === entry.audit_log.id);
if (!existingEntry) {
existingEntry = {
id: entry.audit_log.id,
action: entry.audit_log.action as AuditAction,
ip: entry.audit_log.actor_ip || undefined,
ua: entry.audit_log.actor_ua || undefined,
content: entry.audit_log.content || undefined,
flagged: Boolean(entry.audit_log.flagged),
created_at: entry.audit_log.created_at
};
accum.push(existingEntry);
}
if (entry.user) {
existingEntry.user = {
uuid: entry.user.uuid,
name: entry.user.display_name
};
}
return accum;
}, []);
}
}

View File

@ -0,0 +1,2 @@
export * from './types';
export * from './audit';

View File

@ -0,0 +1,46 @@
import type { RequestEvent } from '@sveltejs/kit';
export enum AuditAction {
LOGIN = 'login',
REGISTRATION = 'registration',
USER_UPDATE = 'user_update',
USER_DELETE = 'user_delete',
TOTP_ACTIVATE = 'totp_activate',
TOTP_DEACTIVATE = 'totp_deactivate',
PASSWORD_CHANGE = 'password_change',
EMAIL_CHANGE = 'email_change',
MALICIOUS_REQUEST = 'malicious_request',
THROTTLE = 'throttle',
DEACTIVATION_REQUEST = 'deactivation_request',
DATA_DOWNLOAD_REQUEST = 'data_download_request',
OAUTH2_CREATE = 'oauth2_create',
OAUTH2_DELETE = 'oauth2_delete',
OAUTH2_UPDATE = 'oauth2_update',
OAUTH2_REGENERATE = 'oauth2_regenerate',
OAUTH2_INVITE = 'oauth2_invite'
}
export type MinimalRequestEvent = Pick<RequestEvent, 'getClientAddress' | 'request'>;
export interface AuditSearchClause {
actions?: AuditAction[] | string;
user?: string;
ip?: string;
ua?: string;
content?: string;
flagged?: boolean;
}
export interface AuditListItem {
id: number;
action: AuditAction;
user?: {
uuid: string;
name: string;
};
ip?: string;
ua?: string;
content?: string;
flagged: boolean;
created_at: Date;
}

View File

@ -26,6 +26,9 @@ export const auditLog = mysqlTable('audit_log', {
actorId: int('actorId').references(() => user.id, { onDelete: 'set null' }) actorId: int('actorId').references(() => user.id, { onDelete: 'set null' })
}); });
export type AuditLog = typeof auditLog.$inferSelect;
export type NewAuditLog = typeof auditLog.$inferInsert;
export const document = mysqlTable('document', { export const document = mysqlTable('document', {
id: int('id').autoincrement().notNull(), id: int('id').autoincrement().notNull(),
title: text('title').notNull(), title: text('title').notNull(),

View File

@ -107,6 +107,7 @@ export class Users {
*/ */
static async toSession(user: User): Promise<UserSession> { static async toSession(user: User): Promise<UserSession> {
return { return {
sid: CryptoUtils.generateString(32),
uid: user.id, uid: user.id,
uuid: user.uuid, uuid: user.uuid,
name: user.display_name, name: user.display_name,
@ -217,8 +218,6 @@ export class Users {
await Users.sendRegistrationEmail(newUser); await Users.sendRegistrationEmail(newUser);
} }
// TODO: audit log
return newUser; return newUser;
} }

View File

@ -1,4 +1,5 @@
export interface UserSession { export interface UserSession {
sid: string;
uid: number; uid: number;
uuid: string; uuid: string;
name: string; name: string;

View File

@ -1,3 +1,5 @@
import { Audit } from '$lib/server/audit/audit.js';
import { AuditAction } from '$lib/server/audit/types.js';
import { Challenge } from '$lib/server/challenge.js'; import { Challenge } from '$lib/server/challenge.js';
import type { User } from '$lib/server/drizzle'; import type { User } from '$lib/server/drizzle';
import { Uploads } from '$lib/server/upload.js'; import { Uploads } from '$lib/server/upload.js';
@ -21,7 +23,7 @@ export const actions = {
await locals.session.destroy(); await locals.session.destroy();
return redirect(303, '/'); return redirect(303, '/');
}, },
update: async ({ request, locals }) => { update: async ({ request, locals, getClientAddress }) => {
const currentUser = await Users.getBySession(locals.session.data?.user); const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) { if (!currentUser) {
await locals.session.destroy(); await locals.session.destroy();
@ -123,10 +125,20 @@ export const actions = {
if (data.newEmail) { if (data.newEmail) {
updates.email = data.newEmail; updates.email = data.newEmail;
await Audit.insertRequest(
AuditAction.EMAIL_CHANGE,
{ request, getClientAddress },
currentUser
);
} }
if (data.newPassword) { if (data.newPassword) {
updates.password = await Users.hashPassword(data.newPassword); updates.password = await Users.hashPassword(data.newPassword);
await Audit.insertRequest(
AuditAction.PASSWORD_CHANGE,
{ request, getClientAddress },
currentUser
);
} }
await Users.update(currentUser, updates); await Users.update(currentUser, updates);
@ -138,7 +150,7 @@ export const actions = {
})); }));
} }
// TODO: audit log await Audit.insertRequest(AuditAction.USER_UPDATE, { request, getClientAddress }, currentUser);
return { return {
success: true, success: true,
@ -147,7 +159,7 @@ export const actions = {
displayName: data.displayName || currentUser.display_name displayName: data.displayName || currentUser.display_name
}; };
}, },
avatar: async ({ request, locals }) => { avatar: async ({ request, locals, getClientAddress }) => {
const currentUser = await Users.getBySession(locals.session.data?.user); const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) { if (!currentUser) {
await locals.session.destroy(); await locals.session.destroy();
@ -165,10 +177,16 @@ export const actions = {
const { file } = formData as { file: File }; const { file } = formData as { file: File };
await Uploads.saveAvatar(currentUser, file); await Uploads.saveAvatar(currentUser, file);
await Audit.insertRequest(
AuditAction.USER_UPDATE,
{ request, getClientAddress },
currentUser,
'upload avatar'
);
return { avatarChanged: true }; return { avatarChanged: true };
}, },
removeAvatar: async ({ locals }) => { removeAvatar: async ({ locals, ...event }) => {
const currentUser = await Users.getBySession(locals.session.data?.user); const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) { if (!currentUser) {
await locals.session.destroy(); await locals.session.destroy();
@ -176,6 +194,7 @@ export const actions = {
} }
await Uploads.removeAvatar(currentUser); await Uploads.removeAvatar(currentUser);
await Audit.insertRequest(AuditAction.USER_UPDATE, event, currentUser, 'remove avatar');
return { avatarChanged: true }; return { avatarChanged: true };
} }

View File

@ -1,3 +1,5 @@
import { Audit } from '$lib/server/audit/audit.js';
import { AuditAction } from '$lib/server/audit/types.js';
import { Challenge, type ChallengeBody } from '$lib/server/challenge.js'; import { Challenge, type ChallengeBody } from '$lib/server/challenge.js';
import { Changesets } from '$lib/server/changesets.js'; import { Changesets } from '$lib/server/changesets.js';
import { CryptoUtils } from '$lib/server/crypto-utils'; import { CryptoUtils } from '$lib/server/crypto-utils';
@ -26,7 +28,7 @@ const issueActivateChallenge = async (subject: User) => {
}; };
export const actions = { export const actions = {
activate: async ({ locals, request }) => { activate: async ({ locals, request, getClientAddress }) => {
const currentUser = await Users.getBySession(locals.session.data?.user); const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) { if (!currentUser) {
await locals.session.destroy(); await locals.session.destroy();
@ -54,11 +56,15 @@ export const actions = {
await TimeOTP.saveUserOtp(currentUser, decoded.data.secret); await TimeOTP.saveUserOtp(currentUser, decoded.data.secret);
// TODO: audit log await Audit.insertRequest(
AuditAction.TOTP_ACTIVATE,
{ request, getClientAddress },
currentUser
);
return { success: true, action: 'activate' }; return { success: true, action: 'activate' };
}, },
deactivate: async ({ request, locals }) => { deactivate: async ({ request, locals, getClientAddress }) => {
const currentUser = await Users.getBySession(locals.session.data?.user); const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) { if (!currentUser) {
await locals.session.destroy(); await locals.session.destroy();
@ -98,7 +104,11 @@ export const actions = {
await UserTokens.remove(userOtp); await UserTokens.remove(userOtp);
// TODO: audit log await Audit.insertRequest(
AuditAction.TOTP_DEACTIVATE,
{ request, getClientAddress },
currentUser
);
return { success: true, action: 'deactivate' }; return { success: true, action: 'deactivate' };
} }

View File

@ -1,3 +1,5 @@
import { Audit } from '$lib/server/audit/audit.js';
import { AuditAction } from '$lib/server/audit/types.js';
import { OAuth2Clients, OAuth2DeviceCodes, OAuth2Users } from '$lib/server/oauth2/index.js'; import { OAuth2Clients, OAuth2DeviceCodes, OAuth2Users } from '$lib/server/oauth2/index.js';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
@ -19,13 +21,21 @@ export const actions = {
const body = await request.formData(); const body = await request.formData();
const code = body.get('code') as string; const code = body.get('code') as string;
if (!body.has('code') || !code) { if (!body.has('code') || !code) {
if (await limiter.isLimited(event)) throw error(429, "You're doing that too much!"); if (await limiter.isLimited(event)) {
await Audit.insertRequest(AuditAction.THROTTLE, event, currentUser, `device code attempt`);
throw error(429, "You're doing that too much!");
}
return fail(400, { errors: ['noCode'] }); return fail(400, { errors: ['noCode'] });
} }
const token = await OAuth2DeviceCodes.getByUserCode(code); const token = await OAuth2DeviceCodes.getByUserCode(code);
if (!token?.clientId) { if (!token?.clientId) {
if (await limiter.isLimited(event)) throw error(429, "You're doing that too much!"); if (await limiter.isLimited(event)) {
await Audit.insertRequest(AuditAction.THROTTLE, event, currentUser, `device code attempt`);
throw error(429, "You're doing that too much!");
}
return fail(404, { errors: ['invalidCode'] }); return fail(404, { errors: ['invalidCode'] });
} }

View File

@ -1,3 +1,5 @@
import { Audit } from '$lib/server/audit/audit.js';
import { AuditAction } from '$lib/server/audit/types.js';
import { Challenge } from '$lib/server/challenge.js'; import { Challenge } from '$lib/server/challenge.js';
import { Changesets } from '$lib/server/changesets.js'; import { Changesets } from '$lib/server/changesets.js';
import { Users } from '$lib/server/users/index.js'; import { Users } from '$lib/server/users/index.js';
@ -27,7 +29,10 @@ const limiter = new RateLimiter({
export const actions = { export const actions = {
default: async (event) => { default: async (event) => {
const { request, locals, url } = event; const { request, locals, url } = event;
if (await limiter.isLimited(event)) throw error(429); if (await limiter.isLimited(event)) {
await Audit.insertRequest(AuditAction.THROTTLE, event, undefined, 'login');
throw error(429);
}
// Redirect // Redirect
const redirectUrl = url.searchParams.has('redirectTo') const redirectUrl = url.searchParams.has('redirectTo')
@ -39,8 +44,6 @@ export const actions = {
return redirect(303, redirectUrl); return redirect(303, redirectUrl);
} }
// TODO: Audit log failed attempts
const body = await request.formData(); const body = await request.formData();
const { email, password, challenge, otpCode } = Changesets.take<LoginParams>( const { email, password, challenge, otpCode } = Changesets.take<LoginParams>(
['email', 'password', 'challenge', 'otpCode'], ['email', 'password', 'challenge', 'otpCode'],
@ -58,7 +61,16 @@ export const actions = {
// Find existing active user // Find existing active user
const loginUser = await Users.getByLogin(email); const loginUser = await Users.getByLogin(email);
if (!loginUser) { if (!loginUser) {
if (await rainbowTableLimiter.isLimited(event)) throw error(429); if (await rainbowTableLimiter.isLimited(event)) {
await Audit.insertRequest(
AuditAction.THROTTLE,
event,
undefined,
`rainbow table\nemail=${email}`
);
throw error(429);
}
return fail(400, { email, incorrect: true }); return fail(400, { email, incorrect: true });
} }
@ -86,7 +98,16 @@ export const actions = {
} else { } else {
// Compare user password // Compare user password
if (!password || !(await Users.validatePassword(loginUser, password))) { if (!password || !(await Users.validatePassword(loginUser, password))) {
if (await rainbowTableLimiter.isLimited(event)) throw error(429); if (await rainbowTableLimiter.isLimited(event)) {
await Audit.insertRequest(
AuditAction.THROTTLE,
event,
loginUser,
`password attempts\nemail=${email}`
);
throw error(429);
}
return fail(400, { email, incorrect: true }); return fail(400, { email, incorrect: true });
} }
} }
@ -104,11 +125,13 @@ export const actions = {
const sessionUser = await Users.toSession(loginUser); const sessionUser = await Users.toSession(loginUser);
await locals.session.set({ user: sessionUser }); await locals.session.set({ user: sessionUser });
await Audit.insertRequest(AuditAction.LOGIN, event, loginUser, sessionUser.sid);
return redirect(303, redirectUrl); return redirect(303, redirectUrl);
} }
} as Actions; } as Actions;
export const load = async ({ locals, url }) => { export const load = async ({ locals, url, ...event }) => {
if (url.searchParams.has('redirectTo')) { if (url.searchParams.has('redirectTo')) {
// Check that the redirect URL is a local path // Check that the redirect URL is a local path
if (!url.searchParams.get('redirectTo')?.startsWith('/')) { if (!url.searchParams.get('redirectTo')?.startsWith('/')) {
@ -134,6 +157,7 @@ export const load = async ({ locals, url }) => {
} }
await Users.activateUserBy(activationInfo.token, activationInfo.user); await Users.activateUserBy(activationInfo.token, activationInfo.user);
await Audit.insertRequest(AuditAction.USER_UPDATE, event, activationInfo?.user, 'activate');
return { return {
activated: true activated: true

View File

@ -1,3 +1,4 @@
import { Audit, AuditAction } from '$lib/server/audit';
import { Changesets } from '$lib/server/changesets.js'; import { Changesets } from '$lib/server/changesets.js';
import { Users } from '$lib/server/users/index.js'; import { Users } from '$lib/server/users/index.js';
import { UserTokens } from '$lib/server/users/tokens.js'; import { UserTokens } from '$lib/server/users/tokens.js';
@ -22,7 +23,11 @@ const limiter = new RateLimiter({
export const actions = { export const actions = {
sendEmail: async (event) => { sendEmail: async (event) => {
const { locals, request } = event; const { locals, request } = event;
if (await limiter.isLimited(event)) throw error(429); if (await limiter.isLimited(event)) {
await Audit.insertRequest(AuditAction.THROTTLE, event, undefined, `password reset email`);
throw error(429);
}
if (locals.session.data?.user) { if (locals.session.data?.user) {
return redirect(303, '/'); return redirect(303, '/');
} }
@ -49,7 +54,7 @@ export const actions = {
return { success: 'sent' }; return { success: 'sent' };
}, },
setPassword: async ({ locals, request, url }) => { setPassword: async ({ locals, request, url, getClientAddress }) => {
if (locals.session.data?.user) { if (locals.session.data?.user) {
return redirect(303, '/'); return redirect(303, '/');
} }
@ -96,6 +101,12 @@ export const actions = {
const hashed = await Users.hashPassword(newPassword); const hashed = await Users.hashPassword(newPassword);
await Users.update(user, { password: hashed }); await Users.update(user, { password: hashed });
await UserTokens.remove(exists); await UserTokens.remove(exists);
await Audit.insertRequest(
AuditAction.PASSWORD_CHANGE,
{ request, getClientAddress },
user,
'reset token'
);
return { success: 'set' }; return { success: 'set' };
} }

View File

@ -3,6 +3,7 @@ import { OAuth2Response } from '$lib/server/oauth2/response.js';
import { OAuth2DeviceAuthorizationController } from '$lib/server/oauth2/controller/device-authorization.js'; import { OAuth2DeviceAuthorizationController } from '$lib/server/oauth2/controller/device-authorization.js';
import { RateLimiter } from 'sveltekit-rate-limiter/server'; import { RateLimiter } from 'sveltekit-rate-limiter/server';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { Audit, AuditAction } from '$lib/server/audit';
const limiter = new RateLimiter({ const limiter = new RateLimiter({
IP: [6, 'm'] IP: [6, 'm']
@ -10,7 +11,15 @@ const limiter = new RateLimiter({
export const POST = async (event) => { export const POST = async (event) => {
const { request, url } = event; const { request, url } = event;
if (await limiter.isLimited(event)) error(429, "You're doing that too much!"); if (await limiter.isLimited(event)) {
await Audit.insertRequest(
AuditAction.THROTTLE,
event,
undefined,
`device authorization attempt`
);
error(429, "You're doing that too much!");
}
try { try {
return await OAuth2DeviceAuthorizationController.postRequest({ request }); return await OAuth2DeviceAuthorizationController.postRequest({ request });

View File

@ -2,6 +2,7 @@ import { OAuth2Error, SlowDown } from '$lib/server/oauth2/error.js';
import { OAuth2Response } from '$lib/server/oauth2/response.js'; import { OAuth2Response } from '$lib/server/oauth2/response.js';
import { OAuth2TokenController } from '$lib/server/oauth2/controller/token.js'; import { OAuth2TokenController } from '$lib/server/oauth2/controller/token.js';
import { RateLimiter } from 'sveltekit-rate-limiter/server'; import { RateLimiter } from 'sveltekit-rate-limiter/server';
import { Audit, AuditAction } from '$lib/server/audit';
const limiter = new RateLimiter({ const limiter = new RateLimiter({
IP: [15, 'm'] IP: [15, 'm']
@ -12,6 +13,7 @@ export const POST = async (event) => {
try { try {
if (await limiter.isLimited(event)) { if (await limiter.isLimited(event)) {
await Audit.insertRequest(AuditAction.THROTTLE, event, undefined, `oauth2 token attempt`);
throw new SlowDown('Please, slow down!'); throw new SlowDown('Please, slow down!');
} }

View File

@ -1,4 +1,6 @@
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { AuditAction } from '$lib/server/audit';
import { Audit } from '$lib/server/audit/audit.js';
import { Changesets } from '$lib/server/changesets.js'; import { Changesets } from '$lib/server/changesets.js';
import { Users } from '$lib/server/users/index.js'; import { Users } from '$lib/server/users/index.js';
import { emailRegex, passwordRegex, usernameRegex } from '$lib/validators.js'; import { emailRegex, passwordRegex, usernameRegex } from '$lib/validators.js';
@ -98,6 +100,8 @@ export const actions = {
// TODO: check for registration token // TODO: check for registration token
const newUser = await Users.register({ username, displayName, password, email }); const newUser = await Users.register({ username, displayName, password, email });
await Audit.insertRequest(AuditAction.REGISTRATION, event, newUser);
return { return {
success: newUser.activated ? 'userCreated' : 'emailSent' success: newUser.activated ? 'userCreated' : 'emailSent'
}; };

View File

@ -1,8 +1,9 @@
import { Audit, AuditAction } from '$lib/server/audit';
import { Users } from '$lib/server/users/index.js'; import { Users } from '$lib/server/users/index.js';
import { hasPrivileges } from '$lib/utils.js'; import { hasPrivileges } from '$lib/utils.js';
import { error, redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
export const load = async ({ url, locals }) => { export const load = async ({ url, locals, ...event }) => {
const userInfo = locals.session.data?.user; const userInfo = locals.session.data?.user;
const currentUser = await Users.getBySession(userInfo); const currentUser = await Users.getBySession(userInfo);
if (!userInfo || !currentUser) { if (!userInfo || !currentUser) {
@ -13,6 +14,13 @@ export const load = async ({ url, locals }) => {
// Only users with 'admin' privilege can access // Only users with 'admin' privilege can access
const privileges = await Users.getUserPrivileges(currentUser); const privileges = await Users.getUserPrivileges(currentUser);
if (!hasPrivileges(privileges, ['admin', 'self:oauth2'])) { if (!hasPrivileges(privileges, ['admin', 'self:oauth2'])) {
await Audit.insertRequest(
AuditAction.MALICIOUS_REQUEST,
event,
currentUser,
`unauthorized direct admin access\nurl=${url.toString()}`
);
return error(404, 'Not Found'); return error(404, 'Not Found');
} }

View File

@ -0,0 +1,43 @@
import { AdminUtils } from '$lib/server/admin-utils';
import { Audit } from '$lib/server/audit/audit.js';
import { AuditAction } from '$lib/server/audit/types.js';
const PAGE_SIZE = 50;
export const load = async ({ url, parent }) => {
const { user: userInfo } = await parent();
AdminUtils.checkPrivileges(userInfo, ['admin:audit']);
let limit = PAGE_SIZE;
let page = 1;
let actions: AuditAction[] | undefined = undefined;
let user: string | undefined = undefined;
let content: string | undefined = undefined;
if (url.searchParams.has('page')) {
page = Number(url.searchParams.get('page')) || 1;
}
if (url.searchParams.has('pageSize')) {
limit = Number(url.searchParams.get('pageSize')) || PAGE_SIZE;
}
if (url.searchParams.has('actions')) {
actions = url.searchParams.getAll('actions') as AuditAction[];
}
if (url.searchParams.has('user')) {
user = url.searchParams.get('user') as string;
}
if (url.searchParams.has('content')) {
content = url.searchParams.get('content') as string;
}
const offset = (page - 1) * limit;
const data = await Audit.searchAudit({ limit, offset, actions, user, content });
return {
...data,
actions: Object.values(AuditAction)
};
};

View File

@ -0,0 +1,60 @@
<script lang="ts">
import Paginator from '$lib/components/Paginator.svelte';
import { t } from '$lib/i18n';
import type { PageData } from './$types';
import { env } from '$env/dynamic/public';
import FormControl from '$lib/components/form/FormControl.svelte';
import ColumnView from '$lib/components/container/ColumnView.svelte';
import { page } from '$app/stores';
import SplitView from '$lib/components/container/SplitView.svelte';
import Button from '$lib/components/Button.svelte';
import AdminAuditCard from '../../../lib/components/admin/AdminAuditCard.svelte';
export let data: PageData;
</script>
<svelte:head>
<title>{$t('admin.audit.title')} - {env.PUBLIC_SITE_NAME} {$t('admin.title')}</title>
</svelte:head>
<h1>{$t('admin.audit.title')} ({data.meta.rowCount})</h1>
<ColumnView>
<form action="" method="get">
<ColumnView>
<SplitView>
<FormControl>
<label for="actions">{$t('admin.audit.action')}</label>
<select name="actions" value={$page.url.searchParams.getAll('actions')} multiple>
{#each data.actions as action}
<option value={action}>{action}</option>
{/each}
</select>
</FormControl>
<FormControl>
<label for="content">{$t('admin.audit.comment')}</label>
<input name="content" value={$page.url.searchParams.get('content')} />
</FormControl>
</SplitView>
<div>
<Button type="submit">{$t('common.filter')}</Button>
</div>
</ColumnView>
</form>
<div class="audit-list">
<Paginator meta={data.meta} />
{#each data.list as audit}
<AdminAuditCard {audit} />
{/each}
<Paginator meta={data.meta} />
</div>
</ColumnView>
<style>
.audit-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@ -1,5 +1,6 @@
import { OAUTH2_MAX_REDIRECTS, OAUTH2_MAX_URLS } from '$lib/constants.js'; import { OAUTH2_MAX_REDIRECTS, OAUTH2_MAX_URLS } from '$lib/constants.js';
import { AdminUtils } from '$lib/server/admin-utils'; import { AdminUtils } from '$lib/server/admin-utils';
import { Audit, AuditAction } from '$lib/server/audit';
import { Changesets } from '$lib/server/changesets.js'; import { Changesets } from '$lib/server/changesets.js';
import { CryptoUtils } from '$lib/server/crypto-utils.js'; import { CryptoUtils } from '$lib/server/crypto-utils.js';
import type { OAuth2Client, User } from '$lib/server/drizzle'; import type { OAuth2Client, User } from '$lib/server/drizzle';
@ -53,7 +54,8 @@ const inviteLimiter = new RateLimiter({
*/ */
const getActionData = async (locals: App.Locals, uuid: string) => { const getActionData = async (locals: App.Locals, uuid: string) => {
const { currentUser, userSession } = await AdminUtils.getActionUser(locals, [ const { currentUser, userSession } = await AdminUtils.getActionUser(locals, [
['admin:oauth2', 'self:oauth2'] 'admin:oauth2',
'self:oauth2'
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']); const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
@ -77,8 +79,8 @@ export const actions = {
/** /**
* Update the OAuth2 Client general information. * Update the OAuth2 Client general information.
*/ */
update: async ({ locals, request, params: { uuid } }) => { update: async ({ locals, request, params: { uuid }, getClientAddress }) => {
const { details, fullPrivileges } = await getActionData(locals, uuid); const { details, fullPrivileges, currentUser } = await getActionData(locals, uuid);
const body = await request.formData(); const body = await request.formData();
const { title, description, activated, verified, confidential } = const { title, description, activated, verified, confidential } =
@ -111,6 +113,13 @@ export const actions = {
confidential: actuallyConfidential confidential: actuallyConfidential
}); });
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
{ request, getClientAddress },
currentUser,
`client_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
}, },
/** /**
@ -118,7 +127,7 @@ export const actions = {
* *
* Only works for disabled clients, and only the owner or admin can do it. * Only works for disabled clients, and only the owner or admin can do it.
*/ */
delete: async ({ locals, params: { uuid } }) => { delete: async ({ locals, params: { uuid }, ...event }) => {
const { currentUser, fullPrivileges, details } = await getActionData(locals, uuid); const { currentUser, fullPrivileges, details } = await getActionData(locals, uuid);
if (details.activated === 1) { if (details.activated === 1) {
@ -131,6 +140,13 @@ export const actions = {
await OAuth2Clients.deleteClient(details); await OAuth2Clients.deleteClient(details);
await Audit.insertRequest(
AuditAction.OAUTH2_DELETE,
event,
currentUser,
`client_id=${details.client_id}\ntitle=${details.title}`
);
return redirect(303, '/ssoadmin/oauth2'); return redirect(303, '/ssoadmin/oauth2');
}, },
/** /**
@ -151,6 +167,12 @@ export const actions = {
// Allow secret regeneration only once per minute. // Allow secret regeneration only once per minute.
if (!fullPrivileges && (await oneOffLimiter.isLimited(event))) { if (!fullPrivileges && (await oneOffLimiter.isLimited(event))) {
await Audit.insertRequest(
AuditAction.THROTTLE,
event,
currentUser,
`secret regeneration attempt`
);
return fail(429, { errors: ['tooManyTimes'] }); return fail(429, { errors: ['tooManyTimes'] });
} }
@ -158,13 +180,20 @@ export const actions = {
client_secret: CryptoUtils.generateSecret() client_secret: CryptoUtils.generateSecret()
}); });
await Audit.insertRequest(
AuditAction.OAUTH2_REGENERATE,
event,
currentUser,
`client_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
}, },
/** /**
* Remove an URL reference. * Remove an URL reference.
*/ */
removeUrl: async ({ locals, url, params: { uuid } }) => { removeUrl: async ({ locals, url, params: { uuid }, ...event }) => {
const { details } = await getActionData(locals, uuid); const { details, currentUser } = await getActionData(locals, uuid);
const id = Number(url.searchParams.get('id')); const id = Number(url.searchParams.get('id'));
if (isNaN(id)) { if (isNaN(id)) {
@ -173,13 +202,20 @@ export const actions = {
await OAuth2Clients.deleteUrl(details, id); await OAuth2Clients.deleteUrl(details, id);
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
event,
currentUser,
`delete url\nclient_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
}, },
/** /**
* Add an URL reference. * Add an URL reference.
*/ */
addUrl: async ({ locals, request, params: { uuid } }) => { addUrl: async ({ locals, request, params: { uuid }, getClientAddress }) => {
const { details } = await getActionData(locals, uuid); const { details, currentUser } = await getActionData(locals, uuid);
const body = await request.formData(); const body = await request.formData();
const { type, url } = Changesets.take<AddUrlRequest>(['type', 'url'], body); const { type, url } = Changesets.take<AddUrlRequest>(['type', 'url'], body);
@ -202,13 +238,20 @@ export const actions = {
await OAuth2Clients.addUrl(details, type, url); await OAuth2Clients.addUrl(details, type, url);
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
{ request, getClientAddress },
currentUser,
`add url\nclient_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
}, },
/** /**
* Remove a privilege reference. * Remove a privilege reference.
*/ */
removePrivilege: async ({ locals, url, params: { uuid } }) => { removePrivilege: async ({ locals, url, params: { uuid }, ...event }) => {
const { details } = await getActionData(locals, uuid); const { details, currentUser } = await getActionData(locals, uuid);
const id = Number(url.searchParams.get('id')); const id = Number(url.searchParams.get('id'));
if (isNaN(id)) { if (isNaN(id)) {
@ -217,13 +260,20 @@ export const actions = {
await OAuth2Clients.deletePrivilege(details, id); await OAuth2Clients.deletePrivilege(details, id);
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
event,
currentUser,
`remove privilege\nclient_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
}, },
/** /**
* Add a privilege reference. * Add a privilege reference.
*/ */
addPrivilege: async ({ locals, request, params: { uuid } }) => { addPrivilege: async ({ locals, request, params: { uuid }, getClientAddress }) => {
const { details } = await getActionData(locals, uuid); const { details, currentUser } = await getActionData(locals, uuid);
const body = await request.formData(); const body = await request.formData();
const { name } = Changesets.take<AddPrivilegeRequest>(['name'], body); const { name } = Changesets.take<AddPrivilegeRequest>(['name'], body);
@ -234,13 +284,20 @@ export const actions = {
await OAuth2Clients.addPrivilege(details, name); await OAuth2Clients.addPrivilege(details, name);
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
{ request, getClientAddress },
currentUser,
`add privilege\nclient_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
}, },
/** /**
* Update the OAuth2 Client allowed grants list. * Update the OAuth2 Client allowed grants list.
*/ */
grants: async ({ locals, request, params: { uuid } }) => { grants: async ({ locals, request, params: { uuid }, getClientAddress }) => {
const { details, fullPrivileges } = await getActionData(locals, uuid); const { details, fullPrivileges, currentUser } = await getActionData(locals, uuid);
const allowedGrants = fullPrivileges const allowedGrants = fullPrivileges
? OAuth2Clients.availableGrantTypes ? OAuth2Clients.availableGrantTypes
@ -257,13 +314,20 @@ export const actions = {
grants: deduplicatedAllowedGrants.join(' ') grants: deduplicatedAllowedGrants.join(' ')
}); });
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
{ request, getClientAddress },
currentUser,
`grants\nclient_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
}, },
/** /**
* Update the OAuth2 Client allowed scopes list. * Update the OAuth2 Client allowed scopes list.
*/ */
scopes: async ({ locals, request, params: { uuid } }) => { scopes: async ({ locals, request, params: { uuid }, getClientAddress }) => {
const { details, fullPrivileges } = await getActionData(locals, uuid); const { details, fullPrivileges, currentUser } = await getActionData(locals, uuid);
const allowedScopes = fullPrivileges const allowedScopes = fullPrivileges
? OAuth2Clients.availableScopes ? OAuth2Clients.availableScopes
@ -280,12 +344,19 @@ export const actions = {
scope: OAuth2Clients.joinScope(deduplicatedAllowedScopes) scope: OAuth2Clients.joinScope(deduplicatedAllowedScopes)
}); });
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
{ request, getClientAddress },
currentUser,
`scopes\nclient_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
}, },
/** /**
* Update the OAuth2 Client icon picture. * Update the OAuth2 Client icon picture.
*/ */
avatar: async ({ request, locals, params: { uuid } }) => { avatar: async ({ request, locals, params: { uuid }, getClientAddress }) => {
const { currentUser, details } = await getActionData(locals, uuid); const { currentUser, details } = await getActionData(locals, uuid);
const formData = Object.fromEntries(await request.formData()); const formData = Object.fromEntries(await request.formData());
@ -299,16 +370,30 @@ export const actions = {
await Uploads.saveClientAvatar(details, currentUser, file); await Uploads.saveClientAvatar(details, currentUser, file);
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
{ request, getClientAddress },
currentUser,
`upload avatar\nclient_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
}, },
/** /**
* Delete the OAuth2 Client icon picture. * Delete the OAuth2 Client icon picture.
*/ */
removeAvatar: async ({ locals, params: { uuid } }) => { removeAvatar: async ({ locals, params: { uuid }, ...event }) => {
const { details } = await getActionData(locals, uuid); const { details, currentUser } = await getActionData(locals, uuid);
await Uploads.removeClientAvatar(details); await Uploads.removeClientAvatar(details);
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
event,
currentUser,
`remove avatar\nclient_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
}, },
/** /**
@ -347,6 +432,13 @@ export const actions = {
await OAuth2Clients.sendManagerInvitationEmail(details as OAuth2Client, currentUser, email); await OAuth2Clients.sendManagerInvitationEmail(details as OAuth2Client, currentUser, email);
await Audit.insertRequest(
AuditAction.OAUTH2_INVITE,
event,
currentUser,
`invite\nclient_id=${details.client_id}\nemail=${email}`
);
return { errors: [] }; return { errors: [] };
}, },
/** /**
@ -354,7 +446,7 @@ export const actions = {
* *
* Only the owner or admin can do it. * Only the owner or admin can do it.
*/ */
removeManager: async ({ locals, url, params: { uuid } }) => { removeManager: async ({ locals, url, params: { uuid }, ...event }) => {
const { details, currentUser, fullPrivileges } = await getActionData(locals, uuid); const { details, currentUser, fullPrivileges } = await getActionData(locals, uuid);
const id = Number(url.searchParams.get('id')); const id = Number(url.searchParams.get('id'));
@ -367,6 +459,12 @@ export const actions = {
} }
await OAuth2Clients.removeManager(details, id); await OAuth2Clients.removeManager(details, id);
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
event,
currentUser,
`remove manager\nclient_id=${details.client_id}`
);
return { errors: [] }; return { errors: [] };
} }

View File

@ -1,4 +1,5 @@
import { AdminUtils } from '$lib/server/admin-utils'; import { AdminUtils } from '$lib/server/admin-utils';
import { Audit, AuditAction } from '$lib/server/audit';
import { Changesets } from '$lib/server/changesets.js'; import { Changesets } from '$lib/server/changesets.js';
import type { OAuth2Client, User } from '$lib/server/drizzle'; import type { OAuth2Client, User } from '$lib/server/drizzle';
import { OAuth2Clients } from '$lib/server/oauth2'; import { OAuth2Clients } from '$lib/server/oauth2';
@ -11,7 +12,7 @@ interface PrivilegesRequest {
} }
export const actions = { export const actions = {
privileges: async ({ locals, params: { uuid, user: userId }, request }) => { privileges: async ({ locals, params: { uuid, user: userId }, request, getClientAddress }) => {
const { currentUser, userSession } = await AdminUtils.getActionUser(locals, [ const { currentUser, userSession } = await AdminUtils.getActionUser(locals, [
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
@ -60,6 +61,13 @@ export const actions = {
await Users.setUserPrivileges(targetUser, splitFilter, details.id); await Users.setUserPrivileges(targetUser, splitFilter, details.id);
await Audit.insertRequest(
AuditAction.OAUTH2_UPDATE,
{ request, getClientAddress },
currentUser,
`update user privileges\nclient_id=${details.client_id}\nuser=${targetUser.uuid}`
);
return redirect(303, '..'); return redirect(303, '..');
} }
}; };

View File

@ -1,4 +1,5 @@
import { AdminUtils } from '$lib/server/admin-utils'; import { AdminUtils } from '$lib/server/admin-utils';
import { Audit, AuditAction } from '$lib/server/audit';
import { Changesets } from '$lib/server/changesets.js'; import { Changesets } from '$lib/server/changesets.js';
import { OAuth2Clients } from '$lib/server/oauth2/index.js'; import { OAuth2Clients } from '$lib/server/oauth2/index.js';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
@ -11,7 +12,7 @@ interface CreateClientRequest {
} }
export const actions = { export const actions = {
default: async ({ locals, request }) => { default: async ({ locals, request, getClientAddress }) => {
const { currentUser } = await AdminUtils.getActionUser(locals, [ const { currentUser } = await AdminUtils.getActionUser(locals, [
['admin:oauth2', 'self:oauth2:create'] ['admin:oauth2', 'self:oauth2:create']
]); ]);
@ -42,6 +43,13 @@ export const actions = {
!!confidential !!confidential
); );
await Audit.insertRequest(
AuditAction.OAUTH2_CREATE,
{ request, getClientAddress },
currentUser,
`client_id=${uuid}`
);
return redirect(303, `/ssoadmin/oauth2/${uuid}`); return redirect(303, `/ssoadmin/oauth2/${uuid}`);
} }
}; };

View File

@ -1,4 +1,6 @@
import { AdminUtils } from '$lib/server/admin-utils'; import { AdminUtils } from '$lib/server/admin-utils';
import { Audit } from '$lib/server/audit/audit.js';
import { AuditAction } from '$lib/server/audit/types.js';
import { Changesets } from '$lib/server/changesets.js'; import { Changesets } from '$lib/server/changesets.js';
import { OAuth2Tokens } from '$lib/server/oauth2/index.js'; import { OAuth2Tokens } from '$lib/server/oauth2/index.js';
import { Uploads } from '$lib/server/upload.js'; import { Uploads } from '$lib/server/upload.js';
@ -30,16 +32,14 @@ export const actions = {
return { errors: [] }; return { errors: [] };
}, },
deleteInfo: async ({ locals, params: { uuid } }) => { deleteInfo: async ({ locals, params: { uuid }, request, getClientAddress }) => {
await AdminUtils.getActionUser(locals, ['admin', 'admin:user']); const { currentUser } = await AdminUtils.getActionUser(locals, ['admin', 'admin:user']);
const targetUser = await Users.getByUuid(uuid, false); const targetUser = await Users.getByUuid(uuid, false);
if (!targetUser || !!targetUser.activated) { if (!targetUser || !!targetUser.activated) {
return fail(404, { errors: ['invalidUuid'] }); return fail(404, { errors: ['invalidUuid'] });
} }
// TODO: audit log
const [stubName] = uuid.split('-'); const [stubName] = uuid.split('-');
// Nuke EVERYTHING // Nuke EVERYTHING
@ -54,6 +54,13 @@ export const actions = {
updated_at: new Date() updated_at: new Date()
}); });
await Audit.insertRequest(
AuditAction.USER_DELETE,
{ request, getClientAddress },
currentUser,
`uuid=${targetUser.uuid}`
);
return { errors: [] }; return { errors: [] };
}, },
email: async ({ locals, params: { uuid }, url }) => { email: async ({ locals, params: { uuid }, url }) => {
@ -80,11 +87,9 @@ export const actions = {
return fail(403, { errors: ['invalidEmailType'] }); return fail(403, { errors: ['invalidEmailType'] });
} }
// TODO: audit log
return { errors: [] }; return { errors: [] };
}, },
update: async ({ locals, params: { uuid }, request }) => { update: async ({ locals, params: { uuid }, request, getClientAddress }) => {
const { currentUser, userSession } = await AdminUtils.getActionUser(locals, [ const { currentUser, userSession } = await AdminUtils.getActionUser(locals, [
'admin', 'admin',
'admin:user' 'admin:user'
@ -139,7 +144,12 @@ export const actions = {
updated_at: new Date() updated_at: new Date()
}); });
// TODO: audit log await Audit.insertRequest(
AuditAction.USER_UPDATE,
{ request, getClientAddress },
currentUser,
`uuid=${targetUser.uuid}`
);
return { errors: [] }; return { errors: [] };
} }