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, }; }