Compare commits

..

2 Commits

Author SHA1 Message Date
c5c85bf771
Merge branch 'master' into ip-management 2025-03-08 10:05:20 +02:00
088417af73
IP management stuff 2025-03-08 10:02:06 +02:00
27 changed files with 4170 additions and 665 deletions

View File

@ -0,0 +1,34 @@
CREATE TABLE `ip_address` (
`id` int unsigned AUTO_INCREMENT NOT NULL,
`ip_address` int unsigned NOT NULL,
`flags` tinyint unsigned NOT NULL,
`listId` int unsigned,
`created_at` datetime(6) NOT NULL DEFAULT current_timestamp(6),
`updated_at` datetime(6) NOT NULL DEFAULT current_timestamp(6),
CONSTRAINT `ip_address_id` PRIMARY KEY(`id`),
CONSTRAINT `ip_address_idx` UNIQUE(`ip_address`)
);
--> statement-breakpoint
CREATE TABLE `ip_list` (
`id` int unsigned AUTO_INCREMENT NOT NULL,
`name` text NOT NULL,
`url` text,
`default_flags` tinyint unsigned NOT NULL,
`created_at` datetime(6) NOT NULL DEFAULT current_timestamp(6),
`updated_at` datetime(6) NOT NULL DEFAULT current_timestamp(6),
CONSTRAINT `ip_list_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `ip_address_user` (
`id` int unsigned AUTO_INCREMENT NOT NULL,
`userId` int,
`ipAddressId` int unsigned,
CONSTRAINT `ip_address_user_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `audit_log` ADD `ipAddressId` int unsigned;--> statement-breakpoint
ALTER TABLE `ip_address` ADD CONSTRAINT `ip_address_listId_ip_list_id_fk` FOREIGN KEY (`listId`) REFERENCES `ip_list`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `ip_address_user` ADD CONSTRAINT `ip_address_user_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `ip_address_user` ADD CONSTRAINT `ip_address_user_ipAddressId_ip_address_id_fk` FOREIGN KEY (`ipAddressId`) REFERENCES `ip_address`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `ip_flags_idx` ON `ip_address` (`flags`);--> statement-breakpoint
ALTER TABLE `audit_log` ADD CONSTRAINT `audit_log_ipAddressId_ip_address_id_fk` FOREIGN KEY (`ipAddressId`) REFERENCES `ip_address`(`id`) ON DELETE set null ON UPDATE no action;

View File

@ -0,0 +1 @@
ALTER TABLE `ip_address` MODIFY COLUMN `ip_address` varbinary(16) NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,20 @@
"when": 1739896136734,
"tag": "0007_slim_dexter_bennett",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1740393886197,
"tag": "0008_faithful_golden_guardian",
"breakpoints": true
},
{
"idx": 9,
"version": "5",
"when": 1740394195704,
"tag": "0009_broad_kylun",
"breakpoints": true
}
]
}

1211
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,44 +12,44 @@
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/kit": "^2.19.0",
"@sveltejs/kit": "^2.17.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/bcryptjs": "^2.4.6",
"@types/eslint": "^9.6.1",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.13.10",
"@types/node": "^22.13.5",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.26.0",
"drizzle-kit": "^0.30.5",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-svelte": "^3.0.3",
"prettier": "^3.5.3",
"@typescript-eslint/eslint-plugin": "^8.24.1",
"@typescript-eslint/parser": "^8.24.1",
"drizzle-kit": "^0.30.4",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
"prettier": "^3.5.2",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.22.6",
"svelte-check": "^4.1.5",
"svelte": "^5.20.2",
"svelte-check": "^4.1.4",
"tslib": "^2.8.1",
"typescript": "^5.8.2",
"vite": "^6.2.1"
"typescript": "^5.7.3",
"vite": "^6.1.1"
},
"type": "module",
"dependencies": {
"@keyv/valkey": "^1.0.3",
"@sveltejs/adapter-node": "^5.2.12",
"bcryptjs": "^3.0.2",
"cache-manager": "^6.4.1",
"cacheable": "^1.8.9",
"cache-manager": "^6.4.0",
"cacheable": "^1.8.8",
"chalk": "^5.4.1",
"cropperjs": "^1.6.2",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.40.0",
"image-size": "^2.0.0",
"jose": "^6.0.8",
"drizzle-orm": "^0.39.3",
"image-size": "^1.2.0",
"jose": "^6.0.4",
"mime-types": "^2.1.35",
"mysql2": "^3.13.0",
"mysql2": "^3.12.0",
"nodemailer": "^6.10.0",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
@ -57,6 +57,6 @@
"sveltekit-i18n": "^2.4.2",
"sveltekit-rate-limiter": "^0.6.1",
"uuid": "^11.1.0",
"vite-plugin-mkcert": "^1.17.7"
"vite-plugin-mkcert": "^1.17.6"
}
}

View File

@ -7,9 +7,11 @@
interface Props {
audit: PageData['list'][0];
ipLink?: boolean;
userLink?: boolean;
}
let { audit }: Props = $props();
let { audit, ipLink, userLink }: Props = $props();
let expanded = $state(false);
</script>
@ -26,11 +28,21 @@
<dd>{audit.action}</dd>
{#if audit.user}
<dt>{$t('admin.audit.user')}</dt>
<dd>{audit.user.uuid} ({audit.user.name})</dd>
<dd>
{audit.user.uuid} ({audit.user.name})
{#if userLink}
(<a href="/ssoadmin/users/{audit.user.uuid}">{$t('admin.audit.userLookup')}</a>)
{/if}
</dd>
{/if}
{#if audit.ip}
<dt>{$t('admin.audit.ip')}</dt>
<dd>{audit.ip}</dd>
<dd>
{audit.ip}
{#if ipLink}
(<a href="/ssoadmin/addresses?ip={audit.ip}">{$t('admin.audit.ipLookup')}</a>)
{/if}
</dd>
{/if}
{#if audit.ua}
<dt>{$t('admin.audit.ua')}</dt>

View File

@ -0,0 +1,68 @@
<script lang="ts">
import type { IPAddressListEntry } from '$lib/server/ip/types';
import { t } from '$lib/i18n';
import AdminDateTime from './AdminDateTime.svelte';
let {
list,
hasAuditPermission,
unpackFlags
}: {
list: IPAddressListEntry[];
unpackFlags: (num: number) => string[];
hasAuditPermission?: boolean;
} = $props();
</script>
<table class="address-table">
<thead>
<tr>
<td>{$t('admin.ip.column.ip')}</td>
<td>{$t('admin.ip.column.flags')}</td>
<td>{$t('admin.ip.column.created_at')}</td>
<td>{$t('admin.ip.column.updated_at')}</td>
<td>{$t('admin.ip.column.user_count')}</td>
<td>{$t('admin.ip.column.list')}</td>
<td></td>
</tr>
</thead>
<tbody>
{#each list as address}
<tr>
<td><a href="/ssoadmin/addresses?ip={address.ip_address}">{address.ip_address}</a></td>
<td>{unpackFlags(address.flags)}</td>
<td><AdminDateTime date={address.created_at} /></td>
<td><AdminDateTime date={address.updated_at} /></td>
<td>{address.user_count}</td>
<td>{address.list?.name || '-'}</td>
<td>
{#if hasAuditPermission}
<a href="/ssoadmin/audit?ip={address.ip_address}">{$t('admin.audit.title')}</a>
{/if}
</td>
</tr>
{#if address.users?.length}
<tr>
<td colspan="7">
{$t('admin.ip.users')}:
{#each address.users as user}
<a href="/ssoadmin/users/{user.uuid}">{user.display_name}</a>
{/each}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
<style>
.address-table {
width: 100%;
tr > td {
padding: 8px;
}
}
</style>

View File

@ -27,6 +27,11 @@
href: '/ssoadmin/audit',
title: $t('admin.menu.audit'),
privileges: ['admin:audit']
},
{
href: '/ssoadmin/addresses',
title: $t('admin.menu.ip'),
privileges: ['admin:ip']
}
];

View File

@ -6,7 +6,8 @@
"close": "Close menu",
"users": "Users",
"oauth2": "OAuth 2.0 applications",
"audit": "Audit logs"
"audit": "Audit logs",
"ip": "IP addresses"
},
"users": {
"title": "Users",
@ -144,12 +145,27 @@
"invalidJwks": "Invalid JSON Web Keys provided, please check your input and try again."
}
},
"ip": {
"title": "IP Addresses",
"user": "Associated user",
"users": "Associated users",
"column": {
"ip": "IP address",
"flags": "Flags",
"created_at": "First seen at",
"updated_at": "Last seen at",
"user_count": "User count",
"list": "List"
}
},
"audit": {
"title": "Audit logs",
"action": "Action",
"comment": "Comment",
"user": "Actor",
"ip": "IP address",
"userLookup": "View user info",
"ipLookup": "View IP info",
"ua": "User agent",
"flagged": "Flagged",
"createdAt": "Created at"

View File

@ -1,5 +1,13 @@
import { SQL, count, desc, eq, inArray, or, sql } from 'drizzle-orm';
import { DB, auditLog, user, type AuditLog, type NewAuditLog, type User } from '../drizzle';
import {
DB,
auditLog,
ipAddress,
user,
type AuditLog,
type NewAuditLog,
type User
} from '../drizzle';
import {
AuditAction,
type AuditListItem,
@ -12,6 +20,7 @@ import { AdminNotificationEmail, Emails } from '../email';
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
import { CacheBackend } from '../cache-backend';
import { IPAddresses, IPFlag } from '../ip';
const FLAG_EMAIL_COOLDOWN = 1 * 60 * 1000;
const FLAG_TRESHOLD_COOLDOWN = 30 * 60 * 1000;
@ -34,7 +43,6 @@ export class Audit {
action,
content: comment,
actorId: user?.id,
actor_ip: ip,
actor_ua: userAgent,
flagged: Number(flagged)
};
@ -47,6 +55,25 @@ export class Audit {
Audit.auditFlagTrigger();
}
if (ip) {
const ipFlags: IPFlag[] = [];
if (flagged) {
ipFlags.push(IPFlag.FLAGGED);
}
if (action === AuditAction.LOGIN) {
ipFlags.push(IPFlag.USER_IP);
}
newAuditLog.ipAddressId = await IPAddresses.storeOrUpdateIpAddress(
ip,
ipFlags,
undefined,
user?.id
);
}
await DB.drizzle.insert(auditLog).values(newAuditLog);
}
@ -67,6 +94,7 @@ export class Audit {
.select({ rowCount: count(auditLog.id).mapWith(Number) })
.from(auditLog)
.leftJoin(user, eq(user.id, auditLog.actorId))
.leftJoin(ipAddress, eq(ipAddress.id, auditLog.ipAddressId))
.where(Audit.getAuditWhere(search));
return rowCount;
}
@ -84,6 +112,7 @@ export class Audit {
.select({ id: auditLog.id })
.from(auditLog)
.leftJoin(user, eq(user.id, auditLog.actorId))
.leftJoin(ipAddress, eq(ipAddress.id, auditLog.ipAddressId))
.limit(limit)
.offset(search.offset || 0)
.where(Audit.getAuditWhere(search))
@ -93,10 +122,12 @@ export class Audit {
const junkList = await DB.drizzle
.select({
audit_log: auditLog,
ip_address: sql<string>`INET6_NTOA(${ipAddress.ip_address})`,
user: user
})
.from(auditSubquery)
.innerJoin(auditLog, eq(auditLog.id, auditSubquery.id))
.leftJoin(ipAddress, eq(ipAddress.id, auditLog.ipAddressId))
.leftJoin(user, eq(user.id, auditLog.actorId));
const list = Audit.mapAuditRows(junkList);
@ -124,6 +155,7 @@ export class Audit {
if (search.ip) {
selectList.push(sql`lower(${auditLog.actor_ip}) LIKE ${`%${search.ip.toLowerCase()}%`}`);
selectList.push(sql`${ipAddress.ip_address} = INET6_ATON(${search.ip})`);
}
if (search.ua) {
@ -144,6 +176,7 @@ export class Audit {
private static mapAuditRows(
rows: {
audit_log: AuditLog;
ip_address?: string;
user?: User | null;
}[]
) {
@ -153,7 +186,7 @@ export class Audit {
existingEntry = {
id: entry.audit_log.id,
action: entry.audit_log.action as AuditAction,
ip: entry.audit_log.actor_ip || undefined,
ip: entry.ip_address || 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),
@ -169,6 +202,10 @@ export class Audit {
};
}
if (entry.ip_address) {
existingEntry.ip = entry.ip_address;
}
return accum;
}, []);
}

View File

@ -11,10 +11,59 @@ import {
mysqlEnum,
index,
type AnyMySqlColumn,
json
json,
uniqueIndex,
varbinary
} from 'drizzle-orm/mysql-core';
import type { JWK } from 'jose';
export const ipList = mysqlTable('ip_list', {
id: int('id', { unsigned: true }).autoincrement().primaryKey(),
name: text('name').notNull(),
url: text('url'),
default_flags: tinyint('default_flags', { unsigned: true }).notNull(),
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull()
});
export type IPList = typeof ipList.$inferSelect;
export type NewIPList = typeof ipList.$inferInsert;
export const ipAddress = mysqlTable(
'ip_address',
{
id: int('id', { unsigned: true }).autoincrement().primaryKey(),
ip_address: varbinary('ip_address', { length: 16 }).notNull(),
flags: tinyint('flags', { unsigned: true }).notNull(),
listId: int('listId', { unsigned: true }).references(() => ipList.id, { onDelete: 'cascade' }),
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull()
},
(table) => [
uniqueIndex('ip_address_idx').on(table.ip_address),
index('ip_flags_idx').on(table.flags)
]
);
export type IPAddress = typeof ipAddress.$inferSelect;
export type NewIPAddress = typeof ipAddress.$inferInsert;
export const ipUser = mysqlTable('ip_address_user', {
id: int('id', { unsigned: true }).autoincrement().primaryKey(),
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
ipAddressId: int('ipAddressId', { unsigned: true }).references(() => ipAddress.id, {
onDelete: 'cascade'
})
});
export const jwks = mysqlTable('jwks', {
uuid: varchar('uuid', { length: 36 }).primaryKey(),
fingerprint: varchar('fingerprint', { length: 64 }).notNull(),
@ -39,7 +88,10 @@ export const auditLog = mysqlTable('audit_log', {
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),
actorId: int('actorId').references(() => user.id, { onDelete: 'set null' })
actorId: int('actorId').references(() => user.id, { onDelete: 'set null' }),
ipAddressId: int('ipAddressId', { unsigned: true }).references(() => ipAddress.id, {
onDelete: 'set null'
})
});
export type AuditLog = typeof auditLog.$inferSelect;
@ -286,6 +338,10 @@ export const auditLogRelations = relations(auditLog, ({ one }) => ({
user: one(user, {
fields: [auditLog.actorId],
references: [user.id]
}),
ip_address: one(ipAddress, {
fields: [auditLog.ipAddressId],
references: [ipAddress.id]
})
}));
@ -413,3 +469,21 @@ export const userTokenRelations = relations(userToken, ({ one }) => ({
references: [user.id]
})
}));
export const ipAddressRelations = relations(ipAddress, ({ one }) => ({
list: one(ipList, {
fields: [ipAddress.listId],
references: [ipList.id]
})
}));
export const ipAddressUserRelations = relations(ipUser, ({ one }) => ({
user: one(user, {
fields: [ipUser.userId],
references: [user.id]
}),
ipAddress: one(ipAddress, {
fields: [ipUser.ipAddressId],
references: [ipAddress.id]
})
}));

View File

@ -11,6 +11,7 @@ const privileges = [
'admin:oauth2',
'admin:audit',
'admin:document',
'admin:ip',
'self:oauth2',
'self:oauth2:implicit',
'self:oauth2:create'

103
src/lib/server/ip/admin.ts Normal file
View File

@ -0,0 +1,103 @@
import { and, count, countDistinct, desc, eq, sql, type SQL } from 'drizzle-orm';
import type { IPAddressListEntry, IPAddressQuery } from './types';
import { DB, ipAddress, ipList, ipUser, user } from '../drizzle';
import type { PaginationMeta, Paginated } from '$lib/types';
export class IPAddressesAdmin {
static async filterSearchIpAddresses(
search: IPAddressQuery & {
limit?: number;
offset?: number;
}
) {
const limit = search.limit || 20;
const rowCount = await IPAddressesAdmin.ipCount(search);
const ipAddressSubquery = DB.drizzle
.select({ id: ipAddress.id })
.from(ipAddress)
.leftJoin(ipUser, eq(ipAddress.id, ipUser.ipAddressId))
.leftJoin(user, eq(user.id, ipUser.userId))
.limit(limit)
.offset(search.offset || 0)
.where(IPAddressesAdmin.getIpAddressWhere(search))
.orderBy(desc(ipAddress.updated_at))
.as('ipAddressSubquery');
const list: IPAddressListEntry[] = await DB.drizzle
.select({
id: ipAddress.id,
ip_address: sql<string>`INET6_NTOA(${ipAddress.ip_address})`,
flags: ipAddress.flags,
created_at: ipAddress.created_at,
updated_at: ipAddress.updated_at,
user_count: countDistinct(user.id),
list: {
id: ipList.id,
name: ipList.name
}
})
.from(ipAddressSubquery)
.innerJoin(ipAddress, eq(ipAddress.id, ipAddressSubquery.id))
.leftJoin(ipList, eq(ipAddress.listId, ipList.id))
.leftJoin(ipUser, eq(ipAddress.id, ipUser.ipAddressId))
.leftJoin(user, eq(user.id, ipUser.userId))
.orderBy(desc(ipAddress.updated_at))
.groupBy(ipAddress.id);
const meta: PaginationMeta = {
rowCount,
pageSize: limit,
pageCount: Math.ceil(rowCount / limit)
};
if (search.includeUsers) {
await Promise.all(
list.map(async (entry) => {
entry.users = await DB.drizzle
.select({
uuid: user.uuid,
display_name: user.display_name
})
.from(user)
.innerJoin(ipUser, eq(user.id, ipUser.userId))
.where(eq(ipUser.ipAddressId, entry.id));
})
);
}
return { list, meta } satisfies Paginated<IPAddressListEntry>;
}
static async ipCount(search: IPAddressQuery) {
const [{ rowCount }] = await DB.drizzle
.select({ rowCount: count(ipAddress.id).mapWith(Number) })
.from(ipAddress)
.leftJoin(ipUser, eq(ipUser.ipAddressId, ipAddress.id))
.leftJoin(user, eq(user.id, ipUser.userId))
.where(IPAddressesAdmin.getIpAddressWhere(search));
return rowCount;
}
static getIpAddressWhere(search: IPAddressQuery) {
const selectList: SQL<unknown>[] = [];
if (search.ip) {
selectList.push(sql`${ipAddress.ip_address} = INET6_ATON(${search.ip})`);
}
if (search.flags) {
selectList.push(sql`${ipAddress.flags} & ${Number(search.flags)}`);
}
if (search.user) {
selectList.push(eq(user.uuid, search.user));
}
if (search.listId) {
selectList.push(eq(ipAddress.listId, Number(search.listId)));
}
return and(...selectList);
}
}

114
src/lib/server/ip/index.ts Normal file
View File

@ -0,0 +1,114 @@
import { and, eq, sql } from 'drizzle-orm';
import { DB, ipAddress, ipList, ipUser } from '../drizzle';
import { CacheBackend } from '../cache-backend';
import { IPFlag } from './types';
import { ensureArray } from '$lib/utils';
export class IPAddresses {
static flagsList = Object.values(IPFlag).filter((entry) => isNaN(Number(entry))) as string[];
static async getIpAddressesByFlag(flag: IPFlag | IPFlag[]) {
const flags = ensureArray(flag);
const combinedFlag = flags.reduce<number>((num, flag) => num | (1 << flag), 0);
const ipAddresses = await DB.drizzle
.select({ ip_address: sql<string>`INET6_NTOA(${ipAddress.ip_address})` })
.from(ipAddress)
.where(sql`${ipAddress.flags} & ${combinedFlag}`);
return ipAddresses.map(({ ip_address }) => ip_address);
}
static async getIpAddressesByUserId(userId: number) {
return DB.drizzle
.select({
id: ipAddress.id,
ip_address: sql<string>`INET6_NTOA(${ipAddress.ip_address})`,
flags: ipAddress.flags,
updated_at: ipAddress.updated_at,
list: {
id: ipList.id,
name: ipList.name
}
})
.from(ipAddress)
.leftJoin(ipUser, eq(ipAddress.id, ipUser.ipAddressId))
.leftJoin(ipList, eq(ipList.id, ipAddress.listId))
.where(eq(ipUser.userId, userId));
}
static async getCachedIpAddressesByFlag(flag: IPFlag) {
const existing = await CacheBackend.get<string[]>(`ipAddressList${flag}`);
if (!existing) {
const result = await IPAddresses.getIpAddressesByFlag(flag);
await CacheBackend.set(`ipAddressList${flag}`, result, 60 * 60 * 1000); // 1 hour cache
return result;
}
return existing;
}
static async isIpFlagged(address: string, flag: IPFlag) {
const [entry] = await DB.drizzle
.select()
.from(ipAddress)
.where(
and(
sql`${ipAddress.ip_address} = INET6_ATON(${address})`,
sql`${ipAddress.flags} & ${1 << flag}`
)
);
return !!entry;
}
static async storeOrUpdateIpAddress(
address: string,
flags: IPFlag[],
listId?: number,
userId?: number
) {
let updateId: number;
const combinedFlag = flags.reduce<number>((num, flag) => num | (1 << flag), 0);
const [existingIp] = await DB.drizzle
.select()
.from(ipAddress)
.where(sql`${ipAddress.ip_address} = INET6_ATON(${address})`);
if (existingIp) {
updateId = existingIp.id;
existingIp.flags = existingIp.flags | combinedFlag;
existingIp.updated_at = new Date();
if (listId) {
existingIp.listId = listId || null;
}
await DB.drizzle.update(ipAddress).set(existingIp).where(eq(ipAddress.id, existingIp.id));
} else {
const [result] = await DB.drizzle
.insert(ipAddress)
.values({
ip_address: sql`INET6_ATON(${address})`,
flags: combinedFlag,
listId: listId || null
})
.$returningId();
updateId = result.id;
}
if (userId) {
const [relationExists] = await DB.drizzle
.select()
.from(ipUser)
.where(and(eq(ipUser.userId, userId), eq(ipUser.ipAddressId, updateId)));
if (relationExists) {
return updateId;
}
await DB.drizzle.insert(ipUser).values({
ipAddressId: updateId,
userId
});
}
return updateId;
}
}
export { IPFlag };

View File

@ -0,0 +1,32 @@
export enum IPFlag {
FLAGGED = 0,
USER_IP,
RESTRICT_UPLOAD,
RESTRICT_REGISTRATION,
REJECT
}
export interface IPAddressQuery {
ip?: string;
flags?: number;
user?: string;
listId?: number;
includeUsers?: boolean;
}
export interface IPAddressListEntry {
id: number;
ip_address: string;
flags: number;
created_at: Date;
updated_at: Date;
list: {
id: number;
name: string;
} | null;
user_count: number;
users?: {
uuid: string;
display_name: string;
}[];
}

View File

@ -2,11 +2,12 @@ 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 { IPAddresses, IPFlag } from '$lib/server/ip';
import { Uploads } from '$lib/server/upload.js';
import { Users, type UserSession } from '$lib/server/users/index.js';
import { TimeOTP } from '$lib/server/users/totp.js';
import { passwordRegex } from '$lib/validators.js';
import { fail, redirect } from '@sveltejs/kit';
import { error, fail, redirect } from '@sveltejs/kit';
interface AccountUpdate {
displayName: string;
@ -166,6 +167,11 @@ export const actions = {
return redirect(303, '/login');
}
// IP banned from uploading files
if (await IPAddresses.isIpFlagged(getClientAddress(), IPFlag.RESTRICT_UPLOAD)) {
throw error(403);
}
const formData = Object.fromEntries(await request.formData());
if (!(formData.file as File)?.name || (formData.file as File).name === 'undefined') {
return fail(400, {

View File

@ -0,0 +1,15 @@
import { IPAddresses, IPFlag } from '$lib/server/ip';
export const GET = () =>
IPAddresses.getCachedIpAddressesByFlag(IPFlag.REJECT)
.then(
(list) =>
new Response(list.join('\n'), { status: 200, headers: { 'Content-Type': 'text/plain' } })
)
.catch(
() =>
new Response('Internal Server Error', {
status: 500,
headers: { 'Content-Type': 'text/plain' }
})
);

View File

@ -19,7 +19,7 @@ const rainbowTableLimiter = new RateLimiter({
});
const limiter = new RateLimiter({
IP: [6, 'm']
IP: [8, 'm']
});
export const actions = {

View File

@ -1,16 +1,12 @@
import { Audit, AuditAction } from '$lib/server/audit';
import { Changesets } from '$lib/server/changesets.js';
import { IPAddresses, IPFlag } from '$lib/server/ip';
import { Users } from '$lib/server/users/index.js';
import { UserTokens } from '$lib/server/users/tokens.js';
import { emailRegex, passwordRegex } from '$lib/validators.js';
import { error, redirect } from '@sveltejs/kit';
import { RateLimiter } from 'sveltekit-rate-limiter/server';
interface PasswordRequest {
newPassword: string;
repeatPassword: string;
}
// Sending an email asynchronously has a similar amount of delay,
// so lets fake it. TODO: offload email sending somewhere else.
const failDelay = () =>
@ -28,6 +24,11 @@ export const actions = {
throw error(429);
}
// IP banned
if (await IPAddresses.isIpFlagged(event.getClientAddress(), IPFlag.RESTRICT_REGISTRATION)) {
throw error(403);
}
if (locals.session.data?.user) {
return redirect(303, '/');
}

View File

@ -2,6 +2,7 @@ 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 { IPAddresses, IPFlag } from '$lib/server/ip/index.js';
import { Users } from '$lib/server/users/index.js';
import { emailRegex, passwordRegex, usernameRegex } from '$lib/validators.js';
import { error, fail, redirect } from '@sveltejs/kit';
@ -30,7 +31,16 @@ const limiter = new RateLimiter({
export const actions = {
default: async (event) => {
const { request, locals } = event;
if (await limiter.isLimited(event)) throw error(429);
// Rate limited
if (await limiter.isLimited(event)) {
throw error(429);
}
// IP banned
if (await IPAddresses.isIpFlagged(event.getClientAddress(), IPFlag.RESTRICT_REGISTRATION)) {
throw error(403);
}
// Logged in users cannot make more accounts
if (locals.session.data?.user || env.REGISTRATIONS === 'false') {

View File

@ -0,0 +1,39 @@
import { AdminUtils } from '$lib/server/admin-utils';
import { Changesets } from '$lib/server/changesets';
import { IPAddressesAdmin } from '$lib/server/ip/admin';
import { IPAddresses } from '$lib/server/ip/index.js';
import { hasPrivileges } from '$lib/utils.js';
const PAGE_SIZE = 100;
export const load = async ({ parent, url }) => {
const { user } = await parent();
AdminUtils.checkPrivileges(user, ['admin:ip']);
const {
page,
pageSize,
ip,
flags,
user: listUser,
listId
} = Changesets.only(['page', 'pageSize', 'user', 'flags', 'ip', 'listId'], url.searchParams);
const limit = Number(pageSize) || PAGE_SIZE;
const offset = ((Number(page) || 1) - 1) * limit;
const data = await IPAddressesAdmin.filterSearchIpAddresses({
limit,
offset,
ip,
includeUsers: !!ip && hasPrivileges(user.privileges, ['admin:user']),
user: listUser,
flags: flags ? Number(flags) : undefined,
listId: listId ? Number(listId) : undefined
});
return {
...data,
flags: IPAddresses.flagsList
};
};

View File

@ -0,0 +1,93 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import type { PageData } from './$types';
import { t } from '$lib/i18n';
import ColumnView from '$lib/components/container/ColumnView.svelte';
import Paginator from '$lib/components/Paginator.svelte';
import { page } from '$app/state';
import { hasPrivileges } from '$lib/utils';
import AdminIpTable from '$lib/components/admin/AdminIpTable.svelte';
import SplitView from '$lib/components/container/SplitView.svelte';
import FormControl from '$lib/components/form/FormControl.svelte';
import Button from '$lib/components/Button.svelte';
import FormActions from '$lib/components/form/FormActions.svelte';
let { data }: { data: PageData } = $props();
const hasAuditPermission = $derived(hasPrivileges(data.user.privileges, ['admin:audit']));
const unpackFlags = (flags: number) =>
data.flags.reduce<string[]>((list, key, index) => {
if (flags & (1 << index)) {
list.push(key);
}
return list;
}, []);
const packFlags = (flags: string[]) =>
flags.reduce<number>((flag, key) => flag | (1 << data.flags.indexOf(key)), 0);
const currentSearchFlags = $derived(
page.url.searchParams.has('flags')
? unpackFlags(Number(page.url.searchParams.get('flags')))
: []
);
let flagsState = $state(0);
const updateFlagsState = (e: Event) => {
const target = e.target as HTMLSelectElement;
const selection = Array.from(target.selectedOptions).map(({ value }) => value);
flagsState = packFlags(selection);
};
$effect(() => {
flagsState = packFlags(currentSearchFlags);
});
</script>
<svelte:head>
<title>{$t('admin.ip.title')} - {env.PUBLIC_SITE_NAME} {$t('admin.title')}</title>
</svelte:head>
<h1>{$t('admin.ip.title')} ({data.meta.rowCount})</h1>
<ColumnView>
<form action="" method="get">
<ColumnView>
<SplitView>
<FormControl>
<label for="ip">{$t('admin.ip.column.ip')}</label>
<input name="ip" id="ip" value={page.url.searchParams.get('ip')} />
</FormControl>
<FormControl>
<label for="user">{$t('admin.ip.user')}</label>
<input name="user" id="user" value={page.url.searchParams.get('user')} />
</FormControl>
</SplitView>
<SplitView>
<FormControl>
<input name="flags" value={flagsState} type="hidden" />
<label for="flagselect">{$t('admin.ip.column.flags')}</label>
<select id="flagselect" value={currentSearchFlags} multiple onchange={updateFlagsState}>
{#each data.flags as flag}
<option value={flag}>{flag}</option>
{/each}
</select>
</FormControl>
</SplitView>
<FormActions padded>
<a href="/ssoadmin/addresses">{$t('common.clear')}</a>
<Button type="submit">{$t('common.filter')}</Button>
</FormActions>
</ColumnView>
</form>
<Paginator meta={data.meta} />
<AdminIpTable list={data.list} {hasAuditPermission} {unpackFlags} />
<Paginator meta={data.meta} />
</ColumnView>

View File

@ -10,12 +10,16 @@
import Button from '$lib/components/Button.svelte';
import AdminAuditCard from '$lib/components/admin/AdminAuditCard.svelte';
import FormActions from '$lib/components/form/FormActions.svelte';
import { hasPrivileges } from '$lib/utils';
interface Props {
data: PageData;
}
let { data }: Props = $props();
const hasIpPermission = $derived(hasPrivileges(data.user.privileges, ['admin:ip']));
const hasUserPermission = $derived(hasPrivileges(data.user.privileges, ['admin:user']));
</script>
<svelte:head>
@ -81,7 +85,7 @@
<div class="audit-list">
<Paginator meta={data.meta} />
{#each data.list as audit}
<AdminAuditCard {audit} />
<AdminAuditCard {audit} ipLink={hasIpPermission} userLink={hasUserPermission} />
{/each}
<Paginator meta={data.meta} />
</div>

View File

@ -4,6 +4,7 @@ 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';
import { IPAddresses, IPFlag } from '$lib/server/ip';
import {
OAuth2ClientURLType,
OAuth2Clients,
@ -359,6 +360,11 @@ export const actions = {
avatar: async ({ request, locals, params: { uuid }, getClientAddress }) => {
const { currentUser, details } = await getActionData(locals, uuid);
// IP banned from uploading files
if (await IPAddresses.isIpFlagged(getClientAddress(), IPFlag.RESTRICT_UPLOAD)) {
throw error(403);
}
const formData = Object.fromEntries(await request.formData());
if (!(formData.file as File)?.name || (formData.file as File).name === 'undefined') {
return fail(400, {

View File

@ -1,20 +1,20 @@
<script lang="ts">
import { get } from 'svelte/store';
import { t } from '$lib/i18n';
import type { ActionData, PageData } from './$types';
import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte';
import { env } from '$env/dynamic/public';
import ActionButton from '$lib/components/ActionButton.svelte';
import { onNavigate } from '$app/navigation';
import { popupFormErrors } from '$lib/form-errors';
import { displayMessage, clearMessages } from '$lib/stores/messages.store';
import { get } from 'svelte/store';
import { hasPrivileges } from '$lib/utils';
import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte';
import SplitView from '$lib/components/container/SplitView.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import FormSection from '$lib/components/form/FormSection.svelte';
import FormControl from '$lib/components/form/FormControl.svelte';
import ColumnView from '$lib/components/container/ColumnView.svelte';
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
import Button from '$lib/components/Button.svelte';
interface Props {
@ -24,7 +24,8 @@
let { data, form }: Props = $props();
const hasAuditPrivilege = hasPrivileges(data.user.privileges, ['admin:audit']);
const hasIpPrivileges = $derived(hasPrivileges(data.user.privileges, ['admin:ip']));
const hasAuditPrivileges = $derived(hasPrivileges(data.user.privileges, ['admin:audit']));
$effect(() => popupFormErrors(form, 'admin.users.errors'));
$effect(() => {
@ -101,7 +102,13 @@
<h3>{$t('admin.users.actions')}</h3>
<ul>
{#if hasAuditPrivilege}
{#if hasIpPrivileges}
<li>
<a href="/ssoadmin/addresses?user={data.details.uuid}">{$t('admin.ip.title')}</a>
</li>
{/if}
{#if hasAuditPrivileges}
<li>
<a href="/ssoadmin/audit?user={data.details.uuid}">{$t('admin.audit.title')}</a>
</li>