Compare commits

...

3 Commits

Author SHA1 Message Date
Evert Prants 529e4a96d7
implement refresh token 2023-06-30 22:48:24 +03:00
Evert Prants d81fc53819
more auth stuff 2023-06-30 21:29:34 +03:00
Evert Prants 1904fe94ed
some auth progress 2023-06-30 19:47:29 +03:00
42 changed files with 1471 additions and 12 deletions

View File

@ -2,6 +2,7 @@ import { Controller } from '@nestjs/common';
import { AuthService } from './services/auth.service';
import { MessagePattern } from '@nestjs/microservices';
import { LoginRequest } from './interfaces/auth.interface';
import { UserInfo } from '@freeblox/shared';
@Controller()
export class AuthController {
@ -11,4 +12,24 @@ export class AuthController {
login({ body }: { body: LoginRequest }) {
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);
}
@MessagePattern('auth.getUserById')
getUserById({ id }: { id: string }) {
return this.authService.getUserById(id);
}
@MessagePattern('auth.getUserBans')
getUserBans({ user }: { user: UserInfo }) {
return this.authService.getUserBans(user);
}
}

View File

@ -9,6 +9,23 @@ import knex from 'knex';
import { security } from './config/security.config';
import { keysProviders } from './providers/keys.providers';
import { JWTService } from './services/jwt.service';
import { UserEntity } from './database/entities/user.entity';
import { UserTokenEntity } from './database/entities/user-token.entity';
import { OTPService } from './services/otp.service';
import { BanEntity } from './database/entities/ban.entity';
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,
UserTokenEntity,
BanEntity,
PrivilegeEntity,
RoleEntity,
];
@Module({
imports: [
@ -19,12 +36,24 @@ import { JWTService } from './services/jwt.service';
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => config.get('typeorm'),
useFactory: (config: ConfigService) => ({
...config.get('typeorm'),
entities,
}),
}),
TypeOrmModule.forFeature(entities),
ClientsModule.register([natsClient('auth')]),
],
controllers: [AuthController],
providers: [AuthService, ...keysProviders, JWTService],
providers: [
...keysProviders,
JWTService,
OTPService,
BanService,
RoleService,
RefreshService,
AuthService,
],
})
export class AuthModule implements OnModuleInit {
constructor(private readonly config: ConfigService) {}

View File

@ -2,7 +2,11 @@ import { registerAs } from '@nestjs/config';
import { resolve } from 'path';
export const security = registerAs('security', () => ({
algorithm: String(process.env.JWT_ALGORITHM || 'RS512'),
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

@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { UserEntity } from './user.entity';
import { Exclude, Expose } from 'class-transformer';
@Entity('bans')
@Expose()
export class BanEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ nullable: false })
reason: string;
@Column({ nullable: true })
ip: string;
@Column({ nullable: true, default: 32 })
cidr: number;
@Column({ type: 'timestamp', name: 'expires_at', nullable: true })
expiresAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'user_id' })
@Exclude()
user: UserEntity;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'admin_id' })
@Exclude()
admin: UserEntity;
}

View File

@ -0,0 +1,18 @@
import { UserMetaEntity } from '@freeblox/shared';
import { Exclude, Expose } from 'class-transformer';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('privileges')
@Exclude()
export class PrivilegeEntity extends UserMetaEntity {
@PrimaryGeneratedColumn()
@Expose()
id: number;
@Column()
@Expose()
privilege: string;
@Column({ default: false })
automatic: boolean;
}

View File

@ -0,0 +1,54 @@
import { UserMetaEntity } from '@freeblox/shared';
import { Exclude, Expose } from 'class-transformer';
import {
Column,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
Tree,
TreeChildren,
TreeParent,
} from 'typeorm';
import { PrivilegeEntity } from './privilege.entity';
@Entity('roles')
@Exclude()
@Tree('materialized-path')
export class RoleEntity extends UserMetaEntity {
@PrimaryGeneratedColumn()
@Expose()
id: number;
@Column()
@Expose()
role: string;
@Column({ name: 'parent_id' })
@Expose()
parentId: number;
@TreeParent()
@JoinColumn({ name: 'parent_id' })
parent: RoleEntity;
@TreeChildren()
children: RoleEntity[];
@Column({ default: false })
automatic: boolean;
@ManyToMany(() => PrivilegeEntity, { eager: true })
@Expose()
@JoinTable({
name: 'role_privilege',
joinColumn: {
name: 'role_id',
},
inverseJoinColumn: {
name: 'privilege_id',
},
})
privileges: PrivilegeEntity[];
}

View File

@ -0,0 +1,39 @@
import { UserTokenType } from '@freeblox/shared';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { UserEntity } from './user.entity';
@Entity('user_tokens')
export class UserTokenEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ nullable: false, type: 'text' })
token: string;
@Column({ nullable: true, type: 'text' })
nonce: string;
@Column({ type: 'enum', enum: UserTokenType, nullable: false })
type: UserTokenType;
@Column({ type: 'timestamp', name: 'expires_at', nullable: true })
expiresAt: 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

@ -0,0 +1,92 @@
import { MetaEntity } from '@freeblox/shared';
import { Exclude, Expose } from 'class-transformer';
import { IsOptional, IsString } from 'class-validator';
import {
Column,
Entity,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
import { PrivilegeEntity } from './privilege.entity';
import { RoleEntity } from './role.entity';
@Expose()
@Entity('users')
export class UserEntity extends MetaEntity {
@PrimaryGeneratedColumn('uuid')
@IsString()
id: string;
@Column()
@IsString()
username: string;
@Column()
@IsString()
email: string;
@Column()
@IsString()
@IsOptional()
phone: string;
@Column({ length: 2 })
@IsString()
@IsOptional()
country: string;
@Column({ default: 'en', length: 2 })
@IsString()
@IsOptional()
language: string;
@Column()
@IsString()
@Exclude()
password: string;
@Column({ nullable: true, name: 'display_name' })
@IsString()
@IsOptional()
displayName: string;
@Column({ default: false })
@IsString()
verified: boolean;
@Column({ default: true })
@IsString()
activated: boolean;
@Column({ name: 'login_at' })
@IsString()
@IsOptional()
loginAt: Date;
@ManyToMany(() => PrivilegeEntity)
@JoinTable({
name: 'user_privilege',
joinColumn: {
name: 'user_id',
},
inverseJoinColumn: {
name: 'privilege_id',
},
})
@Exclude()
privileges: PrivilegeEntity[];
@ManyToMany(() => RoleEntity)
@JoinTable({
name: 'user_role',
joinColumn: {
name: 'user_id',
},
inverseJoinColumn: {
name: 'role_id',
},
})
@Exclude()
roles: RoleEntity[];
}

View File

@ -0,0 +1,25 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('bans', (table) => {
table.increments('id').primary();
table.text('reason').notNullable();
table.string('ip', 255).nullable();
table.integer('cidr', 2).nullable().defaultTo(32).unsigned();
table.uuid('user_id').nullable();
table.uuid('admin_id').nullable();
table.timestamp('expires_at').nullable();
table.timestamp('created_at').notNullable().defaultTo('now()');
table.foreign('user_id').references('users.id').onDelete('CASCADE');
table.foreign('admin_id').references('users.id').onDelete('SET NULL');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('bans');
}

View File

@ -0,0 +1,40 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('user_tokens', (table) => {
table.increments('id').primary();
table.text('token').notNullable();
table.text('nonce').nullable();
table
.enum('type', [
'generic',
'activation',
'deactivation',
'password',
'login',
'gdpr',
'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');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('user_tokens');
}

View File

@ -0,0 +1,21 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('privileges', (table) => {
table.increments('id').primary();
table.text('privilege').notNullable();
table.boolean('automatic').defaultTo(false);
table.uuid('created_by').nullable();
table.uuid('updated_by').nullable();
table.timestamps(true, true);
table.foreign('created_by').references('users.id').onDelete('SET NULL');
table.foreign('updated_by').references('users.id').onDelete('SET NULL');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('privileges');
}

View File

@ -0,0 +1,18 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('user_privilege', (table) => {
table.increments('id').primary();
table.integer('privilege_id').nullable().unsigned();
table.uuid('user_id').nullable();
table
.foreign('privilege_id')
.references('privileges.id')
.onDelete('CASCADE');
table.foreign('user_id').references('users.id').onDelete('CASCADE');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('user_privilege');
}

View File

@ -0,0 +1,24 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('roles', (table) => {
table.increments('id').primary();
table.text('role').notNullable();
table.string('mpath').nullable().defaultTo('');
table.integer('parent_id').nullable().unsigned();
table.boolean('automatic').defaultTo(false);
table.uuid('created_by').nullable();
table.uuid('updated_by').nullable();
table.timestamps(true, true);
table.foreign('parent_id').references('roles.id').onDelete('SET NULL');
table.foreign('created_by').references('users.id').onDelete('SET NULL');
table.foreign('updated_by').references('users.id').onDelete('SET NULL');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('roles');
}

View File

@ -0,0 +1,15 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('user_role', (table) => {
table.increments('id').primary();
table.integer('role_id').nullable().unsigned();
table.uuid('user_id').nullable();
table.foreign('role_id').references('roles.id').onDelete('CASCADE');
table.foreign('user_id').references('users.id').onDelete('CASCADE');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('user_role');
}

View File

@ -0,0 +1,18 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('role_privilege', (table) => {
table.increments('id').primary();
table.integer('role_id').nullable().unsigned();
table.integer('privilege_id').nullable().unsigned();
table.foreign('role_id').references('roles.id').onDelete('CASCADE');
table
.foreign('privilege_id')
.references('privileges.id')
.onDelete('CASCADE');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('role_privilege');
}

View File

@ -0,0 +1,200 @@
import { Knex } from 'knex';
export async function seed(knex: Knex): Promise<void> {
const initialRoles = [
{
name: 'player',
automatic: true,
id: 0,
path: '',
},
{
name: 'member',
id: 0,
parent: 'player',
path: '',
},
{
name: 'moderator',
id: 0,
parent: 'member',
path: '',
},
{
name: 'admin',
id: 0,
parent: 'moderator',
path: '',
},
{
name: 'reduced',
id: 0,
path: '',
},
];
const initialPrivileges = [
{
id: 0,
name: 'web',
roles: ['player', 'reduced'],
},
{
id: 0,
name: 'play',
roles: ['player'],
},
{
id: 0,
name: 'shop',
roles: ['player'],
},
{
id: 0,
name: 'build',
roles: ['player'],
},
{
id: 0,
name: 'trade',
roles: ['player'],
},
{
id: 0,
name: 'oidc',
roles: ['player'],
},
{
id: 0,
name: 'host',
roles: [],
},
{
id: 0,
name: 'ban',
roles: ['moderator', 'admin'],
},
{
id: 0,
name: 'permaban',
roles: ['admin'],
},
{
id: 0,
name: 'stopserver',
roles: ['admin'],
},
{
id: 0,
name: 'banserver',
roles: ['admin'],
},
{
id: 0,
name: 'root',
},
];
for (const role of initialRoles) {
const exists = await knex('roles').where({
role: role.name,
});
if (exists?.length) {
role.id = exists[0].id;
continue;
}
let parentId: number | null = null;
if (role.parent) {
const findRole = initialRoles.find(
(parent) => parent.name === role.parent,
);
if (findRole) {
parentId = findRole.id;
role.path += findRole.path;
}
}
const [created] = await knex('roles')
.insert([
{
role: role.name,
automatic: role?.automatic,
mpath: role.path,
parent_id: parentId,
created_at: new Date(),
},
])
.returning(['id']);
role.id = created.id;
role.path += `${role.path ? '.' : ''}${role.id}`;
await knex('roles').where({ id: role.id }).update({ mpath: role.path });
}
for (const privilege of initialPrivileges) {
const exists = await knex('privileges').where({
privilege: privilege.name,
});
if (exists?.length) {
privilege.id = exists[0].id;
} else {
const [created] = await knex('privileges')
.insert([
{
privilege: privilege.name,
automatic: false,
created_at: new Date(),
},
])
.returning(['id']);
privilege.id = created.id;
}
if (privilege.roles?.length) {
for (const role of privilege.roles) {
const foundRole = initialRoles.find((item) => item.name === role);
if (!foundRole) continue;
const body = {
role_id: foundRole.id,
privilege_id: privilege.id,
};
const exists = await knex('role_privilege').where(body);
if (exists?.length) continue;
await knex('role_privilege').insert(body);
}
}
}
// Add roles to initial user
const userExists = await knex('users').where({ username: 'freeblox' });
if (!userExists?.length) return;
const adminRole = initialRoles.find((role) => role.name === 'admin');
const privileges = initialPrivileges.filter((privilege) =>
['host', 'root'].includes(privilege.name),
);
const bodyUserRole = {
user_id: userExists[0].id,
role_id: adminRole.id,
};
if (!(await knex('user_role').where(bodyUserRole))?.length) {
await knex('user_role').insert(bodyUserRole);
}
await Promise.all(
privileges.map(async (privilege) => {
const body = {
user_id: userExists[0].id,
privilege_id: privilege.id,
};
if (!(await knex('user_privilege').where(body))?.length) {
await knex('user_privilege').insert(body);
}
}),
);
}

View File

@ -1,4 +1,5 @@
export interface LoginRequest {
email: string;
password: string;
totpToken?: string;
}

View File

@ -0,0 +1,4 @@
export interface TokenResponse {
token: string;
expires_in: number;
}

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

@ -1,9 +1,201 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
Injectable,
PreconditionFailedException,
} from '@nestjs/common';
import { LoginRequest } from '../interfaces/auth.interface';
import { JWTService } from './jwt.service';
import { ILike, Repository } from 'typeorm';
import { UserEntity } from '../database/entities/user.entity';
import { compare } from 'bcrypt';
import { instanceToPlain } from 'class-transformer';
import { InjectRepository } from '@nestjs/typeorm';
import { OTPService } from './otp.service';
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 {
constructor(
private readonly jwtService: JWTService,
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,
) {}
/**
* Login by username/email and password
* @param body Username/email and password
* @returns JWT token
*/
async login(body: LoginRequest) {
return { test: body.email };
if (!body.email || !body.password) {
throw new BadRequestException('Invalid username or password');
}
// Prevent wildcards
const userInput = body.email?.replace(/%/g, '');
const userEntity = await this.userRepository.findOne({
where: [
{
username: ILike(userInput),
activated: true,
},
{
email: ILike(userInput),
activated: true,
},
],
});
// User not found
if (!userEntity) {
throw new BadRequestException('Invalid username or password');
}
// Compare passwords
const passwordMatch = await compare(body.password, userEntity.password);
if (!passwordMatch) {
throw new BadRequestException('Invalid username or password');
}
// Check TOTP
const userOTPToken = await this.otpService.getUserTOTP(userEntity);
if (userOTPToken) {
if (!body.totpToken) {
throw new PreconditionFailedException('TOTP Token required');
}
const validate = this.otpService.validateTOTP(
userOTPToken.token,
body.totpToken,
);
if (!validate) {
throw new ForbiddenException('Invalid TOTP Token');
}
}
// Issue access token
const exp = this.config.get('security.jwtTokenExpiry');
const issuedToken = await this.issueToken(userEntity);
// Issue refresh token
const refreshToken = await this.refreshService.issueRefreshToken(
userEntity,
);
// Set login time to now
await this.userRepository.update(
{ id: userEntity.id },
{ loginAt: new Date() },
);
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,
};
}
/**
* Get user from token
* @param token JWT Token
* @returns User entity
*/
async getUserFromToken(token: string) {
const tokenInfo = await this.verifyToken(token);
const user = await this.userRepository.findOneByOrFail({
id: tokenInfo.sub,
activated: true,
});
return instanceToPlain(user);
}
/**
* Verify user token
* @param token JWT token
* @returns User token info
*/
async verifyToken(token: string) {
try {
return await this.jwtService.verify(token);
} catch (e) {
console.error(token, e);
throw new ForbiddenException('Invalid token');
}
}
/**
* Get user entity by ID
* @param id User ID
* @returns User entity
*/
async getUserById(id: string) {
if (!id) throw new BadRequestException('ID is required');
return instanceToPlain(await this.userRepository.findOneByOrFail({ id }));
}
/**
* Get user bans
* @param tokeninfo
*/
async getUserBans(userInfo: UserInfo) {
const user = await this.userRepository.findOneByOrFail({
id: userInfo.sub,
activated: true,
});
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

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BanEntity } from '../database/entities/ban.entity';
import { MoreThan, Repository } from 'typeorm';
import { UserEntity } from '../database/entities/user.entity';
@Injectable()
export class BanService {
constructor(
@InjectRepository(BanEntity)
private readonly banRepository: Repository<BanEntity>,
) {}
async getActiveBansForUser(user: UserEntity) {
return this.banRepository.find({
where: [
{
expiresAt: null,
user: { id: user.id },
},
{
expiresAt: MoreThan(new Date()),
user: { id: user.id },
},
],
});
}
async getAllBansForUser(user: UserEntity) {
return this.banRepository.findBy({
user: { id: user.id },
});
}
}

View File

@ -1,30 +1,34 @@
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.jwtTokenExpiry') +
Math.floor(Date.now() / 1000);
const jwt = await new SignJWT(data)
.setProtectedHeader({ alg })
.setIssuedAt()
.setIssuer('urn:freeblox:auth')
.setAudience(audience)
.setExpirationTime('8d')
.setExpirationTime(exp)
.sign(this.privateKey);
return jwt;
}
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,
@ -33,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,87 @@
import { InjectRepository } from '@nestjs/typeorm';
import { UserTokenEntity } from '../database/entities/user-token.entity';
import { authenticator as totp } from 'otplib';
import { Repository } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { UserEntity } from '../database/entities/user.entity';
import { UserTokenType, generateString } from '@freeblox/shared';
totp.options = {
window: 2,
};
@Injectable()
export class OTPService {
constructor(
@InjectRepository(UserTokenEntity)
private readonly userTokenRepository: Repository<UserTokenEntity>,
) {}
/**
* Check if the user has TOTP enabled
* @param user User object
* @returns true if the user has TOTP enabled
*/
public async userHasTOTP(user: UserEntity): Promise<boolean> {
return !!(await this.getUserTOTP(user));
}
/**
* Get the TOTP token of a user
* @param user User object
* @returns TOTP token
*/
public async getUserTOTP(user: UserEntity): Promise<UserTokenEntity> {
return this.userTokenRepository.findOne({
where: { user: { id: user.id }, type: UserTokenType.TOTP },
relations: ['user'],
});
}
public validateTOTP(secret: string, token: string): boolean {
return totp.verify({ token, secret });
}
public getTOTPURL(secret: string, username: string): string {
return totp.keyuri(username, 'Freeblox', secret);
}
public createTOTPSecret(): string {
return totp.generateSecret();
}
public async activateTOTP(
user: UserEntity,
secret: string,
): Promise<UserTokenEntity[]> {
const totp = new UserTokenEntity();
const recovery = new UserTokenEntity();
totp.user = user;
totp.token = secret;
totp.type = UserTokenType.TOTP;
recovery.user = user;
recovery.token = Array.from({ length: 8 }, () => generateString(8)).join(
' ',
);
recovery.type = UserTokenType.RECOVERY;
await this.userTokenRepository.save(totp);
await this.userTokenRepository.save(recovery);
return [totp, recovery];
}
public async deactivateTOTP(token: UserTokenEntity): Promise<void> {
if (!token) {
return;
}
await this.userTokenRepository.delete({
type: UserTokenType.RECOVERY,
user: { id: token.user.id },
});
await this.userTokenRepository.remove(token);
}
}

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

@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { UserEntity } from '../database/entities/user.entity';
import { RoleEntity } from '../database/entities/role.entity';
import { PrivilegeEntity } from '../database/entities/privilege.entity';
@Injectable()
export class RoleService {
constructor(
@InjectEntityManager() private manager: EntityManager,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}
/**
* Get all applicable privileges for roles
* @param roles Roles to look for privileges for
* @param startingPrivileges Privileges already included
* @returns Unique list of privileges
*/
async getApplicablePrivileges(
roles: RoleEntity[],
startingPrivileges: PrivilegeEntity[] = [],
) {
const roleTree = await this.manager
.getTreeRepository(RoleEntity)
.findTrees({ relations: ['privileges'] });
const privileges: PrivilegeEntity[] = [...startingPrivileges];
for (const { id } of roles) {
for (const role of roleTree) {
const [result, list] = this.accumulatePrivilegesFromTree(role, id);
if (!result) continue;
privileges.push(...list);
break;
}
}
return privileges.filter(
(value, index, array) =>
array.findIndex((entry) => entry.id === value.id) === index,
);
}
/**
* Get all applicable privileges for user.
* @param user User
* @returns Unique privilege list
*/
async getUserPrivileges(user: UserEntity) {
if (!user.privileges || !user.roles) {
user = await this.userRepository.findOne({
where: { id: user.id },
relations: ['privileges', 'roles'],
});
}
return this.getApplicablePrivileges(user.roles, user.privileges);
}
/**
* Accumulate privileges from a tree structure of roles.
* @param root Node to start from
* @param target Target role ID
* @returns `[<Found role entity>, <Privilege list>]`
*/
private accumulatePrivilegesFromTree(root: RoleEntity, target: number) {
const privileges: PrivilegeEntity[] = [...root.privileges];
if (root.id === target) return [root, privileges];
for (const child of root.children || []) {
const [found, list] = this.accumulatePrivilegesFromTree(child, target);
if (found) return [found, [...privileges, ...list]];
continue;
}
return [null, []];
}
}

View File

@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const response = ctx.switchToHttp().getResponse();
return response.locals.user;
},
);

View File

@ -0,0 +1,30 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { Request, Response } from 'express';
import { lastValueFrom } from 'rxjs';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(@Inject('auth') private authClient: ClientProxy) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest() as Request;
const response = context.switchToHttp().getResponse() as Response;
if (!request.headers.authorization) return false;
// Verify token by auth microservice
const [, token] = request.headers.authorization.split(' ');
const user = await lastValueFrom(
this.authClient.send('auth.verify', { token }),
);
// Add token contents to locals
response.locals.user = user;
return true;
}
}

View File

@ -9,6 +9,7 @@ async function bootstrap() {
.setTitle('Freeblox Web Service')
.setDescription('Freeblox Web Service API gateway')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);

View File

@ -2,25 +2,64 @@ import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
Inject,
Post,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { 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',
path: 'auth',
})
@ApiBearerAuth()
@ApiTags('Auth')
@UseInterceptors(ClassSerializerInterceptor)
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> {
return lastValueFrom(this.auth.send('auth.getUserById', { id: user.sub }));
}
@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

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
export class UserDto {
@ApiProperty()
id: string;
@ApiProperty()
username: string;
@ApiProperty()
email: string;
@ApiProperty({ nullable: true })
phone: string;
@ApiProperty({ nullable: true })
country: string;
@ApiProperty()
language: string;
@ApiProperty({ nullable: true })
displayName: string;
@ApiProperty()
verified: boolean;
@ApiProperty()
activated: boolean;
@ApiProperty({ type: Date })
loginAt: string;
}

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

@ -12,6 +12,5 @@ export const makeTypeOrm = (database: string) =>
username: String(process.env.POSTGRES_USER),
password: String(process.env.POSTGRES_PASSWORD),
database,
autoLoadEntities: true,
} as TypeOrmModuleOptions),
);

View File

@ -0,0 +1,22 @@
import { Exclude } from 'class-transformer';
import { Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
export class MetaEntity {
@CreateDateColumn({ name: 'created_at' })
@Exclude()
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
@Exclude()
updatedAt: Date;
}
export class UserMetaEntity extends MetaEntity {
@Column({ name: 'created_by' })
@Exclude()
createdBy: string;
@Column({ name: 'updated_by' })
@Exclude()
updatedBy: string;
}

View File

@ -1,5 +1,9 @@
export * from './shared.module';
export * from './shared.service';
export * from './utils/nats-client';
export * from './utils/tokens';
export * from './database/make-typeorm';
export * from './database/make-knex';
export * from './database/metaentity';
export * from './types/user-token.enum';
export * from './types/userinfo';

View File

@ -0,0 +1,12 @@
export enum UserTokenType {
GENERIC = 'generic',
ACTIVATION = 'activation',
DEACTIVATION = 'deactivation',
PASSWORD = 'password',
LOGIN = 'login',
GDPR = 'gdpr',
TOTP = 'totp',
PUBLIC_KEY = 'public_key',
RECOVERY = 'recovery',
REFRESH = 'refresh',
}

View File

@ -0,0 +1,8 @@
export interface UserInfo {
sub: string;
privileges?: string[];
username: string;
display_name: string;
language: string;
banned?: boolean;
}

View File

@ -0,0 +1,13 @@
import * as crypto from 'crypto';
import { v4 } from 'uuid';
export const generateString = (length: number): string =>
crypto.randomBytes(length).toString('hex').slice(0, length);
export const generateSecret = (): string =>
crypto.randomBytes(256 / 8).toString('hex');
export const insecureHash = (input: string): string =>
crypto.createHash('md5').update(input).digest('hex');
export const createUUID = (): string => v4();

View File

@ -37,11 +37,13 @@
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"nats": "^2.15.1",
"otplib": "^12.0.1",
"pg": "^8.11.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^5.0.1",
"rxjs": "^7.8.1",
"typeorm": "^0.3.17"
"typeorm": "^0.3.17",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.5",

View File

@ -53,6 +53,9 @@ dependencies:
nats:
specifier: ^2.15.1
version: 2.15.1
otplib:
specifier: ^12.0.1
version: 12.0.1
pg:
specifier: ^8.11.1
version: 8.11.1
@ -68,6 +71,9 @@ dependencies:
typeorm:
specifier: ^0.3.17
version: 0.3.17(pg@8.11.1)(ts-node@10.9.1)
uuid:
specifier: ^9.0.0
version: 9.0.0
devDependencies:
'@nestjs/cli':
@ -1236,6 +1242,39 @@ packages:
transitivePeerDependencies:
- encoding
/@otplib/core@12.0.1:
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
dev: false
/@otplib/plugin-crypto@12.0.1:
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
dependencies:
'@otplib/core': 12.0.1
dev: false
/@otplib/plugin-thirty-two@12.0.1:
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
dependencies:
'@otplib/core': 12.0.1
thirty-two: 1.0.2
dev: false
/@otplib/preset-default@12.0.1:
resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==}
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-crypto': 12.0.1
'@otplib/plugin-thirty-two': 12.0.1
dev: false
/@otplib/preset-v11@12.0.1:
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-crypto': 12.0.1
'@otplib/plugin-thirty-two': 12.0.1
dev: false
/@pkgjs/parseargs@0.11.0:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@ -4519,6 +4558,14 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/otplib@12.0.1:
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
dependencies:
'@otplib/core': 12.0.1
'@otplib/preset-default': 12.0.1
'@otplib/preset-v11': 12.0.1
dev: false
/p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@ -5402,6 +5449,11 @@ packages:
any-promise: 1.3.0
dev: false
/thirty-two@1.0.2:
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
engines: {node: '>=0.2.6'}
dev: false
/through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
dev: true