mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-25 11:16:31 -05:00
chore: move password related functions from AuthService to utils file
As these methods will be used in both the AuthService and the IdentityService, it makes sense to extract them and use them in this manner. Especially if one considers that they are quite standalone functions. Signed-off-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
parent
cf8f3b39ec
commit
547f2239cc
4 changed files with 109 additions and 63 deletions
|
@ -7,16 +7,16 @@ import { ConfigModule } from '@nestjs/config';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import appConfigMock from '../config/mock/app.config.mock';
|
import appConfigMock from '../config/mock/app.config.mock';
|
||||||
import { NotInDBError, TokenNotValidError } from '../errors/errors';
|
import { NotInDBError, TokenNotValidError } from '../errors/errors';
|
||||||
|
import { Identity } from '../identity/identity.entity';
|
||||||
import { LoggerModule } from '../logger/logger.module';
|
import { LoggerModule } from '../logger/logger.module';
|
||||||
import { Identity } from '../users/identity.entity';
|
|
||||||
import { Session } from '../users/session.entity';
|
import { Session } from '../users/session.entity';
|
||||||
import { User } from '../users/user.entity';
|
import { User } from '../users/user.entity';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { hashPassword } from '../utils/password';
|
||||||
import { AuthToken } from './auth-token.entity';
|
import { AuthToken } from './auth-token.entity';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@ -74,26 +74,6 @@ describe('AuthService', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('checkPassword', () => {
|
|
||||||
it('works', async () => {
|
|
||||||
const testPassword = 'thisIsATestPassword';
|
|
||||||
const hash = await service.hashPassword(testPassword);
|
|
||||||
await service
|
|
||||||
.checkPassword(testPassword, hash)
|
|
||||||
.then((result) => expect(result).toBeTruthy());
|
|
||||||
});
|
|
||||||
it('fails, if secret is too short', async () => {
|
|
||||||
const secret = service.bufferToBase64Url(randomBytes(54));
|
|
||||||
const hash = await service.hashPassword(secret);
|
|
||||||
await service
|
|
||||||
.checkPassword(secret, hash)
|
|
||||||
.then((result) => expect(result).toBeTruthy());
|
|
||||||
await service
|
|
||||||
.checkPassword(secret.substr(0, secret.length - 1), hash)
|
|
||||||
.then((result) => expect(result).toBeFalsy());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getTokensByUsername', () => {
|
describe('getTokensByUsername', () => {
|
||||||
it('works', async () => {
|
it('works', async () => {
|
||||||
jest
|
jest
|
||||||
|
@ -108,7 +88,7 @@ describe('AuthService', () => {
|
||||||
describe('getAuthToken', () => {
|
describe('getAuthToken', () => {
|
||||||
const token = 'testToken';
|
const token = 'testToken';
|
||||||
it('works', async () => {
|
it('works', async () => {
|
||||||
const accessTokenHash = await service.hashPassword(token);
|
const accessTokenHash = await hashPassword(token);
|
||||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
|
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
|
||||||
...authToken,
|
...authToken,
|
||||||
user: user,
|
user: user,
|
||||||
|
@ -142,7 +122,7 @@ describe('AuthService', () => {
|
||||||
).rejects.toThrow(TokenNotValidError);
|
).rejects.toThrow(TokenNotValidError);
|
||||||
});
|
});
|
||||||
it('AuthToken has wrong validUntil Date', async () => {
|
it('AuthToken has wrong validUntil Date', async () => {
|
||||||
const accessTokenHash = await service.hashPassword(token);
|
const accessTokenHash = await hashPassword(token);
|
||||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
|
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
|
||||||
...authToken,
|
...authToken,
|
||||||
user: user,
|
user: user,
|
||||||
|
@ -185,7 +165,7 @@ describe('AuthService', () => {
|
||||||
describe('validateToken', () => {
|
describe('validateToken', () => {
|
||||||
it('works', async () => {
|
it('works', async () => {
|
||||||
const token = 'testToken';
|
const token = 'testToken';
|
||||||
const accessTokenHash = await service.hashPassword(token);
|
const accessTokenHash = await hashPassword(token);
|
||||||
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce({
|
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce({
|
||||||
...user,
|
...user,
|
||||||
authTokens: [authToken],
|
authTokens: [authToken],
|
||||||
|
@ -303,16 +283,6 @@ describe('AuthService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('bufferToBase64Url', () => {
|
|
||||||
it('works', () => {
|
|
||||||
expect(
|
|
||||||
service.bufferToBase64Url(
|
|
||||||
Buffer.from('testsentence is a test sentence'),
|
|
||||||
),
|
|
||||||
).toEqual('dGVzdHNlbnRlbmNlIGlzIGEgdGVzdCBzZW50ZW5jZQ');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toAuthTokenDto', () => {
|
describe('toAuthTokenDto', () => {
|
||||||
it('works', () => {
|
it('works', () => {
|
||||||
const authToken = new AuthToken();
|
const authToken = new AuthToken();
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Cron, Timeout } from '@nestjs/schedule';
|
import { Cron, Timeout } from '@nestjs/schedule';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { compare, hash } from 'bcrypt';
|
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
@ -16,8 +15,13 @@ import {
|
||||||
TooManyTokensError,
|
TooManyTokensError,
|
||||||
} from '../errors/errors';
|
} from '../errors/errors';
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
|
import { UserRelationEnum } from '../users/user-relation.enum';
|
||||||
import { User } from '../users/user.entity';
|
import { User } from '../users/user.entity';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
import {
|
||||||
|
bufferToBase64Url,
|
||||||
|
hashPassword,
|
||||||
|
} from '../utils/password';
|
||||||
import { TimestampMillis } from '../utils/timestamp';
|
import { TimestampMillis } from '../utils/timestamp';
|
||||||
import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto';
|
import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto';
|
||||||
import { AuthTokenDto } from './auth-token.dto';
|
import { AuthTokenDto } from './auth-token.dto';
|
||||||
|
@ -52,33 +56,14 @@ export class AuthService {
|
||||||
return await this.usersService.getUserByUsername(accessToken.user.userName);
|
return await this.usersService.getUserByUsername(accessToken.user.userName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async hashPassword(cleartext: string): Promise<string> {
|
|
||||||
// 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 await hash(cleartext, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkPassword(cleartext: string, password: string): Promise<boolean> {
|
|
||||||
return await compare(cleartext, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
bufferToBase64Url(text: Buffer): 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 text
|
|
||||||
.toString('base64')
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async createTokenForUser(
|
async createTokenForUser(
|
||||||
userName: string,
|
userName: string,
|
||||||
identifier: string,
|
identifier: string,
|
||||||
validUntil: TimestampMillis,
|
validUntil: TimestampMillis,
|
||||||
): Promise<AuthTokenWithSecretDto> {
|
): Promise<AuthTokenWithSecretDto> {
|
||||||
const user = await this.usersService.getUserByUsername(userName, true);
|
const user = await this.usersService.getUserByUsername(userName, [
|
||||||
|
UserRelationEnum.AUTHTOKENS,
|
||||||
|
]);
|
||||||
if (user.authTokens.length >= 200) {
|
if (user.authTokens.length >= 200) {
|
||||||
// This is a very high ceiling unlikely to hinder legitimate usage,
|
// This is a very high ceiling unlikely to hinder legitimate usage,
|
||||||
// but should prevent possible attack vectors
|
// but should prevent possible attack vectors
|
||||||
|
@ -86,9 +71,9 @@ export class AuthService {
|
||||||
`User '${user.userName}' has already 200 tokens and can't have anymore`,
|
`User '${user.userName}' has already 200 tokens and can't have anymore`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const secret = this.bufferToBase64Url(randomBytes(54));
|
const secret = bufferToBase64Url(randomBytes(54));
|
||||||
const keyId = this.bufferToBase64Url(randomBytes(8));
|
const keyId = bufferToBase64Url(randomBytes(8));
|
||||||
const accessToken = await this.hashPassword(secret);
|
const accessToken = await hashPassword(secret);
|
||||||
let token;
|
let token;
|
||||||
// Tokens can only be valid for a maximum of 2 years
|
// Tokens can only be valid for a maximum of 2 years
|
||||||
const maximumTokenValidity =
|
const maximumTokenValidity =
|
||||||
|
@ -138,7 +123,7 @@ export class AuthService {
|
||||||
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.accessTokenHash))) {
|
if (!(await checkPassword(token, accessToken.accessTokenHash))) {
|
||||||
// hashes are not the same
|
// hashes are not the same
|
||||||
throw new TokenNotValidError(`AuthToken '${token}' is not valid.`);
|
throw new TokenNotValidError(`AuthToken '${token}' is not valid.`);
|
||||||
}
|
}
|
||||||
|
@ -155,7 +140,9 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTokensByUsername(userName: string): Promise<AuthToken[]> {
|
async getTokensByUsername(userName: string): Promise<AuthToken[]> {
|
||||||
const user = await this.usersService.getUserByUsername(userName, true);
|
const user = await this.usersService.getUserByUsername(userName, [
|
||||||
|
UserRelationEnum.AUTHTOKENS,
|
||||||
|
]);
|
||||||
if (user.authTokens === undefined) {
|
if (user.authTokens === undefined) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
59
src/utils/password.spec.ts
Normal file
59
src/utils/password.spec.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
import { bufferToBase64Url, checkPassword, hashPassword } from './password';
|
||||||
|
|
||||||
|
const testPassword = 'thisIsATestPassword';
|
||||||
|
|
||||||
|
describe('hashPassword', () => {
|
||||||
|
it('output looks like a bcrypt hash with 2^12 rounds of hashing', async () => {
|
||||||
|
/*
|
||||||
|
* a bcrypt hash example with the different parts highlighted:
|
||||||
|
* $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
|
||||||
|
* \__/\/ \____________________/\_____________________________/
|
||||||
|
* Alg Cost Salt Hash
|
||||||
|
* from https://en.wikipedia.org/wiki/Bcrypt#Description
|
||||||
|
*/
|
||||||
|
const regexBcrypt = /^\$2[abxy]\$12\$[A-Za-z0-9/.]{53}$/;
|
||||||
|
const hash = await hashPassword(testPassword);
|
||||||
|
expect(regexBcrypt.test(hash)).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('calls bcrypt.hash with the correct parameters', async () => {
|
||||||
|
const spy = jest.spyOn(bcrypt, 'hash');
|
||||||
|
await hashPassword(testPassword);
|
||||||
|
expect(spy).toHaveBeenCalledWith(testPassword, 12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkPassword', () => {
|
||||||
|
it("is returning true if the inputs are a plaintext password and it's bcrypt-hashed version", async () => {
|
||||||
|
const hashOfTestPassword =
|
||||||
|
'$2a$12$WHKCq4c0rg19zyx5WgX0p.or0rjSKYpIBcHhQQGLrxrr6FfMPylIW';
|
||||||
|
await checkPassword(testPassword, hashOfTestPassword).then((result) =>
|
||||||
|
expect(result).toBeTruthy(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('fails, if secret is too short', async () => {
|
||||||
|
const secret = bufferToBase64Url(randomBytes(54));
|
||||||
|
const hash = await hashPassword(secret);
|
||||||
|
await checkPassword(secret, hash).then((result) =>
|
||||||
|
expect(result).toBeTruthy(),
|
||||||
|
);
|
||||||
|
await checkPassword(secret.substr(0, secret.length - 1), hash).then(
|
||||||
|
(result) => expect(result).toBeFalsy(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bufferToBase64Url', () => {
|
||||||
|
it('transforms a buffer to the correct base64url encoded string', () => {
|
||||||
|
expect(
|
||||||
|
bufferToBase64Url(Buffer.from('testsentence is a test sentence')),
|
||||||
|
).toEqual('dGVzdHNlbnRlbmNlIGlzIGEgdGVzdCBzZW50ZW5jZQ');
|
||||||
|
});
|
||||||
|
});
|
30
src/utils/password.ts
Normal file
30
src/utils/password.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import { compare, hash } from 'bcrypt';
|
||||||
|
|
||||||
|
export async function hashPassword(cleartext: string): Promise<string> {
|
||||||
|
// 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 await hash(cleartext, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkPassword(
|
||||||
|
cleartext: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return await compare(cleartext, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bufferToBase64Url(text: Buffer): 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 text
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
}
|
Loading…
Reference in a new issue