From cbf6ac912af98848964e0169270c3edd781af380 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sat, 16 Jan 2021 23:53:46 +0100 Subject: [PATCH 01/27] private: adds tokens controller adds private api adds AuthTokenDto and AuthTokenWithSecretDto adds necessary methods in the users service adds RandomnessError Signed-off-by: Philip Molares --- src/api/private/private-api.module.ts | 15 ++++ .../private/tokens/tokens.controller.spec.ts | 38 +++++++++ src/api/private/tokens/tokens.controller.ts | 54 +++++++++++++ src/errors/errors.ts | 4 + src/users/auth-token-with-secret.dto.ts | 13 +++ src/users/auth-token.dto.ts | 14 ++++ src/users/auth-token.entity.ts | 30 +++++-- src/users/user.entity.ts | 6 +- src/users/users.service.spec.ts | 7 ++ src/users/users.service.ts | 79 ++++++++++++++++++- 10 files changed, 248 insertions(+), 12 deletions(-) create mode 100644 src/api/private/private-api.module.ts create mode 100644 src/api/private/tokens/tokens.controller.spec.ts create mode 100644 src/api/private/tokens/tokens.controller.ts create mode 100644 src/users/auth-token-with-secret.dto.ts create mode 100644 src/users/auth-token.dto.ts diff --git a/src/api/private/private-api.module.ts b/src/api/private/private-api.module.ts new file mode 100644 index 000000000..276742e26 --- /dev/null +++ b/src/api/private/private-api.module.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Module } from '@nestjs/common'; +import { UsersModule } from '../../users/users.module'; +import { TokensController } from './tokens/tokens.controller'; + +@Module({ + imports: [UsersModule], + controllers: [TokensController], +}) +export class PrivateApiModule {} diff --git a/src/api/private/tokens/tokens.controller.spec.ts b/src/api/private/tokens/tokens.controller.spec.ts new file mode 100644 index 000000000..f90278756 --- /dev/null +++ b/src/api/private/tokens/tokens.controller.spec.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { TokensController } from './tokens.controller'; +import { LoggerModule } from '../../../logger/logger.module'; +import { UsersModule } from '../../../users/users.module'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Identity } from '../../../users/identity.entity'; +import { User } from '../../../users/user.entity'; +import { AuthToken } from '../../../users/auth-token.entity'; + +describe('TokensController', () => { + let controller: TokensController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TokensController], + imports: [LoggerModule, UsersModule], + }) + .overrideProvider(getRepositoryToken(User)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) + .overrideProvider(getRepositoryToken(Identity)) + .useValue({}) + .compile(); + + controller = module.get(TokensController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/api/private/tokens/tokens.controller.ts b/src/api/private/tokens/tokens.controller.ts new file mode 100644 index 000000000..c53677326 --- /dev/null +++ b/src/api/private/tokens/tokens.controller.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Post, +} from '@nestjs/common'; +import { ConsoleLoggerService } from '../../../logger/console-logger.service'; +import { UsersService } from '../../../users/users.service'; +import { AuthTokenDto } from '../../../users/auth-token.dto'; +import { AuthTokenWithSecretDto } from '../../../users/auth-token-with-secret.dto'; + +@Controller('tokens') +export class TokensController { + constructor( + private readonly logger: ConsoleLoggerService, + private usersService: UsersService, + ) { + this.logger.setContext(TokensController.name); + } + + @Get() + async getUserTokens(): Promise { + // ToDo: Get real userName + return (await this.usersService.getTokensByUsername('molly')).map((token) => + this.usersService.toAuthTokenDto(token), + ); + } + + @Post() + async postToken(@Body() label: string): Promise { + // ToDo: Get real userName + const authToken = await this.usersService.createTokenForUser( + 'hardcoded', + label, + ); + return this.usersService.toAuthTokenWithSecretDto(authToken); + } + + @Delete('/:timestamp') + @HttpCode(204) + async deleteToken(@Param('timestamp') timestamp: number) { + // ToDo: Get real userName + return this.usersService.removeToken('hardcoded', timestamp); + } +} diff --git a/src/errors/errors.ts b/src/errors/errors.ts index 9052a8c44..a436c6b80 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -15,3 +15,7 @@ export class ClientError extends Error { export class PermissionError extends Error { name = 'PermissionError'; } + +export class RandomnessError extends Error { + name = 'RandomnessError'; +} diff --git a/src/users/auth-token-with-secret.dto.ts b/src/users/auth-token-with-secret.dto.ts new file mode 100644 index 000000000..cca42b9ae --- /dev/null +++ b/src/users/auth-token-with-secret.dto.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IsString } from 'class-validator'; +import { AuthTokenDto } from './auth-token.dto'; + +export class AuthTokenWithSecretDto extends AuthTokenDto { + @IsString() + secret: string; +} diff --git a/src/users/auth-token.dto.ts b/src/users/auth-token.dto.ts new file mode 100644 index 000000000..b59018fcc --- /dev/null +++ b/src/users/auth-token.dto.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IsNumber, IsString } from 'class-validator'; + +export class AuthTokenDto { + @IsString() + label: string; + @IsNumber() + created: number; +} diff --git a/src/users/auth-token.entity.ts b/src/users/auth-token.entity.ts index e445a014d..6b0c63b40 100644 --- a/src/users/auth-token.entity.ts +++ b/src/users/auth-token.entity.ts @@ -4,22 +4,38 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { - Column, - Entity, - ManyToOne, - PrimaryGeneratedColumn, -} from 'typeorm/index'; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { User } from './user.entity'; +import { Type } from 'class-transformer'; @Entity() export class AuthToken { @PrimaryGeneratedColumn() id: number; - @ManyToOne((_) => User, (user) => user.authToken) + @ManyToOne((_) => User, (user) => user.authTokens) user: User; + @Column() + identifier: string; + + @Type(() => Date) + @Column('text') + createdAt: Date; + @Column() accessToken: string; + + public static create( + user: User, + identifier: string, + accessToken: string, + ): Pick { + const newToken = new AuthToken(); + newToken.user = user; + newToken.identifier = identifier; + newToken.accessToken = accessToken; + newToken.createdAt = new Date(); + return newToken; + } } diff --git a/src/users/user.entity.ts b/src/users/user.entity.ts index 3b8762eeb..c6c04d12a 100644 --- a/src/users/user.entity.ts +++ b/src/users/user.entity.ts @@ -10,7 +10,7 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { Column, OneToMany } from 'typeorm/index'; +import { Column, OneToMany } from 'typeorm'; import { Note } from '../notes/note.entity'; import { AuthToken } from './auth-token.entity'; import { Identity } from './identity.entity'; @@ -46,7 +46,7 @@ export class User { ownedNotes: Note[]; @OneToMany((_) => AuthToken, (authToken) => authToken.user) - authToken: AuthToken[]; + authTokens: AuthToken[]; @OneToMany((_) => Identity, (identity) => identity.user) identities: Identity[]; @@ -59,7 +59,7 @@ export class User { displayName: string, ): Pick< User, - 'userName' | 'displayName' | 'ownedNotes' | 'authToken' | 'identities' + 'userName' | 'displayName' | 'ownedNotes' | 'authTokens' | 'identities' > { const newUser = new User(); newUser.userName = userName; diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 407114f23..bb2596b21 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -9,6 +9,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { LoggerModule } from '../logger/logger.module'; import { User } from './user.entity'; import { UsersService } from './users.service'; +import { AuthToken } from './auth-token.entity'; describe('UsersService', () => { let service: UsersService; @@ -21,11 +22,17 @@ describe('UsersService', () => { provide: getRepositoryToken(User), useValue: {}, }, + { + provide: getRepositoryToken(AuthToken), + useValue: {}, + }, ], imports: [LoggerModule], }) .overrideProvider(getRepositoryToken(User)) .useValue({}) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) .compile(); service = module.get(UsersService); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 06624ed09..1154d30d9 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -7,16 +7,22 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { NotInDBError } from '../errors/errors'; +import { NotInDBError, RandomnessError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { UserInfoDto } from './user-info.dto'; import { User } from './user.entity'; +import { AuthToken } from './auth-token.entity'; +import crypt from 'crypto'; +import { AuthTokenDto } from './auth-token.dto'; +import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto'; @Injectable() export class UsersService { constructor( private readonly logger: ConsoleLoggerService, @InjectRepository(User) private userRepository: Repository, + @InjectRepository(AuthToken) + private authTokenRepository: Repository, ) { this.logger.setContext(UsersService.name); } @@ -26,8 +32,29 @@ export class UsersService { return this.userRepository.save(user); } + async createTokenForUser( + userName: string, + identifier: string, + ): Promise { + const user = await this.getUserByUsername(userName); + let accessToken = ''; + for (let i = 0; i < 100; i++) { + try { + accessToken = crypt.randomBytes(64).toString(); + await this.getUserByAuthToken(accessToken); + } catch (NotInDBError) { + const token = AuthToken.create(user, identifier, accessToken); + return this.authTokenRepository.save(token); + } + } + // This should never happen + throw new RandomnessError( + 'You machine is not able to generate not-in-use tokens. This should never happen.', + ); + } + async deleteUser(userName: string) { - //TOOD: Handle owned notes and edits + // TODO: Handle owned notes and edits const user = await this.userRepository.findOne({ where: { userName: userName }, }); @@ -44,6 +71,16 @@ export class UsersService { return user; } + async getUserByAuthToken(token: string): Promise { + const accessToken = await this.authTokenRepository.findOne({ + where: { accessToken: token }, + }); + if (accessToken === undefined) { + throw new NotInDBError(`AuthToken '${token}' not found`); + } + return this.getUserByUsername(accessToken.user.userName); + } + getPhotoUrl(user: User): string { if (user.photo) { return user.photo; @@ -53,6 +90,44 @@ export class UsersService { } } + async getTokensByUsername(userName: string): Promise { + const user = await this.getUserByUsername(userName); + return user.authTokens; + } + + async removeToken(userName: string, timestamp: number) { + const user = await this.getUserByUsername(userName); + const token = await this.authTokenRepository.findOne({ + where: { createdAt: new Date(timestamp), user: user }, + }); + await this.authTokenRepository.remove(token); + } + + toAuthTokenDto(authToken: AuthToken | null | undefined): AuthTokenDto | null { + if (!authToken) { + this.logger.warn(`Recieved ${authToken} argument!`, 'toAuthTokenDto'); + return null; + } + return { + label: authToken.identifier, + created: authToken.createdAt.getTime(), + }; + } + + toAuthTokenWithSecretDto( + authToken: AuthToken | null | undefined, + ): AuthTokenWithSecretDto | null { + if (!authToken) { + this.logger.warn(`Recieved ${authToken} argument!`, 'toAuthTokenDto'); + return null; + } + return { + label: authToken.identifier, + created: authToken.createdAt.getTime(), + secret: authToken.accessToken, + }; + } + toUserDto(user: User | null | undefined): UserInfoDto | null { if (!user) { this.logger.warn(`Recieved ${user} argument!`, 'toUserDto'); From a4522d7230315466075a0b64167fd98153133384 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sun, 17 Jan 2021 15:27:13 +0100 Subject: [PATCH 02/27] auth: hash auth token Since the auth token will be stored in hashed form in the db, we need to hash each provided auth token in order to search in the db for them. Signed-off-by: Philip Molares --- src/users/auth-token.entity.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/users/auth-token.entity.ts b/src/users/auth-token.entity.ts index 6b0c63b40..e49516f7a 100644 --- a/src/users/auth-token.entity.ts +++ b/src/users/auth-token.entity.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { User } from './user.entity'; import { Type } from 'class-transformer'; @@ -19,8 +19,7 @@ export class AuthToken { @Column() identifier: string; - @Type(() => Date) - @Column('text') + @CreateDateColumn() createdAt: Date; @Column() From 025f24122cdc19dcd546537f4fa918010b4cad26 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sat, 16 Jan 2021 23:53:46 +0100 Subject: [PATCH 03/27] private: adds tokens controller adds private api adds AuthTokenDto and AuthTokenWithSecretDto adds necessary methods in the users service adds RandomnessError Signed-off-by: Philip Molares --- src/api/private/tokens/tokens.controller.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/api/private/tokens/tokens.controller.ts b/src/api/private/tokens/tokens.controller.ts index c53677326..18210ad21 100644 --- a/src/api/private/tokens/tokens.controller.ts +++ b/src/api/private/tokens/tokens.controller.ts @@ -36,7 +36,9 @@ export class TokensController { } @Post() - async postToken(@Body() label: string): Promise { + async postTokenRequest( + @Body() label: string, + ): Promise { // ToDo: Get real userName const authToken = await this.usersService.createTokenForUser( 'hardcoded', From 15ca030b672cc420987c37fb068066ffbca4d053 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sun, 17 Jan 2021 14:32:17 +0100 Subject: [PATCH 04/27] auth: add hash function the hash function uses bcrypt with 2^16 iterations. Signed-off-by: Philip Molares --- package.json | 6 ++++++ src/users/users.service.ts | 6 ++++++ yarn.lock | 35 ++++++++++++++++++++++++++++++++--- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 404d81f78..fecec4b18 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,21 @@ "@nestjs/common": "7.6.5", "@nestjs/config": "0.6.2", "@nestjs/core": "7.6.5", + "@nestjs/passport": "^7.1.5", "@nestjs/platform-express": "7.6.5", "@nestjs/swagger": "4.7.12", "@nestjs/typeorm": "7.1.5", + "@types/bcrypt": "^3.0.0", + "@types/passport-http-bearer": "^1.0.36", + "bcrypt": "^5.0.0", "class-transformer": "0.3.2", "class-validator": "0.13.1", "cli-color": "2.0.0", "connect-typeorm": "1.1.4", "file-type": "16.2.0", "joi": "17.3.0", + "passport": "^0.4.1", + "passport-http-bearer": "^1.0.1", "raw-body": "2.4.1", "reflect-metadata": "0.1.13", "rimraf": "3.0.2", diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 1154d30d9..cb27939fd 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -12,6 +12,7 @@ import { ConsoleLoggerService } from '../logger/console-logger.service'; import { UserInfoDto } from './user-info.dto'; import { User } from './user.entity'; import { AuthToken } from './auth-token.entity'; +import { hash } from 'bcrypt' import crypt from 'crypto'; import { AuthTokenDto } from './auth-token.dto'; import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto'; @@ -71,6 +72,11 @@ export class UsersService { return user; } + async hashPassword(password: string): Promise { + // hash the password with bcrypt and 2^16 iterations + return hash(password, 16) + } + async getUserByAuthToken(token: string): Promise { const accessToken = await this.authTokenRepository.findOne({ where: { accessToken: token }, diff --git a/yarn.lock b/yarn.lock index 34287b87f..992d8fbab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -776,6 +776,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/bcrypt@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/bcrypt/-/bcrypt-3.0.0.tgz#851489a9065a067cb7f3c9cbe4ce9bed8bba0876" + integrity sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ== + "@types/body-parser@*": version "1.19.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" @@ -1627,6 +1632,14 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +bcrypt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.0.0.tgz#051407c7cd5ffbfb773d541ca3760ea0754e37e2" + integrity sha512-jB0yCBl4W/kVHM2whjfyqnxTmOHkCX4kHEa5nYKSoGeYe8YrjTYTc87/6bwt1g8cmV0QrbhKriETg9jWtcREhg== + dependencies: + node-addon-api "^3.0.0" + node-pre-gyp "0.15.0" + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -4738,7 +4751,7 @@ mkdirp@1.x, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1: +"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -4820,7 +4833,7 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -needle@^2.2.1: +needle@^2.2.1, needle@^2.5.0: version "2.6.0" resolved "https://registry.yarnpkg.com/needle/-/needle-2.6.0.tgz#24dbb55f2509e2324b4a99d61f413982013ccdbe" integrity sha512-KKYdza4heMsEfSWD7VPUIz3zX2XDwOyX2d+geb4vrERZMT5RMU6ujjaD+I5Yr54uZxQ2w6XRTAhHBbSCyovZBg== @@ -4911,6 +4924,22 @@ node-notifier@^8.0.0: uuid "^8.3.0" which "^2.0.2" +node-pre-gyp@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.15.0.tgz#c2fc383276b74c7ffa842925241553e8b40f1087" + integrity sha512-7QcZa8/fpaU/BKenjcaeFF9hLz2+7S9AqyXFhlH/rilsQ/hPZKK32RtR5EQHJElgu+q5RfbJ34KriI79UWaorA== + dependencies: + detect-libc "^1.0.2" + mkdirp "^0.5.3" + needle "^2.5.0" + nopt "^4.0.1" + npm-packlist "^1.1.6" + npmlog "^4.0.2" + rc "^1.2.7" + rimraf "^2.6.1" + semver "^5.3.0" + tar "^4.4.2" + node-pre-gyp@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz#db1f33215272f692cd38f03238e3e9b47c5dd054" @@ -6451,7 +6480,7 @@ tar@^2.0.0: fstream "^1.0.12" inherits "2" -tar@^4: +tar@^4, tar@^4.4.2: version "4.4.13" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA== From 37a9f6526b5e5f5c581e344b9df3c07cbbc457fd Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sun, 17 Jan 2021 14:38:05 +0100 Subject: [PATCH 05/27] auth: hash auth token Since the auth token will be stored in hashed form in the db, we need to hash each provided auth token in order to search in the db for them. Signed-off-by: Philip Molares --- src/users/users.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/users/users.service.ts b/src/users/users.service.ts index cb27939fd..bde0de6ce 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -78,8 +78,9 @@ export class UsersService { } async getUserByAuthToken(token: string): Promise { + const hash = this.hashPassword(token); const accessToken = await this.authTokenRepository.findOne({ - where: { accessToken: token }, + where: { accessToken: hash }, }); if (accessToken === undefined) { throw new NotInDBError(`AuthToken '${token}' not found`); From 5e6e5d0e5ff27d9b15a173ae821ce5af529e1890 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sun, 17 Jan 2021 14:45:16 +0100 Subject: [PATCH 06/27] private: save token hashed Auth tokens are now saved in hashed form. Signed-off-by: Philip Molares --- src/users/users.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/users/users.service.ts b/src/users/users.service.ts index bde0de6ce..9de3730c0 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -41,7 +41,8 @@ export class UsersService { let accessToken = ''; for (let i = 0; i < 100; i++) { try { - accessToken = crypt.randomBytes(64).toString(); + const randomString = crypt.randomBytes(64).toString(); + accessToken = await this.hashPassword(randomString); await this.getUserByAuthToken(accessToken); } catch (NotInDBError) { const token = AuthToken.create(user, identifier, accessToken); From 0bd7a8f4bc75c182ff93ad6e376a153cfa6217a3 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sun, 17 Jan 2021 14:49:28 +0100 Subject: [PATCH 07/27] db-schema: updates plantuml adds identifier and createdAt to AuthToken renames authToken in User to authTokens Signed-off-by: Philip Molares --- docs/content/dev/db-schema.plantuml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/content/dev/db-schema.plantuml b/docs/content/dev/db-schema.plantuml index cb979df37..4f180986f 100644 --- a/docs/content/dev/db-schema.plantuml +++ b/docs/content/dev/db-schema.plantuml @@ -29,6 +29,8 @@ entity "auth_token"{ -- *userId : uuid *accessToken : text + *identifier: text + *createdAt: date } entity "identity" { @@ -131,7 +133,7 @@ entity "media_upload" { user "1" -- "0..*" note: owner user "1" -u- "1..*" identity -user "1" - "1..*" auth_token +user "1" - "1..*" auth_token: authTokens user "1" -l- "1..*" session user "1" - "0..*" media_upload user "0..*" -- "0..*" note From 0a1c3426c04c78069ca0de19d6cddd0c89b16592 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sun, 17 Jan 2021 19:52:08 +0100 Subject: [PATCH 08/27] private: fixed token generation bugs Signed-off-by: Philip Molares --- src/users/users.service.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 9de3730c0..db2ee724e 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -39,19 +39,24 @@ export class UsersService { ): Promise { const user = await this.getUserByUsername(userName); let accessToken = ''; + let randomString = ''; for (let i = 0; i < 100; i++) { try { - const randomString = crypt.randomBytes(64).toString(); + randomString = crypt.randomBytes(64).toString("base64"); accessToken = await this.hashPassword(randomString); await this.getUserByAuthToken(accessToken); } catch (NotInDBError) { const token = AuthToken.create(user, identifier, accessToken); - return this.authTokenRepository.save(token); + const createdToken = this.authTokenRepository.save(token); + return { + accessToken: randomString, + ...createdToken + } } } // This should never happen throw new RandomnessError( - 'You machine is not able to generate not-in-use tokens. This should never happen.', + 'Your machine is not able to generate not-in-use tokens. This should never happen.', ); } From e8cdbdd6774a2e5e0e87a201aab0141c1154fc28 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sun, 17 Jan 2021 20:35:43 +0100 Subject: [PATCH 09/27] private: removes collision check for tokens this seems very unnecessary as the chance of this is 1 / 2^512 Signed-off-by: Philip Molares --- src/errors/errors.ts | 4 ---- src/users/auth-token.entity.ts | 2 +- src/users/users.service.ts | 30 +++++++++--------------------- 3 files changed, 10 insertions(+), 26 deletions(-) diff --git a/src/errors/errors.ts b/src/errors/errors.ts index a436c6b80..9052a8c44 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -15,7 +15,3 @@ export class ClientError extends Error { export class PermissionError extends Error { name = 'PermissionError'; } - -export class RandomnessError extends Error { - name = 'RandomnessError'; -} diff --git a/src/users/auth-token.entity.ts b/src/users/auth-token.entity.ts index e49516f7a..2497957e1 100644 --- a/src/users/auth-token.entity.ts +++ b/src/users/auth-token.entity.ts @@ -22,7 +22,7 @@ export class AuthToken { @CreateDateColumn() createdAt: Date; - @Column() + @Column({ unique: true }) accessToken: string; public static create( diff --git a/src/users/users.service.ts b/src/users/users.service.ts index db2ee724e..e27f453e4 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -7,7 +7,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { NotInDBError, RandomnessError } from '../errors/errors'; +import { NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { UserInfoDto } from './user-info.dto'; import { User } from './user.entity'; @@ -38,26 +38,14 @@ export class UsersService { identifier: string, ): Promise { const user = await this.getUserByUsername(userName); - let accessToken = ''; - let randomString = ''; - for (let i = 0; i < 100; i++) { - try { - randomString = crypt.randomBytes(64).toString("base64"); - accessToken = await this.hashPassword(randomString); - await this.getUserByAuthToken(accessToken); - } catch (NotInDBError) { - const token = AuthToken.create(user, identifier, accessToken); - const createdToken = this.authTokenRepository.save(token); - return { - accessToken: randomString, - ...createdToken - } - } - } - // This should never happen - throw new RandomnessError( - 'Your machine is not able to generate not-in-use tokens. This should never happen.', - ); + const randomString = crypt.randomBytes(64).toString('base64'); + const accessToken = await this.hashPassword(randomString); + const token = AuthToken.create(user, identifier, accessToken); + const createdToken = this.authTokenRepository.save(token); + return { + accessToken: randomString, + ...createdToken, + }; } async deleteUser(userName: string) { From 9a65a9bd29f51fc19ee49615d3e0115fd256c093 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Thu, 21 Jan 2021 12:33:45 +0100 Subject: [PATCH 10/27] private: Add until to token creation Signed-off-by: Philip Molares --- src/api/private/tokens/tokens.controller.ts | 4 +++- src/users/auth-token.entity.ts | 6 +++++- src/users/users.service.ts | 18 ++++++++++++------ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/api/private/tokens/tokens.controller.ts b/src/api/private/tokens/tokens.controller.ts index 18210ad21..04ad4e138 100644 --- a/src/api/private/tokens/tokens.controller.ts +++ b/src/api/private/tokens/tokens.controller.ts @@ -37,12 +37,14 @@ export class TokensController { @Post() async postTokenRequest( - @Body() label: string, + @Body('label') label: string, + @Body('until') until: number, ): Promise { // ToDo: Get real userName const authToken = await this.usersService.createTokenForUser( 'hardcoded', label, + until, ); return this.usersService.toAuthTokenWithSecretDto(authToken); } diff --git a/src/users/auth-token.entity.ts b/src/users/auth-token.entity.ts index 2497957e1..e391fa83e 100644 --- a/src/users/auth-token.entity.ts +++ b/src/users/auth-token.entity.ts @@ -6,7 +6,6 @@ import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { User } from './user.entity'; -import { Type } from 'class-transformer'; @Entity() export class AuthToken { @@ -25,16 +24,21 @@ export class AuthToken { @Column({ unique: true }) accessToken: string; + @Column({ type: 'date' }) + validUntil: Date; + public static create( user: User, identifier: string, accessToken: string, + validUntil: Date, ): Pick { const newToken = new AuthToken(); newToken.user = user; newToken.identifier = identifier; newToken.accessToken = accessToken; newToken.createdAt = new Date(); + newToken.validUntil = validUntil; return newToken; } } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index e27f453e4..58d9c966a 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -12,7 +12,7 @@ import { ConsoleLoggerService } from '../logger/console-logger.service'; import { UserInfoDto } from './user-info.dto'; import { User } from './user.entity'; import { AuthToken } from './auth-token.entity'; -import { hash } from 'bcrypt' +import { hash, compare } from 'bcrypt' import crypt from 'crypto'; import { AuthTokenDto } from './auth-token.dto'; import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto'; @@ -36,12 +36,13 @@ export class UsersService { async createTokenForUser( userName: string, identifier: string, + until: number, ): Promise { const user = await this.getUserByUsername(userName); - const randomString = crypt.randomBytes(64).toString('base64'); + const randomString = crypt.randomBytes(64).toString('base64url'); const accessToken = await this.hashPassword(randomString); - const token = AuthToken.create(user, identifier, accessToken); - const createdToken = this.authTokenRepository.save(token); + const token = AuthToken.create(user, identifier, accessToken, new Date(until)); + const createdToken = await this.authTokenRepository.save(token); return { accessToken: randomString, ...createdToken, @@ -66,9 +67,14 @@ export class UsersService { return user; } - async hashPassword(password: string): Promise { + async hashPassword(cleartext: string): Promise { // hash the password with bcrypt and 2^16 iterations - return hash(password, 16) + return hash(cleartext, 16) + } + + async checkPassword(cleartext: string, password: string): Promise { + // hash the password with bcrypt and 2^16 iterations + return compare(cleartext, password) } async getUserByAuthToken(token: string): Promise { From 8d89614a4d1cf0406246ed9174d0a0a975189531 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Fri, 15 Jan 2021 18:53:09 +0100 Subject: [PATCH 11/27] auth: adds token-auth to public api adds auth service adds auth module adds token-auth strategy adds token-auth to all public api calls Signed-off-by: Philip Molares --- package.json | 2 +- src/api/public/me/me.controller.ts | 25 ++++++--- src/api/public/media/media.controller.ts | 12 +++-- .../monitoring/monitoring.controller.ts | 5 +- src/api/public/notes/notes.controller.ts | 51 ++++++++++++++++--- src/app.module.ts | 2 + src/auth/auth.module.ts | 11 ++++ src/auth/auth.service.spec.ts | 31 +++++++++++ src/auth/auth.service.ts | 16 ++++++ src/auth/token-auth.guard.ts | 11 ++++ src/auth/token.strategy.ts | 26 ++++++++++ 11 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 src/auth/auth.module.ts create mode 100644 src/auth/auth.service.spec.ts create mode 100644 src/auth/auth.service.ts create mode 100644 src/auth/token-auth.guard.ts create mode 100644 src/auth/token.strategy.ts diff --git a/package.json b/package.json index fecec4b18..e7feac7cf 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "@nestjs/platform-express": "7.6.5", "@nestjs/swagger": "4.7.12", "@nestjs/typeorm": "7.1.5", - "@types/bcrypt": "^3.0.0", "@types/passport-http-bearer": "^1.0.36", + "@types/bcrypt": "^3.0.0", "bcrypt": "^5.0.0", "class-transformer": "0.3.2", "class-validator": "0.13.1", diff --git a/src/api/public/me/me.controller.ts b/src/api/public/me/me.controller.ts index 8efda4437..1fadb6fe1 100644 --- a/src/api/public/me/me.controller.ts +++ b/src/api/public/me/me.controller.ts @@ -13,6 +13,8 @@ import { NotFoundException, Param, Put, + UseGuards, + Request, } from '@nestjs/common'; import { HistoryEntryUpdateDto } from '../../../history/history-entry-update.dto'; import { HistoryEntryDto } from '../../../history/history-entry.dto'; @@ -22,6 +24,7 @@ import { NoteMetadataDto } from '../../../notes/note-metadata.dto'; import { NotesService } from '../../../notes/notes.service'; import { UserInfoDto } from '../../../users/user-info.dto'; import { UsersService } from '../../../users/users.service'; +import { TokenAuthGuard } from '../../../auth/token-auth.guard'; @Controller('me') export class MeController { @@ -34,29 +37,36 @@ export class MeController { this.logger.setContext(MeController.name); } + @UseGuards(TokenAuthGuard) @Get() - async getMe(): Promise { + async getMe(@Request() req): Promise { return this.usersService.toUserDto( - await this.usersService.getUserByUsername('hardcoded'), + await this.usersService.getUserByUsername(req.user.userName), ); } + @UseGuards(TokenAuthGuard) @Get('history') - getUserHistory(): HistoryEntryDto[] { - return this.historyService.getUserHistory('someone'); + getUserHistory(@Request() req): HistoryEntryDto[] { + return this.historyService.getUserHistory(req.user.userName); } + @UseGuards(TokenAuthGuard) @Put('history/:note') updateHistoryEntry( + @Request() req, @Param('note') note: string, @Body() entryUpdateDto: HistoryEntryUpdateDto, ): HistoryEntryDto { + // ToDo: Check if user is allowed to pin this history entry return this.historyService.updateHistoryEntry(note, entryUpdateDto); } + @UseGuards(TokenAuthGuard) @Delete('history/:note') @HttpCode(204) - deleteHistoryEntry(@Param('note') note: string) { + deleteHistoryEntry(@Request() req, @Param('note') note: string) { + // ToDo: Check if user is allowed to delete note try { return this.historyService.deleteHistoryEntry(note); } catch (e) { @@ -64,8 +74,9 @@ export class MeController { } } + @UseGuards(TokenAuthGuard) @Get('notes') - getMyNotes(): NoteMetadataDto[] { - return this.notesService.getUserNotes('someone'); + getMyNotes(@Request() req): NoteMetadataDto[] { + return this.notesService.getUserNotes(req.user.userName); } } diff --git a/src/api/public/media/media.controller.ts b/src/api/public/media/media.controller.ts index 56b84a689..85e740ca1 100644 --- a/src/api/public/media/media.controller.ts +++ b/src/api/public/media/media.controller.ts @@ -12,8 +12,10 @@ import { NotFoundException, Param, Post, + Request, UnauthorizedException, UploadedFile, + UseGuards, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; @@ -25,6 +27,7 @@ import { import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { MediaService } from '../../../media/media.service'; import { MulterFile } from '../../../media/multer-file.interface'; +import { TokenAuthGuard } from '../../../auth/token-auth.guard'; @Controller('media') export class MediaController { @@ -35,14 +38,16 @@ export class MediaController { this.logger.setContext(MediaController.name); } + @UseGuards(TokenAuthGuard) @Post() @UseInterceptors(FileInterceptor('file')) async uploadMedia( + @Request() req, @UploadedFile() file: MulterFile, @Headers('HedgeDoc-Note') noteId: string, ) { //TODO: Get user from request - const username = 'hardcoded'; + const username = req.user.userName; this.logger.debug( `Recieved filename '${file.originalname}' for note '${noteId}' from user '${username}'`, 'uploadImage', @@ -64,10 +69,11 @@ export class MediaController { } } + @UseGuards(TokenAuthGuard) @Delete(':filename') - async deleteMedia(@Param('filename') filename: string) { + async deleteMedia(@Request() req, @Param('filename') filename: string) { //TODO: Get user from request - const username = 'hardcoded'; + const username = req.user.userName; try { await this.mediaService.deleteFile(filename, username); } catch (e) { diff --git a/src/api/public/monitoring/monitoring.controller.ts b/src/api/public/monitoring/monitoring.controller.ts index 1ffdb45b5..356ebee51 100644 --- a/src/api/public/monitoring/monitoring.controller.ts +++ b/src/api/public/monitoring/monitoring.controller.ts @@ -4,18 +4,21 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, UseGuards } from '@nestjs/common'; import { MonitoringService } from '../../../monitoring/monitoring.service'; +import { TokenAuthGuard } from '../../../auth/token-auth.guard'; @Controller('monitoring') export class MonitoringController { constructor(private monitoringService: MonitoringService) {} + @UseGuards(TokenAuthGuard) @Get() getStatus() { return this.monitoringService.getServerStatus(); } + @UseGuards(TokenAuthGuard) @Get('prometheus') getPrometheusStatus() { return ''; diff --git a/src/api/public/notes/notes.controller.ts b/src/api/public/notes/notes.controller.ts index 8cf74bb4b..d2c63339c 100644 --- a/src/api/public/notes/notes.controller.ts +++ b/src/api/public/notes/notes.controller.ts @@ -14,6 +14,8 @@ import { Param, Post, Put, + Request, + UseGuards, } from '@nestjs/common'; import { NotInDBError } from '../../../errors/errors'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; @@ -21,6 +23,7 @@ import { NotePermissionsUpdateDto } from '../../../notes/note-permissions.dto'; import { NotesService } from '../../../notes/notes.service'; import { RevisionsService } from '../../../revisions/revisions.service'; import { MarkdownBody } from '../../utils/markdownbody-decorator'; +import { TokenAuthGuard } from '../../../auth/token-auth.guard'; @Controller('notes') export class NotesController { @@ -32,14 +35,18 @@ export class NotesController { this.logger.setContext(NotesController.name); } + @UseGuards(TokenAuthGuard) @Post() - async createNote(@MarkdownBody() text: string) { + async createNote(@Request() req, @MarkdownBody() text: string) { + // ToDo: provide user for createNoteDto this.logger.debug('Got raw markdown:\n' + text); return this.noteService.createNoteDto(text); } + @UseGuards(TokenAuthGuard) @Get(':noteIdOrAlias') - async getNote(@Param('noteIdOrAlias') noteIdOrAlias: string) { + async getNote(@Request() req, @Param('noteIdOrAlias') noteIdOrAlias: string) { + // ToDo: check if user is allowed to view this note try { return await this.noteService.getNoteDtoByIdOrAlias(noteIdOrAlias); } catch (e) { @@ -50,17 +57,25 @@ export class NotesController { } } + @UseGuards(TokenAuthGuard) @Post(':noteAlias') async createNamedNote( + @Request() req, @Param('noteAlias') noteAlias: string, @MarkdownBody() text: string, ) { + // ToDo: check if user is allowed to view this note this.logger.debug('Got raw markdown:\n' + text); return this.noteService.createNoteDto(text, noteAlias); } + @UseGuards(TokenAuthGuard) @Delete(':noteIdOrAlias') - async deleteNote(@Param('noteIdOrAlias') noteIdOrAlias: string) { + async deleteNote( + @Request() req, + @Param('noteIdOrAlias') noteIdOrAlias: string, + ) { + // ToDo: check if user is allowed to delete this note this.logger.debug('Deleting note: ' + noteIdOrAlias); try { await this.noteService.deleteNoteByIdOrAlias(noteIdOrAlias); @@ -74,11 +89,14 @@ export class NotesController { return; } + @UseGuards(TokenAuthGuard) @Put(':noteIdOrAlias') async updateNote( + @Request() req, @Param('noteIdOrAlias') noteIdOrAlias: string, @MarkdownBody() text: string, ) { + // ToDo: check if user is allowed to change this note this.logger.debug('Got raw markdown:\n' + text); try { return await this.noteService.updateNoteByIdOrAlias(noteIdOrAlias, text); @@ -90,9 +108,14 @@ export class NotesController { } } + @UseGuards(TokenAuthGuard) @Get(':noteIdOrAlias/content') @Header('content-type', 'text/markdown') - async getNoteContent(@Param('noteIdOrAlias') noteIdOrAlias: string) { + async getNoteContent( + @Request() req, + @Param('noteIdOrAlias') noteIdOrAlias: string, + ) { + // ToDo: check if user is allowed to view this notes content try { return await this.noteService.getNoteContent(noteIdOrAlias); } catch (e) { @@ -103,8 +126,13 @@ export class NotesController { } } + @UseGuards(TokenAuthGuard) @Get(':noteIdOrAlias/metadata') - async getNoteMetadata(@Param('noteIdOrAlias') noteIdOrAlias: string) { + async getNoteMetadata( + @Request() req, + @Param('noteIdOrAlias') noteIdOrAlias: string, + ) { + // ToDo: check if user is allowed to view this notes metadata try { return await this.noteService.getNoteMetadata(noteIdOrAlias); } catch (e) { @@ -115,11 +143,14 @@ export class NotesController { } } + @UseGuards(TokenAuthGuard) @Put(':noteIdOrAlias/metadata/permissions') async updateNotePermissions( + @Request() req, @Param('noteIdOrAlias') noteIdOrAlias: string, @Body() updateDto: NotePermissionsUpdateDto, ) { + // ToDo: check if user is allowed to view this notes permissions try { return await this.noteService.updateNotePermissions( noteIdOrAlias, @@ -133,8 +164,13 @@ export class NotesController { } } + @UseGuards(TokenAuthGuard) @Get(':noteIdOrAlias/revisions') - async getNoteRevisions(@Param('noteIdOrAlias') noteIdOrAlias: string) { + async getNoteRevisions( + @Request() req, + @Param('noteIdOrAlias') noteIdOrAlias: string, + ) { + // ToDo: check if user is allowed to view this notes revisions try { return await this.revisionsService.getNoteRevisionMetadatas( noteIdOrAlias, @@ -147,11 +183,14 @@ export class NotesController { } } + @UseGuards(TokenAuthGuard) @Get(':noteIdOrAlias/revisions/:revisionId') async getNoteRevision( + @Request() req, @Param('noteIdOrAlias') noteIdOrAlias: string, @Param('revisionId') revisionId: number, ) { + // ToDo: check if user is allowed to view this notes revision try { return await this.revisionsService.getNoteRevision( noteIdOrAlias, diff --git a/src/app.module.ts b/src/app.module.ts index 06e5bb281..8b8ae93d6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { NotesModule } from './notes/notes.module'; import { PermissionsModule } from './permissions/permissions.module'; import { RevisionsModule } from './revisions/revisions.module'; import { UsersModule } from './users/users.module'; +import { AuthModule } from './auth/auth.module'; import appConfig from './config/app.config'; import mediaConfig from './config/media.config'; import hstsConfig from './config/hsts.config'; @@ -55,6 +56,7 @@ import authConfig from './config/auth.config'; GroupsModule, LoggerModule, MediaModule, + AuthModule, ], controllers: [], providers: [], diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 000000000..95ae873b6 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { UsersModule } from '../users/users.module'; +import { PassportModule } from '@nestjs/passport'; +import { TokenStrategy } from './token.strategy'; + +@Module({ + imports: [UsersModule, PassportModule], + providers: [AuthService, TokenStrategy], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 000000000..df9d1a1de --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,31 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; +import { UsersModule } from '../users/users.module'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../users/user.entity'; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: getRepositoryToken(User), + useValue: {}, + }, + ], + imports: [UsersModule], + }) + .overrideProvider(getRepositoryToken(User)) + .useValue({}) + .compile(); + + service = module.get(AuthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 000000000..af7df69f5 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { UsersService } from '../users/users.service'; +import { User } from '../users/user.entity'; + +@Injectable() +export class AuthService { + constructor(private usersService: UsersService) {} + + async validateToken(token: string): Promise { + const user = await this.usersService.getUserByAuthToken(token); + if (user) { + return user; + } + return null; + } +} diff --git a/src/auth/token-auth.guard.ts b/src/auth/token-auth.guard.ts new file mode 100644 index 000000000..e6b691d3b --- /dev/null +++ b/src/auth/token-auth.guard.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class TokenAuthGuard extends AuthGuard('token') {} diff --git a/src/auth/token.strategy.ts b/src/auth/token.strategy.ts new file mode 100644 index 000000000..317b255f4 --- /dev/null +++ b/src/auth/token.strategy.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Strategy } from 'passport-http-bearer'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { User } from '../users/user.entity'; + +@Injectable() +export class TokenStrategy extends PassportStrategy(Strategy, 'token') { + constructor(private authService: AuthService) { + super(); + } + + async validate(token: string): Promise { + const user = await this.authService.validateToken(token); + if (!user) { + throw new UnauthorizedException(); + } + return user; + } +} From 74fd7abfb285be67f459df78f7fd4d2d9c45cff4 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sat, 16 Jan 2021 17:45:14 +0100 Subject: [PATCH 12/27] openapi: adds auth to all public api routes See: https://docs.nestjs.com/openapi/security Signed-off-by: Philip Molares --- src/api/public/me/me.controller.ts | 2 ++ src/api/public/media/media.controller.ts | 2 ++ src/api/public/monitoring/monitoring.controller.ts | 2 ++ src/api/public/notes/notes.controller.ts | 2 ++ src/main.ts | 4 ++++ 5 files changed, 12 insertions(+) diff --git a/src/api/public/me/me.controller.ts b/src/api/public/me/me.controller.ts index 1fadb6fe1..02e51e2af 100644 --- a/src/api/public/me/me.controller.ts +++ b/src/api/public/me/me.controller.ts @@ -25,7 +25,9 @@ import { NotesService } from '../../../notes/notes.service'; import { UserInfoDto } from '../../../users/user-info.dto'; import { UsersService } from '../../../users/users.service'; import { TokenAuthGuard } from '../../../auth/token-auth.guard'; +import { ApiSecurity } from '@nestjs/swagger'; +@ApiSecurity('token') @Controller('me') export class MeController { constructor( diff --git a/src/api/public/media/media.controller.ts b/src/api/public/media/media.controller.ts index 85e740ca1..3cc8b2471 100644 --- a/src/api/public/media/media.controller.ts +++ b/src/api/public/media/media.controller.ts @@ -28,7 +28,9 @@ import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { MediaService } from '../../../media/media.service'; import { MulterFile } from '../../../media/multer-file.interface'; import { TokenAuthGuard } from '../../../auth/token-auth.guard'; +import { ApiSecurity } from '@nestjs/swagger'; +@ApiSecurity('token') @Controller('media') export class MediaController { constructor( diff --git a/src/api/public/monitoring/monitoring.controller.ts b/src/api/public/monitoring/monitoring.controller.ts index 356ebee51..f32e39701 100644 --- a/src/api/public/monitoring/monitoring.controller.ts +++ b/src/api/public/monitoring/monitoring.controller.ts @@ -7,7 +7,9 @@ import { Controller, Get, UseGuards } from '@nestjs/common'; import { MonitoringService } from '../../../monitoring/monitoring.service'; import { TokenAuthGuard } from '../../../auth/token-auth.guard'; +import { ApiSecurity } from '@nestjs/swagger'; +@ApiSecurity('token') @Controller('monitoring') export class MonitoringController { constructor(private monitoringService: MonitoringService) {} diff --git a/src/api/public/notes/notes.controller.ts b/src/api/public/notes/notes.controller.ts index d2c63339c..5a1782eb3 100644 --- a/src/api/public/notes/notes.controller.ts +++ b/src/api/public/notes/notes.controller.ts @@ -24,7 +24,9 @@ import { NotesService } from '../../../notes/notes.service'; import { RevisionsService } from '../../../revisions/revisions.service'; import { MarkdownBody } from '../../utils/markdownbody-decorator'; import { TokenAuthGuard } from '../../../auth/token-auth.guard'; +import { ApiSecurity } from '@nestjs/swagger'; +@ApiSecurity('token') @Controller('notes') export class NotesController { constructor( diff --git a/src/main.ts b/src/main.ts index 1dbce7ac3..355f60621 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,10 @@ async function bootstrap() { const swaggerOptions = new DocumentBuilder() .setTitle('HedgeDoc') .setVersion('2.0-dev') + .addSecurity('token', { + type: 'http', + scheme: 'bearer', + }) .build(); const document = SwaggerModule.createDocument(app, swaggerOptions); SwaggerModule.setup('apidoc', app, document); From fd70b2d12113909fb32c083076c16466db60f3bb Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sat, 16 Jan 2021 19:33:09 +0100 Subject: [PATCH 13/27] auth: fixes unit and e2e tests adds MockAuthGuard which always return user 'hardcoded' Signed-off-by: Philip Molares --- src/auth/auth.service.spec.ts | 19 ++++++++++--------- src/auth/mock-auth.guard.ts | 18 ++++++++++++++++++ test/public-api/media.e2e-spec.ts | 9 ++++++++- test/public-api/notes.e2e-spec.ts | 12 +++++++++++- 4 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 src/auth/mock-auth.guard.ts diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index df9d1a1de..cf9de2666 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -1,23 +1,24 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from './auth.service'; -import { UsersModule } from '../users/users.module'; +import { PassportModule } from '@nestjs/passport'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { AuthToken } from '../users/auth-token.entity'; import { User } from '../users/user.entity'; +import { UsersModule } from '../users/users.module'; +import { Identity } from '../users/identity.entity'; describe('AuthService', () => { let service: AuthService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ - AuthService, - { - provide: getRepositoryToken(User), - useValue: {}, - }, - ], - imports: [UsersModule], + providers: [AuthService], + imports: [PassportModule, UsersModule], }) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) + .overrideProvider(getRepositoryToken(Identity)) + .useValue({}) .overrideProvider(getRepositoryToken(User)) .useValue({}) .compile(); diff --git a/src/auth/mock-auth.guard.ts b/src/auth/mock-auth.guard.ts new file mode 100644 index 000000000..9d15bc83e --- /dev/null +++ b/src/auth/mock-auth.guard.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ExecutionContext, Injectable } from '@nestjs/common'; + +@Injectable() +export class MockAuthGuard { + canActivate(context: ExecutionContext) { + const req = context.switchToHttp().getRequest(); + req.user = { + userName: 'hardcoded', + }; + return true; + } +} diff --git a/test/public-api/media.e2e-spec.ts b/test/public-api/media.e2e-spec.ts index 6d1c3d14d..f4b57ebf0 100644 --- a/test/public-api/media.e2e-spec.ts +++ b/test/public-api/media.e2e-spec.ts @@ -21,6 +21,9 @@ import { NotesModule } from '../../src/notes/notes.module'; import { NotesService } from '../../src/notes/notes.service'; import { PermissionsModule } from '../../src/permissions/permissions.module'; import { UsersService } from '../../src/users/users.service'; +import { AuthModule } from '../../src/auth/auth.module'; +import { TokenAuthGuard } from '../../src/auth/token-auth.guard'; +import { MockAuthGuard } from '../../src/auth/mock-auth.guard'; describe('Notes', () => { let app: NestExpressApplication; @@ -46,8 +49,12 @@ describe('Notes', () => { PermissionsModule, GroupsModule, LoggerModule, + AuthModule, ], - }).compile(); + }) + .overrideGuard(TokenAuthGuard) + .useClass(MockAuthGuard) + .compile(); app = moduleRef.createNestApplication(); app.useStaticAssets('uploads', { prefix: '/uploads', diff --git a/test/public-api/notes.e2e-spec.ts b/test/public-api/notes.e2e-spec.ts index 500e880c3..bf9ca8c55 100644 --- a/test/public-api/notes.e2e-spec.ts +++ b/test/public-api/notes.e2e-spec.ts @@ -17,6 +17,10 @@ import { LoggerModule } from '../../src/logger/logger.module'; import { NotesModule } from '../../src/notes/notes.module'; import { NotesService } from '../../src/notes/notes.service'; import { PermissionsModule } from '../../src/permissions/permissions.module'; +import { AuthModule } from '../../src/auth/auth.module'; +import { TokenAuthGuard } from '../../src/auth/token-auth.guard'; +import { MockAuthGuard } from '../../src/auth/mock-auth.guard'; +import { UsersService } from '../../src/users/users.service'; describe('Notes', () => { let app: INestApplication; @@ -41,12 +45,18 @@ describe('Notes', () => { dropSchema: true, }), LoggerModule, + AuthModule, ], - }).compile(); + }) + .overrideGuard(TokenAuthGuard) + .useClass(MockAuthGuard) + .compile(); app = moduleRef.createNestApplication(); await app.init(); notesService = moduleRef.get(NotesService); + const usersService: UsersService = moduleRef.get('UsersService'); + await usersService.createUser('testy', 'Testy McTestFace'); }); it(`POST /notes`, async () => { From 599fe57ec6055bbc908b20e7a49cd8e9ffae4e40 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Thu, 21 Jan 2021 19:37:43 +0100 Subject: [PATCH 14/27] tokens: Add token creation Fix token deletion Update plantuml docs Add token validUntil and lastUsed fields Signed-off-by: Philip Molares --- docs/content/dev/db-schema.plantuml | 3 + src/api/private/private-api.module.ts | 3 +- src/api/private/tokens/tokens.controller.ts | 12 +-- src/app.module.ts | 2 + src/auth/auth.service.ts | 4 +- src/errors/errors.ts | 4 + src/users/auth-token.dto.ts | 4 + src/users/auth-token.entity.ts | 30 +++++-- src/users/users.service.ts | 86 +++++++++++++++------ 9 files changed, 113 insertions(+), 35 deletions(-) diff --git a/docs/content/dev/db-schema.plantuml b/docs/content/dev/db-schema.plantuml index 4f180986f..84108edb7 100644 --- a/docs/content/dev/db-schema.plantuml +++ b/docs/content/dev/db-schema.plantuml @@ -28,9 +28,12 @@ entity "auth_token"{ *id : number <> -- *userId : uuid + *keyId: text *accessToken : text *identifier: text *createdAt: date + lastUsed: number + validUntil: number } entity "identity" { diff --git a/src/api/private/private-api.module.ts b/src/api/private/private-api.module.ts index 276742e26..e7eea137c 100644 --- a/src/api/private/private-api.module.ts +++ b/src/api/private/private-api.module.ts @@ -7,9 +7,10 @@ import { Module } from '@nestjs/common'; import { UsersModule } from '../../users/users.module'; import { TokensController } from './tokens/tokens.controller'; +import { LoggerModule } from '../../logger/logger.module'; @Module({ - imports: [UsersModule], + imports: [UsersModule, LoggerModule], controllers: [TokensController], }) export class PrivateApiModule {} diff --git a/src/api/private/tokens/tokens.controller.ts b/src/api/private/tokens/tokens.controller.ts index 04ad4e138..86e28141c 100644 --- a/src/api/private/tokens/tokens.controller.ts +++ b/src/api/private/tokens/tokens.controller.ts @@ -30,9 +30,9 @@ export class TokensController { @Get() async getUserTokens(): Promise { // ToDo: Get real userName - return (await this.usersService.getTokensByUsername('molly')).map((token) => - this.usersService.toAuthTokenDto(token), - ); + return ( + await this.usersService.getTokensByUsername('hardcoded') + ).map((token) => this.usersService.toAuthTokenDto(token)); } @Post() @@ -49,10 +49,10 @@ export class TokensController { return this.usersService.toAuthTokenWithSecretDto(authToken); } - @Delete('/:timestamp') + @Delete('/:keyId') @HttpCode(204) - async deleteToken(@Param('timestamp') timestamp: number) { + async deleteToken(@Param('keyId') keyId: string) { // ToDo: Get real userName - return this.usersService.removeToken('hardcoded', timestamp); + return this.usersService.removeToken('hardcoded', keyId); } } diff --git a/src/app.module.ts b/src/app.module.ts index 8b8ae93d6..0fc3313db 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -25,6 +25,7 @@ import hstsConfig from './config/hsts.config'; import cspConfig from './config/csp.config'; import databaseConfig from './config/database.config'; import authConfig from './config/auth.config'; +import { PrivateApiModule } from './api/private/private-api.module'; @Module({ imports: [ @@ -50,6 +51,7 @@ import authConfig from './config/auth.config'; RevisionsModule, AuthorsModule, PublicApiModule, + PrivateApiModule, HistoryModule, MonitoringModule, PermissionsModule, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index af7df69f5..89565e96c 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -7,8 +7,10 @@ export class AuthService { constructor(private usersService: UsersService) {} async validateToken(token: string): Promise { - const user = await this.usersService.getUserByAuthToken(token); + const parts = token.split('.'); + const user = await this.usersService.getUserByAuthToken(parts[0], parts[1]); if (user) { + await this.usersService.setLastUsedToken(parts[0]) return user; } return null; diff --git a/src/errors/errors.ts b/src/errors/errors.ts index 9052a8c44..b288f5443 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -15,3 +15,7 @@ export class ClientError extends Error { export class PermissionError extends Error { name = 'PermissionError'; } + +export class TokenNotValid extends Error { + name = 'TokenNotValid'; +} diff --git a/src/users/auth-token.dto.ts b/src/users/auth-token.dto.ts index b59018fcc..c4fc8ffba 100644 --- a/src/users/auth-token.dto.ts +++ b/src/users/auth-token.dto.ts @@ -11,4 +11,8 @@ export class AuthTokenDto { label: string; @IsNumber() created: number; + @IsNumber() + validUntil: number | null; + @IsNumber() + lastUsed: number | null; } diff --git a/src/users/auth-token.entity.ts b/src/users/auth-token.entity.ts index e391fa83e..2dc58d2d0 100644 --- a/src/users/auth-token.entity.ts +++ b/src/users/auth-token.entity.ts @@ -4,7 +4,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; import { User } from './user.entity'; @Entity() @@ -12,6 +18,9 @@ export class AuthToken { @PrimaryGeneratedColumn() id: number; + @Column({ unique: true }) + keyId: string; + @ManyToOne((_) => User, (user) => user.authTokens) user: User; @@ -24,21 +33,32 @@ export class AuthToken { @Column({ unique: true }) accessToken: string; - @Column({ type: 'date' }) - validUntil: Date; + @Column({ + nullable: true, + }) + validUntil: number; + + @Column({ + nullable: true, + }) + lastUsed: number; public static create( user: User, identifier: string, + keyId: string, accessToken: string, - validUntil: Date, + validUntil?: number, ): Pick { const newToken = new AuthToken(); newToken.user = user; newToken.identifier = identifier; + newToken.keyId = keyId; newToken.accessToken = accessToken; newToken.createdAt = new Date(); - newToken.validUntil = validUntil; + if (validUntil !== undefined) { + newToken.validUntil = validUntil; + } return newToken; } } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 58d9c966a..ecf7ab4ea 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -7,13 +7,13 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { NotInDBError } from '../errors/errors'; +import { NotInDBError, TokenNotValid } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { UserInfoDto } from './user-info.dto'; import { User } from './user.entity'; import { AuthToken } from './auth-token.entity'; -import { hash, compare } from 'bcrypt' -import crypt from 'crypto'; +import { hash, compare } from 'bcrypt'; +import { randomBytes } from 'crypto'; import { AuthTokenDto } from './auth-token.dto'; import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto'; @@ -33,19 +33,36 @@ export class UsersService { return this.userRepository.save(user); } + randomBase64UrlString(): string { + // This is necessary as the is no base64url encoding in the toString method + // but as can be seen on https://tools.ietf.org/html/rfc4648#page-7 + // base64url is quite easy buildable from base64 + return randomBytes(64) + .toString('base64') + .replace('+', '-') + .replace('/', '_') + .replace(/=+$/, ''); + } + async createTokenForUser( userName: string, identifier: string, until: number, ): Promise { const user = await this.getUserByUsername(userName); - const randomString = crypt.randomBytes(64).toString('base64url'); - const accessToken = await this.hashPassword(randomString); - const token = AuthToken.create(user, identifier, accessToken, new Date(until)); + const secret = this.randomBase64UrlString(); + const keyId = this.randomBase64UrlString(); + const accessToken = await this.hashPassword(secret); + let token; + if (until === 0) { + token = AuthToken.create(user, identifier, keyId, accessToken); + } else { + token = AuthToken.create(user, identifier, keyId, accessToken, until); + } const createdToken = await this.authTokenRepository.save(token); return { - accessToken: randomString, ...createdToken, + accessToken: `${keyId}.${secret}`, }; } @@ -57,9 +74,10 @@ export class UsersService { await this.userRepository.delete(user); } - async getUserByUsername(userName: string): Promise { + async getUserByUsername(userName: string, withTokens = false): Promise { const user = await this.userRepository.findOne({ where: { userName: userName }, + relations: withTokens ? ['authTokens'] : null, }); if (user === undefined) { throw new NotInDBError(`User with username '${userName}' not found`); @@ -69,22 +87,45 @@ export class UsersService { async hashPassword(cleartext: string): Promise { // hash the password with bcrypt and 2^16 iterations - return hash(cleartext, 16) + return hash(cleartext, 16); } async checkPassword(cleartext: string, password: string): Promise { // hash the password with bcrypt and 2^16 iterations - return compare(cleartext, password) + return compare(cleartext, password); } - async getUserByAuthToken(token: string): Promise { - const hash = this.hashPassword(token); + async setLastUsedToken(keyId: string) { const accessToken = await this.authTokenRepository.findOne({ - where: { accessToken: hash }, + where: { keyId: keyId }, + }); + accessToken.lastUsed = new Date().getTime(); + await this.authTokenRepository.save(accessToken); + } + + async getUserByAuthToken(keyId: string, token: string): Promise { + const accessToken = await this.authTokenRepository.findOne({ + where: { keyId: keyId }, + relations: ['user'], }); if (accessToken === undefined) { throw new NotInDBError(`AuthToken '${token}' not found`); } + if (!(await this.checkPassword(token, accessToken.accessToken))) { + // hashes are not the same + throw new TokenNotValid(`AuthToken '${token}' is not valid.`); + } + if ( + accessToken.validUntil && + accessToken.validUntil < new Date().getTime() + ) { + // tokens validUntil Date lies in the past + throw new TokenNotValid( + `AuthToken '${token}' is not valid since ${new Date( + accessToken.validUntil, + )}.`, + ); + } return this.getUserByUsername(accessToken.user.userName); } @@ -98,14 +139,17 @@ export class UsersService { } async getTokensByUsername(userName: string): Promise { - const user = await this.getUserByUsername(userName); + const user = await this.getUserByUsername(userName, true); + if (user.authTokens === undefined) { + return []; + } return user.authTokens; } - async removeToken(userName: string, timestamp: number) { + async removeToken(userName: string, keyId: string) { const user = await this.getUserByUsername(userName); const token = await this.authTokenRepository.findOne({ - where: { createdAt: new Date(timestamp), user: user }, + where: { keyId: keyId, user: user }, }); await this.authTokenRepository.remove(token); } @@ -118,19 +162,17 @@ export class UsersService { return { label: authToken.identifier, created: authToken.createdAt.getTime(), + validUntil: authToken.validUntil, + lastUsed: authToken.lastUsed, }; } toAuthTokenWithSecretDto( authToken: AuthToken | null | undefined, ): AuthTokenWithSecretDto | null { - if (!authToken) { - this.logger.warn(`Recieved ${authToken} argument!`, 'toAuthTokenDto'); - return null; - } + const tokeDto = this.toAuthTokenDto(authToken) return { - label: authToken.identifier, - created: authToken.createdAt.getTime(), + ...tokeDto, secret: authToken.accessToken, }; } From 84ec528d149aa13ec2fd2a0095ef47e77ee511e9 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Fri, 22 Jan 2021 15:29:10 +0100 Subject: [PATCH 15/27] auth: Add tests for AuthService Move AuthTokens to auth folder Signed-off-by: Philip Molares --- src/api/private/private-api.module.ts | 5 +- .../private/tokens/tokens.controller.spec.ts | 6 +- src/api/private/tokens/tokens.controller.ts | 21 +-- src/api/public/me/me.controller.spec.ts | 2 +- src/api/public/media/media.controller.spec.ts | 2 +- src/api/public/notes/notes.controller.spec.ts | 2 +- .../auth-token-with-secret.dto.ts | 0 src/{users => auth}/auth-token.dto.ts | 2 + src/{users => auth}/auth-token.entity.ts | 8 +- src/auth/auth.module.ts | 17 +- src/auth/auth.service.spec.ts | 135 +++++++++++++++- src/auth/auth.service.ts | 146 +++++++++++++++++- src/errors/errors.ts | 4 +- src/groups/group.entity.ts | 2 +- src/media/media.service.spec.ts | 2 +- src/notes/author-color.entity.ts | 2 +- src/notes/notes.service.spec.ts | 2 +- .../note-group-permission.entity.ts | 2 +- .../note-user-permission.entity.ts | 2 +- src/revisions/authorship.entity.ts | 2 +- src/revisions/revision-metadata.dto.ts | 2 +- src/revisions/revision.entity.ts | 2 +- src/revisions/revisions.service.spec.ts | 2 +- src/users/identity.entity.ts | 2 +- src/users/session.entity.ts | 2 +- src/users/user.entity.ts | 2 +- src/users/users.module.ts | 6 +- src/users/users.service.spec.ts | 7 - src/users/users.service.ts | 125 +-------------- test/public-api/users.e2e-spec.ts | 1 - 30 files changed, 329 insertions(+), 186 deletions(-) rename src/{users => auth}/auth-token-with-secret.dto.ts (100%) rename src/{users => auth}/auth-token.dto.ts (92%) rename src/{users => auth}/auth-token.entity.ts (86%) diff --git a/src/api/private/private-api.module.ts b/src/api/private/private-api.module.ts index e7eea137c..74dc95fe8 100644 --- a/src/api/private/private-api.module.ts +++ b/src/api/private/private-api.module.ts @@ -5,12 +5,13 @@ */ import { Module } from '@nestjs/common'; -import { UsersModule } from '../../users/users.module'; import { TokensController } from './tokens/tokens.controller'; import { LoggerModule } from '../../logger/logger.module'; +import { UsersModule } from '../../users/users.module'; +import { AuthModule } from '../../auth/auth.module'; @Module({ - imports: [UsersModule, LoggerModule], + imports: [LoggerModule, UsersModule, AuthModule], controllers: [TokensController], }) export class PrivateApiModule {} diff --git a/src/api/private/tokens/tokens.controller.spec.ts b/src/api/private/tokens/tokens.controller.spec.ts index f90278756..287a5f0a2 100644 --- a/src/api/private/tokens/tokens.controller.spec.ts +++ b/src/api/private/tokens/tokens.controller.spec.ts @@ -7,11 +7,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TokensController } from './tokens.controller'; import { LoggerModule } from '../../../logger/logger.module'; -import { UsersModule } from '../../../users/users.module'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Identity } from '../../../users/identity.entity'; import { User } from '../../../users/user.entity'; -import { AuthToken } from '../../../users/auth-token.entity'; +import { AuthToken } from '../../../auth/auth-token.entity'; +import { AuthModule } from '../../../auth/auth.module'; describe('TokensController', () => { let controller: TokensController; @@ -19,7 +19,7 @@ describe('TokensController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [TokensController], - imports: [LoggerModule, UsersModule], + imports: [LoggerModule, AuthModule], }) .overrideProvider(getRepositoryToken(User)) .useValue({}) diff --git a/src/api/private/tokens/tokens.controller.ts b/src/api/private/tokens/tokens.controller.ts index 86e28141c..89d47cbde 100644 --- a/src/api/private/tokens/tokens.controller.ts +++ b/src/api/private/tokens/tokens.controller.ts @@ -14,15 +14,15 @@ import { Post, } from '@nestjs/common'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; -import { UsersService } from '../../../users/users.service'; -import { AuthTokenDto } from '../../../users/auth-token.dto'; -import { AuthTokenWithSecretDto } from '../../../users/auth-token-with-secret.dto'; +import { AuthTokenDto } from '../../../auth/auth-token.dto'; +import { AuthTokenWithSecretDto } from '../../../auth/auth-token-with-secret.dto'; +import { AuthService } from '../../../auth/auth.service'; @Controller('tokens') export class TokensController { constructor( private readonly logger: ConsoleLoggerService, - private usersService: UsersService, + private authService: AuthService, ) { this.logger.setContext(TokensController.name); } @@ -31,8 +31,8 @@ export class TokensController { async getUserTokens(): Promise { // ToDo: Get real userName return ( - await this.usersService.getTokensByUsername('hardcoded') - ).map((token) => this.usersService.toAuthTokenDto(token)); + await this.authService.getTokensByUsername('hardcoded') + ).map((token) => this.authService.toAuthTokenDto(token)); } @Post() @@ -41,18 +41,13 @@ export class TokensController { @Body('until') until: number, ): Promise { // ToDo: Get real userName - const authToken = await this.usersService.createTokenForUser( - 'hardcoded', - label, - until, - ); - return this.usersService.toAuthTokenWithSecretDto(authToken); + return this.authService.createTokenForUser('hardcoded', label, until); } @Delete('/:keyId') @HttpCode(204) async deleteToken(@Param('keyId') keyId: string) { // ToDo: Get real userName - return this.usersService.removeToken('hardcoded', keyId); + return this.authService.removeToken('hardcoded', keyId); } } diff --git a/src/api/public/me/me.controller.spec.ts b/src/api/public/me/me.controller.spec.ts index b7b26a915..e3f262396 100644 --- a/src/api/public/me/me.controller.spec.ts +++ b/src/api/public/me/me.controller.spec.ts @@ -14,7 +14,7 @@ import { NotesModule } from '../../../notes/notes.module'; import { Tag } from '../../../notes/tag.entity'; import { Authorship } from '../../../revisions/authorship.entity'; import { Revision } from '../../../revisions/revision.entity'; -import { AuthToken } from '../../../users/auth-token.entity'; +import { AuthToken } from '../../../auth/auth-token.entity'; import { Identity } from '../../../users/identity.entity'; import { User } from '../../../users/user.entity'; import { UsersModule } from '../../../users/users.module'; diff --git a/src/api/public/media/media.controller.spec.ts b/src/api/public/media/media.controller.spec.ts index 2e5180993..eb699f1c2 100644 --- a/src/api/public/media/media.controller.spec.ts +++ b/src/api/public/media/media.controller.spec.ts @@ -18,7 +18,7 @@ import { NotesModule } from '../../../notes/notes.module'; import { Tag } from '../../../notes/tag.entity'; import { Authorship } from '../../../revisions/authorship.entity'; import { Revision } from '../../../revisions/revision.entity'; -import { AuthToken } from '../../../users/auth-token.entity'; +import { AuthToken } from '../../../auth/auth-token.entity'; import { Identity } from '../../../users/identity.entity'; import { User } from '../../../users/user.entity'; import { MediaController } from './media.controller'; diff --git a/src/api/public/notes/notes.controller.spec.ts b/src/api/public/notes/notes.controller.spec.ts index 448c9f345..13ae993bc 100644 --- a/src/api/public/notes/notes.controller.spec.ts +++ b/src/api/public/notes/notes.controller.spec.ts @@ -14,7 +14,7 @@ import { Tag } from '../../../notes/tag.entity'; import { Authorship } from '../../../revisions/authorship.entity'; import { Revision } from '../../../revisions/revision.entity'; import { RevisionsModule } from '../../../revisions/revisions.module'; -import { AuthToken } from '../../../users/auth-token.entity'; +import { AuthToken } from '../../../auth/auth-token.entity'; import { Identity } from '../../../users/identity.entity'; import { User } from '../../../users/user.entity'; import { UsersModule } from '../../../users/users.module'; diff --git a/src/users/auth-token-with-secret.dto.ts b/src/auth/auth-token-with-secret.dto.ts similarity index 100% rename from src/users/auth-token-with-secret.dto.ts rename to src/auth/auth-token-with-secret.dto.ts diff --git a/src/users/auth-token.dto.ts b/src/auth/auth-token.dto.ts similarity index 92% rename from src/users/auth-token.dto.ts rename to src/auth/auth-token.dto.ts index c4fc8ffba..1c3592db3 100644 --- a/src/users/auth-token.dto.ts +++ b/src/auth/auth-token.dto.ts @@ -9,6 +9,8 @@ import { IsNumber, IsString } from 'class-validator'; export class AuthTokenDto { @IsString() label: string; + @IsString() + keyId: string; @IsNumber() created: number; @IsNumber() diff --git a/src/users/auth-token.entity.ts b/src/auth/auth-token.entity.ts similarity index 86% rename from src/users/auth-token.entity.ts rename to src/auth/auth-token.entity.ts index 2dc58d2d0..c0538f52a 100644 --- a/src/users/auth-token.entity.ts +++ b/src/auth/auth-token.entity.ts @@ -11,7 +11,7 @@ import { ManyToOne, PrimaryGeneratedColumn, } from 'typeorm'; -import { User } from './user.entity'; +import { User } from '../users/user.entity'; @Entity() export class AuthToken { @@ -31,7 +31,7 @@ export class AuthToken { createdAt: Date; @Column({ unique: true }) - accessToken: string; + accessTokenHash: string; @Column({ nullable: true, @@ -49,12 +49,12 @@ export class AuthToken { keyId: string, accessToken: string, validUntil?: number, - ): Pick { + ): Pick { const newToken = new AuthToken(); newToken.user = user; newToken.identifier = identifier; newToken.keyId = keyId; - newToken.accessToken = accessToken; + newToken.accessTokenHash = accessToken; newToken.createdAt = new Date(); if (validUntil !== undefined) { newToken.validUntil = validUntil; diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 95ae873b6..b39aa89a3 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,11 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { UsersModule } from '../users/users.module'; import { PassportModule } from '@nestjs/passport'; import { TokenStrategy } from './token.strategy'; +import { LoggerModule } from '../logger/logger.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthToken } from './auth-token.entity'; @Module({ - imports: [UsersModule, PassportModule], + imports: [ + UsersModule, + PassportModule, + LoggerModule, + TypeOrmModule.forFeature([AuthToken]), + ], providers: [AuthService, TokenStrategy], + exports: [AuthService], }) export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index cf9de2666..94c516f41 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -1,26 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from './auth.service'; import { PassportModule } from '@nestjs/passport'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { AuthToken } from '../users/auth-token.entity'; +import { AuthToken } from './auth-token.entity'; import { User } from '../users/user.entity'; import { UsersModule } from '../users/users.module'; import { Identity } from '../users/identity.entity'; +import { LoggerModule } from '../logger/logger.module'; describe('AuthService', () => { let service: AuthService; + let user: User; + let authToken: AuthToken; beforeEach(async () => { + user = { + authTokens: [], + createdAt: new Date(), + displayName: 'hardcoded', + id: '1', + identities: [], + ownedNotes: [], + updatedAt: new Date(), + userName: 'Testy', + }; + + authToken = { + accessTokenHash: '', + createdAt: new Date(), + id: 1, + identifier: 'testIdentifier', + keyId: 'abc', + lastUsed: null, + user: null, + validUntil: null, + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [AuthService], - imports: [PassportModule, UsersModule], + providers: [ + AuthService, + { + provide: getRepositoryToken(AuthToken), + useValue: {}, + }, + ], + imports: [PassportModule, UsersModule, LoggerModule], }) .overrideProvider(getRepositoryToken(AuthToken)) - .useValue({}) + .useValue({ + findOne: (): AuthToken => { + return { + ...authToken, + user: user, + }; + }, + save: async (entity: AuthToken) => { + if (entity.lastUsed === undefined) { + expect(entity.lastUsed).toBeUndefined(); + } else { + expect(entity.lastUsed).toBeLessThanOrEqual(new Date().getTime()); + } + return entity; + }, + remove: async (entity: AuthToken) => { + expect(entity).toEqual({ + ...authToken, + user: user, + }); + }, + }) .overrideProvider(getRepositoryToken(Identity)) .useValue({}) .overrideProvider(getRepositoryToken(User)) - .useValue({}) + .useValue({ + findOne: (): User => { + return { + ...user, + authTokens: [authToken], + }; + }, + }) .compile(); service = module.get(AuthService); @@ -29,4 +94,64 @@ describe('AuthService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + it('checkPassword', async () => { + const testPassword = 'thisIsATestPassword'; + const hash = await service.hashPassword(testPassword); + service + .checkPassword(testPassword, hash) + .then((result) => expect(result).toBeTruthy()); + }); + + it('getTokensByUsername', async () => { + const tokens = await service.getTokensByUsername(user.userName); + expect(tokens).toHaveLength(1); + expect(tokens).toEqual([authToken]); + }); + + it('getAuthToken', async () => { + const token = 'testToken'; + authToken.accessTokenHash = await service.hashPassword(token); + const authTokenFromCall = await service.getAuthToken( + authToken.keyId, + token, + ); + expect(authTokenFromCall).toEqual({ + ...authToken, + user: user, + }); + }); + + it('setLastUsedToken', async () => { + await service.setLastUsedToken(authToken.keyId); + }); + + it('validateToken', async () => { + const token = 'testToken'; + authToken.accessTokenHash = await service.hashPassword(token); + const userByToken = await service.validateToken( + `${authToken.keyId}.${token}`, + ); + expect(userByToken).toEqual({ + ...user, + authTokens: [authToken], + }); + }); + + it('removeToken', async () => { + await service.removeToken(user.userName, authToken.keyId); + }); + + it('createTokenForUser', async () => { + const identifier = 'identifier2'; + const token = await service.createTokenForUser( + user.userName, + identifier, + 0, + ); + expect(token.label).toEqual(identifier); + expect(token.validUntil).toBeUndefined(); + expect(token.lastUsed).toBeUndefined(); + expect(token.secret.startsWith(token.keyId)).toBeTruthy(); + }); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 89565e96c..3354a031b 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,18 +1,158 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Injectable } from '@nestjs/common'; import { UsersService } from '../users/users.service'; import { User } from '../users/user.entity'; +import { AuthToken } from './auth-token.entity'; +import { AuthTokenDto } from './auth-token.dto'; +import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto'; +import { compare, hash } from 'bcrypt'; +import { NotInDBError, TokenNotValidError } from '../errors/errors'; +import { randomBytes } from 'crypto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConsoleLoggerService } from '../logger/console-logger.service'; @Injectable() export class AuthService { - constructor(private usersService: UsersService) {} + constructor( + private readonly logger: ConsoleLoggerService, + private usersService: UsersService, + @InjectRepository(AuthToken) + private authTokenRepository: Repository, + ) { + this.logger.setContext(AuthService.name); + } async validateToken(token: string): Promise { const parts = token.split('.'); - const user = await this.usersService.getUserByAuthToken(parts[0], parts[1]); + const accessToken = await this.getAuthToken(parts[0], parts[1]); + const user = await this.usersService.getUserByUsername( + accessToken.user.userName, + ); if (user) { - await this.usersService.setLastUsedToken(parts[0]) + await this.setLastUsedToken(parts[0]); return user; } return null; } + + async hashPassword(cleartext: string): Promise { + // hash the password with bcrypt and 2^16 iterations + return hash(cleartext, 12); + } + + async checkPassword(cleartext: string, password: string): Promise { + // hash the password with bcrypt and 2^16 iterations + return compare(cleartext, password); + } + + randomBase64UrlString(length = 64): string { + // This is necessary as the is no base64url encoding in the toString method + // but as can be seen on https://tools.ietf.org/html/rfc4648#page-7 + // base64url is quite easy buildable from base64 + return randomBytes(length) + .toString('base64') + .replace('+', '-') + .replace('/', '_') + .replace(/=+$/, ''); + } + + async createTokenForUser( + userName: string, + identifier: string, + until: number, + ): Promise { + const user = await this.usersService.getUserByUsername(userName); + const secret = this.randomBase64UrlString(); + const keyId = this.randomBase64UrlString(8); + const accessToken = await this.hashPassword(secret); + let token; + if (until === 0) { + token = AuthToken.create(user, identifier, keyId, accessToken); + } else { + token = AuthToken.create(user, identifier, keyId, accessToken, until); + } + const createdToken = await this.authTokenRepository.save(token); + return this.toAuthTokenWithSecretDto(createdToken, `${keyId}.${secret}`); + } + + async setLastUsedToken(keyId: string) { + const accessToken = await this.authTokenRepository.findOne({ + where: { keyId: keyId }, + }); + accessToken.lastUsed = new Date().getTime(); + await this.authTokenRepository.save(accessToken); + } + + async getAuthToken(keyId: string, token: string): Promise { + const accessToken = await this.authTokenRepository.findOne({ + where: { keyId: keyId }, + relations: ['user'], + }); + if (accessToken === undefined) { + throw new NotInDBError(`AuthToken '${token}' not found`); + } + if (!(await this.checkPassword(token, accessToken.accessTokenHash))) { + // hashes are not the same + throw new TokenNotValidError(`AuthToken '${token}' is not valid.`); + } + if ( + accessToken.validUntil && + accessToken.validUntil < new Date().getTime() + ) { + // tokens validUntil Date lies in the past + throw new TokenNotValidError( + `AuthToken '${token}' is not valid since ${new Date( + accessToken.validUntil, + )}.`, + ); + } + return accessToken; + } + + async getTokensByUsername(userName: string): Promise { + const user = await this.usersService.getUserByUsername(userName, true); + if (user.authTokens === undefined) { + return []; + } + return user.authTokens; + } + + async removeToken(userName: string, keyId: string) { + const user = await this.usersService.getUserByUsername(userName); + const token = await this.authTokenRepository.findOne({ + where: { keyId: keyId, user: user }, + }); + await this.authTokenRepository.remove(token); + } + + toAuthTokenDto(authToken: AuthToken | null | undefined): AuthTokenDto | null { + if (!authToken) { + this.logger.warn(`Recieved ${authToken} argument!`, 'toAuthTokenDto'); + return null; + } + return { + label: authToken.identifier, + keyId: authToken.keyId, + created: authToken.createdAt.getTime(), + validUntil: authToken.validUntil, + lastUsed: authToken.lastUsed, + }; + } + + toAuthTokenWithSecretDto( + authToken: AuthToken | null | undefined, + secret: string, + ): AuthTokenWithSecretDto | null { + const tokeDto = this.toAuthTokenDto(authToken); + return { + ...tokeDto, + secret: secret, + }; + } } diff --git a/src/errors/errors.ts b/src/errors/errors.ts index b288f5443..1b4821bf7 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -16,6 +16,6 @@ export class PermissionError extends Error { name = 'PermissionError'; } -export class TokenNotValid extends Error { - name = 'TokenNotValid'; +export class TokenNotValidError extends Error { + name = 'TokenNotValidError'; } diff --git a/src/groups/group.entity.ts b/src/groups/group.entity.ts index b96ccdce1..427709718 100644 --- a/src/groups/group.entity.ts +++ b/src/groups/group.entity.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm/index'; +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class Group { diff --git a/src/media/media.service.spec.ts b/src/media/media.service.spec.ts index 6eb99deb1..7b32af5a6 100644 --- a/src/media/media.service.spec.ts +++ b/src/media/media.service.spec.ts @@ -15,7 +15,7 @@ import { NotesModule } from '../notes/notes.module'; import { Tag } from '../notes/tag.entity'; import { Authorship } from '../revisions/authorship.entity'; import { Revision } from '../revisions/revision.entity'; -import { AuthToken } from '../users/auth-token.entity'; +import { AuthToken } from '../auth/auth-token.entity'; import { Identity } from '../users/identity.entity'; import { User } from '../users/user.entity'; import { UsersModule } from '../users/users.module'; diff --git a/src/notes/author-color.entity.ts b/src/notes/author-color.entity.ts index 322667310..965d5e6f9 100644 --- a/src/notes/author-color.entity.ts +++ b/src/notes/author-color.entity.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Column, Entity, ManyToOne } from 'typeorm/index'; +import { Column, Entity, ManyToOne } from 'typeorm'; import { User } from '../users/user.entity'; import { Note } from './note.entity'; diff --git a/src/notes/notes.service.spec.ts b/src/notes/notes.service.spec.ts index 41c514a77..2c623796a 100644 --- a/src/notes/notes.service.spec.ts +++ b/src/notes/notes.service.spec.ts @@ -10,7 +10,7 @@ import { LoggerModule } from '../logger/logger.module'; import { Authorship } from '../revisions/authorship.entity'; import { Revision } from '../revisions/revision.entity'; import { RevisionsModule } from '../revisions/revisions.module'; -import { AuthToken } from '../users/auth-token.entity'; +import { AuthToken } from '../auth/auth-token.entity'; import { Identity } from '../users/identity.entity'; import { User } from '../users/user.entity'; import { UsersModule } from '../users/users.module'; diff --git a/src/permissions/note-group-permission.entity.ts b/src/permissions/note-group-permission.entity.ts index 68c774033..79ea2d51d 100644 --- a/src/permissions/note-group-permission.entity.ts +++ b/src/permissions/note-group-permission.entity.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Column, Entity, ManyToOne } from 'typeorm/index'; +import { Column, Entity, ManyToOne } from 'typeorm'; import { Group } from '../groups/group.entity'; import { Note } from '../notes/note.entity'; diff --git a/src/permissions/note-user-permission.entity.ts b/src/permissions/note-user-permission.entity.ts index 62c96d9b9..c0f8f0e83 100644 --- a/src/permissions/note-user-permission.entity.ts +++ b/src/permissions/note-user-permission.entity.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Column, Entity, ManyToOne } from 'typeorm/index'; +import { Column, Entity, ManyToOne } from 'typeorm'; import { Note } from '../notes/note.entity'; import { User } from '../users/user.entity'; diff --git a/src/revisions/authorship.entity.ts b/src/revisions/authorship.entity.ts index 19772f320..24eaa084e 100644 --- a/src/revisions/authorship.entity.ts +++ b/src/revisions/authorship.entity.ts @@ -12,7 +12,7 @@ import { ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, -} from 'typeorm/index'; +} from 'typeorm'; import { User } from '../users/user.entity'; import { Revision } from './revision.entity'; diff --git a/src/revisions/revision-metadata.dto.ts b/src/revisions/revision-metadata.dto.ts index bcbfa08a5..6f2e6832e 100644 --- a/src/revisions/revision-metadata.dto.ts +++ b/src/revisions/revision-metadata.dto.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { IsDate, IsNumber, IsString } from 'class-validator'; +import { IsDate, IsNumber } from 'class-validator'; import { Revision } from './revision.entity'; export class RevisionMetadataDto { diff --git a/src/revisions/revision.entity.ts b/src/revisions/revision.entity.ts index e2c9e2f49..967ef26c5 100644 --- a/src/revisions/revision.entity.ts +++ b/src/revisions/revision.entity.ts @@ -11,7 +11,7 @@ import { ManyToOne, PrimaryGeneratedColumn, } from 'typeorm'; -import { JoinTable, ManyToMany } from 'typeorm/index'; +import { JoinTable, ManyToMany } from 'typeorm'; import { Note } from '../notes/note.entity'; import { Authorship } from './authorship.entity'; diff --git a/src/revisions/revisions.service.spec.ts b/src/revisions/revisions.service.spec.ts index 50029d698..5c1d9d437 100644 --- a/src/revisions/revisions.service.spec.ts +++ b/src/revisions/revisions.service.spec.ts @@ -10,7 +10,7 @@ import { LoggerModule } from '../logger/logger.module'; import { AuthorColor } from '../notes/author-color.entity'; import { Note } from '../notes/note.entity'; import { NotesModule } from '../notes/notes.module'; -import { AuthToken } from '../users/auth-token.entity'; +import { AuthToken } from '../auth/auth-token.entity'; import { Identity } from '../users/identity.entity'; import { User } from '../users/user.entity'; import { Authorship } from './authorship.entity'; diff --git a/src/users/identity.entity.ts b/src/users/identity.entity.ts index 73e2d8098..f8999bc82 100644 --- a/src/users/identity.entity.ts +++ b/src/users/identity.entity.ts @@ -11,7 +11,7 @@ import { ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, -} from 'typeorm/index'; +} from 'typeorm'; import { User } from './user.entity'; @Entity() diff --git a/src/users/session.entity.ts b/src/users/session.entity.ts index aed832e49..9603373e8 100644 --- a/src/users/session.entity.ts +++ b/src/users/session.entity.ts @@ -5,7 +5,7 @@ */ import { ISession } from 'connect-typeorm'; -import { Column, Entity, Index, PrimaryColumn } from 'typeorm/index'; +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; @Entity() export class Session implements ISession { diff --git a/src/users/user.entity.ts b/src/users/user.entity.ts index c6c04d12a..cd0ab9c84 100644 --- a/src/users/user.entity.ts +++ b/src/users/user.entity.ts @@ -12,7 +12,7 @@ import { } from 'typeorm'; import { Column, OneToMany } from 'typeorm'; import { Note } from '../notes/note.entity'; -import { AuthToken } from './auth-token.entity'; +import { AuthToken } from '../auth/auth-token.entity'; import { Identity } from './identity.entity'; @Entity() diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 9cbe048a1..fe68b2398 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -7,16 +7,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { LoggerModule } from '../logger/logger.module'; -import { AuthToken } from './auth-token.entity'; import { Identity } from './identity.entity'; import { User } from './user.entity'; import { UsersService } from './users.service'; @Module({ - imports: [ - TypeOrmModule.forFeature([User, AuthToken, Identity]), - LoggerModule, - ], + imports: [TypeOrmModule.forFeature([User, Identity]), LoggerModule], providers: [UsersService], exports: [UsersService], }) diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index bb2596b21..407114f23 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -9,7 +9,6 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { LoggerModule } from '../logger/logger.module'; import { User } from './user.entity'; import { UsersService } from './users.service'; -import { AuthToken } from './auth-token.entity'; describe('UsersService', () => { let service: UsersService; @@ -22,17 +21,11 @@ describe('UsersService', () => { provide: getRepositoryToken(User), useValue: {}, }, - { - provide: getRepositoryToken(AuthToken), - useValue: {}, - }, ], imports: [LoggerModule], }) .overrideProvider(getRepositoryToken(User)) .useValue({}) - .overrideProvider(getRepositoryToken(AuthToken)) - .useValue({}) .compile(); service = module.get(UsersService); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index ecf7ab4ea..cf30e0651 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -7,23 +7,16 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { NotInDBError, TokenNotValid } from '../errors/errors'; +import { NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { UserInfoDto } from './user-info.dto'; import { User } from './user.entity'; -import { AuthToken } from './auth-token.entity'; -import { hash, compare } from 'bcrypt'; -import { randomBytes } from 'crypto'; -import { AuthTokenDto } from './auth-token.dto'; -import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto'; @Injectable() export class UsersService { constructor( private readonly logger: ConsoleLoggerService, @InjectRepository(User) private userRepository: Repository, - @InjectRepository(AuthToken) - private authTokenRepository: Repository, ) { this.logger.setContext(UsersService.name); } @@ -33,39 +26,6 @@ export class UsersService { return this.userRepository.save(user); } - randomBase64UrlString(): string { - // This is necessary as the is no base64url encoding in the toString method - // but as can be seen on https://tools.ietf.org/html/rfc4648#page-7 - // base64url is quite easy buildable from base64 - return randomBytes(64) - .toString('base64') - .replace('+', '-') - .replace('/', '_') - .replace(/=+$/, ''); - } - - async createTokenForUser( - userName: string, - identifier: string, - until: number, - ): Promise { - const user = await this.getUserByUsername(userName); - const secret = this.randomBase64UrlString(); - const keyId = this.randomBase64UrlString(); - const accessToken = await this.hashPassword(secret); - let token; - if (until === 0) { - token = AuthToken.create(user, identifier, keyId, accessToken); - } else { - token = AuthToken.create(user, identifier, keyId, accessToken, until); - } - const createdToken = await this.authTokenRepository.save(token); - return { - ...createdToken, - accessToken: `${keyId}.${secret}`, - }; - } - async deleteUser(userName: string) { // TODO: Handle owned notes and edits const user = await this.userRepository.findOne({ @@ -85,50 +45,6 @@ export class UsersService { return user; } - async hashPassword(cleartext: string): Promise { - // hash the password with bcrypt and 2^16 iterations - return hash(cleartext, 16); - } - - async checkPassword(cleartext: string, password: string): Promise { - // hash the password with bcrypt and 2^16 iterations - return compare(cleartext, password); - } - - async setLastUsedToken(keyId: string) { - const accessToken = await this.authTokenRepository.findOne({ - where: { keyId: keyId }, - }); - accessToken.lastUsed = new Date().getTime(); - await this.authTokenRepository.save(accessToken); - } - - async getUserByAuthToken(keyId: string, token: string): Promise { - const accessToken = await this.authTokenRepository.findOne({ - where: { keyId: keyId }, - relations: ['user'], - }); - if (accessToken === undefined) { - throw new NotInDBError(`AuthToken '${token}' not found`); - } - if (!(await this.checkPassword(token, accessToken.accessToken))) { - // hashes are not the same - throw new TokenNotValid(`AuthToken '${token}' is not valid.`); - } - if ( - accessToken.validUntil && - accessToken.validUntil < new Date().getTime() - ) { - // tokens validUntil Date lies in the past - throw new TokenNotValid( - `AuthToken '${token}' is not valid since ${new Date( - accessToken.validUntil, - )}.`, - ); - } - return this.getUserByUsername(accessToken.user.userName); - } - getPhotoUrl(user: User): string { if (user.photo) { return user.photo; @@ -138,45 +54,6 @@ export class UsersService { } } - async getTokensByUsername(userName: string): Promise { - const user = await this.getUserByUsername(userName, true); - if (user.authTokens === undefined) { - return []; - } - return user.authTokens; - } - - async removeToken(userName: string, keyId: string) { - const user = await this.getUserByUsername(userName); - const token = await this.authTokenRepository.findOne({ - where: { keyId: keyId, user: user }, - }); - await this.authTokenRepository.remove(token); - } - - toAuthTokenDto(authToken: AuthToken | null | undefined): AuthTokenDto | null { - if (!authToken) { - this.logger.warn(`Recieved ${authToken} argument!`, 'toAuthTokenDto'); - return null; - } - return { - label: authToken.identifier, - created: authToken.createdAt.getTime(), - validUntil: authToken.validUntil, - lastUsed: authToken.lastUsed, - }; - } - - toAuthTokenWithSecretDto( - authToken: AuthToken | null | undefined, - ): AuthTokenWithSecretDto | null { - const tokeDto = this.toAuthTokenDto(authToken) - return { - ...tokeDto, - secret: authToken.accessToken, - }; - } - toUserDto(user: User | null | undefined): UserInfoDto | null { if (!user) { this.logger.warn(`Recieved ${user} argument!`, 'toUserDto'); diff --git a/test/public-api/users.e2e-spec.ts b/test/public-api/users.e2e-spec.ts index ae502d670..fd4689c3d 100644 --- a/test/public-api/users.e2e-spec.ts +++ b/test/public-api/users.e2e-spec.ts @@ -8,7 +8,6 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import * as request from 'supertest'; import { AppModule } from '../../src/app.module'; -//import { UsersService } from '../../src/users/users.service'; import { UserInfoDto } from '../../src/users/user-info.dto'; import { HistoryService } from '../../src/history/history.service'; import { NotesService } from '../../src/notes/notes.service'; From 265195e305d83368712c78e63932125d69fb401b Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sat, 23 Jan 2021 19:04:00 +0100 Subject: [PATCH 16/27] auth: Split randomBase64UrlString in two functions add test for BufferToBase64Url and toAuthTokenDto Signed-off-by: Philip Molares --- src/auth/auth.service.spec.ts | 17 ++++++++++++++++- src/auth/auth.service.ts | 32 +++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 94c516f41..13c5a9007 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -112,7 +112,7 @@ describe('AuthService', () => { it('getAuthToken', async () => { const token = 'testToken'; authToken.accessTokenHash = await service.hashPassword(token); - const authTokenFromCall = await service.getAuthToken( + const authTokenFromCall = await service.getAuthTokenAndValidate( authToken.keyId, token, ); @@ -154,4 +154,19 @@ describe('AuthService', () => { expect(token.lastUsed).toBeUndefined(); expect(token.secret.startsWith(token.keyId)).toBeTruthy(); }); + + it('BufferToBase64Url', () => { + expect( + service.BufferToBase64Url(Buffer.from('testsentence is a test sentence')), + ).toEqual('dGVzdHNlbnRlbmNlIGlzIGEgdGVzdCBzZW50ZW5jZQ'); + }); + + it('toAuthTokenDto', async () => { + const tokenDto = await service.toAuthTokenDto(authToken); + expect(tokenDto.keyId).toEqual(authToken.keyId); + expect(tokenDto.lastUsed).toBeNull(); + expect(tokenDto.label).toEqual(authToken.identifier); + expect(tokenDto.validUntil).toBeNull(); + expect(tokenDto.created).toEqual(authToken.createdAt.getTime()); + }); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3354a031b..636d7a776 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -30,7 +30,7 @@ export class AuthService { async validateToken(token: string): Promise { const parts = token.split('.'); - const accessToken = await this.getAuthToken(parts[0], parts[1]); + const accessToken = await this.getAuthTokenAndValidate(parts[0], parts[1]); const user = await this.usersService.getUserByUsername( accessToken.user.userName, ); @@ -42,20 +42,26 @@ export class AuthService { } async hashPassword(cleartext: string): Promise { - // hash the password with bcrypt and 2^16 iterations + // hash the password with bcrypt and 2^12 iterations return hash(cleartext, 12); } async checkPassword(cleartext: string, password: string): Promise { - // hash the password with bcrypt and 2^16 iterations return compare(cleartext, password); } - randomBase64UrlString(length = 64): string { + async randomString(length: number): Promise { // This is necessary as the is no base64url encoding in the toString method // but as can be seen on https://tools.ietf.org/html/rfc4648#page-7 // base64url is quite easy buildable from base64 - return randomBytes(length) + if (length <= 0) { + return null; + } + return randomBytes(length); + } + + BufferToBase64Url(text: Buffer): string { + return text .toString('base64') .replace('+', '-') .replace('/', '_') @@ -68,9 +74,10 @@ export class AuthService { until: number, ): Promise { const user = await this.usersService.getUserByUsername(userName); - const secret = this.randomBase64UrlString(); - const keyId = this.randomBase64UrlString(8); - const accessToken = await this.hashPassword(secret); + const secret = await this.randomString(64); + const keyId = this.BufferToBase64Url(await this.randomString(8)); + const accessTokenString = await this.hashPassword(secret.toString()); + const accessToken = this.BufferToBase64Url(Buffer.from(accessTokenString)); let token; if (until === 0) { token = AuthToken.create(user, identifier, keyId, accessToken); @@ -89,7 +96,10 @@ export class AuthService { await this.authTokenRepository.save(accessToken); } - async getAuthToken(keyId: string, token: string): Promise { + async getAuthTokenAndValidate( + keyId: string, + token: string, + ): Promise { const accessToken = await this.authTokenRepository.findOne({ where: { keyId: keyId }, relations: ['user'], @@ -149,9 +159,9 @@ export class AuthService { authToken: AuthToken | null | undefined, secret: string, ): AuthTokenWithSecretDto | null { - const tokeDto = this.toAuthTokenDto(authToken); + const tokenDto = this.toAuthTokenDto(authToken); return { - ...tokeDto, + ...tokenDto, secret: secret, }; } From f68caab6e827b9b17f103be35a53349bbfff2689 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sat, 23 Jan 2021 21:24:11 +0100 Subject: [PATCH 17/27] auth: Integrate suggestions by @davidmehren Add number type alias TimestampMillis Remove solved ToDos Change AuthToken and AuthTokenDto to use Date Rename authService unit tests Signed-off-by: Philip Molares --- src/api/private/tokens/tokens.controller.ts | 5 +- src/api/public/media/media.controller.ts | 2 - src/auth/auth-token.dto.ts | 16 ++- src/auth/auth-token.entity.ts | 20 ++- src/auth/auth.service.spec.ts | 148 ++++++++++++-------- src/auth/auth.service.ts | 44 ++++-- src/utils/timestamp.ts | 7 + 7 files changed, 149 insertions(+), 93 deletions(-) create mode 100644 src/utils/timestamp.ts diff --git a/src/api/private/tokens/tokens.controller.ts b/src/api/private/tokens/tokens.controller.ts index 89d47cbde..780e11be2 100644 --- a/src/api/private/tokens/tokens.controller.ts +++ b/src/api/private/tokens/tokens.controller.ts @@ -17,6 +17,7 @@ import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { AuthTokenDto } from '../../../auth/auth-token.dto'; import { AuthTokenWithSecretDto } from '../../../auth/auth-token-with-secret.dto'; import { AuthService } from '../../../auth/auth.service'; +import { TimestampMillis } from '../../../utils/timestamp'; @Controller('tokens') export class TokensController { @@ -38,10 +39,10 @@ export class TokensController { @Post() async postTokenRequest( @Body('label') label: string, - @Body('until') until: number, + @Body('validUntil') validUntil: TimestampMillis, ): Promise { // ToDo: Get real userName - return this.authService.createTokenForUser('hardcoded', label, until); + return this.authService.createTokenForUser('hardcoded', label, validUntil); } @Delete('/:keyId') diff --git a/src/api/public/media/media.controller.ts b/src/api/public/media/media.controller.ts index 3cc8b2471..f8fd3bb45 100644 --- a/src/api/public/media/media.controller.ts +++ b/src/api/public/media/media.controller.ts @@ -48,7 +48,6 @@ export class MediaController { @UploadedFile() file: MulterFile, @Headers('HedgeDoc-Note') noteId: string, ) { - //TODO: Get user from request const username = req.user.userName; this.logger.debug( `Recieved filename '${file.originalname}' for note '${noteId}' from user '${username}'`, @@ -74,7 +73,6 @@ export class MediaController { @UseGuards(TokenAuthGuard) @Delete(':filename') async deleteMedia(@Request() req, @Param('filename') filename: string) { - //TODO: Get user from request const username = req.user.userName; try { await this.mediaService.deleteFile(filename, username); diff --git a/src/auth/auth-token.dto.ts b/src/auth/auth-token.dto.ts index 1c3592db3..46af7d6be 100644 --- a/src/auth/auth-token.dto.ts +++ b/src/auth/auth-token.dto.ts @@ -4,17 +4,19 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { IsNumber, IsString } from 'class-validator'; +import { IsDate, IsOptional, IsString } from 'class-validator'; export class AuthTokenDto { @IsString() label: string; @IsString() keyId: string; - @IsNumber() - created: number; - @IsNumber() - validUntil: number | null; - @IsNumber() - lastUsed: number | null; + @IsDate() + createdAt: Date; + @IsDate() + @IsOptional() + validUntil: Date; + @IsDate() + @IsOptional() + lastUsed: Date; } diff --git a/src/auth/auth-token.entity.ts b/src/auth/auth-token.entity.ts index c0538f52a..9d216b40c 100644 --- a/src/auth/auth-token.entity.ts +++ b/src/auth/auth-token.entity.ts @@ -36,29 +36,35 @@ export class AuthToken { @Column({ nullable: true, }) - validUntil: number; + validUntil: Date; @Column({ nullable: true, }) - lastUsed: number; + lastUsed: Date; public static create( user: User, identifier: string, keyId: string, accessToken: string, - validUntil?: number, - ): Pick { + validUntil?: Date, + ): Pick< + AuthToken, + | 'user' + | 'identifier' + | 'keyId' + | 'accessTokenHash' + | 'createdAt' + | 'validUntil' + > { const newToken = new AuthToken(); newToken.user = user; newToken.identifier = identifier; newToken.keyId = keyId; newToken.accessTokenHash = accessToken; newToken.createdAt = new Date(); - if (validUntil !== undefined) { - newToken.validUntil = validUntil; - } + newToken.validUntil = validUntil; return newToken; } } diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 13c5a9007..e5375e15d 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -64,7 +64,9 @@ describe('AuthService', () => { if (entity.lastUsed === undefined) { expect(entity.lastUsed).toBeUndefined(); } else { - expect(entity.lastUsed).toBeLessThanOrEqual(new Date().getTime()); + expect(entity.lastUsed.getTime()).toBeLessThanOrEqual( + new Date().getTime(), + ); } return entity; }, @@ -95,78 +97,100 @@ describe('AuthService', () => { expect(service).toBeDefined(); }); - it('checkPassword', async () => { - const testPassword = 'thisIsATestPassword'; - const hash = await service.hashPassword(testPassword); - service - .checkPassword(testPassword, hash) - .then((result) => expect(result).toBeTruthy()); - }); - - it('getTokensByUsername', async () => { - const tokens = await service.getTokensByUsername(user.userName); - expect(tokens).toHaveLength(1); - expect(tokens).toEqual([authToken]); - }); - - it('getAuthToken', async () => { - const token = 'testToken'; - authToken.accessTokenHash = await service.hashPassword(token); - const authTokenFromCall = await service.getAuthTokenAndValidate( - authToken.keyId, - token, - ); - expect(authTokenFromCall).toEqual({ - ...authToken, - user: user, + describe('checkPassword', () => { + it('works', async () => { + const testPassword = 'thisIsATestPassword'; + const hash = await service.hashPassword(testPassword); + service + .checkPassword(testPassword, hash) + .then((result) => expect(result).toBeTruthy()); }); }); - it('setLastUsedToken', async () => { - await service.setLastUsedToken(authToken.keyId); - }); - - it('validateToken', async () => { - const token = 'testToken'; - authToken.accessTokenHash = await service.hashPassword(token); - const userByToken = await service.validateToken( - `${authToken.keyId}.${token}`, - ); - expect(userByToken).toEqual({ - ...user, - authTokens: [authToken], + describe('getTokensByUsername', () => { + it('works', async () => { + const tokens = await service.getTokensByUsername(user.userName); + expect(tokens).toHaveLength(1); + expect(tokens).toEqual([authToken]); }); }); - it('removeToken', async () => { - await service.removeToken(user.userName, authToken.keyId); + describe('getAuthToken', () => { + it('works', async () => { + const token = 'testToken'; + authToken.accessTokenHash = await service.hashPassword(token); + const authTokenFromCall = await service.getAuthTokenAndValidate( + authToken.keyId, + token, + ); + expect(authTokenFromCall).toEqual({ + ...authToken, + user: user, + }); + }); }); - it('createTokenForUser', async () => { - const identifier = 'identifier2'; - const token = await service.createTokenForUser( - user.userName, - identifier, - 0, - ); - expect(token.label).toEqual(identifier); - expect(token.validUntil).toBeUndefined(); - expect(token.lastUsed).toBeUndefined(); - expect(token.secret.startsWith(token.keyId)).toBeTruthy(); + describe('setLastUsedToken', () => { + it('works', async () => { + await service.setLastUsedToken(authToken.keyId); + }); }); - it('BufferToBase64Url', () => { - expect( - service.BufferToBase64Url(Buffer.from('testsentence is a test sentence')), - ).toEqual('dGVzdHNlbnRlbmNlIGlzIGEgdGVzdCBzZW50ZW5jZQ'); + describe('validateToken', () => { + it('works', async () => { + const token = 'testToken'; + authToken.accessTokenHash = await service.hashPassword(token); + const userByToken = await service.validateToken( + `${authToken.keyId}.${token}`, + ); + expect(userByToken).toEqual({ + ...user, + authTokens: [authToken], + }); + }); }); - it('toAuthTokenDto', async () => { - const tokenDto = await service.toAuthTokenDto(authToken); - expect(tokenDto.keyId).toEqual(authToken.keyId); - expect(tokenDto.lastUsed).toBeNull(); - expect(tokenDto.label).toEqual(authToken.identifier); - expect(tokenDto.validUntil).toBeNull(); - expect(tokenDto.created).toEqual(authToken.createdAt.getTime()); + describe('removeToken', () => { + it('works', async () => { + await service.removeToken(user.userName, authToken.keyId); + }); + }); + + describe('createTokenForUser', () => { + it('works', async () => { + const identifier = 'identifier2'; + const token = await service.createTokenForUser( + user.userName, + identifier, + 0, + ); + expect(token.label).toEqual(identifier); + expect(token.validUntil).toBeNull(); + expect(token.lastUsed).toBeNull(); + expect(token.secret.startsWith(token.keyId)).toBeTruthy(); + }); + }); + + describe('BufferToBase64Url', () => { + it('works', () => { + expect( + service.BufferToBase64Url( + Buffer.from('testsentence is a test sentence'), + ), + ).toEqual('dGVzdHNlbnRlbmNlIGlzIGEgdGVzdCBzZW50ZW5jZQ'); + }); + }); + + describe('toAuthTokenDto', () => { + it('works', async () => { + const tokenDto = await service.toAuthTokenDto(authToken); + expect(tokenDto.keyId).toEqual(authToken.keyId); + expect(tokenDto.lastUsed).toBeNull(); + expect(tokenDto.label).toEqual(authToken.identifier); + expect(tokenDto.validUntil).toBeNull(); + expect(tokenDto.createdAt.getTime()).toEqual( + authToken.createdAt.getTime(), + ); + }); }); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 636d7a776..10a427ced 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -16,6 +16,7 @@ import { randomBytes } from 'crypto'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConsoleLoggerService } from '../logger/console-logger.service'; +import { TimestampMillis } from '../utils/timestamp'; @Injectable() export class AuthService { @@ -29,13 +30,13 @@ export class AuthService { } async validateToken(token: string): Promise { - const parts = token.split('.'); - const accessToken = await this.getAuthTokenAndValidate(parts[0], parts[1]); + const [keyId, secret] = token.split('.'); + const accessToken = await this.getAuthTokenAndValidate(keyId, secret); const user = await this.usersService.getUserByUsername( accessToken.user.userName, ); if (user) { - await this.setLastUsedToken(parts[0]); + await this.setLastUsedToken(keyId); return user; } return null; @@ -43,6 +44,7 @@ export class AuthService { async hashPassword(cleartext: string): Promise { // hash the password with bcrypt and 2^12 iterations + // this was decided on the basis of https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#bcrypt return hash(cleartext, 12); } @@ -71,7 +73,7 @@ export class AuthService { async createTokenForUser( userName: string, identifier: string, - until: number, + validUntil: TimestampMillis, ): Promise { const user = await this.usersService.getUserByUsername(userName); const secret = await this.randomString(64); @@ -79,10 +81,16 @@ export class AuthService { const accessTokenString = await this.hashPassword(secret.toString()); const accessToken = this.BufferToBase64Url(Buffer.from(accessTokenString)); let token; - if (until === 0) { + if (validUntil === 0) { token = AuthToken.create(user, identifier, keyId, accessToken); } else { - token = AuthToken.create(user, identifier, keyId, accessToken, until); + token = AuthToken.create( + user, + identifier, + keyId, + accessToken, + new Date(validUntil), + ); } const createdToken = await this.authTokenRepository.save(token); return this.toAuthTokenWithSecretDto(createdToken, `${keyId}.${secret}`); @@ -92,7 +100,7 @@ export class AuthService { const accessToken = await this.authTokenRepository.findOne({ where: { keyId: keyId }, }); - accessToken.lastUsed = new Date().getTime(); + accessToken.lastUsed = new Date(); await this.authTokenRepository.save(accessToken); } @@ -113,7 +121,7 @@ export class AuthService { } if ( accessToken.validUntil && - accessToken.validUntil < new Date().getTime() + accessToken.validUntil.getTime() < new Date().getTime() ) { // tokens validUntil Date lies in the past throw new TokenNotValidError( @@ -141,18 +149,28 @@ export class AuthService { await this.authTokenRepository.remove(token); } - toAuthTokenDto(authToken: AuthToken | null | undefined): AuthTokenDto | null { + toAuthTokenDto(authToken: AuthToken): AuthTokenDto | null { if (!authToken) { this.logger.warn(`Recieved ${authToken} argument!`, 'toAuthTokenDto'); return null; } - return { + const tokenDto: AuthTokenDto = { + lastUsed: null, + validUntil: null, label: authToken.identifier, keyId: authToken.keyId, - created: authToken.createdAt.getTime(), - validUntil: authToken.validUntil, - lastUsed: authToken.lastUsed, + createdAt: authToken.createdAt, }; + + if (authToken.validUntil) { + tokenDto.validUntil = new Date(authToken.validUntil); + } + + if (authToken.lastUsed) { + tokenDto.lastUsed = new Date(authToken.lastUsed); + } + + return tokenDto; } toAuthTokenWithSecretDto( diff --git a/src/utils/timestamp.ts b/src/utils/timestamp.ts new file mode 100644 index 000000000..4e427aab4 --- /dev/null +++ b/src/utils/timestamp.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type TimestampMillis = number; From cc2fcac5322c1db1dfaac550628ec2138bd342de Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sat, 23 Jan 2021 22:24:59 +0100 Subject: [PATCH 18/27] auth: Remove userName parameter of removeToken function As suggested by @innaytool Signed-off-by: Philip Molares --- src/api/private/tokens/tokens.controller.ts | 3 +-- src/auth/auth.service.spec.ts | 2 +- src/auth/auth.service.ts | 5 ++--- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/api/private/tokens/tokens.controller.ts b/src/api/private/tokens/tokens.controller.ts index 780e11be2..af2aa792f 100644 --- a/src/api/private/tokens/tokens.controller.ts +++ b/src/api/private/tokens/tokens.controller.ts @@ -48,7 +48,6 @@ export class TokensController { @Delete('/:keyId') @HttpCode(204) async deleteToken(@Param('keyId') keyId: string) { - // ToDo: Get real userName - return this.authService.removeToken('hardcoded', keyId); + return this.authService.removeToken(keyId); } } diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index e5375e15d..ae57414a3 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -152,7 +152,7 @@ describe('AuthService', () => { describe('removeToken', () => { it('works', async () => { - await service.removeToken(user.userName, authToken.keyId); + await service.removeToken(authToken.keyId); }); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 10a427ced..e37535104 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -141,10 +141,9 @@ export class AuthService { return user.authTokens; } - async removeToken(userName: string, keyId: string) { - const user = await this.usersService.getUserByUsername(userName); + async removeToken(keyId: string) { const token = await this.authTokenRepository.findOne({ - where: { keyId: keyId, user: user }, + where: { keyId: keyId }, }); await this.authTokenRepository.remove(token); } From 0a3247492a4189c8a18e3ad2e669cb70d159a748 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sun, 24 Jan 2021 20:37:04 +0100 Subject: [PATCH 19/27] auth: Add cron to clean old tokens Rename AuthToken.identifier to label Signed-off-by: Philip Molares --- docs/content/dev/db-schema.plantuml | 2 +- package.json | 4 +++- src/app.module.ts | 2 ++ src/auth/auth-token.entity.ts | 11 +++-------- src/auth/auth.service.spec.ts | 4 ++-- src/auth/auth.service.ts | 21 ++++++++++++++++----- 6 files changed, 27 insertions(+), 17 deletions(-) diff --git a/docs/content/dev/db-schema.plantuml b/docs/content/dev/db-schema.plantuml index 84108edb7..ca8d0a3c6 100644 --- a/docs/content/dev/db-schema.plantuml +++ b/docs/content/dev/db-schema.plantuml @@ -30,7 +30,7 @@ entity "auth_token"{ *userId : uuid *keyId: text *accessToken : text - *identifier: text + *label: text *createdAt: date lastUsed: number validUntil: number diff --git a/package.json b/package.json index e7feac7cf..0a90ce822 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,11 @@ "@nestjs/passport": "^7.1.5", "@nestjs/platform-express": "7.6.5", "@nestjs/swagger": "4.7.12", + "@nestjs/schedule": "^0.4.2", "@nestjs/typeorm": "7.1.5", - "@types/passport-http-bearer": "^1.0.36", "@types/bcrypt": "^3.0.0", + "@types/cron": "^1.7.2", + "@types/passport-http-bearer": "^1.0.36", "bcrypt": "^5.0.0", "class-transformer": "0.3.2", "class-validator": "0.13.1", diff --git a/src/app.module.ts b/src/app.module.ts index 0fc3313db..2f09ba425 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -26,6 +26,7 @@ import cspConfig from './config/csp.config'; import databaseConfig from './config/database.config'; import authConfig from './config/auth.config'; import { PrivateApiModule } from './api/private/private-api.module'; +import { ScheduleModule } from '@nestjs/schedule'; @Module({ imports: [ @@ -46,6 +47,7 @@ import { PrivateApiModule } from './api/private/private-api.module'; ], isGlobal: true, }), + ScheduleModule.forRoot(), NotesModule, UsersModule, RevisionsModule, diff --git a/src/auth/auth-token.entity.ts b/src/auth/auth-token.entity.ts index 9d216b40c..59fe5c82f 100644 --- a/src/auth/auth-token.entity.ts +++ b/src/auth/auth-token.entity.ts @@ -25,7 +25,7 @@ export class AuthToken { user: User; @Column() - identifier: string; + label: string; @CreateDateColumn() createdAt: Date; @@ -51,16 +51,11 @@ export class AuthToken { validUntil?: Date, ): Pick< AuthToken, - | 'user' - | 'identifier' - | 'keyId' - | 'accessTokenHash' - | 'createdAt' - | 'validUntil' + 'user' | 'label' | 'keyId' | 'accessTokenHash' | 'createdAt' | 'validUntil' > { const newToken = new AuthToken(); newToken.user = user; - newToken.identifier = identifier; + newToken.label = identifier; newToken.keyId = keyId; newToken.accessTokenHash = accessToken; newToken.createdAt = new Date(); diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index ae57414a3..5b4428b52 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -35,7 +35,7 @@ describe('AuthService', () => { accessTokenHash: '', createdAt: new Date(), id: 1, - identifier: 'testIdentifier', + label: 'testIdentifier', keyId: 'abc', lastUsed: null, user: null, @@ -186,7 +186,7 @@ describe('AuthService', () => { const tokenDto = await service.toAuthTokenDto(authToken); expect(tokenDto.keyId).toEqual(authToken.keyId); expect(tokenDto.lastUsed).toBeNull(); - expect(tokenDto.label).toEqual(authToken.identifier); + expect(tokenDto.label).toEqual(authToken.label); expect(tokenDto.validUntil).toBeNull(); expect(tokenDto.createdAt.getTime()).toEqual( authToken.createdAt.getTime(), diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index e37535104..df529ec1e 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -17,6 +17,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { TimestampMillis } from '../utils/timestamp'; +import { Cron } from '@nestjs/schedule'; @Injectable() export class AuthService { @@ -32,11 +33,11 @@ export class AuthService { async validateToken(token: string): Promise { const [keyId, secret] = token.split('.'); const accessToken = await this.getAuthTokenAndValidate(keyId, secret); + await this.setLastUsedToken(keyId); const user = await this.usersService.getUserByUsername( accessToken.user.userName, ); if (user) { - await this.setLastUsedToken(keyId); return user; } return null; @@ -125,9 +126,7 @@ export class AuthService { ) { // tokens validUntil Date lies in the past throw new TokenNotValidError( - `AuthToken '${token}' is not valid since ${new Date( - accessToken.validUntil, - )}.`, + `AuthToken '${token}' is not valid since ${accessToken.validUntil}.`, ); } return accessToken; @@ -156,7 +155,7 @@ export class AuthService { const tokenDto: AuthTokenDto = { lastUsed: null, validUntil: null, - label: authToken.identifier, + label: authToken.label, keyId: authToken.keyId, createdAt: authToken.createdAt, }; @@ -182,4 +181,16 @@ export class AuthService { secret: secret, }; } + + // Delete all non valid tokens every sunday on 3:00 AM + @Cron('0 0 3 * * 0') + async handleCron() { + const currentTime = new Date().getTime(); + const tokens: AuthToken[] = await this.authTokenRepository.find(); + for (const token of tokens) { + if (token.validUntil && token.validUntil.getTime() <= currentTime) { + await this.authTokenRepository.remove(token); + } + } + } } From ee6293f5a32c78c493c05a891fc109cfd642546f Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Fri, 15 Jan 2021 18:53:09 +0100 Subject: [PATCH 20/27] auth: adds token-auth to public api adds auth service adds auth module adds token-auth strategy adds token-auth to all public api calls Signed-off-by: Philip Molares --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0a90ce822..c14340401 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,9 @@ "@nestjs/swagger": "4.7.12", "@nestjs/schedule": "^0.4.2", "@nestjs/typeorm": "7.1.5", - "@types/bcrypt": "^3.0.0", "@types/cron": "^1.7.2", "@types/passport-http-bearer": "^1.0.36", + "@types/bcrypt": "^3.0.0", "bcrypt": "^5.0.0", "class-transformer": "0.3.2", "class-validator": "0.13.1", From 28abc37e2cebf78bcb0b8b6840383e898bd83ccc Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Sat, 16 Jan 2021 19:33:09 +0100 Subject: [PATCH 21/27] auth: fixes unit and e2e tests adds MockAuthGuard which always return user 'hardcoded' Signed-off-by: Philip Molares --- src/auth/auth.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 5b4428b52..515868d6f 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -8,7 +8,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from './auth.service'; import { PassportModule } from '@nestjs/passport'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { AuthToken } from './auth-token.entity'; +import { AuthToken } from '../users/auth-token.entity'; import { User } from '../users/user.entity'; import { UsersModule } from '../users/users.module'; import { Identity } from '../users/identity.entity'; From c96edb31a5e5e4173e590a34e5126cdd90d0b861 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Thu, 21 Jan 2021 19:37:43 +0100 Subject: [PATCH 22/27] tokens: Add token creation Fix token deletion Update plantuml docs Add token validUntil and lastUsed fields Signed-off-by: Philip Molares --- src/auth/auth-token.entity.ts | 7 ++----- src/users/auth-token.dto.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 src/users/auth-token.dto.ts diff --git a/src/auth/auth-token.entity.ts b/src/auth/auth-token.entity.ts index 59fe5c82f..299b7df30 100644 --- a/src/auth/auth-token.entity.ts +++ b/src/auth/auth-token.entity.ts @@ -48,11 +48,8 @@ export class AuthToken { identifier: string, keyId: string, accessToken: string, - validUntil?: Date, - ): Pick< - AuthToken, - 'user' | 'label' | 'keyId' | 'accessTokenHash' | 'createdAt' | 'validUntil' - > { + validUntil: Date, + ): Pick { const newToken = new AuthToken(); newToken.user = user; newToken.label = identifier; diff --git a/src/users/auth-token.dto.ts b/src/users/auth-token.dto.ts new file mode 100644 index 000000000..c4fc8ffba --- /dev/null +++ b/src/users/auth-token.dto.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IsNumber, IsString } from 'class-validator'; + +export class AuthTokenDto { + @IsString() + label: string; + @IsNumber() + created: number; + @IsNumber() + validUntil: number | null; + @IsNumber() + lastUsed: number | null; +} From c2d759da538972a047244cc00494255e8f052d39 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 25 Jan 2021 12:05:25 +0100 Subject: [PATCH 23/27] auth: Add token limit of 200 This is a very high ceiling unlikely to hinder legitimate usage, but should prevent possible attack vectors Signed-off-by: Philip Molares --- src/auth/auth.service.ts | 15 +++++++++++++-- src/errors/errors.ts | 4 ++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index df529ec1e..d921cb4c2 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -11,7 +11,11 @@ import { AuthToken } from './auth-token.entity'; import { AuthTokenDto } from './auth-token.dto'; import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto'; import { compare, hash } from 'bcrypt'; -import { NotInDBError, TokenNotValidError } from '../errors/errors'; +import { + NotInDBError, + TokenNotValidError, + TooManyTokensError, +} from '../errors/errors'; import { randomBytes } from 'crypto'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -76,7 +80,14 @@ export class AuthService { identifier: string, validUntil: TimestampMillis, ): Promise { - const user = await this.usersService.getUserByUsername(userName); + const user = await this.usersService.getUserByUsername(userName, true); + if (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.displayName}' has already 200 tokens and can't have anymore`, + ); + } const secret = await this.randomString(64); const keyId = this.BufferToBase64Url(await this.randomString(8)); const accessTokenString = await this.hashPassword(secret.toString()); diff --git a/src/errors/errors.ts b/src/errors/errors.ts index 1b4821bf7..084a84bb4 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -19,3 +19,7 @@ export class PermissionError extends Error { export class TokenNotValidError extends Error { name = 'TokenNotValidError'; } + +export class TooManyTokensError extends Error { + name = 'TooManyTokensError'; +} From 99d6b39e00ccea475eb8429323e9d6ae08135e24 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 25 Jan 2021 18:16:08 +0100 Subject: [PATCH 24/27] auth: Run removeInvalidTokens 5s after startup This should prevent problem with the AuthToken purge on Sundays, as the service is either running on sunday or will be restarted there after. Also move base64url comment to right function Signed-off-by: Philip Molares --- src/auth/auth.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d921cb4c2..5fa99ecca 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -85,7 +85,7 @@ export class AuthService { // This is a very high ceiling unlikely to hinder legitimate usage, // but should prevent possible attack vectors throw new TooManyTokensError( - `User '${user.displayName}' has already 200 tokens and can't have anymore`, + `User '${user.userName}' has already 200 tokens and can't have anymore`, ); } const secret = await this.randomString(64); From 67a5f3c7eccf21e951f75d8513962602024568a9 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 25 Jan 2021 12:14:26 +0100 Subject: [PATCH 25/27] auth: Add maximum token lifetime of 2 years. Signed-off-by: Philip Molares --- src/api/private/tokens/tokens.controller.ts | 4 ++-- src/auth/auth-token.entity.ts | 9 ++++++--- src/auth/auth.service.spec.ts | 7 +++++-- src/auth/auth.service.ts | 13 +++++++++++-- src/users/auth-token.dto.ts | 18 ------------------ 5 files changed, 24 insertions(+), 27 deletions(-) delete mode 100644 src/users/auth-token.dto.ts diff --git a/src/api/private/tokens/tokens.controller.ts b/src/api/private/tokens/tokens.controller.ts index af2aa792f..e5aaf8e30 100644 --- a/src/api/private/tokens/tokens.controller.ts +++ b/src/api/private/tokens/tokens.controller.ts @@ -14,10 +14,10 @@ import { Post, } from '@nestjs/common'; import { ConsoleLoggerService } from '../../../logger/console-logger.service'; -import { AuthTokenDto } from '../../../auth/auth-token.dto'; -import { AuthTokenWithSecretDto } from '../../../auth/auth-token-with-secret.dto'; import { AuthService } from '../../../auth/auth.service'; import { TimestampMillis } from '../../../utils/timestamp'; +import { AuthTokenDto } from '../../../auth/auth-token.dto'; +import { AuthTokenWithSecretDto } from '../../../auth/auth-token-with-secret.dto'; @Controller('tokens') export class TokensController { diff --git a/src/auth/auth-token.entity.ts b/src/auth/auth-token.entity.ts index 299b7df30..22f5a965f 100644 --- a/src/auth/auth-token.entity.ts +++ b/src/auth/auth-token.entity.ts @@ -45,14 +45,17 @@ export class AuthToken { public static create( user: User, - identifier: string, + label: string, keyId: string, accessToken: string, validUntil: Date, - ): Pick { + ): Pick< + AuthToken, + 'user' | 'label' | 'keyId' | 'accessTokenHash' | 'createdAt' | 'validUntil' + > { const newToken = new AuthToken(); newToken.user = user; - newToken.label = identifier; + newToken.label = label; newToken.keyId = keyId; newToken.accessTokenHash = accessToken; newToken.createdAt = new Date(); diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 515868d6f..271973dd5 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -8,11 +8,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from './auth.service'; import { PassportModule } from '@nestjs/passport'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { AuthToken } from '../users/auth-token.entity'; import { User } from '../users/user.entity'; import { UsersModule } from '../users/users.module'; import { Identity } from '../users/identity.entity'; import { LoggerModule } from '../logger/logger.module'; +import { AuthToken } from './auth-token.entity'; describe('AuthService', () => { let service: AuthService; @@ -165,7 +165,10 @@ describe('AuthService', () => { 0, ); expect(token.label).toEqual(identifier); - expect(token.validUntil).toBeNull(); + expect( + token.validUntil.getTime() - + (new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000), + ).toBeLessThanOrEqual(10000); expect(token.lastUsed).toBeNull(); expect(token.secret.startsWith(token.keyId)).toBeTruthy(); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 5fa99ecca..fc2dcfcfc 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -93,8 +93,17 @@ export class AuthService { const accessTokenString = await this.hashPassword(secret.toString()); const accessToken = this.BufferToBase64Url(Buffer.from(accessTokenString)); let token; - if (validUntil === 0) { - token = AuthToken.create(user, identifier, keyId, accessToken); + // Tokens can only be valid for a maximum of 2 years + const maximumTokenValidity = + new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000; + if (validUntil === 0 || validUntil > maximumTokenValidity) { + token = AuthToken.create( + user, + identifier, + keyId, + accessToken, + new Date(maximumTokenValidity), + ); } else { token = AuthToken.create( user, diff --git a/src/users/auth-token.dto.ts b/src/users/auth-token.dto.ts deleted file mode 100644 index c4fc8ffba..000000000 --- a/src/users/auth-token.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { IsNumber, IsString } from 'class-validator'; - -export class AuthTokenDto { - @IsString() - label: string; - @IsNumber() - created: number; - @IsNumber() - validUntil: number | null; - @IsNumber() - lastUsed: number | null; -} From bfe14dad8dd610ccb72e59576254db27ddf7fba2 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 25 Jan 2021 16:29:09 +0100 Subject: [PATCH 26/27] auth: Run removeInvalidTokens 5s after startup This should prevent problem with the AuthToken purge on Sundays, as the service is either running on sunday or will be restarted there after. Also move base64url comment to right function Signed-off-by: Philip Molares --- src/auth/auth.service.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index fc2dcfcfc..3d5751e89 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -21,7 +21,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { TimestampMillis } from '../utils/timestamp'; -import { Cron } from '@nestjs/schedule'; +import { Cron, Timeout } from '@nestjs/schedule'; @Injectable() export class AuthService { @@ -58,9 +58,6 @@ export class AuthService { } async randomString(length: number): Promise { - // This is necessary as the is no base64url encoding in the toString method - // but as can be seen on https://tools.ietf.org/html/rfc4648#page-7 - // base64url is quite easy buildable from base64 if (length <= 0) { return null; } @@ -68,6 +65,9 @@ export class AuthService { } BufferToBase64Url(text: Buffer): string { + // This is necessary as the is no base64url encoding in the toString method + // but as can be seen on https://tools.ietf.org/html/rfc4648#page-7 + // base64url is quite easy buildable from base64 return text .toString('base64') .replace('+', '-') @@ -205,12 +205,28 @@ export class AuthService { // Delete all non valid tokens every sunday on 3:00 AM @Cron('0 0 3 * * 0') async handleCron() { + return this.removeInvalidTokens(); + } + + // Delete all non valid tokens 5 sec after startup + @Timeout(5000) + async handleTimeout() { + return this.removeInvalidTokens(); + } + + async removeInvalidTokens() { 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`); await this.authTokenRepository.remove(token); + removedTokens++; } } + this.logger.log( + `${removedTokens} invalid AuthTokens were purged from the DB.`, + ); } } From 2c38bd55a8993883c753e0b5557164195995c38b Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Mon, 25 Jan 2021 21:32:17 +0100 Subject: [PATCH 27/27] regenerated yarn.lock Signed-off-by: Philip Molares --- yarn.lock | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/yarn.lock b/yarn.lock index 992d8fbab..896f76312 100644 --- a/yarn.lock +++ b/yarn.lock @@ -614,6 +614,11 @@ resolved "https://registry.yarnpkg.com/@nestjs/mapped-types/-/mapped-types-0.3.0.tgz#1dcf178c198e948c548ca803850e2eba639900d4" integrity sha512-AdWVTOg3AhAEcVhPGgUJiLbLXb7L5Pe7vc20YQ0oOXP/KD/nJj0I3BcytVdBhzmgepol67BdivNUvo27Hx3Ndw== +"@nestjs/passport@^7.1.5": + version "7.1.5" + resolved "https://registry.yarnpkg.com/@nestjs/passport/-/passport-7.1.5.tgz#b32fc0492008d73ebae4327fbc0012a738a83664" + integrity sha512-Hu9hPxTdBZA0C4GrWTsSflzwsJ99oAk9jqAwpcszdFNqfjMjkPGuCM9QsVZbBP2bE8fxrVrPsNOILS6puY8e/A== + "@nestjs/platform-express@7.6.5": version "7.6.5" resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-7.6.5.tgz#7ee3df2c104aadac766af8b310bb4d04f4039d4a" @@ -625,6 +630,14 @@ multer "1.4.2" tslib "2.0.3" +"@nestjs/schedule@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@nestjs/schedule/-/schedule-0.4.2.tgz#7503545586b3a2ba82e46241e9fdbe2c3e33cf9f" + integrity sha512-TLfGTe7YT6FofE6MJmmf0i73OvB0k9EWGulbz3gRnNVtMiyvnY8RaRwwDXlO5873p5wfDFWz+7PDOzdI+lLN7w== + dependencies: + cron "1.7.2" + uuid "8.3.2" + "@nestjs/schematics@7.2.7", "@nestjs/schematics@^7.1.0": version "7.2.7" resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-7.2.7.tgz#22cd9d687afbbce068a7d20df02806c6f85832a8" @@ -738,6 +751,13 @@ resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.1.1.tgz#f0d92c12f87079ddfd1b29f614758b9696bc29e3" integrity sha512-XO6INPbZCxdprl+9qa/AAbFFOMzzwqYxpjPgLICrMD6C2FCw6qfJOPcBk6JqqPLSaZ/Qx87qn4rpPmPMwaAK6w== +"@types/accepts@*": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575" + integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ== + dependencies: + "@types/node" "*" + "@types/anymatch@*": version "1.3.1" resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" @@ -796,11 +816,34 @@ dependencies: "@types/node" "*" +"@types/content-disposition@*": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.3.tgz#0aa116701955c2faa0717fc69cd1596095e49d96" + integrity sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg== + "@types/cookiejar@*": version "2.1.2" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.2.tgz#66ad9331f63fe8a3d3d9d8c6e3906dd10f6446e8" integrity sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog== +"@types/cookies@*": + version "0.7.6" + resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.6.tgz#71212c5391a976d3bae57d4b09fac20fc6bda504" + integrity sha512-FK4U5Qyn7/Sc5ih233OuHO0qAkOpEcD/eG6584yEiLKizTFRny86qHLe/rej3HFQrkBuUjF4whFliAdODbVN/w== + dependencies: + "@types/connect" "*" + "@types/express" "*" + "@types/keygrip" "*" + "@types/node" "*" + +"@types/cron@^1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@types/cron/-/cron-1.7.2.tgz#e9fb420da616920dae82d13adfca53282ffaab6e" + integrity sha512-AEpNLRcsVSc5AdseJKNHpz0d4e8+ow+abTaC0fKDbAU86rF1evoFF0oC2fV9FdqtfVXkG2LKshpLTJCFOpyvTg== + dependencies: + "@types/node" "*" + moment ">=2.14.0" + "@types/debug@0.0.31": version "0.0.31" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-0.0.31.tgz#bac8d8aab6a823e91deb7f79083b2a35fa638f33" @@ -870,6 +913,16 @@ dependencies: "@types/node" "*" +"@types/http-assert@*": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b" + integrity sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ== + +"@types/http-errors@*": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.0.tgz#682477dbbbd07cd032731cb3b0e7eaee3d026b69" + integrity sha512-2aoSC4UUbHDj2uCsCxcG/vRMXey/m17bC7UwitVm5hn22nI8O8Y9iDpA76Orc+DWkQ4zZrOKEshCqR/jSuXAHA== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -907,6 +960,32 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/keygrip@*": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72" + integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw== + +"@types/koa-compose@*": + version "3.2.5" + resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d" + integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ== + dependencies: + "@types/koa" "*" + +"@types/koa@*": + version "2.11.6" + resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.11.6.tgz#b7030caa6b44af801c2aea13ba77d74aff7484d5" + integrity sha512-BhyrMj06eQkk04C97fovEDQMpLpd2IxCB4ecitaXwOKGq78Wi2tooaDOWOFGajPk8IkQOAtMppApgSVkYe1F/A== + dependencies: + "@types/accepts" "*" + "@types/content-disposition" "*" + "@types/cookies" "*" + "@types/http-assert" "*" + "@types/http-errors" "*" + "@types/keygrip" "*" + "@types/koa-compose" "*" + "@types/node" "*" + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -932,6 +1011,22 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/passport-http-bearer@^1.0.36": + version "1.0.36" + resolved "https://registry.yarnpkg.com/@types/passport-http-bearer/-/passport-http-bearer-1.0.36.tgz#c48e3040441de10b140bf8e5cec1a73df9c07172" + integrity sha512-D6yFiojv/JSxuQY2FcT/dzFHw+ypVOkKN4QzTdt6xZyrmMQBI7p1wr5F3+clCNUgxRgoNaBVRuzlwu5NSV530w== + dependencies: + "@types/express" "*" + "@types/koa" "*" + "@types/passport" "*" + +"@types/passport@*": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.5.tgz#1ff54ec3e30fa6480c5e8b8de949c6dc40ddfa2a" + integrity sha512-wNL4kT/5rnZgyGkqX7V2qH/R/te+bklv+nXcvHzyX99vNggx9DGN+F8CEOW3P/gRi7Cjm991uidRgTHsYkSuMg== + dependencies: + "@types/express" "*" + "@types/prettier@^2.0.0": version "2.1.6" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.1.6.tgz#f4b1efa784e8db479cdb8b14403e2144b1e9ff03" @@ -2207,6 +2302,13 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cron@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/cron/-/cron-1.7.2.tgz#2ea1f35c138a07edac2ac5af5084ed6fee5723db" + integrity sha512-+SaJ2OfeRvfQqwXQ2kgr0Y5pzBR/lijf5OpnnaruwWnmI799JfWr2jN2ItOV9s3A/+TFOt6mxvKzQq5F0Jp6VQ== + dependencies: + moment-timezone "^0.5.x" + cross-spawn@^6.0.0: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -4758,6 +4860,18 @@ mkdirp@1.x, mkdirp@^1.0.4: dependencies: minimist "^1.2.5" +moment-timezone@^0.5.x: + version "0.5.32" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.32.tgz#db7677cc3cc680fd30303ebd90b0da1ca0dfecc2" + integrity sha512-Z8QNyuQHQAmWucp8Knmgei8YNo28aLjJq6Ma+jy1ZSpSk5nyfRT8xgUbSQvD2+2UajISfenndwvFuH3NGS+nvA== + dependencies: + moment ">= 2.9.0" + +"moment@>= 2.9.0", moment@>=2.14.0: + version "2.29.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -5341,6 +5455,26 @@ pascalcase@^0.1.1: resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= +passport-http-bearer@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/passport-http-bearer/-/passport-http-bearer-1.0.1.tgz#147469ea3669e2a84c6167ef99dbb77e1f0098a8" + integrity sha1-FHRp6jZp4qhMYWfvmdu3fh8AmKg= + dependencies: + passport-strategy "1.x.x" + +passport-strategy@1.x.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" + integrity sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ= + +passport@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/passport/-/passport-0.4.1.tgz#941446a21cb92fc688d97a0861c38ce9f738f270" + integrity sha512-IxXgZZs8d7uFSt3eqNjM9NQ3g3uQCW5avD8mRNoXV99Yig50vjuaez6dQK2qC0kVWPRTujxY0dWgGfT09adjYg== + dependencies: + passport-strategy "1.x.x" + pause "0.0.1" + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" @@ -5393,6 +5527,11 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pause@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" + integrity sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10= + peek-readable@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-3.1.3.tgz#932480d46cf6aa553c46c68566c4fb69a82cd2b1"