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:
Philip Molares 2021-01-23 21:24:11 +01:00
parent 265195e305
commit f68caab6e8
7 changed files with 149 additions and 93 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),
);
});
}); });
}); });

View file

@ -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
View 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;