Move session entity to sessions folder

Signed-off-by: Yannick Bungers <git@innay.de>
This commit is contained in:
Yannick Bungers 2023-06-25 21:52:44 +02:00 committed by Tilman Vatteroth
parent eeef0ea025
commit f362d27d3f
23 changed files with 26 additions and 26 deletions

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ISession } from 'connect-typeorm';
import {
Column,
DeleteDateColumn,
Entity,
Index,
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { Author } from '../authors/author.entity';
@Entity()
export class Session implements ISession {
@PrimaryColumn('varchar', { length: 255 })
public id = '';
@Index()
@Column('bigint')
public expiredAt = Date.now();
@Column('text')
public json = '';
@DeleteDateColumn()
public destroyedAt?: Date;
@ManyToOne(() => Author, (author) => author.sessions)
author: Promise<Author>;
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Session } from './session.entity';
import { SessionService } from './session.service';
@Module({
imports: [TypeOrmModule.forFeature([Session])],
exports: [SessionService],
providers: [SessionService],
})
export class SessionModule {}

View file

@ -0,0 +1,203 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as ConnectTypeormModule from 'connect-typeorm';
import { TypeormStore } from 'connect-typeorm';
import * as parseCookieModule from 'cookie';
import * as cookieSignatureModule from 'cookie-signature';
import { IncomingMessage } from 'http';
import { Mock } from 'ts-mockery';
import { Repository } from 'typeorm';
import { AuthConfig } from '../config/auth.config';
import { DatabaseType } from '../config/database-type.enum';
import { DatabaseConfig } from '../config/database.config';
import { HEDGEDOC_SESSION } from '../utils/session';
import { Session } from './session.entity';
import { SessionService, SessionState } from './session.service';
jest.mock('cookie');
jest.mock('cookie-signature');
describe('SessionService', () => {
let mockedTypeormStore: TypeormStore;
let mockedSessionRepository: Repository<Session>;
let databaseConfigMock: DatabaseConfig;
let authConfigMock: AuthConfig;
let typeormStoreConstructorMock: jest.SpyInstance;
const mockedExistingSessionId = 'mockedExistingSessionId';
const mockUsername = 'mock-user';
const mockSecret = 'mockSecret';
let sessionService: SessionService;
beforeEach(() => {
jest.resetModules();
jest.restoreAllMocks();
const mockedExistingSession = Mock.of<SessionState>({
username: mockUsername,
});
mockedTypeormStore = Mock.of<TypeormStore>({
connect: jest.fn(() => mockedTypeormStore),
get: jest.fn(((sessionId, callback) => {
if (sessionId === mockedExistingSessionId) {
callback(undefined, mockedExistingSession);
} else {
callback(new Error("Session doesn't exist"), undefined);
}
}) as TypeormStore['get']),
});
mockedSessionRepository = Mock.of<Repository<Session>>({});
databaseConfigMock = Mock.of<DatabaseConfig>({
type: DatabaseType.SQLITE,
});
authConfigMock = Mock.of<AuthConfig>({
session: {
secret: mockSecret,
},
});
typeormStoreConstructorMock = jest
.spyOn(ConnectTypeormModule, 'TypeormStore')
.mockReturnValue(mockedTypeormStore);
sessionService = new SessionService(
mockedSessionRepository,
databaseConfigMock,
authConfigMock,
);
});
it('creates a new TypeormStore on create', () => {
expect(typeormStoreConstructorMock).toHaveBeenCalledWith({
cleanupLimit: 2,
limitSubquery: true,
});
expect(mockedTypeormStore.connect).toHaveBeenCalledWith(
mockedSessionRepository,
);
expect(sessionService.getTypeormStore()).toBe(mockedTypeormStore);
});
it('can fetch a username for an existing session', async () => {
await expect(
sessionService.fetchUsernameForSessionId(mockedExistingSessionId),
).resolves.toBe(mockUsername);
});
it("can't fetch a username for a non-existing session", async () => {
await expect(
sessionService.fetchUsernameForSessionId("doesn't exist"),
).rejects.toThrow();
});
describe('extract verified session id from request', () => {
const validCookieHeader = 'validCookieHeader';
const validSessionId = 'validSessionId';
function mockParseCookieModule(sessionCookieContent: string): void {
jest
.spyOn(parseCookieModule, 'parse')
.mockImplementation((header: string): Record<string, string> => {
if (header === validCookieHeader) {
return {
[HEDGEDOC_SESSION]: sessionCookieContent,
};
} else {
return {};
}
});
}
beforeEach(() => {
jest.spyOn(parseCookieModule, 'parse').mockImplementation(() => {
throw new Error('call not expected!');
});
jest
.spyOn(cookieSignatureModule, 'unsign')
.mockImplementation((value, secret) => {
if (value.endsWith('.validSignature') && secret === mockSecret) {
return 'decryptedValue';
} else {
return false;
}
});
});
it('fails if no cookie header is present', () => {
const mockedRequest = Mock.of<IncomingMessage>({
headers: {},
});
expect(
sessionService.extractSessionIdFromRequest(mockedRequest).isEmpty(),
).toBeTruthy();
});
it("fails if the cookie header isn't valid", () => {
const mockedRequest = Mock.of<IncomingMessage>({
headers: { cookie: 'no' },
});
mockParseCookieModule(`s:anyValidSessionId.validSignature`);
expect(
sessionService.extractSessionIdFromRequest(mockedRequest).isEmpty(),
).toBeTruthy();
});
it("fails if the hedgedoc session cookie isn't marked as signed", () => {
const mockedRequest = Mock.of<IncomingMessage>({
headers: { cookie: validCookieHeader },
});
mockParseCookieModule('sessionId.validSignature');
expect(() =>
sessionService.extractSessionIdFromRequest(mockedRequest),
).toThrow(
'cookie "hedgedoc-session" doesn\'t look like a signed session cookie',
);
});
it("fails if the hedgedoc session cookie doesn't contain a session id", () => {
const mockedRequest = Mock.of<IncomingMessage>({
headers: { cookie: validCookieHeader },
});
mockParseCookieModule('s:.validSignature');
expect(() =>
sessionService.extractSessionIdFromRequest(mockedRequest),
).toThrow(
'cookie "hedgedoc-session" doesn\'t look like a signed session cookie',
);
});
it("fails if the hedgedoc session cookie doesn't contain a signature", () => {
const mockedRequest = Mock.of<IncomingMessage>({
headers: { cookie: validCookieHeader },
});
mockParseCookieModule('s:sessionId.');
expect(() =>
sessionService.extractSessionIdFromRequest(mockedRequest),
).toThrow(
'cookie "hedgedoc-session" doesn\'t look like a signed session cookie',
);
});
it("fails if the hedgedoc session cookie isn't signed correctly", () => {
const mockedRequest = Mock.of<IncomingMessage>({
headers: { cookie: validCookieHeader },
});
mockParseCookieModule('s:sessionId.invalidSignature');
expect(() =>
sessionService.extractSessionIdFromRequest(mockedRequest),
).toThrow('signature of cookie "hedgedoc-session" isn\'t valid.');
});
it('can extract a session id from a valid request', () => {
const mockedRequest = Mock.of<IncomingMessage>({
headers: { cookie: validCookieHeader },
});
mockParseCookieModule(`s:${validSessionId}.validSignature`);
expect(
sessionService.extractSessionIdFromRequest(mockedRequest).get(),
).toBe(validSessionId);
});
});
});

View file

@ -0,0 +1,107 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Optional } from '@mrdrogdrog/optional';
import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeormStore } from 'connect-typeorm';
import { parse as parseCookie } from 'cookie';
import { unsign } from 'cookie-signature';
import { IncomingMessage } from 'http';
import { Repository } from 'typeorm';
import authConfiguration, { AuthConfig } from '../config/auth.config';
import { DatabaseType } from '../config/database-type.enum';
import databaseConfiguration, {
DatabaseConfig,
} from '../config/database.config';
import { HEDGEDOC_SESSION } from '../utils/session';
import { Username } from '../utils/username';
import { Session } from './session.entity';
export interface SessionState {
cookie: unknown;
username?: Username;
authProvider: string;
}
/**
* Finds {@link Session sessions} by session id and verifies session cookies.
*/
@Injectable()
export class SessionService {
private static readonly sessionCookieContentRegex = /^s:(([^.]+)\.(.+))$/;
private readonly typeormStore: TypeormStore;
constructor(
@InjectRepository(Session) private sessionRepository: Repository<Session>,
@Inject(databaseConfiguration.KEY)
private dbConfig: DatabaseConfig,
@Inject(authConfiguration.KEY)
private authConfig: AuthConfig,
) {
this.typeormStore = new TypeormStore({
cleanupLimit: 2,
limitSubquery: dbConfig.type !== DatabaseType.MARIADB,
}).connect(sessionRepository);
}
getTypeormStore(): TypeormStore {
return this.typeormStore;
}
/**
* Finds the username of the user that own the given session id.
*
* @async
* @param sessionId The session id for which the owning user should be found
* @return A Promise that either resolves with the username or rejects with an error
*/
fetchUsernameForSessionId(sessionId: string): Promise<Username | undefined> {
return new Promise((resolve, reject) => {
this.typeormStore.get(sessionId, (error?: Error, result?: SessionState) =>
error || !result ? reject(error) : resolve(result.username as Username),
);
});
}
/**
* Extracts the hedgedoc session cookie from the given {@link IncomingMessage request} and checks if the signature is correct.
*
* @param request The http request that contains a session cookie
* @return An {@link Optional optional} that either contains the extracted session id or is empty if no session cookie has been found
* @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),
);
}
/**
* Parses the given session cookie content and extracts the session id.
*
* @param rawCookie The cookie to parse
* @return The extracted session id
* @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
*/
private extractVerifiedSessionIdFromCookieContent(rawCookie: string): string {
const parsedCookie =
SessionService.sessionCookieContentRegex.exec(rawCookie);
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) {
throw new Error(`signature of cookie "${HEDGEDOC_SESSION}" isn't valid.`);
}
return parsedCookie[2];
}
}