mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-23 10:16:32 -05:00
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 <philip.molares@udo.edu>
This commit is contained in:
parent
265195e305
commit
f68caab6e8
7 changed files with 149 additions and 93 deletions
|
@ -17,6 +17,7 @@ import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||||
import { AuthTokenDto } from '../../../auth/auth-token.dto';
|
import { AuthTokenDto } from '../../../auth/auth-token.dto';
|
||||||
import { AuthTokenWithSecretDto } from '../../../auth/auth-token-with-secret.dto';
|
import { AuthTokenWithSecretDto } from '../../../auth/auth-token-with-secret.dto';
|
||||||
import { AuthService } from '../../../auth/auth.service';
|
import { AuthService } from '../../../auth/auth.service';
|
||||||
|
import { TimestampMillis } from '../../../utils/timestamp';
|
||||||
|
|
||||||
@Controller('tokens')
|
@Controller('tokens')
|
||||||
export class TokensController {
|
export class TokensController {
|
||||||
|
@ -38,10 +39,10 @@ export class TokensController {
|
||||||
@Post()
|
@Post()
|
||||||
async postTokenRequest(
|
async postTokenRequest(
|
||||||
@Body('label') label: string,
|
@Body('label') label: string,
|
||||||
@Body('until') until: number,
|
@Body('validUntil') validUntil: TimestampMillis,
|
||||||
): Promise<AuthTokenWithSecretDto> {
|
): Promise<AuthTokenWithSecretDto> {
|
||||||
// ToDo: Get real userName
|
// ToDo: Get real userName
|
||||||
return this.authService.createTokenForUser('hardcoded', label, until);
|
return this.authService.createTokenForUser('hardcoded', label, validUntil);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('/:keyId')
|
@Delete('/:keyId')
|
||||||
|
|
|
@ -48,7 +48,6 @@ export class MediaController {
|
||||||
@UploadedFile() file: MulterFile,
|
@UploadedFile() file: MulterFile,
|
||||||
@Headers('HedgeDoc-Note') noteId: string,
|
@Headers('HedgeDoc-Note') noteId: string,
|
||||||
) {
|
) {
|
||||||
//TODO: Get user from request
|
|
||||||
const username = req.user.userName;
|
const username = req.user.userName;
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Recieved filename '${file.originalname}' for note '${noteId}' from user '${username}'`,
|
`Recieved filename '${file.originalname}' for note '${noteId}' from user '${username}'`,
|
||||||
|
@ -74,7 +73,6 @@ export class MediaController {
|
||||||
@UseGuards(TokenAuthGuard)
|
@UseGuards(TokenAuthGuard)
|
||||||
@Delete(':filename')
|
@Delete(':filename')
|
||||||
async deleteMedia(@Request() req, @Param('filename') filename: string) {
|
async deleteMedia(@Request() req, @Param('filename') filename: string) {
|
||||||
//TODO: Get user from request
|
|
||||||
const username = req.user.userName;
|
const username = req.user.userName;
|
||||||
try {
|
try {
|
||||||
await this.mediaService.deleteFile(filename, username);
|
await this.mediaService.deleteFile(filename, username);
|
||||||
|
|
|
@ -4,17 +4,19 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IsNumber, IsString } from 'class-validator';
|
import { IsDate, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class AuthTokenDto {
|
export class AuthTokenDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
label: string;
|
label: string;
|
||||||
@IsString()
|
@IsString()
|
||||||
keyId: string;
|
keyId: string;
|
||||||
@IsNumber()
|
@IsDate()
|
||||||
created: number;
|
createdAt: Date;
|
||||||
@IsNumber()
|
@IsDate()
|
||||||
validUntil: number | null;
|
@IsOptional()
|
||||||
@IsNumber()
|
validUntil: Date;
|
||||||
lastUsed: number | null;
|
@IsDate()
|
||||||
|
@IsOptional()
|
||||||
|
lastUsed: Date;
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,29 +36,35 @@ export class AuthToken {
|
||||||
@Column({
|
@Column({
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
validUntil: number;
|
validUntil: Date;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
lastUsed: number;
|
lastUsed: Date;
|
||||||
|
|
||||||
public static create(
|
public static create(
|
||||||
user: User,
|
user: User,
|
||||||
identifier: string,
|
identifier: string,
|
||||||
keyId: string,
|
keyId: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
validUntil?: number,
|
validUntil?: Date,
|
||||||
): Pick<AuthToken, 'user' | 'accessTokenHash'> {
|
): Pick<
|
||||||
|
AuthToken,
|
||||||
|
| 'user'
|
||||||
|
| 'identifier'
|
||||||
|
| 'keyId'
|
||||||
|
| 'accessTokenHash'
|
||||||
|
| 'createdAt'
|
||||||
|
| 'validUntil'
|
||||||
|
> {
|
||||||
const newToken = new AuthToken();
|
const newToken = new AuthToken();
|
||||||
newToken.user = user;
|
newToken.user = user;
|
||||||
newToken.identifier = identifier;
|
newToken.identifier = identifier;
|
||||||
newToken.keyId = keyId;
|
newToken.keyId = keyId;
|
||||||
newToken.accessTokenHash = accessToken;
|
newToken.accessTokenHash = accessToken;
|
||||||
newToken.createdAt = new Date();
|
newToken.createdAt = new Date();
|
||||||
if (validUntil !== undefined) {
|
newToken.validUntil = validUntil;
|
||||||
newToken.validUntil = validUntil;
|
|
||||||
}
|
|
||||||
return newToken;
|
return newToken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,7 +64,9 @@ describe('AuthService', () => {
|
||||||
if (entity.lastUsed === undefined) {
|
if (entity.lastUsed === undefined) {
|
||||||
expect(entity.lastUsed).toBeUndefined();
|
expect(entity.lastUsed).toBeUndefined();
|
||||||
} else {
|
} else {
|
||||||
expect(entity.lastUsed).toBeLessThanOrEqual(new Date().getTime());
|
expect(entity.lastUsed.getTime()).toBeLessThanOrEqual(
|
||||||
|
new Date().getTime(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return entity;
|
return entity;
|
||||||
},
|
},
|
||||||
|
@ -95,78 +97,100 @@ describe('AuthService', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('checkPassword', async () => {
|
describe('checkPassword', () => {
|
||||||
const testPassword = 'thisIsATestPassword';
|
it('works', async () => {
|
||||||
const hash = await service.hashPassword(testPassword);
|
const testPassword = 'thisIsATestPassword';
|
||||||
service
|
const hash = await service.hashPassword(testPassword);
|
||||||
.checkPassword(testPassword, hash)
|
service
|
||||||
.then((result) => expect(result).toBeTruthy());
|
.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,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setLastUsedToken', async () => {
|
describe('getTokensByUsername', () => {
|
||||||
await service.setLastUsedToken(authToken.keyId);
|
it('works', async () => {
|
||||||
});
|
const tokens = await service.getTokensByUsername(user.userName);
|
||||||
|
expect(tokens).toHaveLength(1);
|
||||||
it('validateToken', async () => {
|
expect(tokens).toEqual([authToken]);
|
||||||
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 () => {
|
describe('getAuthToken', () => {
|
||||||
await service.removeToken(user.userName, authToken.keyId);
|
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 () => {
|
describe('setLastUsedToken', () => {
|
||||||
const identifier = 'identifier2';
|
it('works', async () => {
|
||||||
const token = await service.createTokenForUser(
|
await service.setLastUsedToken(authToken.keyId);
|
||||||
user.userName,
|
});
|
||||||
identifier,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
expect(token.label).toEqual(identifier);
|
|
||||||
expect(token.validUntil).toBeUndefined();
|
|
||||||
expect(token.lastUsed).toBeUndefined();
|
|
||||||
expect(token.secret.startsWith(token.keyId)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('BufferToBase64Url', () => {
|
describe('validateToken', () => {
|
||||||
expect(
|
it('works', async () => {
|
||||||
service.BufferToBase64Url(Buffer.from('testsentence is a test sentence')),
|
const token = 'testToken';
|
||||||
).toEqual('dGVzdHNlbnRlbmNlIGlzIGEgdGVzdCBzZW50ZW5jZQ');
|
authToken.accessTokenHash = await service.hashPassword(token);
|
||||||
|
const userByToken = await service.validateToken(
|
||||||
|
`${authToken.keyId}.${token}`,
|
||||||
|
);
|
||||||
|
expect(userByToken).toEqual({
|
||||||
|
...user,
|
||||||
|
authTokens: [authToken],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toAuthTokenDto', async () => {
|
describe('removeToken', () => {
|
||||||
const tokenDto = await service.toAuthTokenDto(authToken);
|
it('works', async () => {
|
||||||
expect(tokenDto.keyId).toEqual(authToken.keyId);
|
await service.removeToken(user.userName, authToken.keyId);
|
||||||
expect(tokenDto.lastUsed).toBeNull();
|
});
|
||||||
expect(tokenDto.label).toEqual(authToken.identifier);
|
});
|
||||||
expect(tokenDto.validUntil).toBeNull();
|
|
||||||
expect(tokenDto.created).toEqual(authToken.createdAt.getTime());
|
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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { randomBytes } from 'crypto';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
|
import { TimestampMillis } from '../utils/timestamp';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
@ -29,13 +30,13 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateToken(token: string): Promise<User> {
|
async validateToken(token: string): Promise<User> {
|
||||||
const parts = token.split('.');
|
const [keyId, secret] = token.split('.');
|
||||||
const accessToken = await this.getAuthTokenAndValidate(parts[0], parts[1]);
|
const accessToken = await this.getAuthTokenAndValidate(keyId, secret);
|
||||||
const user = await this.usersService.getUserByUsername(
|
const user = await this.usersService.getUserByUsername(
|
||||||
accessToken.user.userName,
|
accessToken.user.userName,
|
||||||
);
|
);
|
||||||
if (user) {
|
if (user) {
|
||||||
await this.setLastUsedToken(parts[0]);
|
await this.setLastUsedToken(keyId);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
@ -43,6 +44,7 @@ export class AuthService {
|
||||||
|
|
||||||
async hashPassword(cleartext: string): Promise<string> {
|
async hashPassword(cleartext: string): Promise<string> {
|
||||||
// hash the password with bcrypt and 2^12 iterations
|
// 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);
|
return hash(cleartext, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,7 +73,7 @@ export class AuthService {
|
||||||
async createTokenForUser(
|
async createTokenForUser(
|
||||||
userName: string,
|
userName: string,
|
||||||
identifier: string,
|
identifier: string,
|
||||||
until: number,
|
validUntil: TimestampMillis,
|
||||||
): Promise<AuthTokenWithSecretDto> {
|
): Promise<AuthTokenWithSecretDto> {
|
||||||
const user = await this.usersService.getUserByUsername(userName);
|
const user = await this.usersService.getUserByUsername(userName);
|
||||||
const secret = await this.randomString(64);
|
const secret = await this.randomString(64);
|
||||||
|
@ -79,10 +81,16 @@ export class AuthService {
|
||||||
const accessTokenString = await this.hashPassword(secret.toString());
|
const accessTokenString = await this.hashPassword(secret.toString());
|
||||||
const accessToken = this.BufferToBase64Url(Buffer.from(accessTokenString));
|
const accessToken = this.BufferToBase64Url(Buffer.from(accessTokenString));
|
||||||
let token;
|
let token;
|
||||||
if (until === 0) {
|
if (validUntil === 0) {
|
||||||
token = AuthToken.create(user, identifier, keyId, accessToken);
|
token = AuthToken.create(user, identifier, keyId, accessToken);
|
||||||
} else {
|
} 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);
|
const createdToken = await this.authTokenRepository.save(token);
|
||||||
return this.toAuthTokenWithSecretDto(createdToken, `${keyId}.${secret}`);
|
return this.toAuthTokenWithSecretDto(createdToken, `${keyId}.${secret}`);
|
||||||
|
@ -92,7 +100,7 @@ export class AuthService {
|
||||||
const accessToken = await this.authTokenRepository.findOne({
|
const accessToken = await this.authTokenRepository.findOne({
|
||||||
where: { keyId: keyId },
|
where: { keyId: keyId },
|
||||||
});
|
});
|
||||||
accessToken.lastUsed = new Date().getTime();
|
accessToken.lastUsed = new Date();
|
||||||
await this.authTokenRepository.save(accessToken);
|
await this.authTokenRepository.save(accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,7 +121,7 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
accessToken.validUntil &&
|
accessToken.validUntil &&
|
||||||
accessToken.validUntil < new Date().getTime()
|
accessToken.validUntil.getTime() < new Date().getTime()
|
||||||
) {
|
) {
|
||||||
// tokens validUntil Date lies in the past
|
// tokens validUntil Date lies in the past
|
||||||
throw new TokenNotValidError(
|
throw new TokenNotValidError(
|
||||||
|
@ -141,18 +149,28 @@ export class AuthService {
|
||||||
await this.authTokenRepository.remove(token);
|
await this.authTokenRepository.remove(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
toAuthTokenDto(authToken: AuthToken | null | undefined): AuthTokenDto | null {
|
toAuthTokenDto(authToken: AuthToken): AuthTokenDto | null {
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
this.logger.warn(`Recieved ${authToken} argument!`, 'toAuthTokenDto');
|
this.logger.warn(`Recieved ${authToken} argument!`, 'toAuthTokenDto');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
const tokenDto: AuthTokenDto = {
|
||||||
|
lastUsed: null,
|
||||||
|
validUntil: null,
|
||||||
label: authToken.identifier,
|
label: authToken.identifier,
|
||||||
keyId: authToken.keyId,
|
keyId: authToken.keyId,
|
||||||
created: authToken.createdAt.getTime(),
|
createdAt: authToken.createdAt,
|
||||||
validUntil: authToken.validUntil,
|
|
||||||
lastUsed: authToken.lastUsed,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (authToken.validUntil) {
|
||||||
|
tokenDto.validUntil = new Date(authToken.validUntil);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authToken.lastUsed) {
|
||||||
|
tokenDto.lastUsed = new Date(authToken.lastUsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
toAuthTokenWithSecretDto(
|
toAuthTokenWithSecretDto(
|
||||||
|
|
7
src/utils/timestamp.ts
Normal file
7
src/utils/timestamp.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TimestampMillis = number;
|
Loading…
Reference in a new issue