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;
$: lastPage = pageNum === meta.pageCount;
$: 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>
<nav class="pager">
<a
class="page-button page-prev {firstPage ? 'disabled' : ''}"
href={`?page=${pageNum - 1}`}
href={`?${makePageUrl($page.url.searchParams, pageNum - 1)}`}
tabindex={firstPage ? -1 : 0}
aria-label={$t('common.previous')}
aria-disabled={firstPage}>&lt;</a
@ -23,7 +29,7 @@
{@const active = buttonNumber === pageNum}
<a
class="page-button page-link {active ? 'disabled' : ''}"
href={`?page=${buttonNumber}`}
href={`?${makePageUrl($page.url.searchParams, buttonNumber)}`}
tabindex={active ? -1 : 0}
aria-label={`${$t('common.page')} ${buttonNumber}`}
aria-disabled={active}>{buttonNumber}</a
@ -33,7 +39,7 @@
<a
class="page-button page-prev {lastPage ? 'disabled' : ''}"
tabindex={lastPage ? -1 : 0}
href={`?page=${pageNum + 1}`}
href={`?${makePageUrl($page.url.searchParams, pageNum + 1)}`}
aria-label={$t('common.next')}
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">
import { t } from '$lib/i18n';
import type { PageData } from '../../../routes/ssoadmin/oauth2/$types';
import AdminDateTime from './AdminDateTime.svelte';
export let client: PageData['list'][0];
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeStyle: 'medium' });
const formatDate = dateFormat.format.bind(null);
</script>
<div class="client">
@ -37,7 +35,7 @@
<dd>{$t(`common.bool.${Boolean(client.confidential)}`)}</dd>
<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>
<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">
import { t } from '$lib/i18n';
import AdminDateTime from './AdminDateTime.svelte';
import type { PageData } from '../../../routes/ssoadmin/users/$types';
export let user: PageData['list'][0];
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeStyle: 'medium' });
const formatDate = dateFormat.format.bind(null);
</script>
<div class="user">
@ -33,7 +31,7 @@
<dd>{$t(`common.bool.${Boolean(user.activated)}`)}</dd>
<dt>{$t('admin.users.registered')}</dt>
<dd>{formatDate(user.created_at)}</dd>
<dd><AdminDateTime date={user.created_at} /></dd>
</dl>
</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.",
"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' })
});
export type AuditLog = typeof auditLog.$inferSelect;
export type NewAuditLog = typeof auditLog.$inferInsert;
export const document = mysqlTable('document', {
id: int('id').autoincrement().notNull(),
title: text('title').notNull(),

View File

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

View File

@ -1,4 +1,5 @@
export interface UserSession {
sid: string;
uid: number;
uuid: 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 type { User } from '$lib/server/drizzle';
import { Uploads } from '$lib/server/upload.js';
@ -21,7 +23,7 @@ export const actions = {
await locals.session.destroy();
return redirect(303, '/');
},
update: async ({ request, locals }) => {
update: async ({ request, locals, getClientAddress }) => {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
@ -123,10 +125,20 @@ export const actions = {
if (data.newEmail) {
updates.email = data.newEmail;
await Audit.insertRequest(
AuditAction.EMAIL_CHANGE,
{ request, getClientAddress },
currentUser
);
}
if (data.newPassword) {
updates.password = await Users.hashPassword(data.newPassword);
await Audit.insertRequest(
AuditAction.PASSWORD_CHANGE,
{ request, getClientAddress },
currentUser
);
}
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 {
success: true,
@ -147,7 +159,7 @@ export const actions = {
displayName: data.displayName || currentUser.display_name
};
},
avatar: async ({ request, locals }) => {
avatar: async ({ request, locals, getClientAddress }) => {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
@ -165,10 +177,16 @@ export const actions = {
const { file } = formData as { file: File };
await Uploads.saveAvatar(currentUser, file);
await Audit.insertRequest(
AuditAction.USER_UPDATE,
{ request, getClientAddress },
currentUser,
'upload avatar'
);
return { avatarChanged: true };
},
removeAvatar: async ({ locals }) => {
removeAvatar: async ({ locals, ...event }) => {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
@ -176,6 +194,7 @@ export const actions = {
}
await Uploads.removeAvatar(currentUser);
await Audit.insertRequest(AuditAction.USER_UPDATE, event, currentUser, 'remove avatar');
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 { Changesets } from '$lib/server/changesets.js';
import { CryptoUtils } from '$lib/server/crypto-utils';
@ -26,7 +28,7 @@ const issueActivateChallenge = async (subject: User) => {
};
export const actions = {
activate: async ({ locals, request }) => {
activate: async ({ locals, request, getClientAddress }) => {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
@ -54,11 +56,15 @@ export const actions = {
await TimeOTP.saveUserOtp(currentUser, decoded.data.secret);
// TODO: audit log
await Audit.insertRequest(
AuditAction.TOTP_ACTIVATE,
{ request, getClientAddress },
currentUser
);
return { success: true, action: 'activate' };
},
deactivate: async ({ request, locals }) => {
deactivate: async ({ request, locals, getClientAddress }) => {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
@ -98,7 +104,11 @@ export const actions = {
await UserTokens.remove(userOtp);
// TODO: audit log
await Audit.insertRequest(
AuditAction.TOTP_DEACTIVATE,
{ request, getClientAddress },
currentUser
);
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 { Users } from '$lib/server/users';
import { error, fail, redirect } from '@sveltejs/kit';
@ -19,13 +21,21 @@ export const actions = {
const body = await request.formData();
const code = body.get('code') as string;
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'] });
}
const token = await OAuth2DeviceCodes.getByUserCode(code);
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'] });
}

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

View File

@ -1,3 +1,4 @@
import { Audit, AuditAction } from '$lib/server/audit';
import { Changesets } from '$lib/server/changesets.js';
import { Users } from '$lib/server/users/index.js';
import { UserTokens } from '$lib/server/users/tokens.js';
@ -22,7 +23,11 @@ const limiter = new RateLimiter({
export const actions = {
sendEmail: async (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) {
return redirect(303, '/');
}
@ -49,7 +54,7 @@ export const actions = {
return { success: 'sent' };
},
setPassword: async ({ locals, request, url }) => {
setPassword: async ({ locals, request, url, getClientAddress }) => {
if (locals.session.data?.user) {
return redirect(303, '/');
}
@ -96,6 +101,12 @@ export const actions = {
const hashed = await Users.hashPassword(newPassword);
await Users.update(user, { password: hashed });
await UserTokens.remove(exists);
await Audit.insertRequest(
AuditAction.PASSWORD_CHANGE,
{ request, getClientAddress },
user,
'reset token'
);
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 { RateLimiter } from 'sveltekit-rate-limiter/server';
import { error } from '@sveltejs/kit';
import { Audit, AuditAction } from '$lib/server/audit';
const limiter = new RateLimiter({
IP: [6, 'm']
@ -10,7 +11,15 @@ const limiter = new RateLimiter({
export const POST = async (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 {
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 { OAuth2TokenController } from '$lib/server/oauth2/controller/token.js';
import { RateLimiter } from 'sveltekit-rate-limiter/server';
import { Audit, AuditAction } from '$lib/server/audit';
const limiter = new RateLimiter({
IP: [15, 'm']
@ -12,6 +13,7 @@ export const POST = async (event) => {
try {
if (await limiter.isLimited(event)) {
await Audit.insertRequest(AuditAction.THROTTLE, event, undefined, `oauth2 token attempt`);
throw new SlowDown('Please, slow down!');
}

View File

@ -1,4 +1,6 @@
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 { Users } from '$lib/server/users/index.js';
import { emailRegex, passwordRegex, usernameRegex } from '$lib/validators.js';
@ -98,6 +100,8 @@ export const actions = {
// TODO: check for registration token
const newUser = await Users.register({ username, displayName, password, email });
await Audit.insertRequest(AuditAction.REGISTRATION, event, newUser);
return {
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 { hasPrivileges } from '$lib/utils.js';
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 currentUser = await Users.getBySession(userInfo);
if (!userInfo || !currentUser) {
@ -13,6 +14,13 @@ export const load = async ({ url, locals }) => {
// Only users with 'admin' privilege can access
const privileges = await Users.getUserPrivileges(currentUser);
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');
}

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

View File

@ -1,4 +1,5 @@
import { AdminUtils } from '$lib/server/admin-utils';
import { Audit, AuditAction } from '$lib/server/audit';
import { Changesets } from '$lib/server/changesets.js';
import type { OAuth2Client, User } from '$lib/server/drizzle';
import { OAuth2Clients } from '$lib/server/oauth2';
@ -11,7 +12,7 @@ interface PrivilegesRequest {
}
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, [
['admin:oauth2', 'self:oauth2']
]);
@ -60,6 +61,13 @@ export const actions = {
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, '..');
}
};

View File

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

View File

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