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');