mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-18 00:54:43 -04:00
fix(repository): Move backend code into subdirectory
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
86584e705f
commit
bf30cbcf48
272 changed files with 87 additions and 67 deletions
44
backend/src/auth/auth-token.dto.ts
Normal file
44
backend/src/auth/auth-token.dto.ts
Normal 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;
|
||||
}
|
63
backend/src/auth/auth-token.entity.ts
Normal file
63
backend/src/auth/auth-token.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
26
backend/src/auth/auth.module.ts
Normal file
26
backend/src/auth/auth.module.ts
Normal 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 {}
|
328
backend/src/auth/auth.service.spec.ts
Normal file
328
backend/src/auth/auth.service.spec.ts
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
241
backend/src/auth/auth.service.ts
Normal file
241
backend/src/auth/auth.service.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
32
backend/src/auth/mock-auth.guard.ts
Normal file
32
backend/src/auth/mock-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
36
backend/src/auth/token.strategy.ts
Normal file
36
backend/src/auth/token.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue