refactor: replace TypeORM with knex.js

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2025-03-14 23:33:29 +01:00
parent 6e151c8a1b
commit c0ce00b3f9
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
242 changed files with 4601 additions and 6871 deletions

View file

@ -1,47 +0,0 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { CompleteRequest } from '../api/utils/request.type';
import { NotInDBError, TokenNotValidError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { ApiTokenService } from './api-token.service';
@Injectable()
export class ApiTokenGuard implements CanActivate {
constructor(
private readonly logger: ConsoleLoggerService,
private readonly apiTokenService: ApiTokenService,
) {
this.logger.setContext(ApiTokenGuard.name);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request: CompleteRequest = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader) {
return false;
}
const [method, token] = authHeader.trim().split(' ');
if (method !== 'Bearer') {
return false;
}
try {
request.user = await this.apiTokenService.validateToken(token.trim());
return true;
} catch (error) {
if (
!(error instanceof TokenNotValidError || error instanceof NotInDBError)
) {
this.logger.error(
`Error during API token validation: ${String(error)}`,
'canActivate',
);
}
return false;
}
}
}

View file

@ -4,17 +4,16 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { KnexModule } from 'nest-knexjs';
import { ApiTokenGuard } from '../api/utils/guards/api-token.guard';
import { MockApiTokenGuard } from '../api/utils/guards/mock-api-token.guard';
import { LoggerModule } from '../logger/logger.module';
import { UsersModule } from '../users/users.module';
import { ApiToken } from './api-token.entity';
import { ApiTokenGuard } from './api-token.guard';
import { ApiTokenService } from './api-token.service';
import { MockApiTokenGuard } from './mock-api-token.guard';
@Module({
imports: [UsersModule, LoggerModule, TypeOrmModule.forFeature([ApiToken])],
imports: [UsersModule, LoggerModule, KnexModule],
providers: [ApiTokenService, ApiTokenGuard, MockApiTokenGuard],
exports: [ApiTokenService, ApiTokenGuard],
})

View file

@ -104,13 +104,13 @@ describe('ApiTokenService', () => {
describe('getTokensByUser', () => {
it('works', async () => {
createQueryBuilderFunc.getMany = () => [apiToken];
const tokens = await service.getTokensByUser(user);
const tokens = await service.getTokensOfUserById(user);
expect(tokens).toHaveLength(1);
expect(tokens).toEqual([apiToken]);
});
it('should return empty array if token for user do not exists', async () => {
jest.spyOn(apiTokenRepo, 'find').mockImplementationOnce(async () => []);
const tokens = await service.getTokensByUser(user);
const tokens = await service.getTokensOfUserById(user);
expect(tokens).toHaveLength(0);
expect(tokens).toEqual([]);
});
@ -153,13 +153,13 @@ describe('ApiTokenService', () => {
);
expect(() =>
service.checkToken(secret, accessToken as ApiToken),
service.ensureTokenIsValid(secret, accessToken as ApiToken),
).not.toThrow();
});
it('AuthToken has wrong hash', () => {
const [accessToken] = service.createToken(user, 'TestToken', null);
expect(() =>
service.checkToken('secret', accessToken as ApiToken),
service.ensureTokenIsValid('secret', accessToken as ApiToken),
).toThrow(TokenNotValidError);
});
it('AuthToken has wrong validUntil Date', () => {
@ -168,9 +168,9 @@ describe('ApiTokenService', () => {
'Test',
new Date(1549312452000),
);
expect(() => service.checkToken(secret, accessToken as ApiToken)).toThrow(
TokenNotValidError,
);
expect(() =>
service.ensureTokenIsValid(secret, accessToken as ApiToken),
).toThrow(TokenNotValidError);
});
});
@ -222,7 +222,7 @@ describe('ApiTokenService', () => {
.mockImplementationOnce(async (_, __): Promise<ApiToken> => {
return apiToken;
});
const userByToken = await service.validateToken(
const userByToken = await service.getUserIdForToken(
`hd2.${apiToken.keyId}.${testSecret}`,
);
expect(userByToken).toEqual({
@ -233,27 +233,27 @@ describe('ApiTokenService', () => {
describe('fails:', () => {
it('the prefix is missing', async () => {
await expect(
service.validateToken(`${apiToken.keyId}.${'a'.repeat(73)}`),
service.getUserIdForToken(`${apiToken.keyId}.${'a'.repeat(73)}`),
).rejects.toThrow(TokenNotValidError);
});
it('the prefix is wrong', async () => {
await expect(
service.validateToken(`hd1.${apiToken.keyId}.${'a'.repeat(73)}`),
service.getUserIdForToken(`hd1.${apiToken.keyId}.${'a'.repeat(73)}`),
).rejects.toThrow(TokenNotValidError);
});
it('the secret is missing', async () => {
await expect(
service.validateToken(`hd2.${apiToken.keyId}`),
service.getUserIdForToken(`hd2.${apiToken.keyId}`),
).rejects.toThrow(TokenNotValidError);
});
it('the secret is too long', async () => {
await expect(
service.validateToken(`hd2.${apiToken.keyId}.${'a'.repeat(73)}`),
service.getUserIdForToken(`hd2.${apiToken.keyId}.${'a'.repeat(73)}`),
).rejects.toThrow(TokenNotValidError);
});
it('the token contains sections after the secret', async () => {
await expect(
service.validateToken(
service.getUserIdForToken(
`hd2.${apiToken.keyId}.${'a'.repeat(73)}.extra`,
),
).rejects.toThrow(TokenNotValidError);

View file

@ -6,19 +6,23 @@
import { ApiTokenDto, ApiTokenWithSecretDto } from '@hedgedoc/commons';
import { Injectable } from '@nestjs/common';
import { Cron, Timeout } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
import { Repository } from 'typeorm';
import { randomBytes } from 'crypto';
import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs';
import { User } from '../database/user.entity';
import { ApiToken, FieldNameApiToken, TableApiToken } from '../database/types';
import { TypeInsertApiToken } from '../database/types/api-token';
import {
NotInDBError,
TokenNotValidError,
TooManyTokensError,
} from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { bufferToBase64Url, checkTokenEquality } from '../utils/password';
import { ApiToken } from './api-token.entity';
import {
bufferToBase64Url,
checkTokenEquality,
hashApiToken,
} from '../utils/password';
export const AUTH_TOKEN_PREFIX = 'hd2';
@ -26,13 +30,22 @@ export const AUTH_TOKEN_PREFIX = 'hd2';
export class ApiTokenService {
constructor(
private readonly logger: ConsoleLoggerService,
@InjectRepository(ApiToken)
private authTokenRepository: Repository<ApiToken>,
@InjectConnection()
private readonly knex: Knex,
) {
this.logger.setContext(ApiTokenService.name);
}
async validateToken(tokenString: string): Promise<User> {
/**
* Validates a given token string and returns the userId if the token is valid
* The usage of this token is tracked in the database
*
* @param tokenString The token string to validate and parse
* @return The userId associated with the token
* @throws TokenNotValidError if the token is not valid
*/
async getUserIdForToken(tokenString: string): Promise<number> {
const [prefix, keyId, secret, ...rest] = tokenString.split('.');
if (!keyId || !secret || prefix !== AUTH_TOKEN_PREFIX || rest.length > 0) {
throw new TokenNotValidError('Invalid API token format');
@ -44,179 +57,202 @@ export class ApiTokenService {
`API token '${tokenString}' has incorrect length`,
);
}
const token = await this.getToken(keyId);
this.checkToken(secret, token);
await this.setLastUsedToken(keyId);
return token.user;
return await this.knex.transaction(async (transaction) => {
const token = await transaction(TableApiToken)
.select(
FieldNameApiToken.secretHash,
FieldNameApiToken.userId,
FieldNameApiToken.validUntil,
)
.where(FieldNameApiToken.id, keyId)
.first();
if (token === undefined) {
throw new TokenNotValidError('Token not found');
}
const tokenHash = token[FieldNameApiToken.secretHash];
const validUntil = token[FieldNameApiToken.validUntil];
this.ensureTokenIsValid(secret, tokenHash, validUntil);
await transaction(TableApiToken)
.update(FieldNameApiToken.lastUsedAt, this.knex.fn.now())
.where(FieldNameApiToken.id, keyId);
return token[FieldNameApiToken.userId];
});
}
createToken(
user: User,
identifier: string,
userDefinedValidUntil: Date | null,
): [Omit<ApiToken, 'id' | 'createdAt'>, string] {
const secret = bufferToBase64Url(randomBytes(64));
const keyId = bufferToBase64Url(randomBytes(8));
// More about the choice of SHA-512 in the dev docs
const accessTokenHash = createHash('sha512').update(secret).digest('hex');
// Tokens can only be valid for a maximum of 2 years
const maximumTokenValidity = new Date();
maximumTokenValidity.setTime(
maximumTokenValidity.getTime() + 2 * 365 * 24 * 60 * 60 * 1000,
);
const isTokenLimitedToMaximumValidity =
!userDefinedValidUntil || userDefinedValidUntil > maximumTokenValidity;
const validUntil = isTokenLimitedToMaximumValidity
? maximumTokenValidity
: userDefinedValidUntil;
const token = ApiToken.create(
keyId,
user,
identifier,
accessTokenHash,
new Date(validUntil),
);
return [token, secret];
}
async addToken(
user: User,
identifier: string,
validUntil: Date | null,
/**
* Creates a new API token for the given user
*
* @param userId The id of the user to create the token for
* @param tokenLabel The label of the token
* @param userDefinedValidUntil Maximum date until the token is valid, will be truncated to 2 years
* @throws TooManyTokensError if the user already has 200 tokens
* @returns The created token together with the secret
*/
async createToken(
userId: number,
tokenLabel: string,
userDefinedValidUntil?: Date,
): Promise<ApiTokenWithSecretDto> {
user.apiTokens = this.getTokensByUser(user);
return await this.knex.transaction(async (transaction) => {
const existingTokensForUser = await transaction(TableApiToken)
.select(FieldNameApiToken.id)
.where(FieldNameApiToken.userId, userId);
if (existingTokensForUser.length >= 200) {
// This is a very high ceiling unlikely to hinder legitimate usage,
// but should prevent possible attack vectors
throw new TooManyTokensError(
`User '${userId}' has already 200 API tokens and can't have more`,
);
}
if ((await user.apiTokens).length >= 200) {
// This is a very high ceiling unlikely to hinder legitimate usage,
// but should prevent possible attack vectors
throw new TooManyTokensError(
`User '${user.username}' has already 200 API tokens and can't have more`,
const secret = bufferToBase64Url(randomBytes(64));
const keyId = bufferToBase64Url(randomBytes(8));
const accessTokenHash = hashApiToken(secret);
// Tokens can only be valid for a maximum of 2 years
const maximumTokenValidity = new Date();
maximumTokenValidity.setTime(
maximumTokenValidity.getTime() + 2 * 365 * 24 * 60 * 60 * 1000,
);
const isTokenLimitedToMaximumValidity =
!userDefinedValidUntil || userDefinedValidUntil > maximumTokenValidity;
const validUntil = isTokenLimitedToMaximumValidity
? maximumTokenValidity
: userDefinedValidUntil;
const token: TypeInsertApiToken = {
[FieldNameApiToken.id]: keyId,
[FieldNameApiToken.label]: tokenLabel,
[FieldNameApiToken.userId]: userId,
[FieldNameApiToken.secretHash]: accessTokenHash,
[FieldNameApiToken.validUntil]: validUntil,
[FieldNameApiToken.createdAt]: new Date(),
};
await this.knex(TableApiToken).insert(token);
return this.toAuthTokenWithSecretDto(
{
...token,
[FieldNameApiToken.lastUsedAt]: null,
},
secret,
);
}
const [token, secret] = this.createToken(user, identifier, validUntil);
const createdToken = (await this.authTokenRepository.save(
token,
)) as ApiToken;
return this.toAuthTokenWithSecretDto(
createdToken,
`${AUTH_TOKEN_PREFIX}.${createdToken.keyId}.${secret}`,
);
}
async setLastUsedToken(keyId: string): Promise<void> {
const token = await this.authTokenRepository.findOne({
where: { keyId: keyId },
});
if (token === null) {
throw new NotInDBError(`API token with id '${keyId}' not found`);
}
token.lastUsedAt = new Date();
await this.authTokenRepository.save(token);
}
async getToken(keyId: string): Promise<ApiToken> {
const token = await this.authTokenRepository.findOne({
where: { keyId: keyId },
relations: ['user'],
});
if (token === null) {
throw new NotInDBError(`API token with id '${keyId}' not found`);
}
return token;
}
checkToken(secret: string, token: ApiToken): void {
if (!checkTokenEquality(secret, token.hash)) {
// hashes are not the same
/**
* Ensures that the given token secret is valid for the given token
* This method does not return any value but throws an error if the token is not valid
*
* @param secret The secret to compare against the hash from the database
* @param tokenHash The hash from the database
* @param validUntil Expiry of the API token
* @throws TokenNotValidError if the token is invalid
*/
ensureTokenIsValid(
secret: string,
tokenHash: string,
validUntil: Date,
): void {
// First, verify token expiry is not in the past (cheap operation)
if (validUntil.getTime() < new Date().getTime()) {
throw new TokenNotValidError(
`Secret does not match Token ${token.label}.`,
`Auth token is not valid since ${validUntil.toISOString()}`,
);
}
if (token.validUntil && token.validUntil.getTime() < new Date().getTime()) {
// tokens validUntil Date lies in the past
throw new TokenNotValidError(
`AuthToken '${
token.label
}' is not valid since ${token.validUntil.toISOString()}.`,
);
// Second, verify the secret (costly operation)
if (!checkTokenEquality(secret, tokenHash)) {
throw new TokenNotValidError(`Secret does not match token hash`);
}
}
async getTokensByUser(user: User): Promise<ApiToken[]> {
const tokens = await this.authTokenRepository.find({
where: { user: { id: user.id } },
});
if (tokens === null) {
return [];
}
return tokens;
/**
* Returns all tokens of a user
*
* @param userId The id of the user to get the tokens for
* @return The tokens of the user
*/
getTokensOfUserById(userId: number): Promise<ApiToken[]> {
return this.knex(TableApiToken)
.select()
.where(FieldNameApiToken.userId, userId);
}
async removeToken(keyId: string): Promise<void> {
const token = await this.authTokenRepository.findOne({
where: { keyId: keyId },
});
if (token === null) {
throw new NotInDBError(`API token with id '${keyId}' not found`);
/**
* Removes a token from the database
*
* @param keyId The id of the token to remove
* @param userId The id of the user who owns the token
* @throws NotInDBError if the token is not found
*/
async removeToken(keyId: string, userId: number): Promise<void> {
const numberOfDeletedTokens = await this.knex(TableApiToken)
.where(FieldNameApiToken.id, keyId)
.andWhere(FieldNameApiToken.userId, userId)
.delete();
if (numberOfDeletedTokens === 0) {
throw new NotInDBError('Token not found');
}
await this.authTokenRepository.remove(token);
}
toAuthTokenDto(authToken: ApiToken): ApiTokenDto {
const tokenDto: ApiTokenDto = {
label: authToken.label,
keyId: authToken.keyId,
createdAt: authToken.createdAt.toISOString(),
validUntil: authToken.validUntil.toISOString(),
lastUsedAt: null,
/**
* Converts an ApiToken to an ApiTokenDto
*
* @param apiToken The token to convert
* @return The converted token
*/
toAuthTokenDto(apiToken: ApiToken): ApiTokenDto {
return {
label: apiToken[FieldNameApiToken.label],
keyId: apiToken[FieldNameApiToken.id],
createdAt: apiToken[FieldNameApiToken.createdAt].toISOString(),
validUntil: apiToken[FieldNameApiToken.validUntil].toISOString(),
lastUsedAt: apiToken[FieldNameApiToken.lastUsedAt]?.toISOString() ?? null,
};
if (authToken.lastUsedAt) {
tokenDto.lastUsedAt = new Date(authToken.lastUsedAt).toISOString();
}
return tokenDto;
}
/**
* Converts an ApiToken to an ApiTokenWithSecretDto
*
* @param apiToken The token to convert
* @param secret The secret of the token
* @return The converted token
*/
toAuthTokenWithSecretDto(
authToken: ApiToken,
apiToken: ApiToken,
secret: string,
): ApiTokenWithSecretDto {
const tokenDto = this.toAuthTokenDto(authToken);
const tokenDto = this.toAuthTokenDto(apiToken);
const fullToken = `${AUTH_TOKEN_PREFIX}.${tokenDto.keyId}.${secret}`;
return {
...tokenDto,
secret: secret,
secret: fullToken,
};
}
// Delete all non valid tokens every sunday on 3:00 AM
// Deletes all invalid tokens every sunday on 3:00 AM
@Cron('0 0 3 * * 0')
async handleCron(): Promise<void> {
return await this.removeInvalidTokens();
}
// Delete all non valid tokens 5 sec after startup
@Timeout(5000)
// Delete all invalid tokens 60 sec after startup
@Timeout(60 * 1000)
async handleTimeout(): Promise<void> {
return await this.removeInvalidTokens();
}
/**
* Removes all expired tokens from the database
* This method is called by the cron job and the timeout
*/
async removeInvalidTokens(): Promise<void> {
const currentTime = new Date().getTime();
const tokens: ApiToken[] = await this.authTokenRepository.find();
let removedTokens = 0;
for (const token of tokens) {
if (token.validUntil && token.validUntil.getTime() <= currentTime) {
this.logger.debug(
`AuthToken '${token.keyId}' was removed`,
'removeInvalidTokens',
);
await this.authTokenRepository.remove(token);
removedTokens++;
}
}
const numberOfDeletedTokens = await this.knex(TableApiToken)
.where(FieldNameApiToken.validUntil, '<', new Date())
.delete();
this.logger.log(
`${removedTokens} invalid AuthTokens were purged from the DB.`,
`${numberOfDeletedTokens} invalid AuthTokens were purged from the DB.`,
'removeInvalidTokens',
);
}

View file

@ -1,37 +0,0 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ExecutionContext, Injectable } from '@nestjs/common';
import { CompleteRequest } from '../api/utils/request.type';
import { User } from '../database/user.entity';
import { UsersService } from '../users/users.service';
@Injectable()
export class MockApiTokenGuard {
private user: User;
constructor(private usersService: UsersService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req: CompleteRequest = context.switchToHttp().getRequest();
if (!this.user) {
// this assures that we can create the user 'hardcoded', if we need them before any calls are made or
// create them on the fly when the first call to the api is made
try {
this.user = await this.usersService.getUserByUsername('hardcoded');
} catch (e) {
this.user = await this.usersService.createUser(
'hardcoded',
'Testy',
null,
null,
);
}
}
req.user = this.user;
return true;
}
}