fix(repository): Move backend code into subdirectory

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-10-02 20:10:32 +02:00 committed by David Mehren
parent 86584e705f
commit bf30cbcf48
272 changed files with 87 additions and 67 deletions

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Type } from 'class-transformer';
import { IsDate, IsNumber, IsOptional, IsString } from 'class-validator';
import { BaseDto } from '../utils/base.dto.';
import { TimestampMillis } from '../utils/timestamp';
export class AuthTokenDto extends BaseDto {
@IsString()
label: string;
@IsString()
keyId: string;
@IsDate()
@Type(() => Date)
createdAt: Date;
@IsDate()
@Type(() => Date)
validUntil: Date;
@IsDate()
@Type(() => Date)
@IsOptional()
lastUsedAt: Date | null;
}
export class AuthTokenWithSecretDto extends AuthTokenDto {
@IsString()
secret: string;
}
export class AuthTokenCreateDto extends BaseDto {
@IsString()
label: string;
@IsNumber()
validUntil: TimestampMillis;
}

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../users/user.entity';
@Entity()
export class AuthToken {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
keyId: string;
@ManyToOne((_) => User, (user) => user.authTokens, {
onDelete: 'CASCADE', // This deletes the AuthToken, when the associated User is deleted
})
user: Promise<User>;
@Column()
label: string;
@CreateDateColumn()
createdAt: Date;
@Column({ unique: true })
accessTokenHash: string;
@Column()
validUntil: Date;
@Column({
nullable: true,
type: 'date',
})
lastUsedAt: Date | null;
public static create(
keyId: string,
user: User,
label: string,
tokenString: string,
validUntil: Date,
): Omit<AuthToken, 'id' | 'createdAt'> {
const token = new AuthToken();
token.keyId = keyId;
token.user = Promise.resolve(user);
token.label = label;
token.accessTokenHash = tokenString;
token.validUntil = validUntil;
token.lastUsedAt = null;
return token;
}
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggerModule } from '../logger/logger.module';
import { UsersModule } from '../users/users.module';
import { AuthToken } from './auth-token.entity';
import { AuthService } from './auth.service';
import { TokenStrategy } from './token.strategy';
@Module({
imports: [
UsersModule,
PassportModule,
LoggerModule,
TypeOrmModule.forFeature([AuthToken]),
],
providers: [AuthService, TokenStrategy],
exports: [AuthService],
})
export class AuthModule {}

View file

@ -0,0 +1,328 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ConfigModule } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import crypto from 'crypto';
import { Repository } from 'typeorm';
import appConfigMock from '../config/mock/app.config.mock';
import { NotInDBError, TokenNotValidError } from '../errors/errors';
import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module';
import { Session } from '../users/session.entity';
import { User } from '../users/user.entity';
import { UsersModule } from '../users/users.module';
import { AuthToken } from './auth-token.entity';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let service: AuthService;
let user: User;
let authToken: AuthToken;
let userRepo: Repository<User>;
let authTokenRepo: Repository<AuthToken>;
class CreateQueryBuilderClass {
leftJoinAndSelect: () => CreateQueryBuilderClass;
where: () => CreateQueryBuilderClass;
orWhere: () => CreateQueryBuilderClass;
setParameter: () => CreateQueryBuilderClass;
getOne: () => AuthToken;
getMany: () => AuthToken[];
}
let createQueryBuilderFunc: CreateQueryBuilderClass;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: getRepositoryToken(AuthToken),
useClass: Repository,
},
],
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfigMock],
}),
PassportModule,
UsersModule,
LoggerModule,
],
})
.overrideProvider(getRepositoryToken(Identity))
.useValue({})
.overrideProvider(getRepositoryToken(User))
.useClass(Repository)
.overrideProvider(getRepositoryToken(Session))
.useValue({})
.compile();
service = module.get<AuthService>(AuthService);
userRepo = module.get<Repository<User>>(getRepositoryToken(User));
authTokenRepo = module.get<Repository<AuthToken>>(
getRepositoryToken(AuthToken),
);
user = User.create('hardcoded', 'Testy') as User;
authToken = AuthToken.create(
'testKeyId',
user,
'testToken',
'abc',
new Date(new Date().getTime() + 60000), // make this AuthToken valid for 1min
) as AuthToken;
const createQueryBuilder = {
leftJoinAndSelect: () => createQueryBuilder,
where: () => createQueryBuilder,
orWhere: () => createQueryBuilder,
setParameter: () => createQueryBuilder,
getOne: () => authToken,
getMany: () => [authToken],
};
createQueryBuilderFunc = createQueryBuilder;
jest
.spyOn(authTokenRepo, 'createQueryBuilder')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.mockImplementation(() => createQueryBuilder);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getTokensByUser', () => {
it('works', async () => {
createQueryBuilderFunc.getMany = () => [authToken];
const tokens = await service.getTokensByUser(user);
expect(tokens).toHaveLength(1);
expect(tokens).toEqual([authToken]);
});
});
describe('getAuthToken', () => {
const token = 'testToken';
it('works', async () => {
const accessTokenHash = crypto
.createHash('sha512')
.update(token)
.digest('hex');
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
...authToken,
user: Promise.resolve(user),
accessTokenHash: accessTokenHash,
});
const authTokenFromCall = await service.getAuthToken(authToken.keyId);
expect(authTokenFromCall).toEqual({
...authToken,
user: Promise.resolve(user),
accessTokenHash: accessTokenHash,
});
});
describe('fails:', () => {
it('AuthToken could not be found', async () => {
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce(null);
await expect(service.getAuthToken(authToken.keyId)).rejects.toThrow(
NotInDBError,
);
});
});
});
describe('checkToken', () => {
it('works', () => {
const [accessToken, secret] = service.createToken(
user,
'TestToken',
undefined,
);
expect(() =>
service.checkToken(secret, accessToken as AuthToken),
).not.toThrow();
});
it('AuthToken has wrong hash', () => {
const [accessToken] = service.createToken(user, 'TestToken', undefined);
expect(() =>
service.checkToken('secret', accessToken as AuthToken),
).toThrow(TokenNotValidError);
});
it('AuthToken has wrong validUntil Date', () => {
const [accessToken, secret] = service.createToken(
user,
'Test',
1549312452000,
);
expect(() =>
service.checkToken(secret, accessToken as AuthToken),
).toThrow(TokenNotValidError);
});
});
describe('setLastUsedToken', () => {
it('works', async () => {
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
...authToken,
user: Promise.resolve(user),
lastUsedAt: new Date(1549312452000),
});
jest
.spyOn(authTokenRepo, 'save')
.mockImplementationOnce(
async (authTokenSaved, _): Promise<AuthToken> => {
expect(authTokenSaved.keyId).toEqual(authToken.keyId);
expect(authTokenSaved.lastUsedAt).not.toEqual(1549312452000);
return authToken;
},
);
await service.setLastUsedToken(authToken.keyId);
});
it('throws if the token is not in the database', async () => {
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce(null);
await expect(service.setLastUsedToken(authToken.keyId)).rejects.toThrow(
NotInDBError,
);
});
});
describe('validateToken', () => {
it('works', async () => {
const testSecret =
'gNrv_NJ4FHZ0UFZJQu_q_3i3-GP_d6tELVtkYiMFLkLWNl_dxEmPVAsCNKxP3N3DB9aGBVFYE1iptvw7hFMJvA';
const accessTokenHash = crypto
.createHash('sha512')
.update(testSecret)
.digest('hex');
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce({
...user,
authTokens: Promise.resolve([authToken]),
});
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValue({
...authToken,
user: Promise.resolve(user),
accessTokenHash: accessTokenHash,
});
jest
.spyOn(authTokenRepo, 'save')
.mockImplementationOnce(async (_, __): Promise<AuthToken> => {
return authToken;
});
const userByToken = await service.validateToken(
`${authToken.keyId}.${testSecret}`,
);
expect(userByToken).toEqual({
...user,
authTokens: Promise.resolve([authToken]),
});
});
describe('fails:', () => {
it('the secret is missing', async () => {
await expect(
service.validateToken(`${authToken.keyId}`),
).rejects.toThrow(TokenNotValidError);
});
it('the secret is too long', async () => {
await expect(
service.validateToken(`${authToken.keyId}.${'a'.repeat(73)}`),
).rejects.toThrow(TokenNotValidError);
});
});
});
describe('removeToken', () => {
it('works', async () => {
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValue({
...authToken,
user: Promise.resolve(user),
});
jest
.spyOn(authTokenRepo, 'remove')
.mockImplementationOnce(async (token, __): Promise<AuthToken> => {
expect(token).toEqual({
...authToken,
user: Promise.resolve(user),
});
return authToken;
});
await service.removeToken(authToken.keyId);
});
it('throws if the token is not in the database', async () => {
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce(null);
await expect(service.removeToken(authToken.keyId)).rejects.toThrow(
NotInDBError,
);
});
});
describe('addToken', () => {
describe('works', () => {
const identifier = 'testIdentifier';
it('with validUntil 0', async () => {
jest.spyOn(authTokenRepo, 'find').mockResolvedValueOnce([authToken]);
jest
.spyOn(authTokenRepo, 'save')
.mockImplementationOnce(
async (authTokenSaved: AuthToken, _): Promise<AuthToken> => {
expect(authTokenSaved.lastUsedAt).toBeNull();
return authTokenSaved;
},
);
const token = await service.addToken(user, identifier, 0);
expect(token.label).toEqual(identifier);
expect(
token.validUntil.getTime() -
(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000),
).toBeLessThanOrEqual(10000);
expect(token.lastUsedAt).toBeNull();
expect(token.secret.startsWith(token.keyId)).toBeTruthy();
});
it('with validUntil not 0', async () => {
jest.spyOn(authTokenRepo, 'find').mockResolvedValueOnce([authToken]);
jest
.spyOn(authTokenRepo, 'save')
.mockImplementationOnce(
async (authTokenSaved: AuthToken, _): Promise<AuthToken> => {
expect(authTokenSaved.lastUsedAt).toBeNull();
return authTokenSaved;
},
);
const validUntil = new Date().getTime() + 30000;
const token = await service.addToken(user, identifier, validUntil);
expect(token.label).toEqual(identifier);
expect(token.validUntil.getTime()).toEqual(validUntil);
expect(token.lastUsedAt).toBeNull();
expect(token.secret.startsWith(token.keyId)).toBeTruthy();
});
});
});
describe('toAuthTokenDto', () => {
it('works', () => {
const authToken = new AuthToken();
authToken.keyId = 'testKeyId';
authToken.label = 'testLabel';
const date = new Date();
date.setHours(date.getHours() - 1);
authToken.createdAt = date;
authToken.validUntil = new Date();
const tokenDto = service.toAuthTokenDto(authToken);
expect(tokenDto.keyId).toEqual(authToken.keyId);
expect(tokenDto.lastUsedAt).toBeNull();
expect(tokenDto.label).toEqual(authToken.label);
expect(tokenDto.validUntil.getTime()).toEqual(
authToken.validUntil.getTime(),
);
expect(tokenDto.createdAt.getTime()).toEqual(
authToken.createdAt.getTime(),
);
});
});
});

View file

@ -0,0 +1,241 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Cron, Timeout } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import crypto, { randomBytes } from 'crypto';
import { Repository } from 'typeorm';
import {
NotInDBError,
TokenNotValidError,
TooManyTokensError,
} from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { User } from '../users/user.entity';
import { UsersService } from '../users/users.service';
import { bufferToBase64Url } from '../utils/password';
import { TimestampMillis } from '../utils/timestamp';
import { AuthTokenDto, AuthTokenWithSecretDto } from './auth-token.dto';
import { AuthToken } from './auth-token.entity';
@Injectable()
export class AuthService {
constructor(
private readonly logger: ConsoleLoggerService,
private usersService: UsersService,
@InjectRepository(AuthToken)
private authTokenRepository: Repository<AuthToken>,
) {
this.logger.setContext(AuthService.name);
}
async validateToken(tokenString: string): Promise<User> {
const [keyId, secret] = tokenString.split('.');
if (!secret) {
throw new TokenNotValidError('Invalid AuthToken format');
}
if (secret.length != 86) {
// We always expect 86 characters, as the secret is generated with 64 bytes
// and then converted to a base64url string
throw new TokenNotValidError(
`AuthToken '${tokenString}' has incorrect length`,
);
}
const token = await this.getAuthToken(keyId);
this.checkToken(secret, token);
await this.setLastUsedToken(keyId);
return await token.user;
}
createToken(
user: User,
identifier: string,
validUntil: TimestampMillis | undefined,
): [Omit<AuthToken, '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 = crypto
.createHash('sha512')
.update(secret)
.digest('hex');
let token;
// Tokens can only be valid for a maximum of 2 years
const maximumTokenValidity =
new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000;
if (!validUntil || validUntil === 0 || validUntil > maximumTokenValidity) {
token = AuthToken.create(
keyId,
user,
identifier,
accessTokenHash,
new Date(maximumTokenValidity),
);
} else {
token = AuthToken.create(
keyId,
user,
identifier,
accessTokenHash,
new Date(validUntil),
);
}
return [token, secret];
}
async addToken(
user: User,
identifier: string,
validUntil: TimestampMillis | undefined,
): Promise<AuthTokenWithSecretDto> {
user.authTokens = this.getTokensByUser(user);
if ((await user.authTokens).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 tokens and can't have anymore`,
);
}
const [token, secret] = this.createToken(user, identifier, validUntil);
const createdToken = (await this.authTokenRepository.save(
token,
)) as AuthToken;
return this.toAuthTokenWithSecretDto(
createdToken,
`${createdToken.keyId}.${secret}`,
);
}
async setLastUsedToken(keyId: string): Promise<void> {
const token = await this.authTokenRepository.findOne({
where: { keyId: keyId },
});
if (token === null) {
throw new NotInDBError(`AuthToken for key '${keyId}' not found`);
}
token.lastUsedAt = new Date();
await this.authTokenRepository.save(token);
}
async getAuthToken(keyId: string): Promise<AuthToken> {
const token = await this.authTokenRepository.findOne({
where: { keyId: keyId },
relations: ['user'],
});
if (token === null) {
throw new NotInDBError(`AuthToken '${keyId}' not found`);
}
return token;
}
checkToken(secret: string, token: AuthToken): void {
const userHash = Buffer.from(
crypto.createHash('sha512').update(secret).digest('hex'),
);
const dbHash = Buffer.from(token.accessTokenHash);
if (
// Normally, both hashes have the same length, as they are both SHA512
// This is only defense-in-depth, as timingSafeEqual throws if the buffers are not of the same length
userHash.length !== dbHash.length ||
!crypto.timingSafeEqual(userHash, dbHash)
) {
// hashes are not the same
throw new TokenNotValidError(
`Secret does not match Token ${token.label}.`,
);
}
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()}.`,
);
}
}
async getTokensByUser(user: User): Promise<AuthToken[]> {
const tokens = await this.authTokenRepository
.createQueryBuilder('token')
.where('token.userId = :userId', { userId: user.id })
.getMany();
if (tokens === null) {
return [];
}
return tokens;
}
async removeToken(keyId: string): Promise<void> {
const token = await this.authTokenRepository.findOne({
where: { keyId: keyId },
});
if (token === null) {
throw new NotInDBError(`AuthToken for key '${keyId}' not found`);
}
await this.authTokenRepository.remove(token);
}
toAuthTokenDto(authToken: AuthToken): AuthTokenDto {
const tokenDto: AuthTokenDto = {
label: authToken.label,
keyId: authToken.keyId,
createdAt: authToken.createdAt,
validUntil: authToken.validUntil,
lastUsedAt: null,
};
if (authToken.lastUsedAt) {
tokenDto.lastUsedAt = new Date(authToken.lastUsedAt);
}
return tokenDto;
}
toAuthTokenWithSecretDto(
authToken: AuthToken,
secret: string,
): AuthTokenWithSecretDto {
const tokenDto = this.toAuthTokenDto(authToken);
return {
...tokenDto,
secret: secret,
};
}
// Delete all non valid 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)
async handleTimeout(): Promise<void> {
return await this.removeInvalidTokens();
}
async removeInvalidTokens(): Promise<void> {
const currentTime = new Date().getTime();
const tokens: AuthToken[] = 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++;
}
}
this.logger.log(
`${removedTokens} invalid AuthTokens were purged from the DB.`,
'removeInvalidTokens',
);
}
}

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { User } from '../users/user.entity';
import { UsersService } from '../users/users.service';
@Injectable()
export class MockAuthGuard {
private user: User;
constructor(private usersService: UsersService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req: Request = 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');
}
}
req.user = this.user;
return true;
}
}

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard, PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-http-bearer';
import { NotInDBError, TokenNotValidError } from '../errors/errors';
import { User } from '../users/user.entity';
import { AuthService } from './auth.service';
@Injectable()
export class TokenAuthGuard extends AuthGuard('token') {}
@Injectable()
export class TokenStrategy extends PassportStrategy(Strategy, 'token') {
constructor(private authService: AuthService) {
super();
}
async validate(token: string): Promise<User> {
try {
return await this.authService.validateToken(token);
} catch (error) {
if (
error instanceof NotInDBError ||
error instanceof TokenNotValidError
) {
throw new UnauthorizedException(error.message);
}
throw error;
}
}
}