tokens: Add token creation

Fix token deletion
Update plantuml docs
Add token validUntil and lastUsed fields

Signed-off-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
Philip Molares 2021-01-21 19:37:43 +01:00 committed by David Mehren
parent cce1626c48
commit c9751404f7
No known key found for this signature in database
GPG key ID: 185982BA4C42B7C3
9 changed files with 113 additions and 35 deletions

View file

@ -28,9 +28,12 @@ entity "auth_token"{
*id : number <<generated>> *id : number <<generated>>
-- --
*userId : uuid *userId : uuid
*keyId: text
*accessToken : text *accessToken : text
*identifier: text *identifier: text
*createdAt: date *createdAt: date
lastUsed: number
validUntil: number
} }
entity "identity" { entity "identity" {

View file

@ -7,9 +7,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { UsersModule } from '../../users/users.module'; import { UsersModule } from '../../users/users.module';
import { TokensController } from './tokens/tokens.controller'; import { TokensController } from './tokens/tokens.controller';
import { LoggerModule } from '../../logger/logger.module';
@Module({ @Module({
imports: [UsersModule], imports: [UsersModule, LoggerModule],
controllers: [TokensController], controllers: [TokensController],
}) })
export class PrivateApiModule {} export class PrivateApiModule {}

View file

@ -30,9 +30,9 @@ export class TokensController {
@Get() @Get()
async getUserTokens(): Promise<AuthTokenDto[]> { async getUserTokens(): Promise<AuthTokenDto[]> {
// ToDo: Get real userName // ToDo: Get real userName
return (await this.usersService.getTokensByUsername('molly')).map((token) => return (
this.usersService.toAuthTokenDto(token), await this.usersService.getTokensByUsername('hardcoded')
); ).map((token) => this.usersService.toAuthTokenDto(token));
} }
@Post() @Post()
@ -49,10 +49,10 @@ export class TokensController {
return this.usersService.toAuthTokenWithSecretDto(authToken); return this.usersService.toAuthTokenWithSecretDto(authToken);
} }
@Delete('/:timestamp') @Delete('/:keyId')
@HttpCode(204) @HttpCode(204)
async deleteToken(@Param('timestamp') timestamp: number) { async deleteToken(@Param('keyId') keyId: string) {
// ToDo: Get real userName // ToDo: Get real userName
return this.usersService.removeToken('hardcoded', timestamp); return this.usersService.removeToken('hardcoded', keyId);
} }
} }

View file

@ -25,6 +25,7 @@ import hstsConfig from './config/hsts.config';
import cspConfig from './config/csp.config'; import cspConfig from './config/csp.config';
import databaseConfig from './config/database.config'; import databaseConfig from './config/database.config';
import authConfig from './config/auth.config'; import authConfig from './config/auth.config';
import { PrivateApiModule } from './api/private/private-api.module';
@Module({ @Module({
imports: [ imports: [
@ -50,6 +51,7 @@ import authConfig from './config/auth.config';
RevisionsModule, RevisionsModule,
AuthorsModule, AuthorsModule,
PublicApiModule, PublicApiModule,
PrivateApiModule,
HistoryModule, HistoryModule,
MonitoringModule, MonitoringModule,
PermissionsModule, PermissionsModule,

View file

@ -7,8 +7,10 @@ export class AuthService {
constructor(private usersService: UsersService) {} constructor(private usersService: UsersService) {}
async validateToken(token: string): Promise<User> { async validateToken(token: string): Promise<User> {
const user = await this.usersService.getUserByAuthToken(token); const parts = token.split('.');
const user = await this.usersService.getUserByAuthToken(parts[0], parts[1]);
if (user) { if (user) {
await this.usersService.setLastUsedToken(parts[0])
return user; return user;
} }
return null; return null;

View file

@ -15,3 +15,7 @@ export class ClientError extends Error {
export class PermissionError extends Error { export class PermissionError extends Error {
name = 'PermissionError'; name = 'PermissionError';
} }
export class TokenNotValid extends Error {
name = 'TokenNotValid';
}

View file

@ -11,4 +11,8 @@ export class AuthTokenDto {
label: string; label: string;
@IsNumber() @IsNumber()
created: number; created: number;
@IsNumber()
validUntil: number | null;
@IsNumber()
lastUsed: number | null;
} }

View file

@ -4,7 +4,13 @@
* SPDX-License-Identifier: AGPL-3.0-only * 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'; import { User } from './user.entity';
@Entity() @Entity()
@ -12,6 +18,9 @@ export class AuthToken {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@Column({ unique: true })
keyId: string;
@ManyToOne((_) => User, (user) => user.authTokens) @ManyToOne((_) => User, (user) => user.authTokens)
user: User; user: User;
@ -24,21 +33,32 @@ export class AuthToken {
@Column({ unique: true }) @Column({ unique: true })
accessToken: string; accessToken: string;
@Column({ type: 'date' }) @Column({
validUntil: Date; nullable: true,
})
validUntil: number;
@Column({
nullable: true,
})
lastUsed: number;
public static create( public static create(
user: User, user: User,
identifier: string, identifier: string,
keyId: string,
accessToken: string, accessToken: string,
validUntil: Date, validUntil?: number,
): Pick<AuthToken, 'user' | 'accessToken'> { ): Pick<AuthToken, 'user' | 'accessToken'> {
const newToken = new AuthToken(); const newToken = new AuthToken();
newToken.user = user; newToken.user = user;
newToken.identifier = identifier; newToken.identifier = identifier;
newToken.keyId = keyId;
newToken.accessToken = accessToken; newToken.accessToken = accessToken;
newToken.createdAt = new Date(); newToken.createdAt = new Date();
if (validUntil !== undefined) {
newToken.validUntil = validUntil; newToken.validUntil = validUntil;
}
return newToken; return newToken;
} }
} }

View file

@ -7,13 +7,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { NotInDBError } from '../errors/errors'; import { NotInDBError, TokenNotValid } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service'; import { ConsoleLoggerService } from '../logger/console-logger.service';
import { UserInfoDto } from './user-info.dto'; import { UserInfoDto } from './user-info.dto';
import { User } from './user.entity'; import { User } from './user.entity';
import { AuthToken } from './auth-token.entity'; import { AuthToken } from './auth-token.entity';
import { hash, compare } from 'bcrypt' import { hash, compare } from 'bcrypt';
import crypt from 'crypto'; import { randomBytes } from 'crypto';
import { AuthTokenDto } from './auth-token.dto'; import { AuthTokenDto } from './auth-token.dto';
import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto'; import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto';
@ -33,19 +33,36 @@ export class UsersService {
return this.userRepository.save(user); 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( async createTokenForUser(
userName: string, userName: string,
identifier: string, identifier: string,
until: number, until: number,
): Promise<AuthToken> { ): Promise<AuthToken> {
const user = await this.getUserByUsername(userName); const user = await this.getUserByUsername(userName);
const randomString = crypt.randomBytes(64).toString('base64url'); const secret = this.randomBase64UrlString();
const accessToken = await this.hashPassword(randomString); const keyId = this.randomBase64UrlString();
const token = AuthToken.create(user, identifier, accessToken, new Date(until)); 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); const createdToken = await this.authTokenRepository.save(token);
return { return {
accessToken: randomString,
...createdToken, ...createdToken,
accessToken: `${keyId}.${secret}`,
}; };
} }
@ -57,9 +74,10 @@ export class UsersService {
await this.userRepository.delete(user); await this.userRepository.delete(user);
} }
async getUserByUsername(userName: string): Promise<User> { async getUserByUsername(userName: string, withTokens = false): Promise<User> {
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { userName: userName }, where: { userName: userName },
relations: withTokens ? ['authTokens'] : null,
}); });
if (user === undefined) { if (user === undefined) {
throw new NotInDBError(`User with username '${userName}' not found`); throw new NotInDBError(`User with username '${userName}' not found`);
@ -69,22 +87,45 @@ export class UsersService {
async hashPassword(cleartext: string): Promise<string> { async hashPassword(cleartext: string): Promise<string> {
// hash the password with bcrypt and 2^16 iterations // hash the password with bcrypt and 2^16 iterations
return hash(cleartext, 16) return hash(cleartext, 16);
} }
async checkPassword(cleartext: string, password: string): Promise<boolean> { async checkPassword(cleartext: string, password: string): Promise<boolean> {
// hash the password with bcrypt and 2^16 iterations // hash the password with bcrypt and 2^16 iterations
return compare(cleartext, password) return compare(cleartext, password);
} }
async getUserByAuthToken(token: string): Promise<User> { async setLastUsedToken(keyId: string) {
const hash = this.hashPassword(token);
const accessToken = await this.authTokenRepository.findOne({ 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<User> {
const accessToken = await this.authTokenRepository.findOne({
where: { keyId: keyId },
relations: ['user'],
}); });
if (accessToken === undefined) { if (accessToken === undefined) {
throw new NotInDBError(`AuthToken '${token}' not found`); 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); return this.getUserByUsername(accessToken.user.userName);
} }
@ -98,14 +139,17 @@ export class UsersService {
} }
async getTokensByUsername(userName: string): Promise<AuthToken[]> { async getTokensByUsername(userName: string): Promise<AuthToken[]> {
const user = await this.getUserByUsername(userName); const user = await this.getUserByUsername(userName, true);
if (user.authTokens === undefined) {
return [];
}
return user.authTokens; return user.authTokens;
} }
async removeToken(userName: string, timestamp: number) { async removeToken(userName: string, keyId: string) {
const user = await this.getUserByUsername(userName); const user = await this.getUserByUsername(userName);
const token = await this.authTokenRepository.findOne({ const token = await this.authTokenRepository.findOne({
where: { createdAt: new Date(timestamp), user: user }, where: { keyId: keyId, user: user },
}); });
await this.authTokenRepository.remove(token); await this.authTokenRepository.remove(token);
} }
@ -118,19 +162,17 @@ export class UsersService {
return { return {
label: authToken.identifier, label: authToken.identifier,
created: authToken.createdAt.getTime(), created: authToken.createdAt.getTime(),
validUntil: authToken.validUntil,
lastUsed: authToken.lastUsed,
}; };
} }
toAuthTokenWithSecretDto( toAuthTokenWithSecretDto(
authToken: AuthToken | null | undefined, authToken: AuthToken | null | undefined,
): AuthTokenWithSecretDto | null { ): AuthTokenWithSecretDto | null {
if (!authToken) { const tokeDto = this.toAuthTokenDto(authToken)
this.logger.warn(`Recieved ${authToken} argument!`, 'toAuthTokenDto');
return null;
}
return { return {
label: authToken.identifier, ...tokeDto,
created: authToken.createdAt.getTime(),
secret: authToken.accessToken, secret: authToken.accessToken,
}; };
} }