mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-28 22:15:12 -04:00
auth: Add tests for AuthService
Move AuthTokens to auth folder Signed-off-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
parent
c9751404f7
commit
508ad26771
30 changed files with 329 additions and 186 deletions
13
src/auth/auth-token-with-secret.dto.ts
Normal file
13
src/auth/auth-token-with-secret.dto.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { IsString } from 'class-validator';
|
||||
import { AuthTokenDto } from './auth-token.dto';
|
||||
|
||||
export class AuthTokenWithSecretDto extends AuthTokenDto {
|
||||
@IsString()
|
||||
secret: string;
|
||||
}
|
20
src/auth/auth-token.dto.ts
Normal file
20
src/auth/auth-token.dto.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class AuthTokenDto {
|
||||
@IsString()
|
||||
label: string;
|
||||
@IsString()
|
||||
keyId: string;
|
||||
@IsNumber()
|
||||
created: number;
|
||||
@IsNumber()
|
||||
validUntil: number | null;
|
||||
@IsNumber()
|
||||
lastUsed: number | null;
|
||||
}
|
64
src/auth/auth-token.entity.ts
Normal file
64
src/auth/auth-token.entity.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../users/user.entity';
|
||||
|
||||
@Entity()
|
||||
export class AuthToken {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
keyId: string;
|
||||
|
||||
@ManyToOne((_) => User, (user) => user.authTokens)
|
||||
user: User;
|
||||
|
||||
@Column()
|
||||
identifier: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ unique: true })
|
||||
accessTokenHash: string;
|
||||
|
||||
@Column({
|
||||
nullable: true,
|
||||
})
|
||||
validUntil: number;
|
||||
|
||||
@Column({
|
||||
nullable: true,
|
||||
})
|
||||
lastUsed: number;
|
||||
|
||||
public static create(
|
||||
user: User,
|
||||
identifier: string,
|
||||
keyId: string,
|
||||
accessToken: string,
|
||||
validUntil?: number,
|
||||
): Pick<AuthToken, 'user' | 'accessTokenHash'> {
|
||||
const newToken = new AuthToken();
|
||||
newToken.user = user;
|
||||
newToken.identifier = identifier;
|
||||
newToken.keyId = keyId;
|
||||
newToken.accessTokenHash = accessToken;
|
||||
newToken.createdAt = new Date();
|
||||
if (validUntil !== undefined) {
|
||||
newToken.validUntil = validUntil;
|
||||
}
|
||||
return newToken;
|
||||
}
|
||||
}
|
|
@ -1,11 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TokenStrategy } from './token.strategy';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthToken } from './auth-token.entity';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, PassportModule],
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
LoggerModule,
|
||||
TypeOrmModule.forFeature([AuthToken]),
|
||||
],
|
||||
providers: [AuthService, TokenStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
|
@ -1,26 +1,91 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { AuthToken } from '../users/auth-token.entity';
|
||||
import { AuthToken } from './auth-token.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { Identity } from '../users/identity.entity';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let user: User;
|
||||
let authToken: AuthToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = {
|
||||
authTokens: [],
|
||||
createdAt: new Date(),
|
||||
displayName: 'hardcoded',
|
||||
id: '1',
|
||||
identities: [],
|
||||
ownedNotes: [],
|
||||
updatedAt: new Date(),
|
||||
userName: 'Testy',
|
||||
};
|
||||
|
||||
authToken = {
|
||||
accessTokenHash: '',
|
||||
createdAt: new Date(),
|
||||
id: 1,
|
||||
identifier: 'testIdentifier',
|
||||
keyId: 'abc',
|
||||
lastUsed: null,
|
||||
user: null,
|
||||
validUntil: null,
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AuthService],
|
||||
imports: [PassportModule, UsersModule],
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: getRepositoryToken(AuthToken),
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
imports: [PassportModule, UsersModule, LoggerModule],
|
||||
})
|
||||
.overrideProvider(getRepositoryToken(AuthToken))
|
||||
.useValue({})
|
||||
.useValue({
|
||||
findOne: (): AuthToken => {
|
||||
return {
|
||||
...authToken,
|
||||
user: user,
|
||||
};
|
||||
},
|
||||
save: async (entity: AuthToken) => {
|
||||
if (entity.lastUsed === undefined) {
|
||||
expect(entity.lastUsed).toBeUndefined();
|
||||
} else {
|
||||
expect(entity.lastUsed).toBeLessThanOrEqual(new Date().getTime());
|
||||
}
|
||||
return entity;
|
||||
},
|
||||
remove: async (entity: AuthToken) => {
|
||||
expect(entity).toEqual({
|
||||
...authToken,
|
||||
user: user,
|
||||
});
|
||||
},
|
||||
})
|
||||
.overrideProvider(getRepositoryToken(Identity))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(User))
|
||||
.useValue({})
|
||||
.useValue({
|
||||
findOne: (): User => {
|
||||
return {
|
||||
...user,
|
||||
authTokens: [authToken],
|
||||
};
|
||||
},
|
||||
})
|
||||
.compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
|
@ -29,4 +94,64 @@ describe('AuthService', () => {
|
|||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('checkPassword', async () => {
|
||||
const testPassword = 'thisIsATestPassword';
|
||||
const hash = await service.hashPassword(testPassword);
|
||||
service
|
||||
.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.getAuthToken(
|
||||
authToken.keyId,
|
||||
token,
|
||||
);
|
||||
expect(authTokenFromCall).toEqual({
|
||||
...authToken,
|
||||
user: user,
|
||||
});
|
||||
});
|
||||
|
||||
it('setLastUsedToken', async () => {
|
||||
await service.setLastUsedToken(authToken.keyId);
|
||||
});
|
||||
|
||||
it('validateToken', async () => {
|
||||
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 () => {
|
||||
await service.removeToken(user.userName, authToken.keyId);
|
||||
});
|
||||
|
||||
it('createTokenForUser', async () => {
|
||||
const identifier = 'identifier2';
|
||||
const token = await service.createTokenForUser(
|
||||
user.userName,
|
||||
identifier,
|
||||
0,
|
||||
);
|
||||
expect(token.label).toEqual(identifier);
|
||||
expect(token.validUntil).toBeUndefined();
|
||||
expect(token.lastUsed).toBeUndefined();
|
||||
expect(token.secret.startsWith(token.keyId)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,18 +1,158 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { User } from '../users/user.entity';
|
||||
import { AuthToken } from './auth-token.entity';
|
||||
import { AuthTokenDto } from './auth-token.dto';
|
||||
import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto';
|
||||
import { compare, hash } from 'bcrypt';
|
||||
import { NotInDBError, TokenNotValidError } from '../errors/errors';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private usersService: UsersService) {}
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private usersService: UsersService,
|
||||
@InjectRepository(AuthToken)
|
||||
private authTokenRepository: Repository<AuthToken>,
|
||||
) {
|
||||
this.logger.setContext(AuthService.name);
|
||||
}
|
||||
|
||||
async validateToken(token: string): Promise<User> {
|
||||
const parts = token.split('.');
|
||||
const user = await this.usersService.getUserByAuthToken(parts[0], parts[1]);
|
||||
const accessToken = await this.getAuthToken(parts[0], parts[1]);
|
||||
const user = await this.usersService.getUserByUsername(
|
||||
accessToken.user.userName,
|
||||
);
|
||||
if (user) {
|
||||
await this.usersService.setLastUsedToken(parts[0])
|
||||
await this.setLastUsedToken(parts[0]);
|
||||
return user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async hashPassword(cleartext: string): Promise<string> {
|
||||
// hash the password with bcrypt and 2^16 iterations
|
||||
return hash(cleartext, 12);
|
||||
}
|
||||
|
||||
async checkPassword(cleartext: string, password: string): Promise<boolean> {
|
||||
// hash the password with bcrypt and 2^16 iterations
|
||||
return compare(cleartext, password);
|
||||
}
|
||||
|
||||
randomBase64UrlString(length = 64): 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(length)
|
||||
.toString('base64')
|
||||
.replace('+', '-')
|
||||
.replace('/', '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async createTokenForUser(
|
||||
userName: string,
|
||||
identifier: string,
|
||||
until: number,
|
||||
): Promise<AuthTokenWithSecretDto> {
|
||||
const user = await this.usersService.getUserByUsername(userName);
|
||||
const secret = this.randomBase64UrlString();
|
||||
const keyId = this.randomBase64UrlString(8);
|
||||
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);
|
||||
return this.toAuthTokenWithSecretDto(createdToken, `${keyId}.${secret}`);
|
||||
}
|
||||
|
||||
async setLastUsedToken(keyId: string) {
|
||||
const accessToken = await this.authTokenRepository.findOne({
|
||||
where: { keyId: keyId },
|
||||
});
|
||||
accessToken.lastUsed = new Date().getTime();
|
||||
await this.authTokenRepository.save(accessToken);
|
||||
}
|
||||
|
||||
async getAuthToken(keyId: string, token: string): Promise<AuthToken> {
|
||||
const accessToken = await this.authTokenRepository.findOne({
|
||||
where: { keyId: keyId },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (accessToken === undefined) {
|
||||
throw new NotInDBError(`AuthToken '${token}' not found`);
|
||||
}
|
||||
if (!(await this.checkPassword(token, accessToken.accessTokenHash))) {
|
||||
// hashes are not the same
|
||||
throw new TokenNotValidError(`AuthToken '${token}' is not valid.`);
|
||||
}
|
||||
if (
|
||||
accessToken.validUntil &&
|
||||
accessToken.validUntil < new Date().getTime()
|
||||
) {
|
||||
// tokens validUntil Date lies in the past
|
||||
throw new TokenNotValidError(
|
||||
`AuthToken '${token}' is not valid since ${new Date(
|
||||
accessToken.validUntil,
|
||||
)}.`,
|
||||
);
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async getTokensByUsername(userName: string): Promise<AuthToken[]> {
|
||||
const user = await this.usersService.getUserByUsername(userName, true);
|
||||
if (user.authTokens === undefined) {
|
||||
return [];
|
||||
}
|
||||
return user.authTokens;
|
||||
}
|
||||
|
||||
async removeToken(userName: string, keyId: string) {
|
||||
const user = await this.usersService.getUserByUsername(userName);
|
||||
const token = await this.authTokenRepository.findOne({
|
||||
where: { keyId: keyId, user: user },
|
||||
});
|
||||
await this.authTokenRepository.remove(token);
|
||||
}
|
||||
|
||||
toAuthTokenDto(authToken: AuthToken | null | undefined): AuthTokenDto | null {
|
||||
if (!authToken) {
|
||||
this.logger.warn(`Recieved ${authToken} argument!`, 'toAuthTokenDto');
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: authToken.identifier,
|
||||
keyId: authToken.keyId,
|
||||
created: authToken.createdAt.getTime(),
|
||||
validUntil: authToken.validUntil,
|
||||
lastUsed: authToken.lastUsed,
|
||||
};
|
||||
}
|
||||
|
||||
toAuthTokenWithSecretDto(
|
||||
authToken: AuthToken | null | undefined,
|
||||
secret: string,
|
||||
): AuthTokenWithSecretDto | null {
|
||||
const tokeDto = this.toAuthTokenDto(authToken);
|
||||
return {
|
||||
...tokeDto,
|
||||
secret: secret,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue