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); return this.authService.login(body);
} }
@MessagePattern('auth.loginByRefreshToken')
loginByRefreshToken({ token }: { token: string }) {
return this.authService.loginByRefreshToken(token);
}
@MessagePattern('auth.verify') @MessagePattern('auth.verify')
verify({ token }: { token: string }) { verify({ token }: { token: string }) {
return this.authService.verifyToken(token); 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 { PrivilegeEntity } from './database/entities/privilege.entity';
import { RoleService } from './services/role.service'; import { RoleService } from './services/role.service';
import { RoleEntity } from './database/entities/role.entity'; import { RoleEntity } from './database/entities/role.entity';
import { RefreshService } from './services/refresh.service';
const entities = [ const entities = [
UserEntity, UserEntity,
@ -50,6 +51,7 @@ const entities = [
OTPService, OTPService,
BanService, BanService,
RoleService, RoleService,
RefreshService,
AuthService, AuthService,
], ],
}) })

View File

@ -2,8 +2,11 @@ import { registerAs } from '@nestjs/config';
import { resolve } from 'path'; import { resolve } from 'path';
export const security = registerAs('security', () => ({ export const security = registerAs('security', () => ({
algorithm: String(process.env.JWT_ALGORITHM || 'RS512'), jwtAlgorithm: String(process.env.JWT_ALGORITHM || 'RS512'),
tokenExpiry: Number(process.env.JWT_EXPIRY) || 60 * 60, 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)), privateKeyPath: resolve(String(process.env.PRIVATE_KEY_FILE)),
publicKeyPath: resolve(String(process.env.PUBLIC_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 }) @Column({ type: 'enum', enum: UserTokenType, nullable: false })
type: UserTokenType; type: UserTokenType;
@Column({ type: 'timestamp', nullable: true }) @Column({ type: 'timestamp', name: 'expires_at', nullable: true })
expires_at: Date; expiresAt: Date;
@CreateDateColumn() @CreateDateColumn({ name: 'created_at' })
created_at: Date; createdAt: Date;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' }) @JoinColumn({ name: 'user_id' })
user: UserEntity; 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', 'totp',
'public_key', 'public_key',
'recovery', 'recovery',
'refresh',
]) ])
.notNullable(); .notNullable();
table.uuid('user_id').notNullable(); table.uuid('user_id').notNullable();
table.integer('previous_id').nullable().unsigned();
table.timestamp('expires_at').nullable(); table.timestamp('expires_at').nullable();
table.timestamp('created_at').notNullable().defaultTo('now()'); table.timestamp('created_at').notNullable().defaultTo('now()');
table.foreign('user_id').references('users.id').onDelete('CASCADE'); 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') .readFile(config.get('security.privateKeyPath'), 'utf-8')
.then((key) => jose.importPKCS8(key, 'RS512')), .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>{ <FactoryProvider>{
provide: 'APP_PUBLIC_KEY', provide: 'APP_PUBLIC_KEY',
inject: [ConfigService], inject: [ConfigService],

View File

@ -16,6 +16,7 @@ import { BanService } from './ban.service';
import { UserInfo } from '@freeblox/shared'; import { UserInfo } from '@freeblox/shared';
import { RoleService } from './role.service'; import { RoleService } from './role.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { RefreshService } from './refresh.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -24,6 +25,7 @@ export class AuthService {
private readonly otpService: OTPService, private readonly otpService: OTPService,
private readonly banService: BanService, private readonly banService: BanService,
private readonly roleService: RoleService, private readonly roleService: RoleService,
private readonly refreshService: RefreshService,
@InjectRepository(UserEntity) @InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>, private readonly userRepository: Repository<UserEntity>,
private readonly config: ConfigService, private readonly config: ConfigService,
@ -82,25 +84,14 @@ export class AuthService {
} }
} }
// Check for active ban // Issue access token
const bans = await this.banService.getActiveBansForUser(userEntity); const exp = this.config.get('security.jwtTokenExpiry');
const banned = !!bans.length; const issuedToken = await this.issueToken(userEntity);
// Get all privileges applicable to user // Issue refresh token
const privileges = await this.roleService.getUserPrivileges(userEntity); const refreshToken = await this.refreshService.issueRefreshToken(
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),
});
// Set login time to now // Set login time to now
await this.userRepository.update( await this.userRepository.update(
@ -110,6 +101,28 @@ export class AuthService {
return { return {
token: issuedToken, 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, expires_in: exp,
}; };
} }
@ -164,4 +177,25 @@ export class AuthService {
const bans = await this.banService.getAllBansForUser(user); const bans = await this.banService.getAllBansForUser(user);
return instanceToPlain(bans); 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 { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JWK, KeyLike, SignJWT, jwtVerify } from 'jose'; import { EncryptJWT, JWK, KeyLike, SignJWT, jwtDecrypt, jwtVerify } from 'jose';
@Injectable() @Injectable()
export class JWTService { export class JWTService {
constructor( constructor(
@Inject('APP_PRIVATE_KEY') private readonly privateKey: KeyLike, @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') private readonly publicKey: KeyLike,
@Inject('APP_PUBLIC_KEY_JWK') private readonly publicKeyJWK: JWK, @Inject('APP_PUBLIC_KEY_JWK') private readonly publicKeyJWK: JWK,
private readonly config: ConfigService, private readonly config: ConfigService,
) {} ) {}
async sign(data: Record<string, unknown>, audience = 'urn:freeblox:service') { 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 = 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) const jwt = await new SignJWT(data)
.setProtectedHeader({ alg }) .setProtectedHeader({ alg })
.setIssuedAt() .setIssuedAt()
@ -26,7 +28,7 @@ export class JWTService {
} }
async verify(jwt: string, audience = 'urn:freeblox:service') { 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, { const { payload, protectedHeader } = await jwtVerify(jwt, this.publicKey, {
issuer: 'urn:freeblox:auth', issuer: 'urn:freeblox:auth',
audience, audience,
@ -35,4 +37,33 @@ export class JWTService {
throw new ForbiddenException('Provided JWT contains invalid headers.'); throw new ForbiddenException('Provided JWT contains invalid headers.');
return payload; 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, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices'; 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 { LoginDto } from './dtos/login.dto';
import { User } from '../../decorators/user.decorator'; import { User } from '../../decorators/user.decorator';
import { UserInfo } from '@freeblox/shared'; import { UserInfo } from '@freeblox/shared';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { AuthGuard } from '../../guards/auth.guard'; import { AuthGuard } from '../../guards/auth.guard';
import { UserDto } from './dtos/user.dto'; import { UserDto } from './dtos/user.dto';
import { LoginResponseDto } from './dtos/login-response.dto';
import { LoginByRefreshTokenDto } from './dtos/login-refresh-token.dto';
@Controller({ @Controller({
version: '1', version: '1',
@ -28,11 +35,21 @@ export class AuthController {
constructor(@Inject('auth') private auth: ClientProxy) {} constructor(@Inject('auth') private auth: ClientProxy) {}
@Post('login') @Post('login')
@ApiOperation({ summary: 'Login by username or email and password' })
@ApiOkResponse({ type: LoginResponseDto })
async login(@Body() body: LoginDto) { async login(@Body() body: LoginDto) {
return this.auth.send('auth.login', { body }); 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') @Get('me')
@ApiOperation({ summary: 'Current user information' })
@ApiOkResponse({ type: UserDto }) @ApiOkResponse({ type: UserDto })
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
async myInfo(@User() user: UserInfo): Promise<UserDto> { async myInfo(@User() user: UserInfo): Promise<UserDto> {
@ -40,6 +57,7 @@ export class AuthController {
} }
@Get('bans') @Get('bans')
@ApiOperation({ summary: 'Current user ban history' })
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
async banInfo(@User() user: UserInfo) { async banInfo(@User() user: UserInfo) {
return lastValueFrom(this.auth.send('auth.getUserBans', { user })); 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 - POSTGRES_PASSWORD=FREEBLOXDataBaseDEV@123
- PRIVATE_KEY_FILE=private/jwt.private.pem - PRIVATE_KEY_FILE=private/jwt.private.pem
- PUBLIC_KEY_FILE=private/jwt.public.pem - PUBLIC_KEY_FILE=private/jwt.public.pem
- SECRET_KEY=mkt9Hngcmhbd9wX4EzGbGysDWzCo793XvvswOS+wolTVM83I1K2b/j41WwsCfsv1iS901N2rTHu2hZHbsYO3RQ==
volumes: volumes:
- ./apps:/usr/src/app/apps - ./apps:/usr/src/app/apps
- ./libs:/usr/src/app/libs - ./libs:/usr/src/app/libs

View File

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