mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-20 10:15:17 -04:00
Split methods getAuthTokenAndValidate
and createTokenForUser
Signed-off-by: Yannick Bungers <git@innay.de>
This commit is contained in:
parent
c835339633
commit
499f632d8d
5 changed files with 102 additions and 92 deletions
|
@ -53,7 +53,7 @@ export class TokensController {
|
||||||
@Body() createDto: AuthTokenCreateDto,
|
@Body() createDto: AuthTokenCreateDto,
|
||||||
@RequestUser() user: User,
|
@RequestUser() user: User,
|
||||||
): Promise<AuthTokenWithSecretDto> {
|
): Promise<AuthTokenWithSecretDto> {
|
||||||
return await this.authService.createTokenForUser(
|
return await this.authService.addToken(
|
||||||
user,
|
user,
|
||||||
createDto.label,
|
createDto.label,
|
||||||
createDto.validUntil,
|
createDto.validUntil,
|
||||||
|
|
|
@ -48,16 +48,16 @@ export class AuthToken {
|
||||||
keyId: string,
|
keyId: string,
|
||||||
user: User,
|
user: User,
|
||||||
label: string,
|
label: string,
|
||||||
accessToken: string,
|
tokenString: string,
|
||||||
validUntil: Date,
|
validUntil: Date,
|
||||||
): Omit<AuthToken, 'id' | 'createdAt'> {
|
): Omit<AuthToken, 'id' | 'createdAt'> {
|
||||||
const newToken = new AuthToken();
|
const token = new AuthToken();
|
||||||
newToken.keyId = keyId;
|
token.keyId = keyId;
|
||||||
newToken.user = Promise.resolve(user);
|
token.user = Promise.resolve(user);
|
||||||
newToken.label = label;
|
token.label = label;
|
||||||
newToken.accessTokenHash = accessToken;
|
token.accessTokenHash = tokenString;
|
||||||
newToken.validUntil = validUntil;
|
token.validUntil = validUntil;
|
||||||
newToken.lastUsedAt = null;
|
token.lastUsedAt = null;
|
||||||
return newToken;
|
return token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,6 @@ import { LoggerModule } from '../logger/logger.module';
|
||||||
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';
|
||||||
|
|
||||||
|
@ -96,10 +95,7 @@ describe('AuthService', () => {
|
||||||
user: Promise.resolve(user),
|
user: Promise.resolve(user),
|
||||||
accessTokenHash: accessTokenHash,
|
accessTokenHash: accessTokenHash,
|
||||||
});
|
});
|
||||||
const authTokenFromCall = await service.getAuthTokenAndValidate(
|
const authTokenFromCall = await service.getAuthToken(authToken.keyId);
|
||||||
authToken.keyId,
|
|
||||||
token,
|
|
||||||
);
|
|
||||||
expect(authTokenFromCall).toEqual({
|
expect(authTokenFromCall).toEqual({
|
||||||
...authToken,
|
...authToken,
|
||||||
user: Promise.resolve(user),
|
user: Promise.resolve(user),
|
||||||
|
@ -109,34 +105,41 @@ describe('AuthService', () => {
|
||||||
describe('fails:', () => {
|
describe('fails:', () => {
|
||||||
it('AuthToken could not be found', async () => {
|
it('AuthToken could not be found', async () => {
|
||||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce(null);
|
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce(null);
|
||||||
await expect(
|
await expect(service.getAuthToken(authToken.keyId)).rejects.toThrow(
|
||||||
service.getAuthTokenAndValidate(authToken.keyId, token),
|
NotInDBError,
|
||||||
).rejects.toThrow(NotInDBError);
|
);
|
||||||
});
|
|
||||||
it('AuthToken has wrong hash', async () => {
|
|
||||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
|
|
||||||
...authToken,
|
|
||||||
user: Promise.resolve(user),
|
|
||||||
accessTokenHash: 'the wrong hash',
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
service.getAuthTokenAndValidate(authToken.keyId, token),
|
|
||||||
).rejects.toThrow(TokenNotValidError);
|
|
||||||
});
|
|
||||||
it('AuthToken has wrong validUntil Date', async () => {
|
|
||||||
const accessTokenHash = await hashPassword(token);
|
|
||||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
|
|
||||||
...authToken,
|
|
||||||
user: Promise.resolve(user),
|
|
||||||
accessTokenHash: accessTokenHash,
|
|
||||||
validUntil: new Date(1549312452000),
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
service.getAuthTokenAndValidate(authToken.keyId, token),
|
|
||||||
).rejects.toThrow(TokenNotValidError);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('checkToken', () => {
|
||||||
|
it('works', () => {
|
||||||
|
const [accessToken, secret] = service.createToken(
|
||||||
|
user,
|
||||||
|
'TestToken',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
service.checkToken(secret, accessToken as AuthToken),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
it('AuthToken has wrong hash', () => {
|
||||||
|
const [accessToken] = service.createToken(user, 'TestToken', undefined);
|
||||||
|
expect(() =>
|
||||||
|
service.checkToken('secret', accessToken as AuthToken),
|
||||||
|
).toThrow(TokenNotValidError);
|
||||||
|
});
|
||||||
|
it('AuthToken has wrong validUntil Date', () => {
|
||||||
|
const [accessToken, secret] = service.createToken(
|
||||||
|
user,
|
||||||
|
'Test',
|
||||||
|
1549312452000,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
service.checkToken(secret, accessToken as AuthToken),
|
||||||
|
).toThrow(TokenNotValidError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('setLastUsedToken', () => {
|
describe('setLastUsedToken', () => {
|
||||||
it('works', async () => {
|
it('works', async () => {
|
||||||
|
@ -233,7 +236,7 @@ describe('AuthService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createTokenForUser', () => {
|
describe('addToken', () => {
|
||||||
describe('works', () => {
|
describe('works', () => {
|
||||||
const identifier = 'testIdentifier';
|
const identifier = 'testIdentifier';
|
||||||
it('with validUntil 0', async () => {
|
it('with validUntil 0', async () => {
|
||||||
|
@ -246,7 +249,7 @@ describe('AuthService', () => {
|
||||||
return authTokenSaved;
|
return authTokenSaved;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const token = await service.createTokenForUser(user, identifier, 0);
|
const token = await service.addToken(user, identifier, 0);
|
||||||
expect(token.label).toEqual(identifier);
|
expect(token.label).toEqual(identifier);
|
||||||
expect(
|
expect(
|
||||||
token.validUntil.getTime() -
|
token.validUntil.getTime() -
|
||||||
|
@ -266,11 +269,7 @@ describe('AuthService', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const validUntil = new Date().getTime() + 30000;
|
const validUntil = new Date().getTime() + 30000;
|
||||||
const token = await service.createTokenForUser(
|
const token = await service.addToken(user, identifier, validUntil);
|
||||||
user,
|
|
||||||
identifier,
|
|
||||||
validUntil,
|
|
||||||
);
|
|
||||||
expect(token.label).toEqual(identifier);
|
expect(token.label).toEqual(identifier);
|
||||||
expect(token.validUntil.getTime()).toEqual(validUntil);
|
expect(token.validUntil.getTime()).toEqual(validUntil);
|
||||||
expect(token.lastUsedAt).toBeNull();
|
expect(token.lastUsedAt).toBeNull();
|
||||||
|
|
|
@ -33,39 +33,29 @@ export class AuthService {
|
||||||
this.logger.setContext(AuthService.name);
|
this.logger.setContext(AuthService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateToken(token: string): Promise<User> {
|
async validateToken(tokenString: string): Promise<User> {
|
||||||
const [keyId, secret] = token.split('.');
|
const [keyId, secret] = tokenString.split('.');
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new TokenNotValidError('Invalid AuthToken format');
|
throw new TokenNotValidError('Invalid AuthToken format');
|
||||||
}
|
}
|
||||||
if (secret.length != 86) {
|
if (secret.length != 86) {
|
||||||
// We always expect 86 characters, as the secret is generated with 64 bytes
|
// We always expect 86 characters, as the secret is generated with 64 bytes
|
||||||
// and then converted to a base64url string
|
// and then converted to a base64url string
|
||||||
throw new TokenNotValidError(`AuthToken '${token}' has incorrect length`);
|
throw new TokenNotValidError(
|
||||||
|
`AuthToken '${tokenString}' has incorrect length`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const accessToken = await this.getAuthTokenAndValidate(keyId, secret);
|
const token = await this.getAuthToken(keyId);
|
||||||
|
this.checkToken(secret, token);
|
||||||
await this.setLastUsedToken(keyId);
|
await this.setLastUsedToken(keyId);
|
||||||
return await this.usersService.getUserByUsername(
|
return await token.user;
|
||||||
(
|
|
||||||
await accessToken.user
|
|
||||||
).username,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTokenForUser(
|
createToken(
|
||||||
user: User,
|
user: User,
|
||||||
identifier: string,
|
identifier: string,
|
||||||
validUntil: TimestampMillis | undefined,
|
validUntil: TimestampMillis | undefined,
|
||||||
): Promise<AuthTokenWithSecretDto> {
|
): [Omit<AuthToken, 'id' | 'createdAt'>, string] {
|
||||||
user.authTokens = this.getTokensByUser(user);
|
|
||||||
|
|
||||||
if ((await user.authTokens).length >= 200) {
|
|
||||||
// This is a very high ceiling unlikely to hinder legitimate usage,
|
|
||||||
// but should prevent possible attack vectors
|
|
||||||
throw new TooManyTokensError(
|
|
||||||
`User '${user.username}' has already 200 tokens and can't have anymore`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const secret = bufferToBase64Url(randomBytes(64));
|
const secret = bufferToBase64Url(randomBytes(64));
|
||||||
const keyId = bufferToBase64Url(randomBytes(8));
|
const keyId = bufferToBase64Url(randomBytes(8));
|
||||||
// More about the choice of SHA-512 in the dev docs
|
// More about the choice of SHA-512 in the dev docs
|
||||||
|
@ -94,39 +84,60 @@ export class AuthService {
|
||||||
new Date(validUntil),
|
new Date(validUntil),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return [token, secret];
|
||||||
|
}
|
||||||
|
|
||||||
|
async addToken(
|
||||||
|
user: User,
|
||||||
|
identifier: string,
|
||||||
|
validUntil: TimestampMillis | undefined,
|
||||||
|
): Promise<AuthTokenWithSecretDto> {
|
||||||
|
user.authTokens = this.getTokensByUser(user);
|
||||||
|
|
||||||
|
if ((await user.authTokens).length >= 200) {
|
||||||
|
// This is a very high ceiling unlikely to hinder legitimate usage,
|
||||||
|
// but should prevent possible attack vectors
|
||||||
|
throw new TooManyTokensError(
|
||||||
|
`User '${user.username}' has already 200 tokens and can't have anymore`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [token, secret] = this.createToken(user, identifier, validUntil);
|
||||||
const createdToken = (await this.authTokenRepository.save(
|
const createdToken = (await this.authTokenRepository.save(
|
||||||
token,
|
token,
|
||||||
)) as AuthToken;
|
)) as AuthToken;
|
||||||
return this.toAuthTokenWithSecretDto(createdToken, `${keyId}.${secret}`);
|
return this.toAuthTokenWithSecretDto(
|
||||||
|
createdToken,
|
||||||
|
`${createdToken.keyId}.${secret}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setLastUsedToken(keyId: string): Promise<void> {
|
async setLastUsedToken(keyId: string): Promise<void> {
|
||||||
const accessToken = await this.authTokenRepository.findOne({
|
const token = await this.authTokenRepository.findOne({
|
||||||
where: { keyId: keyId },
|
where: { keyId: keyId },
|
||||||
});
|
});
|
||||||
if (accessToken === null) {
|
if (token === null) {
|
||||||
throw new NotInDBError(`AuthToken for key '${keyId}' not found`);
|
throw new NotInDBError(`AuthToken for key '${keyId}' not found`);
|
||||||
}
|
}
|
||||||
accessToken.lastUsedAt = new Date();
|
token.lastUsedAt = new Date();
|
||||||
await this.authTokenRepository.save(accessToken);
|
await this.authTokenRepository.save(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAuthTokenAndValidate(
|
async getAuthToken(keyId: string): Promise<AuthToken> {
|
||||||
keyId: string,
|
const token = await this.authTokenRepository.findOne({
|
||||||
token: string,
|
|
||||||
): Promise<AuthToken> {
|
|
||||||
const accessToken = await this.authTokenRepository.findOne({
|
|
||||||
where: { keyId: keyId },
|
where: { keyId: keyId },
|
||||||
relations: ['user'],
|
relations: ['user'],
|
||||||
});
|
});
|
||||||
if (accessToken === null) {
|
if (token === null) {
|
||||||
throw new NotInDBError(`AuthToken '${token}' not found`);
|
throw new NotInDBError(`AuthToken '${keyId}' not found`);
|
||||||
}
|
}
|
||||||
// Hash the user-provided token
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkToken(secret: string, token: AuthToken): void {
|
||||||
const userHash = Buffer.from(
|
const userHash = Buffer.from(
|
||||||
crypto.createHash('sha512').update(token).digest('hex'),
|
crypto.createHash('sha512').update(secret).digest('hex'),
|
||||||
);
|
);
|
||||||
const dbHash = Buffer.from(accessToken.accessTokenHash);
|
const dbHash = Buffer.from(token.accessTokenHash);
|
||||||
if (
|
if (
|
||||||
// Normally, both hashes have the same length, as they are both SHA512
|
// Normally, both hashes have the same length, as they are both SHA512
|
||||||
// This is only defense-in-depth, as timingSafeEqual throws if the buffers are not of the same length
|
// This is only defense-in-depth, as timingSafeEqual throws if the buffers are not of the same length
|
||||||
|
@ -134,18 +145,18 @@ export class AuthService {
|
||||||
!crypto.timingSafeEqual(userHash, dbHash)
|
!crypto.timingSafeEqual(userHash, dbHash)
|
||||||
) {
|
) {
|
||||||
// hashes are not the same
|
// hashes are not the same
|
||||||
throw new TokenNotValidError(`AuthToken '${token}' is not valid.`);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
accessToken.validUntil &&
|
|
||||||
accessToken.validUntil.getTime() < new Date().getTime()
|
|
||||||
) {
|
|
||||||
// tokens validUntil Date lies in the past
|
|
||||||
throw new TokenNotValidError(
|
throw new TokenNotValidError(
|
||||||
`AuthToken '${token}' is not valid since ${accessToken.validUntil.toISOString()}.`,
|
`Secret does not match Token ${token.label}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (token.validUntil && token.validUntil.getTime() < new Date().getTime()) {
|
||||||
|
// tokens validUntil Date lies in the past
|
||||||
|
throw new TokenNotValidError(
|
||||||
|
`AuthToken '${
|
||||||
|
token.label
|
||||||
|
}' is not valid since ${token.validUntil.toISOString()}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return accessToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTokensByUser(user: User): Promise<AuthToken[]> {
|
async getTokensByUser(user: User): Promise<AuthToken[]> {
|
||||||
|
|
|
@ -343,7 +343,7 @@ export class TestSetupBuilder {
|
||||||
// create auth tokens
|
// create auth tokens
|
||||||
this.testSetup.authTokens = await Promise.all(
|
this.testSetup.authTokens = await Promise.all(
|
||||||
this.testSetup.users.map(async (user) => {
|
this.testSetup.users.map(async (user) => {
|
||||||
return await this.testSetup.authService.createTokenForUser(
|
return await this.testSetup.authService.addToken(
|
||||||
user,
|
user,
|
||||||
'test',
|
'test',
|
||||||
new Date().getTime() + 60 * 60 * 1000,
|
new Date().getTime() + 60 * 60 * 1000,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue