implement refresh token
This commit is contained in:
parent
d81fc53819
commit
529e4a96d7
@ -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);
|
||||
|
@ -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,
|
||||
],
|
||||
})
|
||||
|
@ -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'),
|
||||
}));
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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],
|
||||
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
120
apps/auth/src/services/refresh.service.ts
Normal file
120
apps/auth/src/services/refresh.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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 }));
|
||||
|
@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class LoginByRefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
token: string;
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiProperty()
|
||||
token: string;
|
||||
|
||||
@ApiProperty()
|
||||
refresh: string;
|
||||
|
||||
@ApiProperty()
|
||||
expires_in: number;
|
||||
}
|
@ -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
|
||||
|
@ -8,4 +8,5 @@ export enum UserTokenType {
|
||||
TOTP = 'totp',
|
||||
PUBLIC_KEY = 'public_key',
|
||||
RECOVERY = 'recovery',
|
||||
REFRESH = 'refresh',
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user