mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2026-06-23 04:10:17 +00:00
refactor(sessions): move session store into database
This allows session persistence across restarts of the backend. At the same time it makes future scaling of HedgeDoc easier since we reduce the amount of in-memory stored data by this change. Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { promisify } from 'node:util';
|
||||
import { AuthProviderType } from '@hedgedoc/commons';
|
||||
import {
|
||||
BadRequestException,
|
||||
@@ -41,20 +42,21 @@ export class AuthController {
|
||||
@UseGuards(SessionGuard)
|
||||
@Delete('logout')
|
||||
@OpenApi(200, 400, 401)
|
||||
logout(@Req() request: RequestWithSession): LogoutResponseDto {
|
||||
async logout(@Req() request: RequestWithSession): Promise<LogoutResponseDto> {
|
||||
let logoutUrl: string | null = null;
|
||||
if (request.session.authProviderType === AuthProviderType.OIDC) {
|
||||
if (request.session.loginAuthProviderType === AuthProviderType.OIDC) {
|
||||
logoutUrl = this.oidcService.getLogoutUrl(request);
|
||||
}
|
||||
request.session.destroy((err) => {
|
||||
if (err) {
|
||||
this.logger.error('Error during logout:' + String(err), undefined, 'logout');
|
||||
throw new InternalServerErrorException('Unable to log out');
|
||||
}
|
||||
});
|
||||
return LogoutResponseDto.create({
|
||||
redirect: logoutUrl || '/',
|
||||
});
|
||||
const destroySessionPromise = promisify(request.session.destroy).bind(request.session);
|
||||
try {
|
||||
await destroySessionPromise();
|
||||
return LogoutResponseDto.create({
|
||||
redirect: logoutUrl || '/',
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Error during logout:' + String(error), undefined, 'logout');
|
||||
throw new InternalServerErrorException('Unable to log out');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('pending-user')
|
||||
@@ -88,16 +90,22 @@ export class AuthController {
|
||||
request.session.pendingUser.authProviderIdentifier,
|
||||
request.session.pendingUser.providerUserId,
|
||||
);
|
||||
request.session.authProviderType = request.session.pendingUser.authProviderType;
|
||||
request.session.authProviderIdentifier = request.session.pendingUser.authProviderIdentifier;
|
||||
request.session.loginAuthProviderType = request.session.pendingUser.authProviderType;
|
||||
request.session.loginAuthProviderIdentifier =
|
||||
request.session.pendingUser.authProviderIdentifier;
|
||||
// Cleanup
|
||||
request.session.pendingUser = undefined;
|
||||
request.session.pendingUser = null;
|
||||
}
|
||||
|
||||
@Delete('pending-user')
|
||||
@OpenApi(204, 400)
|
||||
deletePendingUserData(@Req() request: RequestWithSession): void {
|
||||
request.session.pendingUser = undefined;
|
||||
request.session.oidc = undefined;
|
||||
request.session.pendingUser = null;
|
||||
request.session.oidc = {
|
||||
idToken: null,
|
||||
sid: null,
|
||||
loginCode: null,
|
||||
loginState: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class GuestController {
|
||||
): Promise<GuestRegistrationResponseDto> {
|
||||
const [uuid, userId] = await this.usersService.createGuestUser();
|
||||
// Log the user in after registration
|
||||
request.session.authProviderType = AuthProviderType.GUEST;
|
||||
request.session.loginAuthProviderType = AuthProviderType.GUEST;
|
||||
request.session.userId = userId;
|
||||
return GuestRegistrationResponseDto.create({
|
||||
uuid,
|
||||
@@ -48,7 +48,7 @@ export class GuestController {
|
||||
@Body() loginDto: GuestLoginDto,
|
||||
): Promise<void> {
|
||||
const userId = await this.usersService.getUserIdByGuestUuid(loginDto.uuid);
|
||||
request.session.authProviderType = AuthProviderType.GUEST;
|
||||
request.session.loginAuthProviderType = AuthProviderType.GUEST;
|
||||
request.session.userId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,8 @@ export class LdapController {
|
||||
userInfo.photoUrl,
|
||||
);
|
||||
}
|
||||
request.session.authProviderType = AuthProviderType.LDAP;
|
||||
request.session.authProviderIdentifier = ldapIdentifier;
|
||||
request.session.loginAuthProviderType = AuthProviderType.LDAP;
|
||||
request.session.loginAuthProviderIdentifier = ldapIdentifier;
|
||||
request.session.userId = identity[FieldNameIdentity.userId];
|
||||
return LdapLoginResponseDto.create({ newUser: false });
|
||||
} catch (error) {
|
||||
|
||||
@@ -47,9 +47,9 @@ export class LocalController {
|
||||
registerDto.displayName,
|
||||
);
|
||||
// Log the user in after registration
|
||||
request.session.authProviderType = AuthProviderType.LOCAL;
|
||||
request.session.loginAuthProviderType = AuthProviderType.LOCAL;
|
||||
request.session.userId = userId;
|
||||
request.session.pendingUser = undefined;
|
||||
request.session.pendingUser = null;
|
||||
}
|
||||
|
||||
@UseGuards(LoginEnabledGuard, SessionGuard)
|
||||
@@ -82,8 +82,8 @@ export class LocalController {
|
||||
loginDto.password,
|
||||
);
|
||||
request.session.userId = identity[FieldNameIdentity.userId];
|
||||
request.session.authProviderType = AuthProviderType.LOCAL;
|
||||
request.session.pendingUser = undefined;
|
||||
request.session.loginAuthProviderType = AuthProviderType.LOCAL;
|
||||
request.session.pendingUser = null;
|
||||
} catch (error) {
|
||||
this.logger.log(`Failed to log in user: ${String(error)}`, 'login');
|
||||
throw new UnauthorizedException('Invalid username or password');
|
||||
|
||||
@@ -48,6 +48,8 @@ export class OidcController {
|
||||
request.session.oidc = {
|
||||
loginCode: code,
|
||||
loginState: state,
|
||||
idToken: null,
|
||||
sid: null,
|
||||
};
|
||||
request.session.pendingUser = {
|
||||
authProviderType: AuthProviderType.OIDC,
|
||||
@@ -92,9 +94,9 @@ export class OidcController {
|
||||
}
|
||||
|
||||
request.session.userId = userId;
|
||||
request.session.authProviderType = AuthProviderType.OIDC;
|
||||
request.session.authProviderIdentifier = oidcIdentifier;
|
||||
request.session.pendingUser = undefined;
|
||||
request.session.loginAuthProviderType = AuthProviderType.OIDC;
|
||||
request.session.loginAuthProviderIdentifier = oidcIdentifier;
|
||||
request.session.pendingUser = null;
|
||||
return { url: '/' };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { AuthProviderType } from '@hedgedoc/commons';
|
||||
import { Body, Controller, Delete, Get, Put, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
InternalServerErrorException,
|
||||
Put,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SessionGuard } from '../../../auth/session.guard';
|
||||
@@ -16,6 +25,8 @@ import { UsersService } from '../../../users/users.service';
|
||||
import { OpenApi } from '../../utils/decorators/openapi.decorator';
|
||||
import { RequestUserId } from '../../utils/decorators/request-user-id.decorator';
|
||||
import { SessionAuthProvider } from '../../utils/decorators/session-authprovider.decorator';
|
||||
import { promisify } from 'node:util';
|
||||
import { RequestWithSession } from '../../utils/request.type';
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@OpenApi(401)
|
||||
@@ -49,7 +60,10 @@ export class MeController {
|
||||
|
||||
@Delete()
|
||||
@OpenApi(204, 404, 500)
|
||||
async deleteUser(@RequestUserId() userId: number): Promise<void> {
|
||||
async deleteUser(
|
||||
@Req() request: RequestWithSession,
|
||||
@RequestUserId() userId: number,
|
||||
): Promise<void> {
|
||||
const mediaUploads = await this.mediaService.getMediaUploadUuidsByUserId(userId);
|
||||
for (const mediaUpload of mediaUploads) {
|
||||
await this.mediaService.deleteFile(mediaUpload);
|
||||
@@ -57,6 +71,11 @@ export class MeController {
|
||||
this.logger.debug(`Deleted all media uploads for user with id ${userId}`);
|
||||
await this.userService.deleteUser(userId);
|
||||
this.logger.debug(`Deleted user with id ${userId}`);
|
||||
const destroySessionPromise = promisify(request.session.destroy).bind(request.session);
|
||||
destroySessionPromise().catch((error) => {
|
||||
this.logger.error('Error while destroying session:' + String(error), undefined, 'deleteUser');
|
||||
throw new InternalServerErrorException('Error trying to destroy session of deleted user');
|
||||
});
|
||||
}
|
||||
|
||||
@Put('profile')
|
||||
|
||||
@@ -19,9 +19,9 @@ import { CompleteRequest } from '../request.type';
|
||||
// oxlint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const SessionAuthProvider = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
|
||||
const request: CompleteRequest = ctx.switchToHttp().getRequest();
|
||||
if (!request.session?.authProviderType) {
|
||||
if (!request.session?.loginAuthProviderType) {
|
||||
// We should have an auth provider here, otherwise something is wrong
|
||||
throw new InternalServerErrorException('Session is missing an auth provider identifier');
|
||||
}
|
||||
return request.session.authProviderType;
|
||||
return request.session.loginAuthProviderType;
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { AuthProviderType } from '@hedgedoc/commons';
|
||||
import { FieldNameNote, FieldNameUser, Note, User } from '@hedgedoc/database';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { SessionState } from 'src/sessions/session-state.type';
|
||||
import { SessionState } from 'src/sessions/session-state';
|
||||
|
||||
export type CompleteRequest = FastifyRequest & {
|
||||
userId?: User[FieldNameUser.id];
|
||||
|
||||
@@ -73,12 +73,12 @@ export async function setupApp(
|
||||
await setupSessionMiddleware(
|
||||
app as INestApplication,
|
||||
authConfig,
|
||||
app.get(SessionService).getSessionStore(),
|
||||
app.get(SessionService).getFastifySessionStore(),
|
||||
);
|
||||
|
||||
// Setup CSRF protection
|
||||
await app.register(fastifyCsrfProtection, {
|
||||
cookieKey: 'hedgedoc-csrf',
|
||||
sessionKey: 'csrfToken',
|
||||
sessionPlugin: '@fastify/session',
|
||||
getToken: (req) => req.headers['csrf-token'] as string | undefined,
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SessionModule } from '../sessions/session.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { IdentityService } from './identity.service';
|
||||
import { LdapService } from './ldap/ldap.service';
|
||||
@@ -12,7 +13,7 @@ import { LocalService } from './local/local.service';
|
||||
import { OidcService } from './oidc/oidc.service';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
imports: [UsersModule, SessionModule],
|
||||
controllers: [],
|
||||
providers: [IdentityService, LdapService, LocalService, OidcService],
|
||||
exports: [IdentityService, LdapService, LocalService, OidcService],
|
||||
|
||||
@@ -173,8 +173,8 @@ export class OidcService {
|
||||
const client = clientConfig.client;
|
||||
const oidcConfig = clientConfig.config;
|
||||
const params = client.callbackParams(request.raw);
|
||||
const code = request.session.oidc?.loginCode;
|
||||
const state = request.session.oidc?.loginState;
|
||||
const code = request.session.oidc?.loginCode ?? undefined;
|
||||
const state = request.session.oidc?.loginState ?? undefined;
|
||||
const isAutodiscovered = clientConfig.config.authorizeUrl === undefined;
|
||||
const callbackMethod = isAutodiscovered
|
||||
? client.callback.bind(client)
|
||||
@@ -185,8 +185,14 @@ export class OidcService {
|
||||
state,
|
||||
});
|
||||
|
||||
// If the id_token is not present, we are not able to extract claims like the sessionId from it, therefore we set it to null.
|
||||
// This is the case with non-OIDC-compliant OAuth2 auth providers like GitHub.
|
||||
const sid = tokenSet.id_token ? ((tokenSet.claims()?.sid as string | undefined) ?? null) : null;
|
||||
request.session.oidc = {
|
||||
idToken: tokenSet.id_token,
|
||||
idToken: tokenSet.id_token ?? null,
|
||||
sid,
|
||||
loginState: null,
|
||||
loginCode: null,
|
||||
};
|
||||
const userInfoResponse = await client.userinfo(tokenSet);
|
||||
const userId = OidcService.getResponseFieldValue(
|
||||
@@ -270,7 +276,7 @@ export class OidcService {
|
||||
* @returns The logout URL if the user is logged in with OIDC, or null if there is no URL to redirect to
|
||||
*/
|
||||
getLogoutUrl(request: RequestWithSession): string | null {
|
||||
const oidcIdentifier = request.session.authProviderIdentifier;
|
||||
const oidcIdentifier = request.session.loginAuthProviderIdentifier;
|
||||
if (!oidcIdentifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export class SessionGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request: CompleteRequest = context.switchToHttp().getRequest();
|
||||
const userId = request.session?.userId;
|
||||
const authProviderType = request.session?.authProviderType;
|
||||
const authProviderType = request.session?.loginAuthProviderType;
|
||||
if (!userId || !authProviderType) {
|
||||
this.logger.debug('The user has no session.');
|
||||
throw new UnauthorizedException('You have no active session');
|
||||
|
||||
@@ -40,6 +40,8 @@ const {
|
||||
TableUser,
|
||||
TableUserPinnedNote,
|
||||
TableVisitedNote,
|
||||
FieldNameSession,
|
||||
TableSession,
|
||||
} = require('@hedgedoc/database');
|
||||
|
||||
const up = async function (knex) {
|
||||
@@ -401,10 +403,52 @@ const up = async function (knex) {
|
||||
table.index([FieldNameVisitedNote.userId], 'idx_visited_notes_user_id');
|
||||
table.index([FieldNameVisitedNote.noteId], 'idx_visited_notes_note_id');
|
||||
});
|
||||
|
||||
// Create session table
|
||||
await knex.schema.createTable(TableSession, (table) => {
|
||||
table.string(FieldNameSession.id).primary();
|
||||
table
|
||||
.integer(FieldNameSession.userId)
|
||||
.unsigned()
|
||||
.nullable()
|
||||
.references(FieldNameUser.id)
|
||||
.inTable(TableUser)
|
||||
.onDelete('CASCADE');
|
||||
table.string(FieldNameSession.csrfToken).nullable();
|
||||
table
|
||||
.enu(
|
||||
FieldNameSession.loginAuthProviderType,
|
||||
[
|
||||
AuthProviderType.LDAP,
|
||||
AuthProviderType.LOCAL,
|
||||
AuthProviderType.OIDC,
|
||||
AuthProviderType.GUEST,
|
||||
],
|
||||
{
|
||||
useNative: true,
|
||||
enumName: FieldNameSession.loginAuthProviderType,
|
||||
},
|
||||
)
|
||||
.nullable();
|
||||
table.string(FieldNameSession.loginAuthProviderIdentifier).nullable();
|
||||
table.string(FieldNameSession.oidcIdToken).nullable();
|
||||
table.string(FieldNameSession.oidcSid).nullable();
|
||||
table.string(FieldNameSession.oidcLoginState).nullable();
|
||||
table.string(FieldNameSession.oidcLoginCode).nullable();
|
||||
table.text(FieldNameSession.pendingUserData);
|
||||
table.timestamp(FieldNameSession.createdAt, { useTz: false, precision: 3 }).notNullable();
|
||||
table.timestamp(FieldNameSession.updatedAt, { useTz: false, precision: 3 }).notNullable();
|
||||
table.timestamp(FieldNameSession.expiresAt, { useTz: false, precision: 3 }).notNullable();
|
||||
|
||||
table.index([FieldNameSession.userId], 'idx_session_user_id');
|
||||
table.index([FieldNameSession.oidcSid], 'idx_session_oidc_sid');
|
||||
table.index([FieldNameSession.expiresAt], 'idx_session_expires_at');
|
||||
});
|
||||
};
|
||||
|
||||
const down = async function (knex) {
|
||||
// Drop tables in reverse order of creation to avoid integer key constraints
|
||||
await knex.schema.dropTableIfExists(TableSession);
|
||||
await knex.schema.dropTableIfExists(TableVisitedNote);
|
||||
await knex.schema.dropTableIfExists(TableUserPinnedNote);
|
||||
await knex.schema.dropTableIfExists(TableMediaUpload);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
NoteUserPermission,
|
||||
Revision,
|
||||
RevisionTag,
|
||||
Session,
|
||||
TableAlias,
|
||||
TableApiToken,
|
||||
TableAuthorshipInfo,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
TableNoteUserPermission,
|
||||
TableRevision,
|
||||
TableRevisionTag,
|
||||
TableSession,
|
||||
TableUser,
|
||||
TableUserPinnedNote,
|
||||
TypeInsertGroup,
|
||||
@@ -71,6 +73,7 @@ declare module 'knex/types/tables.js' {
|
||||
NoteUserPermission,
|
||||
TypeUpdateNoteUserPermission
|
||||
>;
|
||||
[TableSession]: Session;
|
||||
[TableRevision]: KnexOriginal.CompositeTableType<Revision>;
|
||||
[TableRevisionTag]: RevisionTag;
|
||||
[TableUser]: KnexOriginal.CompositeTableType<User, TypeInsertUser, TypeUpdateUser>;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
import { PermissionLevel } from '@hedgedoc/commons';
|
||||
import { FieldNameUser } from '@hedgedoc/database';
|
||||
import { Optional } from '@mrdrogdrog/optional';
|
||||
import { Provider } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
@@ -112,11 +111,8 @@ describe('Websocket gateway', () => {
|
||||
|
||||
jest
|
||||
.spyOn(sessionService, 'extractSessionIdFromRequest')
|
||||
.mockImplementation(
|
||||
(request: IncomingMessage): Optional<string> =>
|
||||
Optional.ofNullable(
|
||||
request.headers?.cookie === mockedValidSessionCookie ? mockedSessionIdWithUser : null,
|
||||
),
|
||||
.mockImplementation((request: IncomingMessage): string | null =>
|
||||
request.headers?.cookie === mockedValidSessionCookie ? mockedSessionIdWithUser : null,
|
||||
);
|
||||
|
||||
const mockUsername = 'Testy';
|
||||
|
||||
@@ -126,9 +126,9 @@ export class WebsocketGateway implements OnGatewayConnection {
|
||||
*/
|
||||
private async findUserIdByRequestSession(request: IncomingMessage): Promise<number | undefined> {
|
||||
const sessionId = this.sessionService.extractSessionIdFromRequest(request);
|
||||
if (sessionId.isEmpty()) {
|
||||
if (sessionId === null) {
|
||||
return undefined;
|
||||
}
|
||||
return await this.sessionService.getUserIdForSessionId(sessionId.get());
|
||||
return await this.sessionService.getUserIdForSessionId(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ interface RateLimitConfig {
|
||||
* Extracts the user ID from the session if present.
|
||||
*
|
||||
* @param req The incoming Fastify request
|
||||
* @returns The user ID if authenticated, undefined otherwise
|
||||
* @returns The user ID if authenticated, null otherwise
|
||||
*/
|
||||
function getUserIdFromSession(req: FastifyRequest): number | undefined {
|
||||
function getUserIdFromSession(req: FastifyRequest): number | null {
|
||||
return (req as RequestWithSession).session?.userId;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function getUserIdFromSession(req: FastifyRequest): number | undefined {
|
||||
*/
|
||||
export function generateRateLimitKey(req: FastifyRequest): string {
|
||||
const userId = getUserIdFromSession(req);
|
||||
if (userId !== undefined) {
|
||||
if (userId !== null) {
|
||||
return `user:${userId}`;
|
||||
}
|
||||
return `ip:${req.ip}`;
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import '@fastify/session';
|
||||
import { SessionState } from './session-state.type';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface Session extends SessionState {}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import Keyv from 'keyv';
|
||||
import { Session } from 'fastify';
|
||||
import { SessionStore } from '@fastify/session';
|
||||
import type {} from './fastify-session.d';
|
||||
|
||||
export interface SessionStoreOptions {
|
||||
/** The time how long a session lives in seconds */
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export class KeyvSessionStore implements SessionStore {
|
||||
private readonly dataStore: Keyv<Session>;
|
||||
|
||||
constructor(options?: SessionStoreOptions) {
|
||||
this.dataStore = new Keyv<Session>({
|
||||
namespace: 'sessions',
|
||||
ttl: options?.ttl ? options.ttl * 1000 : undefined,
|
||||
// TODO Add support for non-in-memory keyv backends like redis/valkey
|
||||
});
|
||||
}
|
||||
|
||||
destroy(sessionId: string, callback: (error?: Error) => void): void {
|
||||
this.dataStore
|
||||
.delete(sessionId)
|
||||
.then(() => callback())
|
||||
.catch(callback);
|
||||
}
|
||||
|
||||
get(sessionId: string, callback: (error: Error | null, session?: Session | null) => void): void {
|
||||
this.dataStore
|
||||
.get(sessionId)
|
||||
.then((session) => callback(null, session ?? null))
|
||||
.catch((error: Error) => callback(error));
|
||||
}
|
||||
|
||||
set(sessionId: string, session: Session, callback: (error?: Error) => void): void {
|
||||
this.dataStore
|
||||
.set(sessionId, session)
|
||||
.then(() => callback())
|
||||
.catch(callback);
|
||||
}
|
||||
|
||||
getAsync(sessionId: string): Promise<Session | undefined> {
|
||||
return this.dataStore.get(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { AuthProviderType } from '@hedgedoc/commons';
|
||||
import { FieldNameSession, Session, TableSession } from '@hedgedoc/database';
|
||||
import { Session as FastifySession } from 'fastify';
|
||||
import knex, { Knex } from 'knex';
|
||||
import { createTracker, MockClient, Tracker } from 'knex-mock-client';
|
||||
|
||||
import { expectBindings } from '../database/mock/expect-bindings';
|
||||
import { mockDelete, mockInsert, mockSelect } from '../database/mock/mock-queries';
|
||||
import { dateTimeToDB, dbToDateTime, isoStringToDateTime } from '../utils/datetime';
|
||||
import { KnexSessionStore } from './knex-session-store';
|
||||
|
||||
const mockNowIso = '2026-03-04T12:00:00.000Z';
|
||||
const mockNowDb = dateTimeToDB(isoStringToDateTime(mockNowIso));
|
||||
const ttl = 3600;
|
||||
const mockExpiryDb = dateTimeToDB(isoStringToDateTime(mockNowIso).plus({ seconds: ttl }));
|
||||
|
||||
const insertColumns = [
|
||||
FieldNameSession.createdAt,
|
||||
FieldNameSession.csrfToken,
|
||||
FieldNameSession.expiresAt,
|
||||
FieldNameSession.id,
|
||||
FieldNameSession.loginAuthProviderIdentifier,
|
||||
FieldNameSession.loginAuthProviderType,
|
||||
FieldNameSession.oidcIdToken,
|
||||
FieldNameSession.oidcLoginCode,
|
||||
FieldNameSession.oidcLoginState,
|
||||
FieldNameSession.oidcSid,
|
||||
FieldNameSession.pendingUserData,
|
||||
FieldNameSession.updatedAt,
|
||||
FieldNameSession.userId,
|
||||
];
|
||||
|
||||
describe('KnexSessionStore', () => {
|
||||
let db: Knex;
|
||||
let tracker: Tracker;
|
||||
let store: KnexSessionStore;
|
||||
|
||||
beforeAll(() => {
|
||||
db = knex({ client: MockClient, dialect: 'pg' });
|
||||
tracker = createTracker(db);
|
||||
store = new KnexSessionStore({ ttl, knex: db });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date(mockNowIso));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tracker.reset();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('issues a DELETE for the given session ID', (done) => {
|
||||
mockDelete(tracker, TableSession, [FieldNameSession.id], 1);
|
||||
store.destroy('session-1', (error) => {
|
||||
expect(error).toBeUndefined();
|
||||
expectBindings(tracker, 'delete', [['session-1']]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns null when no matching session exists', (done) => {
|
||||
mockSelect(
|
||||
tracker,
|
||||
[],
|
||||
TableSession,
|
||||
[FieldNameSession.id, FieldNameSession.expiresAt],
|
||||
undefined,
|
||||
);
|
||||
store.get('session-1', (error, session) => {
|
||||
expect(error).toBeNull();
|
||||
expect(session).toBeNull();
|
||||
expectBindings(tracker, 'select', [['session-1', mockNowDb]], true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('maps all database columns to the correct session fields', (done) => {
|
||||
mockSelect(
|
||||
tracker,
|
||||
[],
|
||||
TableSession,
|
||||
[FieldNameSession.id, FieldNameSession.expiresAt],
|
||||
[
|
||||
{
|
||||
[FieldNameSession.id]: 'session-1',
|
||||
[FieldNameSession.userId]: 42,
|
||||
[FieldNameSession.csrfToken]: 'my-csrf',
|
||||
[FieldNameSession.loginAuthProviderType]: AuthProviderType.LOCAL,
|
||||
[FieldNameSession.loginAuthProviderIdentifier]: 'local',
|
||||
[FieldNameSession.oidcIdToken]: 'id-tok',
|
||||
[FieldNameSession.oidcSid]: 'oidc-sid',
|
||||
[FieldNameSession.oidcLoginCode]: 'oidc-code',
|
||||
[FieldNameSession.oidcLoginState]: 'oidc-state',
|
||||
[FieldNameSession.pendingUserData]: JSON.stringify({ providerUserId: 'prov-1' }),
|
||||
[FieldNameSession.createdAt]: mockNowDb,
|
||||
[FieldNameSession.updatedAt]: mockNowDb,
|
||||
[FieldNameSession.expiresAt]: mockExpiryDb,
|
||||
} as Session,
|
||||
],
|
||||
);
|
||||
store.get('session-1', (error, session) => {
|
||||
expect(error).toBeNull();
|
||||
expect(session).not.toBeNull();
|
||||
expect(session?.userId).toBe(42);
|
||||
expect(session?.csrfToken).toBe('my-csrf');
|
||||
expect(session?.loginAuthProviderType).toBe(AuthProviderType.LOCAL);
|
||||
expect(session?.loginAuthProviderIdentifier).toBe('local');
|
||||
expect(session?.oidc.idToken).toBe('id-tok');
|
||||
expect(session?.oidc.sid).toBe('oidc-sid');
|
||||
expect(session?.oidc.loginCode).toBe('oidc-code');
|
||||
expect(session?.oidc.loginState).toBe('oidc-state');
|
||||
expect(session?.pendingUser).toEqual({ providerUserId: 'prov-1' });
|
||||
expect(session?.cookie?.expires).toEqual(dbToDateTime(mockExpiryDb).toJSDate());
|
||||
expect(session?.cookie?.signed).toBe(true);
|
||||
expect(session?.cookie?.secure).toBe('auto');
|
||||
expect(session?.cookie?.httpOnly).toBe(true);
|
||||
expect(session?.cookie?.sameSite).toBe('lax');
|
||||
expect(session?.cookie?.originalMaxAge).toBeNull();
|
||||
expectBindings(tracker, 'select', [['session-1', mockNowDb]], true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('inserts a session where all optional fields are null', (done) => {
|
||||
mockInsert(tracker, TableSession, insertColumns);
|
||||
const session = {
|
||||
cookie: { originalMaxAge: null },
|
||||
csrfToken: null,
|
||||
userId: null,
|
||||
loginAuthProviderType: null,
|
||||
loginAuthProviderIdentifier: null,
|
||||
oidc: { idToken: null, sid: null, loginCode: null, loginState: null },
|
||||
pendingUser: null,
|
||||
} as FastifySession;
|
||||
store.set('session-1', session, (error) => {
|
||||
expect(error).toBeUndefined();
|
||||
expectBindings(tracker, 'insert', [
|
||||
[
|
||||
mockNowDb,
|
||||
null,
|
||||
mockExpiryDb,
|
||||
'session-1',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'{}',
|
||||
mockNowDb,
|
||||
null,
|
||||
],
|
||||
]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts a fully-populated session with auth provider and OIDC data', (done) => {
|
||||
mockInsert(tracker, TableSession, insertColumns);
|
||||
const pendingUser = { providerUserId: 'prov-1' };
|
||||
const session = {
|
||||
cookie: { originalMaxAge: null },
|
||||
csrfToken: 'my-csrf',
|
||||
userId: 42,
|
||||
loginAuthProviderType: AuthProviderType.OIDC,
|
||||
loginAuthProviderIdentifier: 'oidc-provider',
|
||||
oidc: {
|
||||
idToken: 'id-tok',
|
||||
sid: 'oidc-sid',
|
||||
loginCode: 'oidc-code',
|
||||
loginState: 'oidc-state',
|
||||
},
|
||||
pendingUser,
|
||||
} as FastifySession;
|
||||
store.set('session-1', session, (error) => {
|
||||
expect(error).toBeUndefined();
|
||||
expectBindings(tracker, 'insert', [
|
||||
[
|
||||
mockNowDb,
|
||||
'my-csrf',
|
||||
mockExpiryDb,
|
||||
'session-1',
|
||||
'oidc-provider',
|
||||
AuthProviderType.OIDC,
|
||||
'id-tok',
|
||||
'oidc-code',
|
||||
'oidc-state',
|
||||
'oidc-sid',
|
||||
JSON.stringify(pendingUser),
|
||||
mockNowDb,
|
||||
42,
|
||||
],
|
||||
]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Session as FastifySession } from 'fastify';
|
||||
import { SessionStore } from '@fastify/session';
|
||||
import { Knex } from 'knex';
|
||||
import { FieldNameSession, Session, TableSession } from '@hedgedoc/database';
|
||||
import { dateTimeToDB, dbToDateTime, getCurrentDateTime } from '../utils/datetime';
|
||||
import { SessionState } from './session-state';
|
||||
|
||||
export interface SessionStoreOptions {
|
||||
/** The time how long a session lives in seconds */
|
||||
ttl: number;
|
||||
|
||||
/** The Knex instance to use for database operations */
|
||||
knex: Knex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fastify session store implementation using Knex for database operations
|
||||
*/
|
||||
export class KnexSessionStore implements SessionStore {
|
||||
private readonly knex: Knex;
|
||||
private readonly ttl: number;
|
||||
|
||||
constructor(options: SessionStoreOptions) {
|
||||
this.knex = options.knex;
|
||||
this.ttl = options.ttl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a session by its session ID
|
||||
* @param sessionId The session ID to destroy
|
||||
* @param callback Callback for fastify-session when the session has been destroyed
|
||||
*/
|
||||
destroy(sessionId: string, callback: (error?: Error) => void): void {
|
||||
this.knex<Session>(TableSession)
|
||||
.where(FieldNameSession.id, sessionId)
|
||||
.delete()
|
||||
.then(() => callback())
|
||||
.catch((error: Error) => callback(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session by its session ID
|
||||
* @param sessionId The session ID to get the session for
|
||||
* @param callback Callback for fastify-session with the session or null if no session was found
|
||||
*/
|
||||
get(
|
||||
sessionId: string,
|
||||
callback: (error: Error | null, session?: FastifySession | null) => void,
|
||||
): void {
|
||||
const nowDbTime = dateTimeToDB(getCurrentDateTime());
|
||||
this.knex(TableSession)
|
||||
.where(FieldNameSession.id, sessionId)
|
||||
.andWhere(FieldNameSession.expiresAt, '>', nowDbTime)
|
||||
.first()
|
||||
.then((entry?: Session) => {
|
||||
const data = this.convertDatabaseEntryToSession(entry ?? null);
|
||||
return callback(null, data);
|
||||
})
|
||||
.catch((error: Error) => callback(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a session by its session ID
|
||||
* @param sessionId The session ID to set the session for
|
||||
* @param session The session to set
|
||||
* @param callback Callback for fastify-session when the session has been set
|
||||
*/
|
||||
set(sessionId: string, session: FastifySession, callback: (error?: Error) => void): void {
|
||||
const dbEntry = this.convertSessionToDatabaseEntry(sessionId, session);
|
||||
this.knex<Session>(TableSession)
|
||||
.insert(dbEntry)
|
||||
.onConflict(FieldNameSession.id)
|
||||
.merge([
|
||||
FieldNameSession.userId,
|
||||
FieldNameSession.pendingUserData,
|
||||
FieldNameSession.loginAuthProviderType,
|
||||
FieldNameSession.loginAuthProviderIdentifier,
|
||||
FieldNameSession.oidcIdToken,
|
||||
FieldNameSession.oidcSid,
|
||||
FieldNameSession.oidcLoginCode,
|
||||
FieldNameSession.oidcLoginState,
|
||||
FieldNameSession.expiresAt,
|
||||
FieldNameSession.updatedAt,
|
||||
// FieldNameSession.createdAt is missing intentionally because this should not be overwritten on updates
|
||||
])
|
||||
.then(() => callback())
|
||||
.catch((error: Error) => callback(error));
|
||||
}
|
||||
|
||||
private convertDatabaseEntryToSession(dbEntry: Session | null): FastifySession | null {
|
||||
if (dbEntry === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
userId: dbEntry[FieldNameSession.userId],
|
||||
csrfToken: dbEntry[FieldNameSession.csrfToken],
|
||||
pendingUser: JSON.parse(dbEntry[FieldNameSession.pendingUserData]),
|
||||
loginAuthProviderType: dbEntry[FieldNameSession.loginAuthProviderType],
|
||||
loginAuthProviderIdentifier: dbEntry[FieldNameSession.loginAuthProviderIdentifier],
|
||||
oidc: {
|
||||
idToken: dbEntry[FieldNameSession.oidcIdToken],
|
||||
sid: dbEntry[FieldNameSession.oidcSid],
|
||||
loginCode: dbEntry[FieldNameSession.oidcLoginCode],
|
||||
loginState: dbEntry[FieldNameSession.oidcLoginState],
|
||||
},
|
||||
// all cookie attributes except the expiry are static and don't need to be stored and retrieved again
|
||||
cookie: {
|
||||
originalMaxAge: null,
|
||||
expires: dbToDateTime(dbEntry[FieldNameSession.expiresAt]).toJSDate(),
|
||||
signed: true,
|
||||
secure: 'auto',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private convertSessionToDatabaseEntry(sessionId: string, session: SessionState): Session {
|
||||
const now = getCurrentDateTime();
|
||||
const nowDbTime = dateTimeToDB(now);
|
||||
const expiry = now.plus({ seconds: this.ttl });
|
||||
const expiryDbTime = dateTimeToDB(expiry);
|
||||
return {
|
||||
// createdAt is always set to the current time in order for new sessions to work
|
||||
// for existing sessions, the Knex call ignores the createdAt field explicitly, so we can safely set it here
|
||||
[FieldNameSession.createdAt]: nowDbTime,
|
||||
[FieldNameSession.updatedAt]: nowDbTime,
|
||||
[FieldNameSession.expiresAt]: expiryDbTime,
|
||||
[FieldNameSession.id]: sessionId,
|
||||
[FieldNameSession.csrfToken]: session.csrfToken ?? null,
|
||||
[FieldNameSession.userId]: session.userId ?? null,
|
||||
[FieldNameSession.loginAuthProviderType]: session.loginAuthProviderType ?? null,
|
||||
[FieldNameSession.loginAuthProviderIdentifier]: session.loginAuthProviderIdentifier ?? null,
|
||||
[FieldNameSession.pendingUserData]: JSON.stringify(session.pendingUser ?? {}),
|
||||
[FieldNameSession.oidcIdToken]: session.oidc?.idToken ?? null,
|
||||
[FieldNameSession.oidcSid]: session.oidc?.sid ?? null,
|
||||
[FieldNameSession.oidcLoginCode]: session.oidc?.loginCode ?? null,
|
||||
[FieldNameSession.oidcLoginState]: session.oidc?.loginState ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
+22
-14
@@ -3,20 +3,21 @@
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { AuthProviderType } from '@hedgedoc/commons';
|
||||
import { FieldNameUser, User } from '@hedgedoc/database';
|
||||
|
||||
import { PendingUserInfoDto } from '../dtos/pending-user-info.dto';
|
||||
import type { AuthProviderType } from '@hedgedoc/commons';
|
||||
import type { PendingUserInfoDto } from '../dtos/pending-user-info.dto';
|
||||
|
||||
interface OidcAuthSessionState {
|
||||
/** The id token to identify a user session with an OIDC auth provider, required for the logout */
|
||||
idToken?: string;
|
||||
idToken: string | null;
|
||||
|
||||
/** The (random) OIDC code for verifying that OIDC responses match the OIDC requests */
|
||||
loginCode?: string;
|
||||
loginCode: string | null;
|
||||
|
||||
/** The (random) OIDC state for verifying that OIDC responses match the OIDC requests */
|
||||
loginState?: string;
|
||||
loginState: string | null;
|
||||
|
||||
/** The session ID from the OIDC provider, used for backchannel logout */
|
||||
sid: string | null;
|
||||
}
|
||||
|
||||
interface PendingUserSessionState {
|
||||
@@ -35,7 +36,7 @@ interface PendingUserSessionState {
|
||||
|
||||
export interface SessionState {
|
||||
/** Session cookie properties */
|
||||
cookie: {
|
||||
cookie?: {
|
||||
originalMaxAge: number | null;
|
||||
maxAge?: number;
|
||||
signed?: boolean;
|
||||
@@ -47,18 +48,25 @@ export interface SessionState {
|
||||
sameSite?: boolean | 'lax' | 'strict' | 'none';
|
||||
};
|
||||
|
||||
/** Contains the username if logged in completely, is undefined when not being logged in */
|
||||
userId?: User[FieldNameUser.id];
|
||||
/** The current session owner's CSRF token */
|
||||
csrfToken: string | null;
|
||||
|
||||
/** Contains the username if logged in completely, is null when not being logged in */
|
||||
userId: number | null;
|
||||
|
||||
/** The auth provider that is used for the current login */
|
||||
authProviderType?: AuthProviderType;
|
||||
loginAuthProviderType: AuthProviderType | null;
|
||||
|
||||
/** The identifier of the auth provider that is used for the current login */
|
||||
authProviderIdentifier?: string;
|
||||
loginAuthProviderIdentifier: string | null;
|
||||
|
||||
/** Session data used on OIDC login */
|
||||
oidc?: OidcAuthSessionState;
|
||||
oidc: OidcAuthSessionState;
|
||||
|
||||
/** The user data of the user that is currently being created */
|
||||
pendingUser?: PendingUserSessionState;
|
||||
pendingUser: PendingUserSessionState | null;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface Session extends SessionState {}
|
||||
}
|
||||
@@ -4,11 +4,12 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KnexModule } from 'nest-knexjs';
|
||||
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [KnexModule],
|
||||
exports: [SessionService],
|
||||
providers: [SessionService],
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FieldNameSession, TableSession } from '@hedgedoc/database';
|
||||
import { serialize } from 'cookie';
|
||||
import { sign } from 'cookie-signature';
|
||||
import type { Tracker } from 'knex-mock-client';
|
||||
@@ -16,6 +17,7 @@ import { Mock } from 'ts-mockery';
|
||||
import appConfigMock from '../config/mock/app.config.mock';
|
||||
import { createDefaultMockAuthConfig, registerAuthConfig } from '../config/mock/auth.config.mock';
|
||||
import { mockKnexDb } from '../database/mock/provider';
|
||||
import { mockSelect } from '../database/mock/mock-queries';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { HEDGEDOC_SESSION } from '../utils/session';
|
||||
import { SessionService } from './session.service';
|
||||
@@ -48,7 +50,7 @@ describe('SessionService', () => {
|
||||
});
|
||||
|
||||
it('getSessionStore', () => {
|
||||
const store = service.getSessionStore();
|
||||
const store = service.getFastifySessionStore();
|
||||
expect(store).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -56,24 +58,35 @@ describe('SessionService', () => {
|
||||
it('returns the correct user id for session id', async () => {
|
||||
const testSessionId = 'testSessionId';
|
||||
const testUserId = 1337;
|
||||
const sessionsStore = service.getSessionStore();
|
||||
sessionsStore.set(
|
||||
testSessionId,
|
||||
{
|
||||
cookie: {
|
||||
originalMaxAge: null,
|
||||
mockSelect(
|
||||
tracker,
|
||||
[],
|
||||
TableSession,
|
||||
[FieldNameSession.id, FieldNameSession.expiresAt],
|
||||
[
|
||||
{
|
||||
[FieldNameSession.id]: testSessionId,
|
||||
[FieldNameSession.userId]: testUserId,
|
||||
[FieldNameSession.csrfToken]: null,
|
||||
[FieldNameSession.loginAuthProviderType]: null,
|
||||
[FieldNameSession.loginAuthProviderIdentifier]: null,
|
||||
[FieldNameSession.oidcIdToken]: null,
|
||||
[FieldNameSession.oidcSid]: null,
|
||||
[FieldNameSession.oidcLoginCode]: null,
|
||||
[FieldNameSession.oidcLoginState]: null,
|
||||
[FieldNameSession.pendingUserData]: '{}',
|
||||
[FieldNameSession.createdAt]: '2025-01-01 00:00:00',
|
||||
[FieldNameSession.updatedAt]: '2025-01-01 00:00:00',
|
||||
[FieldNameSession.expiresAt]: '2099-12-31 00:00:00',
|
||||
},
|
||||
userId: testUserId,
|
||||
},
|
||||
async (error) => {
|
||||
expect(error).toBeUndefined();
|
||||
const result = await service.getUserIdForSessionId(testSessionId);
|
||||
expect(result).toEqual(testUserId);
|
||||
},
|
||||
],
|
||||
);
|
||||
const result = await service.getUserIdForSessionId(testSessionId);
|
||||
expect(result).toEqual(testUserId);
|
||||
});
|
||||
it('returns undefined for non-valid session id', async () => {
|
||||
const testSessionId = 'non-valid-session-id';
|
||||
mockSelect(tracker, [], TableSession, [FieldNameSession.id, FieldNameSession.expiresAt], []);
|
||||
const result = await service.getUserIdForSessionId(testSessionId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -82,9 +95,9 @@ describe('SessionService', () => {
|
||||
describe('extractSessionIdFromRequest', () => {
|
||||
const mockSocket = Mock.of<Socket>();
|
||||
const sessionId = 'testSessionId';
|
||||
it('returns empty Optional if no cookie header is set', () => {
|
||||
it('returns null if no cookie header is set', () => {
|
||||
const testRequest = new IncomingMessage(mockSocket);
|
||||
expect(service.extractSessionIdFromRequest(testRequest).isEmpty()).toBe(true);
|
||||
expect(service.extractSessionIdFromRequest(testRequest)).toBeNull();
|
||||
});
|
||||
it('returns empty Optional if cookie is malformed', async () => {
|
||||
const testRequest = new IncomingMessage(mockSocket);
|
||||
@@ -100,7 +113,7 @@ describe('SessionService', () => {
|
||||
const signature = sign(sessionId, authConfig.session.secret);
|
||||
const testRequest = new IncomingMessage(mockSocket);
|
||||
testRequest.headers.cookie = serialize(HEDGEDOC_SESSION, `s:${signature}`, {});
|
||||
expect(service.extractSessionIdFromRequest(testRequest).get()).toEqual(sessionId);
|
||||
expect(service.extractSessionIdFromRequest(testRequest)).toEqual(sessionId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { FieldNameUser, User } from '@hedgedoc/database';
|
||||
import { Optional } from '@mrdrogdrog/optional';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { parse as parseCookie } from 'cookie';
|
||||
import { unsign } from 'cookie-signature';
|
||||
import { fastifyCookie } from '@fastify/cookie';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { InjectConnection } from 'nest-knexjs';
|
||||
import { Knex } from 'knex';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import authConfiguration, { AuthConfig } from '../config/auth.config';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { HEDGEDOC_SESSION } from '../utils/session';
|
||||
import { KeyvSessionStore } from './keyv-session-store';
|
||||
import { KnexSessionStore } from './knex-session-store';
|
||||
|
||||
/**
|
||||
* Finds {@link Session sessions} by session id and verifies session cookies.
|
||||
@@ -21,17 +21,21 @@ import { KeyvSessionStore } from './keyv-session-store';
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
private static readonly sessionCookieContentRegex = /^s:(([^.]+)\.(.+))$/;
|
||||
private readonly sessionStore: KeyvSessionStore;
|
||||
private readonly sessionStore: KnexSessionStore;
|
||||
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
|
||||
@Inject(authConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
|
||||
@InjectConnection()
|
||||
private readonly knex: Knex,
|
||||
) {
|
||||
this.logger.setContext(SessionService.name);
|
||||
this.sessionStore = new KeyvSessionStore({
|
||||
this.sessionStore = new KnexSessionStore({
|
||||
ttl: authConfig.session.lifetime,
|
||||
knex: this.knex,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,7 +45,7 @@ export class SessionService {
|
||||
*
|
||||
* @returns The used session store
|
||||
*/
|
||||
getSessionStore(): KeyvSessionStore {
|
||||
getFastifySessionStore(): KnexSessionStore {
|
||||
return this.sessionStore;
|
||||
}
|
||||
|
||||
@@ -51,9 +55,10 @@ export class SessionService {
|
||||
* @param sessionId The session id for which the owning user should be found
|
||||
* @returns A Promise that either resolves with the username or rejects with an error
|
||||
*/
|
||||
async getUserIdForSessionId(sessionId: string): Promise<User[FieldNameUser.id] | undefined> {
|
||||
const session = await this.sessionStore.getAsync(sessionId);
|
||||
return session?.userId;
|
||||
async getUserIdForSessionId(sessionId: string): Promise<number | undefined> {
|
||||
const getSession = promisify(this.sessionStore.get.bind(this.sessionStore));
|
||||
const session = await getSession(sessionId);
|
||||
return session?.userId ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,10 +69,13 @@ export class SessionService {
|
||||
* @throws Error if the cookie has been found but the content is malformed
|
||||
* @throws Error if the cookie has been found but the content isn't signed
|
||||
*/
|
||||
extractSessionIdFromRequest(request: IncomingMessage): Optional<string> {
|
||||
return Optional.ofNullable(request.headers?.cookie)
|
||||
.map((cookieHeader) => parseCookie(cookieHeader)[HEDGEDOC_SESSION])
|
||||
.map((rawCookie) => this.extractVerifiedSessionIdFromCookieContent(rawCookie));
|
||||
extractSessionIdFromRequest(request: IncomingMessage): string | null {
|
||||
const cookies = fastifyCookie.parse(request.headers.cookie ?? '');
|
||||
const sessionCookie = cookies[HEDGEDOC_SESSION];
|
||||
if (!sessionCookie) {
|
||||
return null;
|
||||
}
|
||||
return this.extractVerifiedSessionIdFromCookieContent(sessionCookie);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +91,7 @@ export class SessionService {
|
||||
if (parsedCookie === null) {
|
||||
throw new Error(`cookie "${HEDGEDOC_SESSION}" doesn't look like a signed session cookie`);
|
||||
}
|
||||
if (unsign(parsedCookie[1], this.authConfig.session.secret) === false) {
|
||||
if (!fastifyCookie.unsign(parsedCookie[1], this.authConfig.session.secret).valid) {
|
||||
throw new Error(`signature of cookie "${HEDGEDOC_SESSION}" isn't valid.`);
|
||||
}
|
||||
return parsedCookie[2];
|
||||
|
||||
@@ -9,7 +9,7 @@ import fastifyCookie from '@fastify/cookie';
|
||||
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
|
||||
import { AuthConfig } from '../config/auth.config';
|
||||
import { KeyvSessionStore } from '../sessions/keyv-session-store';
|
||||
import { KnexSessionStore } from '../sessions/knex-session-store';
|
||||
|
||||
export const HEDGEDOC_SESSION = 'hedgedoc-session';
|
||||
|
||||
@@ -23,7 +23,7 @@ export const HEDGEDOC_SESSION = 'hedgedoc-session';
|
||||
export async function setupSessionMiddleware(
|
||||
app: INestApplication,
|
||||
authConfig: AuthConfig,
|
||||
sessionStore: KeyvSessionStore,
|
||||
sessionStore: KnexSessionStore,
|
||||
): Promise<void> {
|
||||
const fastifyApp = app as NestFastifyApplication;
|
||||
|
||||
@@ -35,9 +35,12 @@ export async function setupSessionMiddleware(
|
||||
cookie: {
|
||||
// Handle session duration in seconds instead of ms
|
||||
maxAge: authConfig.session.lifetime * 1000,
|
||||
secure: false,
|
||||
secure: 'auto',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
},
|
||||
saveUninitialized: false,
|
||||
rolling: false,
|
||||
store: sessionStore,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export * from './alias.js'
|
||||
export * from './api-token.js'
|
||||
export * from './authorship-info.js'
|
||||
export * from './group.js'
|
||||
export * from './group-user.js'
|
||||
export * from './group.js'
|
||||
export * from './identity.js'
|
||||
export * from './media-upload.js'
|
||||
export * from './note.js'
|
||||
export * from './note-group-permission.js'
|
||||
export * from './note-user-permission.js'
|
||||
export * from './revision.js'
|
||||
export * from './note.js'
|
||||
export * from './revision-tag.js'
|
||||
export * from './user.js'
|
||||
export * from './revision.js'
|
||||
export * from './session.js'
|
||||
export * from './user-pinned-note.js'
|
||||
export * from './user.js'
|
||||
export * from './visited-note.js'
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AuthProviderType } from './identity'
|
||||
|
||||
/**
|
||||
* Database representation of a session
|
||||
*/
|
||||
export interface Session {
|
||||
/** The session ID (primary key) */
|
||||
[FieldNameSession.id]: string
|
||||
|
||||
/** The CSRF token for the current session */
|
||||
[FieldNameSession.csrfToken]: string | null
|
||||
|
||||
/** The user ID associated with this session (foreign key to user table) */
|
||||
[FieldNameSession.userId]: number | null
|
||||
|
||||
/** The auth provider that is used for the current login */
|
||||
[FieldNameSession.loginAuthProviderType]: AuthProviderType | null
|
||||
|
||||
/** The identifier of the auth provider that is used for the current login */
|
||||
[FieldNameSession.loginAuthProviderIdentifier]: string | null
|
||||
|
||||
/** The OIDC ID token used during authentication, for logout */
|
||||
[FieldNameSession.oidcIdToken]: string | null
|
||||
|
||||
/** The OIDC secret code for verifying authentication callback */
|
||||
[FieldNameSession.oidcLoginCode]: string | null
|
||||
|
||||
/** The OIDC state value for verifying authentication callback */
|
||||
[FieldNameSession.oidcLoginState]: string | null
|
||||
|
||||
/** The OIDC session ID (sid) for OIDC provider backchannel logout */
|
||||
[FieldNameSession.oidcSid]: string | null
|
||||
|
||||
/** JSON representation of the pending user data on registration */
|
||||
[FieldNameSession.pendingUserData]: string
|
||||
|
||||
/** Timestamp when the session was created */
|
||||
[FieldNameSession.createdAt]: string
|
||||
|
||||
/** Timestamp when the session was last updated */
|
||||
[FieldNameSession.updatedAt]: string
|
||||
|
||||
/** Timestamp when the session expires */
|
||||
[FieldNameSession.expiresAt]: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Field names of the {@link Session} table
|
||||
*/
|
||||
export enum FieldNameSession {
|
||||
id = 'id',
|
||||
userId = 'user_id',
|
||||
csrfToken = 'csrf_token',
|
||||
loginAuthProviderType = 'login_auth_provider_type',
|
||||
loginAuthProviderIdentifier = 'login_auth_provider_identifier',
|
||||
oidcIdToken = 'oidc_id_token',
|
||||
oidcLoginCode = 'oidc_login_code',
|
||||
oidcLoginState = 'oidc_login_state',
|
||||
oidcSid = 'oidc_sid',
|
||||
pendingUserData = 'pending_user_data',
|
||||
createdAt = 'created_at',
|
||||
updatedAt = 'updated_at',
|
||||
expiresAt = 'expires_at',
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the session table
|
||||
*/
|
||||
export const TableSession = 'session'
|
||||
Reference in New Issue
Block a user