implement refresh token

This commit is contained in:
Evert Prants 2023-06-30 22:48:24 +03:00
parent d81fc53819
commit 529e4a96d7
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
14 changed files with 280 additions and 29 deletions

View File

@ -13,6 +13,11 @@ export class AuthController {
return this.authService.login(body);
}
@MessagePattern('auth.loginByRefreshToken')
loginByRefreshToken({ token }: { token: string }) {
return this.authService.loginByRefreshToken(token);
}
@MessagePattern('auth.verify')
verify({ token }: { token: string }) {
return this.authService.verifyToken(token);

View File

@ -17,6 +17,7 @@ import { BanService } from './services/ban.service';
import { PrivilegeEntity } from './database/entities/privilege.entity';
import { RoleService } from './services/role.service';
import { RoleEntity } from './database/entities/role.entity';
import { RefreshService } from './services/refresh.service';
const entities = [
UserEntity,
@ -50,6 +51,7 @@ const entities = [
OTPService,
BanService,
RoleService,
RefreshService,
AuthService,
],
})

View File

@ -2,8 +2,11 @@ import { registerAs } from '@nestjs/config';
import { resolve } from 'path';
export const security = registerAs('security', () => ({
algorithm: String(process.env.JWT_ALGORITHM || 'RS512'),
tokenExpiry: Number(process.env.JWT_EXPIRY) || 60 * 60,
jwtAlgorithm: String(process.env.JWT_ALGORITHM || 'RS512'),
jwtTokenExpiry: Number(process.env.JWT_EXPIRY) || 60 * 60,
refreshTokenExpiry: Number(process.env.REFRESH_EXPIRY) || 30 * 60 * 60,
privateKeyPath: resolve(String(process.env.PRIVATE_KEY_FILE)),
publicKeyPath: resolve(String(process.env.PUBLIC_KEY_FILE)),
secretKey: String(process.env.SECRET_KEY),
secretAlgorithm: String(process.env.REFRESH_ALGORITHM || 'A256CBC-HS512'),
}));

View File

@ -23,13 +23,17 @@ export class UserTokenEntity {
@Column({ type: 'enum', enum: UserTokenType, nullable: false })
type: UserTokenType;
@Column({ type: 'timestamp', nullable: true })
expires_at: Date;
@Column({ type: 'timestamp', name: 'expires_at', nullable: true })
expiresAt: Date;
@CreateDateColumn()
created_at: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserEntity;
@ManyToOne(() => UserTokenEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'previous_id' })
previous: UserTokenEntity;
}

View File

@ -17,15 +17,21 @@ export async function up(knex: Knex): Promise<void> {
'totp',
'public_key',
'recovery',
'refresh',
])
.notNullable();
table.uuid('user_id').notNullable();
table.integer('previous_id').nullable().unsigned();
table.timestamp('expires_at').nullable();
table.timestamp('created_at').notNullable().defaultTo('now()');
table.foreign('user_id').references('users.id').onDelete('CASCADE');
table
.foreign('previous_id')
.references('user_tokens.id')
.onDelete('CASCADE');
});
}

View File

@ -16,6 +16,12 @@ export const keysProviders = [
.readFile(config.get('security.privateKeyPath'), 'utf-8')
.then((key) => jose.importPKCS8(key, 'RS512')),
},
<FactoryProvider>{
provide: 'APP_SECRET_KEY',
inject: [ConfigService],
useFactory: async (config: ConfigService) =>
jose.base64url.decode(config.get('security.secretKey')),
},
<FactoryProvider>{
provide: 'APP_PUBLIC_KEY',
inject: [ConfigService],

View File

@ -16,6 +16,7 @@ import { BanService } from './ban.service';
import { UserInfo } from '@freeblox/shared';
import { RoleService } from './role.service';
import { ConfigService } from '@nestjs/config';
import { RefreshService } from './refresh.service';
@Injectable()
export class AuthService {
@ -24,6 +25,7 @@ export class AuthService {
private readonly otpService: OTPService,
private readonly banService: BanService,
private readonly roleService: RoleService,
private readonly refreshService: RefreshService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
private readonly config: ConfigService,
@ -82,25 +84,14 @@ export class AuthService {
}
}
// Check for active ban
const bans = await this.banService.getActiveBansForUser(userEntity);
const banned = !!bans.length;
// Issue access token
const exp = this.config.get('security.jwtTokenExpiry');
const issuedToken = await this.issueToken(userEntity);
// Get all privileges applicable to user
const privileges = await this.roleService.getUserPrivileges(userEntity);
// Issue token
const exp = this.config.get('security.tokenExpiry');
const issuedToken = await this.jwtService.sign({
sub: userEntity.id,
username: userEntity.username,
display_name: userEntity.displayName,
language: userEntity.language,
banned: banned,
privileges: banned
? []
: privileges.map((privilege) => privilege.privilege),
});
// Issue refresh token
const refreshToken = await this.refreshService.issueRefreshToken(
userEntity,
);
// Set login time to now
await this.userRepository.update(
@ -110,6 +101,28 @@ export class AuthService {
return {
token: issuedToken,
refresh: refreshToken,
expires_in: exp,
};
}
async loginByRefreshToken(token: string) {
const refreshToken = await this.refreshService.useRefreshToken(token);
const userEntity = refreshToken.user;
// Issue new access token
const exp = this.config.get('security.jwtTokenExpiry');
const issuedToken = await this.issueToken(userEntity);
// Set login time to now
await this.userRepository.update(
{ id: userEntity.id },
{ loginAt: new Date() },
);
return {
token: issuedToken,
refresh: refreshToken.token,
expires_in: exp,
};
}
@ -164,4 +177,25 @@ export class AuthService {
const bans = await this.banService.getAllBansForUser(user);
return instanceToPlain(bans);
}
private async issueToken(user: UserEntity) {
// Check for active ban
const bans = await this.banService.getActiveBansForUser(user);
const banned = !!bans.length;
// Get all privileges applicable to user
const privileges = await this.roleService.getUserPrivileges(user);
// Issue new token
return this.jwtService.sign({
sub: user.id,
username: user.username,
display_name: user.displayName,
language: user.language,
banned: banned,
privileges: banned
? []
: privileges.map((privilege) => privilege.privilege),
});
}
}

View File

@ -1,20 +1,22 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JWK, KeyLike, SignJWT, jwtVerify } from 'jose';
import { EncryptJWT, JWK, KeyLike, SignJWT, jwtDecrypt, jwtVerify } from 'jose';
@Injectable()
export class JWTService {
constructor(
@Inject('APP_PRIVATE_KEY') private readonly privateKey: KeyLike,
@Inject('APP_SECRET_KEY') private readonly secretKey: KeyLike,
@Inject('APP_PUBLIC_KEY') private readonly publicKey: KeyLike,
@Inject('APP_PUBLIC_KEY_JWK') private readonly publicKeyJWK: JWK,
private readonly config: ConfigService,
) {}
async sign(data: Record<string, unknown>, audience = 'urn:freeblox:service') {
const alg = this.config.get('security.algorithm');
const alg = this.config.get('security.jwtAlgorithm');
const exp =
this.config.get('security.tokenExpiry') + Math.floor(Date.now() / 1000);
this.config.get('security.jwtTokenExpiry') +
Math.floor(Date.now() / 1000);
const jwt = await new SignJWT(data)
.setProtectedHeader({ alg })
.setIssuedAt()
@ -26,7 +28,7 @@ export class JWTService {
}
async verify(jwt: string, audience = 'urn:freeblox:service') {
const alg = this.config.get('security.algorithm');
const alg = this.config.get('security.jwtAlgorithm');
const { payload, protectedHeader } = await jwtVerify(jwt, this.publicKey, {
issuer: 'urn:freeblox:auth',
audience,
@ -35,4 +37,33 @@ export class JWTService {
throw new ForbiddenException('Provided JWT contains invalid headers.');
return payload;
}
async encrypt(
data: Record<string, unknown>,
audience = 'urn:freeblox:service',
) {
const alg = this.config.get('security.secretAlgorithm');
const exp =
this.config.get('security.refreshTokenExpiry') +
Math.floor(Date.now() / 1000);
const jwt = await new EncryptJWT(data)
.setProtectedHeader({ alg: 'dir', enc: alg })
.setIssuedAt()
.setIssuer('urn:freeblox:auth')
.setAudience(audience)
.setExpirationTime(exp)
.encrypt(this.secretKey);
return jwt;
}
async decrypt(jwt: string, audience = 'urn:freeblox:service') {
const alg = this.config.get('security.secretAlgorithm');
const { payload, protectedHeader } = await jwtDecrypt(jwt, this.secretKey, {
issuer: 'urn:freeblox:auth',
audience,
});
if (protectedHeader.enc !== alg)
throw new ForbiddenException('Provided JWT contains invalid headers.');
return payload;
}
}

View File

@ -0,0 +1,120 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserTokenEntity } from '../database/entities/user-token.entity';
import { JWTService } from './jwt.service';
import { UserEntity } from '../database/entities/user.entity';
import { Repository } from 'typeorm';
import { UserTokenType, generateString } from '@freeblox/shared';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class RefreshService {
constructor(
@InjectRepository(UserTokenEntity)
private readonly userTokenRepository: Repository<UserTokenEntity>,
private readonly jwt: JWTService,
private readonly config: ConfigService,
) {}
/**
* Find new refresh token for old token
* @param token Old token
* @returns New Token
*/
async findNewIteration(token: UserTokenEntity) {
return this.userTokenRepository.findOne({
where: {
previous: { id: token.id },
type: UserTokenType.REFRESH,
},
});
}
/**
* Consume a refresh token.
* @param token Refresh token
* @returns New refresh token
*/
async useRefreshToken(token: string) {
if (!token) throw new UnauthorizedException('Invalid refresh token');
const decrypted = await this.jwt.decrypt(token);
if (!decrypted.token || !decrypted.sub)
throw new UnauthorizedException('Invalid refresh token');
const tokenEntity = await this.userTokenRepository.findOneOrFail({
where: {
token: decrypted.token as string,
type: UserTokenType.REFRESH,
},
relations: ['user'],
});
if (decrypted.sub !== tokenEntity.user.id)
throw new UnauthorizedException('Invalid refresh token');
// Using an expired refresh token
if (tokenEntity.expiresAt.getTime() < Date.now()) {
const newIteration = await this.findNewIteration(tokenEntity);
// We have already issued a new refresh token, this is probably stolen
// ..so we delete the whole tree.
if (newIteration) {
await this.userTokenRepository.remove(tokenEntity);
}
throw new UnauthorizedException('Invalid refresh token');
}
// Mark old token as expired
await this.userTokenRepository.update(
{ id: tokenEntity.id },
{ expiresAt: new Date() },
);
// Issue a new refresh token
const newToken = await this.issueRefreshToken(
tokenEntity.user,
tokenEntity,
);
return {
token: newToken,
user: tokenEntity.user,
};
}
/**
* Issue a refresh token.
* @param user User
* @param previous Previous refresh token
* @returns New Refresh token
*/
async issueRefreshToken(user: UserEntity, previous?: UserTokenEntity) {
const newRefreshToken = await this.createRefreshToken(user);
const expiry = new Date(
Date.now() + this.config.get('security.refreshTokenExpiry') * 1000,
);
await this.userTokenRepository.save({
token: newRefreshToken.token,
user,
type: UserTokenType.REFRESH,
expiresAt: expiry,
previous,
});
return newRefreshToken.encrypted;
}
/**
* Generate refresh token and encrypt it
* @param user User to issue the token to
* @returns Encrypted and unencrypted tokens
*/
private async createRefreshToken(user: UserEntity) {
const token = generateString(512);
const encrypted = await this.jwt.encrypt({ sub: user.id, token });
return {
encrypted,
token,
};
}
}

View File

@ -9,13 +9,20 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { LoginDto } from './dtos/login.dto';
import { User } from '../../decorators/user.decorator';
import { UserInfo } from '@freeblox/shared';
import { lastValueFrom } from 'rxjs';
import { AuthGuard } from '../../guards/auth.guard';
import { UserDto } from './dtos/user.dto';
import { LoginResponseDto } from './dtos/login-response.dto';
import { LoginByRefreshTokenDto } from './dtos/login-refresh-token.dto';
@Controller({
version: '1',
@ -28,11 +35,21 @@ export class AuthController {
constructor(@Inject('auth') private auth: ClientProxy) {}
@Post('login')
@ApiOperation({ summary: 'Login by username or email and password' })
@ApiOkResponse({ type: LoginResponseDto })
async login(@Body() body: LoginDto) {
return this.auth.send('auth.login', { body });
}
@Post('refresh')
@ApiOperation({ summary: 'Login by refresh token' })
@ApiOkResponse({ type: LoginResponseDto })
async refresh(@Body() body: LoginByRefreshTokenDto) {
return this.auth.send('auth.loginByRefreshToken', { token: body.token });
}
@Get('me')
@ApiOperation({ summary: 'Current user information' })
@ApiOkResponse({ type: UserDto })
@UseGuards(AuthGuard)
async myInfo(@User() user: UserInfo): Promise<UserDto> {
@ -40,6 +57,7 @@ export class AuthController {
}
@Get('bans')
@ApiOperation({ summary: 'Current user ban history' })
@UseGuards(AuthGuard)
async banInfo(@User() user: UserInfo) {
return lastValueFrom(this.auth.send('auth.getUserBans', { user }));

View File

@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class LoginByRefreshTokenDto {
@ApiProperty()
@IsString()
token: string;
}

View File

@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
export class LoginResponseDto {
@ApiProperty()
token: string;
@ApiProperty()
refresh: string;
@ApiProperty()
expires_in: number;
}

View File

@ -37,6 +37,7 @@ services:
- POSTGRES_PASSWORD=FREEBLOXDataBaseDEV@123
- PRIVATE_KEY_FILE=private/jwt.private.pem
- PUBLIC_KEY_FILE=private/jwt.public.pem
- SECRET_KEY=mkt9Hngcmhbd9wX4EzGbGysDWzCo793XvvswOS+wolTVM83I1K2b/j41WwsCfsv1iS901N2rTHu2hZHbsYO3RQ==
volumes:
- ./apps:/usr/src/app/apps
- ./libs:/usr/src/app/libs

View File

@ -8,4 +8,5 @@ export enum UserTokenType {
TOTP = 'totp',
PUBLIC_KEY = 'public_key',
RECOVERY = 'recovery',
REFRESH = 'refresh',
}