New logger, audit notifications

This commit is contained in:
Evert Prants 2024-12-21 21:00:48 +02:00
parent a86b2a346b
commit 563e0a0350
Signed by: evert
GPG Key ID: 0960A17F9F40237D
22 changed files with 749 additions and 356 deletions

View File

@ -1,3 +1,6 @@
# Current environment - production or development
NODE_ENV=production
# Front-end public URL, without leading slash # Front-end public URL, without leading slash
PUBLIC_URL=http://localhost:5173 PUBLIC_URL=http://localhost:5173
@ -23,6 +26,7 @@ SESSION_SECURE=true
JWT_ALGORITHM=RS256 JWT_ALGORITHM=RS256
JWT_EXPIRATION=7d JWT_EXPIRATION=7d
JWT_ISSUER=http://localhost:5173 JWT_ISSUER=http://localhost:5173
JWT_KEYLENGTH=2048
# SMTP settings # SMTP settings
EMAIL_ENABLED=true EMAIL_ENABLED=true
@ -32,6 +36,7 @@ EMAIL_SMTP_PORT=587
EMAIL_SMTP_SECURE=false EMAIL_SMTP_SECURE=false
EMAIL_SMTP_USER= EMAIL_SMTP_USER=
EMAIL_SMTP_PASS= EMAIL_SMTP_PASS=
EMAIL_ADMIN=
# Enable new account registrations # Enable new account registrations
REGISTRATIONS=true REGISTRATIONS=true

View File

@ -12,10 +12,8 @@ This is a SvelteKit-powered authentication service.
1. Clone the repository. 1. Clone the repository.
2. Install dependenices - `npm install`. 2. Install dependenices - `npm install`.
3. Configure the environment - `cp .env.example .env`. 3. Configure the environment - `cp .env.example .env`.
4. Generate secrets and stuff: 4. Generate secrets:
1. Session secret - `node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))'`. 1. Session secret - `node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))'`.
2. Challenge secret - `node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'`. 2. Challenge secret - `node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'`.
3. Generate JWT keys in the `private` directory - `openssl genpkey -out jwt.private.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048`.
4. Also make the public key - `openssl rsa -in jwt.private.pem -pubout -outform PEM -out jwt.public.pem`.
5. Build the application - `npm run build`. 5. Build the application - `npm run build`.
6. Run the application - `node -r dotenv/config build`. 6. Run the application - `node -r dotenv/config build`.

622
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,40 +12,41 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/kit": "^2.8.2", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^4.0.1", "@sveltejs/vite-plugin-svelte": "^5.0.1",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^22.9.3", "@types/node": "^22.10.1",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.17.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.17.0",
"drizzle-kit": "^0.28.1", "drizzle-kit": "^0.30.0",
"eslint": "^9.15.0", "eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.46.0", "eslint-plugin-svelte": "^2.46.1",
"prettier": "^3.3.3", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2", "prettier-plugin-svelte": "^3.3.2",
"svelte": "^5.2.7", "svelte": "^5.9.1",
"svelte-check": "^4.1.0", "svelte-check": "^4.1.1",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^5.4.11" "vite": "^6.0.3"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@sveltejs/adapter-node": "^5.2.9", "@sveltejs/adapter-node": "^5.2.9",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"chalk": "^5.4.0",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.7",
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.38.0",
"image-size": "^1.1.1", "image-size": "^1.1.1",
"jose": "^5.9.6", "jose": "^5.9.6",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"mysql2": "^3.11.4", "mysql2": "^3.11.5",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",

View File

@ -3,25 +3,34 @@ import { csrf } from '$lib/server/csrf';
import { DB } from '$lib/server/drizzle'; import { DB } from '$lib/server/drizzle';
import { runSeeds } from '$lib/server/drizzle/seeds'; import { runSeeds } from '$lib/server/drizzle/seeds';
import { JWT } from '$lib/server/jwt'; import { JWT } from '$lib/server/jwt';
import { Logger, LogLevel } from '$lib/server/logger';
import type { ThemeModeType } from '$lib/theme-mode'; import type { ThemeModeType } from '$lib/theme-mode';
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks'; import { sequence } from '@sveltejs/kit/hooks';
import { migrate } from 'drizzle-orm/mysql2/migrator'; import { migrate } from 'drizzle-orm/mysql2/migrator';
import { handleSession } from 'svelte-kit-cookie-session'; import { handleSession } from 'svelte-kit-cookie-session';
const { AUTO_MIGRATE, SESSION_SECRET, SESSION_SECURE } = env; const { AUTO_MIGRATE, SESSION_SECRET, SESSION_SECURE, NODE_ENV } = env;
// Set logger to debug mode
if (NODE_ENV === 'development') {
Logger.setLogLevel(LogLevel.DEBUG);
Logger.debug('Debug mode enabled');
}
// Initialize the database // Initialize the database
await DB.init(); await DB.init();
// Initialize the JWT keys
await JWT.init();
// Migrate the database when automatic migration is enabled // Migrate the database when automatic migration is enabled
if (AUTO_MIGRATE === 'true') { if (AUTO_MIGRATE === 'true') {
Logger.info('Running automatic database migrations');
await migrate(DB.drizzle, { migrationsFolder: './migrations' }); await migrate(DB.drizzle, { migrationsFolder: './migrations' });
Logger.info('Database migrations complete');
} }
// Initialize the JWT keys
await JWT.init();
// Run database data seeders // Run database data seeders
await runSeeds(); await runSeeds();
@ -50,3 +59,15 @@ export const handle = sequence(
}), }),
handleThemeHook handleThemeHook
); );
export async function handleError({ event, error, message, status }) {
const request = `${event.request.method} ${event.request.url} ${status} - ${message}`;
if (status && status >= 200 && status < 500) {
// Don't need error trace for non-server errors
Logger.error(request);
} else {
Logger.error(request, error);
}
return { message };
}

View File

@ -1,4 +1,4 @@
import { SQL, count, desc, eq, inArray, or, sql } from 'drizzle-orm'; import { SQL, count, desc, eq, inArray, lt, or, sql } from 'drizzle-orm';
import { DB, auditLog, user, type AuditLog, type NewAuditLog, type User } from '../drizzle'; import { DB, auditLog, user, type AuditLog, type NewAuditLog, type User } from '../drizzle';
import { import {
AuditAction, AuditAction,
@ -7,15 +7,23 @@ import {
type MinimalRequestEvent type MinimalRequestEvent
} from './types'; } from './types';
import type { Paginated, PaginationMeta } from '$lib/types'; import type { Paginated, PaginationMeta } from '$lib/types';
import { Logger } from '../logger';
import { AdminNotificationEmail, Emails } from '../email';
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
const AUTOFLAG = [ const FLAG_EMAIL_COOLDOWN = 1 * 60 * 1000;
AuditAction.MALICIOUS_REQUEST, const FLAG_TRESHOLD_COOLDOWN = 30 * 60 * 1000;
AuditAction.THROTTLE, const FLAG_EMAIL_TRESHOLD = 3;
AuditAction.DEACTIVATION_REQUEST, const AUTOFLAG = [AuditAction.MALICIOUS_REQUEST, AuditAction.THROTTLE];
AuditAction.DATA_DOWNLOAD_REQUEST
];
export class Audit { export class Audit {
protected static logger = new Logger('AUDIT');
protected static flagEmailTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
protected static flagTresholdTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
protected static flagTriggerCount = 0;
protected static flagEmailCount = 0;
public static async insert( public static async insert(
action: AuditAction, action: AuditAction,
comment?: string, comment?: string,
@ -23,16 +31,23 @@ export class Audit {
ip?: string, ip?: string,
userAgent?: string userAgent?: string
) { ) {
const flagged = AUTOFLAG.includes(action);
const newAuditLog: NewAuditLog = { const newAuditLog: NewAuditLog = {
action, action,
content: comment, content: comment,
actorId: user?.id, actorId: user?.id,
actor_ip: ip, actor_ip: ip,
actor_ua: userAgent, actor_ua: userAgent,
flagged: Number(AUTOFLAG.includes(action)) flagged: Number(flagged)
}; };
// TODO: send flagged to administrator Audit.logger.info(
`${action} ${user?.id || '-'} (${ip || '-'}) "${userAgent || '-'}" "${comment}" ${flagged}`
);
if (flagged) {
Audit.auditFlagTrigger();
}
await DB.drizzle.insert(auditLog).values(newAuditLog); await DB.drizzle.insert(auditLog).values(newAuditLog);
} }
@ -159,4 +174,51 @@ export class Audit {
return accum; return accum;
}, []); }, []);
} }
private static async auditFlagTrigger() {
Audit.flagTriggerCount += 1;
clearTimeout(Audit.flagTresholdTimeout);
Audit.flagTresholdTimeout = setTimeout(() => {
Audit.flagTriggerCount = 0;
Audit.flagEmailCount = 0;
}, FLAG_TRESHOLD_COOLDOWN);
if (
Audit.flagTriggerCount < FLAG_EMAIL_TRESHOLD ||
Audit.flagEmailTimeout ||
Audit.flagEmailCount > 1
) {
return;
}
Logger.verbose('Flag treshold reached, sending out audit email to administrator');
Audit.flagEmailTimeout = setTimeout(() => {
void Audit.sendAuditEmail(Audit.flagTriggerCount);
Audit.flagTriggerCount = 0;
Audit.flagEmailCount += 1;
Audit.flagEmailTimeout = undefined;
}, FLAG_EMAIL_COOLDOWN);
}
/**
* Send an audit warning email
*/
private static async sendAuditEmail(count: number) {
if (!env.EMAIL_ADMIN) {
return;
}
const content = AdminNotificationEmail(count);
try {
await Emails.getSender().sendTemplate(
env.EMAIL_ADMIN,
`Audit events on ${publicEnv.PUBLIC_SITE_NAME} may require attention`,
content
);
} catch (error) {
Logger.error('Failed to send audit email:', error);
}
}
} }

View File

@ -1,7 +1,8 @@
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { drizzle } from 'drizzle-orm/mysql2'; import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise'; import mysql from 'mysql2';
import * as schema from './schema'; import * as schema from './schema';
import { Logger } from '../logger';
const { DATABASE_DB, DATABASE_HOST, DATABASE_PASS, DATABASE_USER } = env; const { DATABASE_DB, DATABASE_HOST, DATABASE_PASS, DATABASE_USER } = env;
@ -24,12 +25,14 @@ export class DB {
DB.mysqlConnection.on('connection', (connection) => { DB.mysqlConnection.on('connection', (connection) => {
connection.on('error', (error) => { connection.on('error', (error) => {
// We log warning on connection error, this is not a critical error // We log warning on connection error, this is not a critical error
console.warn('Error received on connection. Will destroy.', { error }); Logger.warn('Error received on connection. Will destroy.', { error });
connection.destroy(); connection.destroy();
}); });
}); });
DB.drizzle = drizzle(DB.mysqlConnection, { schema, mode: 'default' }); DB.drizzle = drizzle({ client: DB.mysqlConnection, schema, mode: 'default' });
Logger.debug('Database initialization complete');
} }
} }

View File

@ -0,0 +1,17 @@
import { env } from '$env/dynamic/public';
import type { EmailTemplate } from '../template.interface';
export const AdminNotificationEmail = (count: number): EmailTemplate => ({
text: `
${env.PUBLIC_SITE_NAME}
There have been ${count} flagged audit messages in the past 30 minutes. Urgent attention may be required.
This email was sent to you because you are the administrator contact on ${env.PUBLIC_SITE_NAME}.`,
html: /* html */ `
<h1>${env.PUBLIC_SITE_NAME}</h1>
<p>There have been <strong>${count}</strong> flagged audit messages in the past 5 minutes. <strong>Urgent attention may be required.</strong></p>
<p>This email was sent to you because you are the administrator contact on ${env.PUBLIC_SITE_NAME}.</p>`
});

View File

@ -2,3 +2,4 @@ export * from './forgot-password.email';
export * from './invitation.email'; export * from './invitation.email';
export * from './oauth2-invitation.email'; export * from './oauth2-invitation.email';
export * from './registration.email'; export * from './registration.email';
export * from './admin-notification.email';

View File

@ -1,4 +1,4 @@
import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises'; import { mkdir, readFile, rm, stat, unlink, writeFile } from 'fs/promises';
import { dirname, join, resolve } from 'path'; import { dirname, join, resolve } from 'path';
export class FileBackend { export class FileBackend {
@ -32,6 +32,15 @@ export class FileBackend {
} }
} }
static async deleteDirectory(path: string | string[]) {
try {
await rm(FileBackend.filePath(path), { recursive: true });
return true;
} catch {
return false;
}
}
private static filePath(path: string | string[]) { private static filePath(path: string | string[]) {
return resolve(Array.isArray(path) ? join(...path) : path); return resolve(Array.isArray(path) ? join(...path) : path);
} }

View File

@ -15,8 +15,10 @@ import { v4 as uuidv4 } from 'uuid';
import { FileBackend } from './file-backend'; import { FileBackend } from './file-backend';
import { DB, jwks, type JsonKey } from './drizzle'; import { DB, jwks, type JsonKey } from './drizzle';
import { CryptoUtils } from './crypto-utils'; import { CryptoUtils } from './crypto-utils';
import { Logger } from './logger';
import { lt } from 'drizzle-orm';
const { JWT_ALGORITHM, JWT_EXPIRATION, JWT_ISSUER } = env; const { JWT_ALGORITHM, JWT_EXPIRATION, JWT_ISSUER, JWT_KEYLENGTH } = env;
const ISSUER_EXPIRY = 365 * 24 * 60 * 60 * 1000; const ISSUER_EXPIRY = 365 * 24 * 60 * 60 * 1000;
const ISSUER_ROTATE = ISSUER_EXPIRY / 2; const ISSUER_ROTATE = ISSUER_EXPIRY / 2;
@ -26,11 +28,6 @@ interface AvailableWebKey extends JsonKey {
publicKeyJWK: JWK; publicKeyJWK: JWK;
} }
/**
* Generate JWT keys using the following commands:
* Private: openssl genpkey -out jwt.private.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048
* Public: openssl rsa -in jwt.private.pem -pubout -outform PEM -out jwt.public.pem
*/
export class JWT { export class JWT {
private static keys: AvailableWebKey[] = []; private static keys: AvailableWebKey[] = [];
@ -44,19 +41,8 @@ export class JWT {
} }
static async init() { static async init() {
const keys = await DB.drizzle.select().from(jwks); await JWT.clearExpiredKeys();
JWT.keys = await JWT.loadKeys(keys); await JWT.loadFromStorage();
// No current key or it is time to rotate
const keyPair = JWT.keys.find(({ current }) => current === 1);
if (!keyPair || keyPair.rotate_at.getTime() < Date.now()) {
const newKey = await JWT.createKeyPair();
if (keyPair) {
keyPair.current = 0;
}
JWT.keys.push(newKey);
}
} }
static async issue(claims: Record<string, unknown>, subject: string, audience?: string) { static async issue(claims: Record<string, unknown>, subject: string, audience?: string) {
@ -95,6 +81,45 @@ export class JWT {
return payload; return payload;
} }
private static async clearExpiredKeys() {
Logger.debug(`Loading expired JWT key information`);
const expiredQuery = lt(jwks.expires_at, new Date());
const expireList = await DB.drizzle.select().from(jwks).where(expiredQuery);
if (!expireList.length) {
Logger.debug(`There are no expired JWT keys to clean up`);
return;
}
for (const expired of expireList) {
Logger.warn(`Deleting expired JWT key pair ${expired.uuid} (${expired.fingerprint})`);
await FileBackend.deleteDirectory(['private', expired.uuid]);
}
// Delete expired keys
await DB.drizzle.delete(jwks).where(expiredQuery);
}
private static async loadFromStorage() {
// Fetch existing keys
const keys = await DB.drizzle.select().from(jwks);
Logger.verbose(`Loading ${keys.length} JWT key pairs into memory`);
JWT.keys = await JWT.loadKeys(keys);
// No current key or it is time to rotate
const keyPair = JWT.keys.find(({ current }) => current === 1);
if (!keyPair || keyPair.rotate_at.getTime() < Date.now()) {
Logger.verbose(`Creating a new JWT key pair`);
const newKey = await JWT.createKeyPair();
if (keyPair) {
keyPair.current = 0;
}
JWT.keys.push(newKey);
}
}
private static getCurrentKey() { private static getCurrentKey() {
const current = JWT.keys.find(({ current }) => current === 1); const current = JWT.keys.find(({ current }) => current === 1);
if (!current) { if (!current) {
@ -104,8 +129,13 @@ export class JWT {
} }
private static async createKeyPair() { private static async createKeyPair() {
/**
* Equivalent openssl commands:
* Private: openssl genpkey -out jwt.private.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048
* Public: openssl rsa -in jwt.private.pem -pubout -outform PEM -out jwt.public.pem
*/
const { privateKey, publicKey } = await generateKeyPair(JWT_ALGORITHM, { const { privateKey, publicKey } = await generateKeyPair(JWT_ALGORITHM, {
modulusLength: 2048 modulusLength: Number(JWT_KEYLENGTH) || 2048
}); });
const jwk = await exportJWK(publicKey); const jwk = await exportJWK(publicKey);
const kid = uuidv4({ random: Buffer.from(jwk.n as string).subarray(0, 16) }); const kid = uuidv4({ random: Buffer.from(jwk.n as string).subarray(0, 16) });

151
src/lib/server/logger.ts Normal file
View File

@ -0,0 +1,151 @@
import chalk from 'chalk';
import { format as strfmt, formatWithOptions as strfmtOptions } from 'node:util';
export enum LogLevel {
DEBUG = 0,
VERBOSE,
INFO,
WARN,
ERROR
}
export interface ILogger {
log: (format: any, ...arg: any[]) => void;
debug: (format: any, ...arg: any[]) => void;
verbose: (format: any, ...arg: any[]) => void;
info: (format: any, ...arg: any[]) => void;
warn: (format: any, ...arg: any[]) => void;
error: (format: any, ...arg: any[]) => void;
}
const chalkColors: Record<LogLevel, (str: string) => string> = {
[LogLevel.DEBUG]: chalk.magenta,
[LogLevel.VERBOSE]: chalk.blueBright,
[LogLevel.INFO]: chalk.green,
[LogLevel.WARN]: chalk.yellow,
[LogLevel.ERROR]: chalk.red
};
export class Logger implements ILogger {
protected static instance?: Logger;
constructor(
public context: string = '',
public logLevel: LogLevel = LogLevel.VERBOSE
) {}
static getInstance() {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
protected get formatted() {
return this.logLevel === LogLevel.DEBUG;
}
protected colorize(level: LogLevel, leading: string, trailing: string) {
if (!this.formatted) {
return leading + trailing;
}
return chalkColors[level](leading) + trailing;
}
protected handle(level: LogLevel, format: any, ...arg: any[]): void {
if (level < this.logLevel) {
return;
}
const timestamp = this.getTimestamp();
const logLevelKey = (Object.values(LogLevel) as string[])[level].toUpperCase().padStart(7);
let formatted = this.formatted
? strfmtOptions({ colors: true, depth: 8 }, format, ...arg)
: strfmt(format, ...arg);
// Keep non-debug logs on one line for neatness
if (!this.formatted) {
formatted = formatted.replace(/\n/g, ' ');
}
const outLine = this.colorize(
level,
`[${process.pid}] ${timestamp} ${logLevelKey}${this.context ? ` [${this.context}]` : ''} `,
formatted
);
this.writeLog(LogLevel.ERROR ? 'err' : 'out', outLine);
}
protected getTimestamp() {
return new Intl.DateTimeFormat('en-GB', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(new Date());
}
protected writeLog(stream: 'out' | 'err', message: string) {
(stream === 'out' ? process.stdout : process.stderr).write(`${message}\r\n`);
}
setLogLevel(newLevel: LogLevel) {
this.logLevel = newLevel;
}
static setLogLevel(newLevel: LogLevel) {
Logger.getInstance().logLevel = newLevel;
}
log(format: any, ...arg: any[]) {
this.handle(LogLevel.INFO, format, ...arg);
}
static log(format: any, ...arg: any[]) {
Logger.getInstance().handle(LogLevel.INFO, format, ...arg);
}
debug(format: any, ...arg: any[]) {
this.handle(LogLevel.DEBUG, format, ...arg);
}
static debug(format: any, ...arg: any[]) {
Logger.getInstance().handle(LogLevel.DEBUG, format, ...arg);
}
verbose(format: any, ...arg: any[]) {
this.handle(LogLevel.VERBOSE, format, ...arg);
}
static verbose(format: any, ...arg: any[]) {
Logger.getInstance().handle(LogLevel.VERBOSE, format, ...arg);
}
info(format: any, ...arg: any[]) {
this.handle(LogLevel.INFO, format, ...arg);
}
static info(format: any, ...arg: any[]) {
Logger.getInstance().handle(LogLevel.INFO, format, ...arg);
}
warn(format: any, ...arg: any[]) {
this.handle(LogLevel.WARN, format, ...arg);
}
static warn(format: any, ...arg: any[]) {
Logger.getInstance().handle(LogLevel.WARN, format, ...arg);
}
error(format: any, ...arg: any[]) {
this.handle(LogLevel.ERROR, format, ...arg);
}
static error(format: any, ...arg: any[]) {
Logger.getInstance().handle(LogLevel.ERROR, format, ...arg);
}
}

View File

@ -1,3 +1,4 @@
import { Logger } from '$lib/server/logger';
import type { UserSession } from '../../users'; import type { UserSession } from '../../users';
import { import {
InvalidRequest, InvalidRequest,
@ -26,7 +27,7 @@ export class OAuth2AuthorizationController {
} }
const redirectUri = url.searchParams.get('redirect_uri') as string; const redirectUri = url.searchParams.get('redirect_uri') as string;
// console.debug('Parameter redirect uri is', redirectUri); Logger.debug('Parameter redirect uri is', redirectUri);
if (!url.searchParams.has('client_id')) { if (!url.searchParams.has('client_id')) {
throw new InvalidRequest('client_id field is mandatory for authorization endpoint'); throw new InvalidRequest('client_id field is mandatory for authorization endpoint');
@ -40,14 +41,14 @@ export class OAuth2AuthorizationController {
} }
const clientId = url.searchParams.get('client_id') as string; const clientId = url.searchParams.get('client_id') as string;
// console.debug('Parameter client_id is', clientId); Logger.debug('Parameter client_id is', clientId);
if (!url.searchParams.has('response_type')) { if (!url.searchParams.has('response_type')) {
throw new InvalidRequest('response_type field is mandatory for authorization endpoint'); throw new InvalidRequest('response_type field is mandatory for authorization endpoint');
} }
const responseType = url.searchParams.get('response_type') as string; const responseType = url.searchParams.get('response_type') as string;
// console.debug('Parameter response_type is', responseType); Logger.debug('Parameter response_type is', responseType);
// Support multiple types // Support multiple types
const responseTypes = responseType.split(' '); const responseTypes = responseType.split(' ');
@ -77,7 +78,7 @@ export class OAuth2AuthorizationController {
throw new InvalidRequest('Grant type "none" cannot be combined with other grant types'); throw new InvalidRequest('Grant type "none" cannot be combined with other grant types');
} }
// console.debug('Parameter grant_type is', grantTypes.join(' ')); Logger.debug('Parameter grant_type is', grantTypes.join(' '));
const client = await OAuth2Clients.fetchById(clientId); const client = await OAuth2Clients.fetchById(clientId);
if (!client || client.activated === 0) { if (!client || client.activated === 0) {
@ -89,7 +90,7 @@ export class OAuth2AuthorizationController {
} else if (!(await OAuth2Clients.checkRedirectUri(client, redirectUri))) { } else if (!(await OAuth2Clients.checkRedirectUri(client, redirectUri))) {
throw new InvalidRequest('Wrong RedirectUri provided'); throw new InvalidRequest('Wrong RedirectUri provided');
} }
// console.debug('redirect_uri check passed'); Logger.debug('redirect_uri check passed');
// The client needs to support all grant types // The client needs to support all grant types
for (const grantType of grantTypes) { for (const grantType of grantTypes) {
@ -97,13 +98,13 @@ export class OAuth2AuthorizationController {
throw new UnauthorizedClient('This client does not support grant type ' + grantType); throw new UnauthorizedClient('This client does not support grant type ' + grantType);
} }
} }
// console.debug('Grant type check passed'); Logger.debug('Grant type check passed');
const scope = OAuth2Clients.transformScope(url.searchParams.get('scope') as string); const scope = OAuth2Clients.transformScope(url.searchParams.get('scope') as string);
if (!OAuth2Clients.checkScope(client, scope)) { if (!OAuth2Clients.checkScope(client, scope)) {
throw new InvalidScope('Client does not allow access to this scope'); throw new InvalidScope('Client does not allow access to this scope');
} }
// console.debug('Scope check passed'); Logger.debug('Scope check passed');
const codeChallenge = url.searchParams.get('code_challenge') as string; const codeChallenge = url.searchParams.get('code_challenge') as string;
const codeChallengeMethod = const codeChallengeMethod =
@ -267,7 +268,7 @@ export class OAuth2AuthorizationController {
throw new AccessDenied('User denied access to the resource'); throw new AccessDenied('User denied access to the resource');
} }
// console.debug('Decision check passed'); Logger.debug('Decision check passed');
} }
await OAuth2Users.saveConsent(user, client, scope); await OAuth2Users.saveConsent(user, client, scope);

View File

@ -1,5 +1,6 @@
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { ApiUtils } from '$lib/server/api-utils'; import { ApiUtils } from '$lib/server/api-utils';
import { Logger } from '$lib/server/logger';
import { InvalidClient, InvalidRequest, InvalidScope, UnauthorizedClient } from '../error'; import { InvalidClient, InvalidRequest, InvalidScope, UnauthorizedClient } from '../error';
import { OAuth2Clients, OAuth2DeviceCodes, OAuth2Tokens } from '../model'; import { OAuth2Clients, OAuth2DeviceCodes, OAuth2Tokens } from '../model';
import { OAuth2Response } from '../response'; import { OAuth2Response } from '../response';
@ -34,7 +35,7 @@ export class OAuth2DeviceAuthorizationController {
clientId = pieces[0]; clientId = pieces[0];
clientSecret = pieces[1]; clientSecret = pieces[1];
// console.debug('Client credentials parsed from basic auth header:', clientId, clientSecret); Logger.debug('Client credentials parsed from basic auth header:', clientId, clientSecret);
} }
if (!clientId) { if (!clientId) {

View File

@ -1,4 +1,5 @@
import { ApiUtils } from '$lib/server/api-utils'; import { ApiUtils } from '$lib/server/api-utils';
import { Logger } from '$lib/server/logger';
import { InvalidClient, InvalidRequest, UnauthorizedClient } from '../error'; import { InvalidClient, InvalidRequest, UnauthorizedClient } from '../error';
import { OAuth2AccessTokens, OAuth2Clients } from '../model'; import { OAuth2AccessTokens, OAuth2Clients } from '../model';
import { OAuth2Response } from '../response'; import { OAuth2Response } from '../response';
@ -13,7 +14,7 @@ export class OAuth2IntrospectionController {
if (body.client_id && body.client_secret) { if (body.client_id && body.client_secret) {
clientId = body.client_id as string; clientId = body.client_id as string;
clientSecret = body.client_secret as string; clientSecret = body.client_secret as string;
// console.debug('Client credentials parsed from body parameters ', clientId, clientSecret); Logger.debug('Client credentials parsed from body parameters ', clientId, clientSecret);
} else { } else {
if (!request.headers?.has('authorization')) { if (!request.headers?.has('authorization')) {
throw new InvalidRequest('No authorization header passed'); throw new InvalidRequest('No authorization header passed');
@ -35,7 +36,7 @@ export class OAuth2IntrospectionController {
clientId = pieces[0]; clientId = pieces[0];
clientSecret = pieces[1]; clientSecret = pieces[1];
// console.debug('Client credentials parsed from basic auth header: ', clientId, clientSecret); Logger.debug('Client credentials parsed from basic auth header: ', clientId, clientSecret);
} }
if (!body.token) { if (!body.token) {

View File

@ -1,4 +1,5 @@
import { ApiUtils } from '$lib/server/api-utils'; import { ApiUtils } from '$lib/server/api-utils';
import { Logger } from '$lib/server/logger';
import { import {
InvalidRequest, InvalidRequest,
InvalidClient, InvalidClient,
@ -22,7 +23,7 @@ export class OAuth2TokenController {
if (body.client_id) { if (body.client_id) {
clientId = body.client_id as string; clientId = body.client_id as string;
clientSecret = body.client_secret as string; clientSecret = body.client_secret as string;
// console.debug('Client credentials parsed from body parameters', clientId, clientSecret); Logger.debug('Client credentials parsed from body parameters', clientId, clientSecret);
} else { } else {
if (!request.headers?.has('authorization')) { if (!request.headers?.has('authorization')) {
throw new InvalidRequest('No authorization header passed'); throw new InvalidRequest('No authorization header passed');
@ -44,7 +45,7 @@ export class OAuth2TokenController {
clientId = pieces[0]; clientId = pieces[0];
clientSecret = pieces[1]; clientSecret = pieces[1];
// console.debug('Client credentials parsed from basic auth header:', clientId, clientSecret); Logger.debug('Client credentials parsed from basic auth header:', clientId, clientSecret);
} }
if (!body.grant_type) { if (!body.grant_type) {
@ -52,7 +53,7 @@ export class OAuth2TokenController {
} }
grantType = body.grant_type as string; grantType = body.grant_type as string;
// console.debug('Parameter grant_type is', grantType); Logger.debug('Parameter grant_type is', grantType);
// The spec does not allow using this grant type directly by this name, // The spec does not allow using this grant type directly by this name,
// but for verification purposes, we will simplify it below. // but for verification purposes, we will simplify it below.
@ -80,7 +81,7 @@ export class OAuth2TokenController {
if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') { if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') {
throw new UnauthorizedClient('Invalid grant type for the client'); throw new UnauthorizedClient('Invalid grant type for the client');
} }
// console.debug('Grant type check passed'); Logger.debug('Grant type check passed');
let tokenResponse: OAuth2TokenResponse = {}; let tokenResponse: OAuth2TokenResponse = {};
try { try {

View File

@ -1,5 +1,6 @@
import { CryptoUtils } from '$lib/server/crypto-utils'; import { CryptoUtils } from '$lib/server/crypto-utils';
import type { OAuth2Client, User } from '$lib/server/drizzle'; import type { OAuth2Client, User } from '$lib/server/drizzle';
import { Logger } from '$lib/server/logger';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
import { InvalidRequest, ServerError, InvalidGrant } from '../../error'; import { InvalidRequest, ServerError, InvalidGrant } from '../../error';
import { import {
@ -38,7 +39,7 @@ export async function authorizationCode(
try { try {
code = await OAuth2Codes.fetchByCode(providedCode); code = await OAuth2Codes.fetchByCode(providedCode);
} catch (err) { } catch (err) {
console.error(err); Logger.error('Failed to fetch OAuth2 access code', err);
throw new ServerError('Failed to call code.fetchByCode function'); throw new ServerError('Failed to call code.fetchByCode function');
} }
@ -54,7 +55,7 @@ export async function authorizationCode(
throw new InvalidGrant('Code not found'); throw new InvalidGrant('Code not found');
} }
// console.debug('Code fetched', code); Logger.debug('Code fetched', code);
const scope = code.scope || ''; const scope = code.scope || '';
const cleanScope = OAuth2Clients.transformScope(scope); const cleanScope = OAuth2Clients.transformScope(scope);
@ -83,15 +84,15 @@ export async function authorizationCode(
} }
} }
// console.debug('Code passed PCKE check'); Logger.debug('Code passed PCKE check');
if (!OAuth2Clients.checkGrantType(client, 'refresh_token')) { if (!OAuth2Clients.checkGrantType(client, 'refresh_token')) {
// console.debug('Client does not allow grant type refresh_token, skip creation'); Logger.debug('Client does not allow grant type refresh_token, skip creation');
} else { } else {
try { try {
respObj.refresh_token = await OAuth2RefreshTokens.create(userId, clientId, scope); respObj.refresh_token = await OAuth2RefreshTokens.create(userId, clientId, scope);
} catch (err) { } catch (err) {
console.error(err); Logger.error('Failed to issue an OAuth2 refresh token', err);
throw new ServerError('Failed to call refreshTokens.create function'); throw new ServerError('Failed to call refreshTokens.create function');
} }
} }
@ -104,17 +105,17 @@ export async function authorizationCode(
OAuth2Tokens.tokenTtl OAuth2Tokens.tokenTtl
); );
} catch (err) { } catch (err) {
console.error(err); Logger.error('Failed to issue an OAuth2 access token', err);
throw new ServerError('Failed to call accessTokens.create function'); throw new ServerError('Failed to call accessTokens.create function');
} }
respObj.expires_in = OAuth2Tokens.tokenTtl; respObj.expires_in = OAuth2Tokens.tokenTtl;
// console.debug('Access token saved:', respObj.access_token); Logger.debug('Access token saved:', respObj.access_token);
try { try {
await OAuth2Codes.removeByCode(providedCode); await OAuth2Codes.removeByCode(providedCode);
} catch (err) { } catch (err) {
console.error(err); Logger.error('Failed to remove OAuth2 access code', err);
throw new ServerError('Failed to call codes.removeByCode function'); throw new ServerError('Failed to call codes.removeByCode function');
} }
@ -129,7 +130,7 @@ export async function authorizationCode(
code.nonce || undefined code.nonce || undefined
); );
} catch (err) { } catch (err) {
console.error(err); Logger.error('Failed to issue a new ID token', err);
throw new ServerError('Failed to issue an ID token'); throw new ServerError('Failed to issue an ID token');
} }
} }

View File

@ -1,4 +1,5 @@
import type { OAuth2Client } from '$lib/server/drizzle'; import type { OAuth2Client } from '$lib/server/drizzle';
import { Logger } from '$lib/server/logger';
import { InvalidScope, ServerError } from '../../error'; import { InvalidScope, ServerError } from '../../error';
import { OAuth2AccessTokens, OAuth2Clients, OAuth2Tokens } from '../../model'; import { OAuth2AccessTokens, OAuth2Clients, OAuth2Tokens } from '../../model';
import type { OAuth2TokenResponse } from '../../response'; import type { OAuth2TokenResponse } from '../../response';
@ -24,7 +25,7 @@ export async function clientCredentials(
throw new InvalidScope('Client does not allow access to this scope'); throw new InvalidScope('Client does not allow access to this scope');
} }
// console.debug('Scope check passed', scope); Logger.debug('Scope check passed', scope);
try { try {
resObj.access_token = await OAuth2AccessTokens.create( resObj.access_token = await OAuth2AccessTokens.create(

View File

@ -1,4 +1,5 @@
import type { OAuth2Client } from '$lib/server/drizzle'; import type { OAuth2Client } from '$lib/server/drizzle';
import { Logger } from '$lib/server/logger';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
import { AccessDenied, AuthorizationPending, ExpiredToken, ServerError } from '../../error'; import { AccessDenied, AuthorizationPending, ExpiredToken, ServerError } from '../../error';
import { import {
@ -51,7 +52,7 @@ export async function device(client: OAuth2Client, deviceCode: string) {
try { try {
resObj.id_token = await OAuth2Users.issueIdToken(user, client, cleanScope); resObj.id_token = await OAuth2Users.issueIdToken(user, client, cleanScope);
} catch (err) { } catch (err) {
console.error(err); Logger.error('Failed to issue a new ID token:', err);
throw new ServerError('Failed to issue an ID token'); throw new ServerError('Failed to issue an ID token');
} }
} }

View File

@ -1,4 +1,5 @@
import type { OAuth2Client, User } from '$lib/server/drizzle'; import type { OAuth2Client, User } from '$lib/server/drizzle';
import { Logger } from '$lib/server/logger';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
import { InvalidRequest, ServerError, InvalidGrant, InvalidClient } from '../../error'; import { InvalidRequest, ServerError, InvalidGrant, InvalidClient } from '../../error';
import { import {
@ -43,7 +44,7 @@ export async function refreshToken(
} }
if (refreshToken.clientId !== client.id) { if (refreshToken.clientId !== client.id) {
console.warn( Logger.warn(
'Client %s tried to fetch a refresh token which belongs to client %s!', 'Client %s tried to fetch a refresh token which belongs to client %s!',
client.id, client.id,
refreshToken.clientId refreshToken.clientId

View File

@ -8,6 +8,7 @@ import { env as privateEnv } from '$env/dynamic/private';
import { Emails, ForgotPasswordEmail, InvitationEmail, RegistrationEmail } from '../email'; import { Emails, ForgotPasswordEmail, InvitationEmail, RegistrationEmail } from '../email';
import { env as publicEnv } from '$env/dynamic/public'; import { env as publicEnv } from '$env/dynamic/public';
import { UserTokens } from './tokens'; import { UserTokens } from './tokens';
import { Logger } from '../logger';
export class Users { export class Users {
/** /**
@ -238,7 +239,6 @@ export class Users {
`${publicEnv.PUBLIC_URL}/login?${params.toString()}` `${publicEnv.PUBLIC_URL}/login?${params.toString()}`
); );
// TODO: logging
try { try {
await Emails.getSender().sendTemplate( await Emails.getSender().sendTemplate(
user.email, user.email,
@ -246,7 +246,7 @@ export class Users {
content content
); );
} catch (error) { } catch (error) {
console.error(error); Logger.error('Failed to send activation email:', error);
await UserTokens.remove(token); await UserTokens.remove(token);
} }
} }
@ -267,14 +267,14 @@ export class Users {
`${publicEnv.PUBLIC_URL}/login/password?${params.toString()}` `${publicEnv.PUBLIC_URL}/login/password?${params.toString()}`
); );
// TODO: logging
try { try {
await Emails.getSender().sendTemplate( await Emails.getSender().sendTemplate(
user.email, user.email,
`Reset your password on ${publicEnv.PUBLIC_SITE_NAME}`, `Reset your password on ${publicEnv.PUBLIC_SITE_NAME}`,
content content
); );
} catch { } catch (error) {
Logger.error('Failed to send password email:', error);
await UserTokens.remove(token); await UserTokens.remove(token);
} }
} }
@ -294,14 +294,14 @@ export class Users {
const params = new URLSearchParams({ token: token.token }); const params = new URLSearchParams({ token: token.token });
const content = InvitationEmail(`${publicEnv.PUBLIC_URL}/register?${params.toString()}`); const content = InvitationEmail(`${publicEnv.PUBLIC_URL}/register?${params.toString()}`);
// TODO: logging
try { try {
await Emails.getSender().sendTemplate( await Emails.getSender().sendTemplate(
email, email,
`You have been invited to create an account on ${publicEnv.PUBLIC_SITE_NAME}`, `You have been invited to create an account on ${publicEnv.PUBLIC_SITE_NAME}`,
content content
); );
} catch { } catch (error) {
Logger.error('Failed to send invitation email:', error);
await UserTokens.remove(token); await UserTokens.remove(token);
} }
} }

View File

@ -12,6 +12,7 @@
import ButtonRow from '$lib/components/container/ButtonRow.svelte'; import ButtonRow from '$lib/components/container/ButtonRow.svelte';
import TitleRow from '$lib/components/container/TitleRow.svelte'; import TitleRow from '$lib/components/container/TitleRow.svelte';
import ThemeButton from '$lib/components/ThemeButton.svelte'; import ThemeButton from '$lib/components/ThemeButton.svelte';
import { page } from '$app/stores';
interface Props { interface Props {
data: PageData; data: PageData;