mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-13 06:34:39 -04:00
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:
parent
cce1626c48
commit
c9751404f7
9 changed files with 113 additions and 35 deletions
|
@ -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" {
|
||||||
|
|
|
@ -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 {}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue