init commit

This commit is contained in:
unurled 2025-02-07 12:47:58 +01:00
commit 2346fe4879
Signed by: unurled
GPG key ID: EFC5F5E709B47DDD
45 changed files with 2573 additions and 0 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
node_modules/
.git/
dist/
.env*
.flaskenv*
!.env.project
!.env.vault
public_answers/

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
DATABASE_URL=
SECRET_KEY=
# Redis
REDIS_URL=

56
.gitignore vendored Normal file
View file

@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

4
.prettierrc Normal file
View file

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

22
Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM oven/bun:alpine
WORKDIR /app
COPY bun.lock ./
COPY package.json ./
COPY tsconfig.json ./
RUN bun install --production --frozen-lockfile
COPY prisma ./prisma/
RUN bunx prisma generate
COPY . .
ENV NODE_ENV=production
RUN bun run build
EXPOSE 4000
CMD bunx prisma migrate deploy && bunx prisma db seed && bun run start:prod

18
build.ts Normal file
View file

@ -0,0 +1,18 @@
// @ts-ignore
await Bun.build({
entrypoints: ["./src/app.ts"],
format: "esm",
outdir: "./dist",
target: "node",
external: [
"@nestjs/websockets/socket-module",
"@nestjs/microservices",
"class-transformer/storage",
"@fastify/view",
"@nestjs/platform-express",
],
minify: {
whitespace: true,
syntax: true,
},
});

1348
bun.lock Normal file

File diff suppressed because it is too large Load diff

2
bunfig.toml Normal file
View file

@ -0,0 +1,2 @@
[run]
bun = true

20
compose.dev.yaml Normal file
View file

@ -0,0 +1,20 @@
services:
db:
image: postgres
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: bracket
ports:
- '5432:5432'
volumes:
- database:/var/lib/postgresql/data
redis:
image: redis
restart: always
ports:
- '6379:6379'
volumes:
database:

35
eslint.config.mjs Normal file
View file

@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);

16
nest-cli.json Normal file
View file

@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"entryFile": "app",
"compilerOptions": {
"deleteOutDir": true,
"plugins": [{
"name": "@nestjs/swagger",
"options": {
"dtoFileNameSuffix": [".dto.ts", ".entity.ts", ".response.ts", ".payload.ts"],
"introspectComments": true
}
}]
}
}

60
package.json Normal file
View file

@ -0,0 +1,60 @@
{
"name": "nestjs-template",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "bun build.ts",
"start": "bun --bun nest start",
"start:dev": "bun --hot ./src/app.ts",
"start:prod": "bun dist/app",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@fastify/helmet": "^13.0.1",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.1.0",
"@keyv/redis": "^4.2.0",
"@nestjs/cache-manager": "^3.0.0",
"@nestjs/common": "^11.0.8",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.8",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.0.8",
"@nestjs/schedule": "^5.0.1",
"@nestjs/swagger": "^11.0.3",
"@nestjs/throttler": "^6.4.0",
"@prisma/client": "6.3.1",
"cache-manager": "^6.4.0",
"cacheable": "^1.8.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"fastify": "^5.2.1",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.10.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"prisma": "^6.3.1",
"qrcode": "^1.5.4",
"swagger-themes": "^1.4.3"
},
"devDependencies": {
"@nestjs/cli": "^11.0.2",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/bun": "^1.2.2",
"@types/nodemailer": "^6.4.17",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/qrcode": "^1.5.5",
"@typescript-eslint/parser": "^8.23.0",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"prettier": "^3.4.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.23.0"
}
}

View file

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "users" (
"id" VARCHAR(36) NOT NULL,
"email" VARCHAR(320) NOT NULL,
"username" VARCHAR(30) NOT NULL,
"password" TEXT NOT NULL,
"token_id" VARCHAR(64) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

20
prisma/schema.prisma Normal file
View file

@ -0,0 +1,20 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Users {
id String @id @db.VarChar(36)
email String @unique @db.VarChar(320)
username String @db.VarChar(30)
password String
token_id String @db.VarChar(64) // Authenticate JWT for security
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@map("users")
}

16
src/app.controller.ts Normal file
View file

@ -0,0 +1,16 @@
import {CacheInterceptor} from "@nestjs/cache-manager";
import {VersionEntity} from "./common/models/entities/version.entity";
import {Controller, Get, UseInterceptors} from "@nestjs/common";
import {ApiTags} from "@nestjs/swagger";
@Controller()
@ApiTags("Misc")
@UseInterceptors(CacheInterceptor)
export class AppController{
@Get("version")
async getVersion(): Promise<VersionEntity>{
return {
version: process.env.npm_package_version,
};
}
}

44
src/app.module.ts Normal file
View file

@ -0,0 +1,44 @@
import { HelperModule } from './common/modules/helper/helper.module';
import { ClassSerializerInterceptor, Module } from '@nestjs/common';
import { AuthModule } from './common/modules/auth/auth.module';
import { ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { ScheduleModule } from '@nestjs/schedule';
import { AppController } from './app.controller';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { CacheableMemory } from 'cacheable';
import KeyvRedis from '@keyv/redis';
@Module({
controllers: [AppController],
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot([
{
ttl: 60000,
limit: 60,
},
]),
ScheduleModule.forRoot(),
CacheModule.registerAsync({
isGlobal: true,
useFactory: async (): Promise<any> => {
const redisUrl: string | undefined = process.env.REDIS_URL;
return {
stores: [redisUrl ? new KeyvRedis(redisUrl) : new CacheableMemory()],
};
},
}),
HelperModule,
AuthModule,
],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: ClassSerializerInterceptor,
},
],
})
export class AppModule {}

83
src/app.ts Normal file
View file

@ -0,0 +1,83 @@
import {FastifyAdapter, NestFastifyApplication} from "@nestjs/platform-fastify";
import {CustomValidationPipe} from "./common/pipes/custom-validation.pipe";
import {LoggerMiddleware} from "./common/middlewares/logger.middleware";
import {SwaggerTheme, SwaggerThemeNameEnum} from "swagger-themes";
import {DocumentBuilder, SwaggerModule} from "@nestjs/swagger";
import {FastifyListenOptions} from "fastify/types/instance";
import fastifyMultipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import fastifyHelmet from "@fastify/helmet";
import {NestFactory} from "@nestjs/core";
import {AppModule} from "./app.module";
import {Logger} from "@nestjs/common";
import * as process from "process";
import {join} from "node:path";
const logger: Logger = new Logger("App");
process.env.APP_NAME = process.env.npm_package_name.split("-").map((word: string): string => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
const port: number = parseInt(process.env.PORT) || 4000;
async function bootstrap(){
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({exposeHeadRoutes: true}),
);
await loadServer(app);
await app.listen({
port: port,
host: "0.0.0.0",
} as FastifyListenOptions);
app.enableShutdownHooks();
logger.log(`Listening on http://0.0.0.0:${port}`);
}
async function loadServer(server: NestFastifyApplication){
// Config
server.setGlobalPrefix(process.env.PREFIX);
server.enableCors({
origin: "*",
});
// Middlewares
server.use(new LoggerMiddleware().use);
await server.register(fastifyMultipart as any);
await server.register(fastifyStatic as any, {
root: join(process.cwd(), "public_answers"),
prefix: "/public_answers/",
});
await server.register(fastifyHelmet as any, {
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: false,
} as any);
// Swagger
const config = new DocumentBuilder()
.setTitle(process.env.APP_NAME)
.setDescription(`Documentation for ${process.env.APP_NAME}`)
.setVersion(process.env.npm_package_version)
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(server, config);
const theme = new SwaggerTheme();
const customCss = theme.getBuffer(SwaggerThemeNameEnum.DARK);
SwaggerModule.setup("api", server, document, {
swaggerOptions: {
filter: true,
displayRequestDuration: true,
persistAuthorization: true,
docExpansion: "none",
tagsSorter: "alpha",
operationsSorter: "method",
},
customCss,
});
server.useGlobalPipes(new CustomValidationPipe());
}
bootstrap();

View file

@ -0,0 +1,52 @@
import {Injectable, Logger, NestMiddleware} from "@nestjs/common";
import {FastifyReply, FastifyRequest} from "fastify";
@Injectable()
export class LoggerMiddleware implements NestMiddleware{
static logger: Logger = new Logger(LoggerMiddleware.name);
use(req: FastifyRequest["raw"], res: FastifyReply["raw"], next: () => void){
const startTime = Date.now();
(res as any).on("finish", () => {
const path = req.url;
try{
const protocol = LoggerMiddleware.getProtocol(req);
const method = req.method;
if(method === "OPTIONS")
return;
const statusCode = res.statusCode;
const duration = Date.now() - startTime;
const resSize: any = res.getHeader("Content-Length") || "0";
const intResSize = parseInt(resSize);
LoggerMiddleware.logger.log(`${protocol} ${method} ${path} ${statusCode} ${duration}ms ${intResSize}`);
LoggerMiddleware.logRequestTime(path, method, duration);
}catch(e){
LoggerMiddleware.logger.warn(`Can't log route ${path} : ${e}`);
}
});
next();
}
static getProtocol(req: FastifyRequest["raw"]): string{
const localPort: number = req.connection.localPort;
if(!localPort)
return "H2";
return localPort.toString() === process.env.HTTPS_PORT ? "HTTPS" : "HTTP";
}
static logRequestTime(path: string, method: string, duration: number): void{
const thresholds: Record<string, number> = {
GET: 750,
POST: 1500,
PUT: 1500,
PATCH: 500,
DELETE: 500,
};
const threshold = thresholds[method];
if(threshold && duration > threshold){
LoggerMiddleware.logger.warn(
`${method} (${path}) request exceeded ${threshold}ms (${duration}ms)`,
);
}
}
}

View file

@ -0,0 +1,13 @@
import {IsNumber, IsOptional, Min} from "class-validator";
export class PaginationDto{
@IsNumber()
@Min(1)
@IsOptional()
take?: number;
@IsNumber()
@Min(0)
@IsOptional()
skip?: number;
}

View file

@ -0,0 +1,6 @@
import {ApiProperty} from "@nestjs/swagger";
export class VersionEntity{
@ApiProperty()
version: string;
}

View file

@ -0,0 +1,6 @@
export class PaginationResponse<T>{
data: T;
total: number;
take: number;
skip: number;
}

View file

@ -0,0 +1,11 @@
import { Controller, Delete, NotImplementedException } from "@nestjs/common";
@Controller("auth")
export class AuthController {
constructor() {}
@Delete("logout/all")
logoutAll() {
throw new NotImplementedException();
}
}

View file

@ -0,0 +1,35 @@
import { RegisterController } from './register.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LoginController } from './login.controller';
import { AuthController } from './auth.controller';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { LoginService } from './login.service';
import { RegisterService } from './register.service';
import { UsersModule } from '../../../modules/users/users.module';
import { ConfigService } from '@nestjs/config';
import { AuthJwtStrategy } from './strategies/auth-jwt.strategy';
@Module({
controllers: [AuthController, LoginController, RegisterController],
providers: [JwtStrategy, AuthJwtStrategy, LoginService, RegisterService],
imports: [
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('SECRET_KEY'),
signOptions: {
expiresIn: '7d',
algorithm: 'HS512',
issuer: configService.get<string>('APP_NAME'),
},
verifyOptions: {
algorithms: ['HS512'],
issuer: configService.get<string>('APP_NAME'),
},
}),
}),
UsersModule,
],
})
export class AuthModule {}

View file

@ -0,0 +1,5 @@
import {Injectable} from "@nestjs/common";
import {AuthGuard} from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt"){}

View file

@ -0,0 +1,94 @@
import {
Body,
Controller,
HttpCode,
NotImplementedException,
Post,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { LoginPayload } from './models/payloads/login.payload';
import { LocalLoginDto } from './models/dto/local-login.dto';
import { UserEntity } from './models/entities/user.entity';
import { JwtScope } from './models/enums/jwt-scope';
import { LoginService } from './login.service';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { UsersService } from '../../../modules/users/users.service';
@Controller('auth/login')
@ApiTags('Auth')
export class LoginController {
constructor(
private readonly loginService: LoginService,
private readonly usersService: UsersService,
) {}
/**
* Logs in the user using email and password
*
* @throws {401} Invalid password
* @throws {404} User not found
* @throws {500} Internal server error
*/
@Post('')
async login(@Body() body: LocalLoginDto): Promise<LoginPayload> {
const user: UserEntity = await this.loginService.validateUser(
body.email,
body.password,
);
if (!(await this.loginService.isUserVerified(user.id)))
throw new UnauthorizedException('User not verified');
const token: string = this.loginService.generateToken(
user.id,
user.tokenId,
JwtScope.USAGE,
);
return new LoginPayload({
user,
token,
});
}
/**
* Handles the login callback for the authentication process (Magic Link, Passkey, 2FA)
*
* @throws {401} Unauthorized
* @throws {500} Internal server error
*/
@Post('callback')
@UseGuards(AuthGuard('auth-jwt'))
@ApiBearerAuth()
loginCallback(@Req() req: any): Promise<LoginPayload> {
// TODO: Fix this line
req = req.user;
if (req.scope === JwtScope.MAGIC) {
// Check for 2FA/Passkey
const token: string = this.loginService.generateToken(
req.user.id,
req.user.tokenId,
JwtScope.USAGE,
);
return new LoginPayload({
user: req.user,
token,
});
}
const authToken: string = this.loginService.generateToken(
req.user.id,
req.user.tokenId,
JwtScope.USAGE,
);
return new LoginPayload({
user: req.user,
token: authToken,
});
}
@Post('passkey')
loginPasskey() {
// Generate JWT token
throw new NotImplementedException();
}
}

View file

@ -0,0 +1,46 @@
import {
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { UserEntity } from './models/entities/user.entity';
import { CipherService } from '../helper/cipher.service';
import { PrismaService } from '../helper/prisma.service';
import { JwtScope } from './models/enums/jwt-scope';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../../../modules/users/users.service';
@Injectable()
export class LoginService {
constructor(
private readonly prismaService: PrismaService,
private readonly cipherService: CipherService,
private readonly jwtService: JwtService,
private readonly usersService: UsersService,
) {}
async validateUser(email: string, password: string): Promise<UserEntity> {
const user: UserEntity = await this.usersService.getUserByEmail(email);
if (!this.cipherService.comparePassword(password, user.password))
throw new UnauthorizedException('Invalid password');
return user;
}
async isUserVerified(userId: string): Promise<boolean> {
const user: UserEntity = await this.usersService.getUserById(userId);
return user.verified;
}
generateToken(userId: string, userTokenId: string, scope: JwtScope): string {
return this.jwtService.sign(
{
scope,
},
{
subject: userId,
expiresIn: scope !== JwtScope.USAGE ? '5m' : '7d',
jwtid: userTokenId,
},
);
}
}

View file

@ -0,0 +1,11 @@
import {IsNotEmpty, IsString} from "class-validator";
export class LocalLoginDto{
@IsString()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
password: string;
}

View file

@ -0,0 +1,5 @@
export class RegisterDto{
email: string;
username: string;
password: string;
}

View file

@ -0,0 +1,20 @@
import {Exclude} from "class-transformer";
export class UserEntity{
id: string;
email: string;
username: string;
createdAt: Date;
updatedAt: Date;
verified: boolean;
@Exclude()
password: string;
@Exclude()
tokenId: string;
constructor(partial: Partial<UserEntity>){
Object.assign(this, partial);
}
}

View file

@ -0,0 +1,4 @@
export enum JwtScope {
AUTH = 'AUTH',
USAGE = 'USAGE',
}

View file

@ -0,0 +1,9 @@
import { UserEntity } from '../entities/user.entity';
export class LoginPayload {
user: UserEntity;
token: string;
constructor(partial: Partial<LoginPayload>) {
Object.assign(this, partial);
}
}

View file

@ -0,0 +1,27 @@
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RegisterDto } from './models/dto/register.dto';
import { RegisterService } from './register.service';
@Controller('auth/register')
@ApiTags('Auth')
export class RegisterController {
constructor(private readonly registerService: RegisterService) {}
/**
* Registers a new user
*
* @throws {400} Bad request
* @throws {409} Email already used
* @throws {500} Internal server error
*/
@Post('')
@HttpCode(204)
async register(@Body() body: RegisterDto): Promise<void> {
await this.registerService.register(
body.email,
body.username,
body.password,
);
}
}

View file

@ -0,0 +1,45 @@
import {
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from '../helper/prisma.service';
import { CipherService } from '../helper/cipher.service';
import {
EmailVerifications,
Passkeys,
TwoFactorAuth,
Users,
} from '@prisma/client';
import { UserEntity } from './models/entities/user.entity';
@Injectable()
export class RegisterService {
constructor(
private readonly prismaService: PrismaService,
private readonly cipherService: CipherService,
) {}
async register(
email: string,
username: string,
password: string,
): Promise<void> {
const emailExists: Users = await this.prismaService.users.findFirst({
where: {
email,
},
});
if (emailExists) throw new ConflictException('Email already registered');
const hashedPassword: string = this.cipherService.hashPassword(password);
await this.prismaService.users.create({
data: {
id: Bun.randomUUIDv7(),
email,
username,
password: hashedPassword,
token_id: this.cipherService.generateRandomBytes(),
},
});
}
}

View file

@ -0,0 +1,32 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '../../../../modules/users/users.service';
import { JwtPayload } from 'jsonwebtoken';
import { JwtScope } from '../models/enums/jwt-scope';
import { UserEntity } from '../models/entities/user.entity';
@Injectable()
export class AuthJwtStrategy extends PassportStrategy(Strategy, 'auth-jwt') {
constructor(private readonly usersService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.SECRET_KEY,
issuer: process.env.APP_NAME,
algorithms: ['HS512'],
});
}
async validate(payload: JwtPayload): Promise<any> {
if (payload.scope === JwtScope.USAGE)
throw new UnauthorizedException('Invalid scope');
const user: UserEntity = await this.usersService.getUserById(payload.sub);
if (user.tokenId !== payload.jti)
throw new UnauthorizedException('Invalid token');
return {
user,
scope: payload.scope,
};
}
}

View file

@ -0,0 +1,29 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserEntity } from '../models/entities/user.entity';
import { JwtScope } from '../models/enums/jwt-scope';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { UsersService } from '../../../../modules/users/users.service';
import { JwtPayload } from 'jsonwebtoken';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly usersService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.SECRET_KEY,
issuer: process.env.APP_NAME,
algorithms: ['HS512'],
});
}
async validate(payload: JwtPayload) {
if (payload.scope !== JwtScope.USAGE)
throw new UnauthorizedException('Invalid scope');
const user: UserEntity = await this.usersService.getUserById(payload.sub);
if (user.tokenId !== payload.jti)
throw new UnauthorizedException('Invalid token');
return user;
}
}

View file

@ -0,0 +1,173 @@
import {Injectable} from "@nestjs/common";
import * as crypto from "crypto";
@Injectable()
export class CipherService{
// Hash functions
getSum(content: Bun.BlobOrStringOrBuffer): string{
if(!content) content = "";
return new Bun.CryptoHasher("sha256").update(content).digest().toString("hex");
}
hashPassword(content: Bun.StringOrBuffer, cost = 10): string{
return Bun.password.hashSync(content, {
algorithm: "argon2id",
timeCost: cost,
});
}
comparePassword(password: Bun.StringOrBuffer, hash: string): boolean{
return Bun.password.verifySync(password, hash);
}
// Symmetric functions
private prepareEncryptionKey(encryptionKey: string | Buffer): Buffer{
let keyBuffer: Buffer;
if(typeof encryptionKey === "string")
keyBuffer = Buffer.from(encryptionKey);
else
keyBuffer = encryptionKey;
const key = Buffer.alloc(64);
keyBuffer.copy(key);
return key;
}
cipherSymmetric(content: string, encryptionKey: string | Buffer): string{
if(!content) content = "";
return this.cipherBufferSymmetric(Buffer.from(content, "utf-8"), encryptionKey).toString("hex");
}
cipherBufferSymmetric(content: Buffer, encryptionKey: string | Buffer): Buffer{
if(!content) content = Buffer.alloc(0);
const iv = crypto.randomBytes(12);
const key = this.prepareEncryptionKey(encryptionKey);
const cipher = crypto.createCipheriv("aes-256-gcm", key.subarray(0, 32), iv);
const encrypted = Buffer.concat([cipher.update(content), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, encrypted, tag]);
}
decipherSymmetric(encryptedContent: string, encryptionKey: string | Buffer): string{
return this.decipherBufferSymmetric(Buffer.from(encryptedContent, "hex"), encryptionKey).toString("utf-8");
}
decipherBufferSymmetric(encryptedContent: Buffer, encryptionKey: string | Buffer): Buffer{
const iv = encryptedContent.subarray(0, 12);
const encrypted = encryptedContent.subarray(12, encryptedContent.length - 16);
const tag = encryptedContent.subarray(encryptedContent.length - 16);
const key = this.prepareEncryptionKey(encryptionKey);
const decipher = crypto.createDecipheriv("aes-256-gcm", key.subarray(0, 32), iv);
decipher.setAuthTag(tag);
try{
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}catch(_){
throw new Error("Decryption failed");
}
}
cipherHardSymmetric(content: string, encryptionKey: string | Buffer, timeCost = 200000){
if(!content) content = "";
const salt = crypto.randomBytes(32);
const key = crypto.pbkdf2Sync(encryptionKey, salt, timeCost, 64, "sha512");
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key.subarray(0, 32), iv);
let encrypted = cipher.update(content, "utf-8", "hex");
encrypted += cipher.final("hex");
const hmac = crypto.createHmac("sha256", key.subarray(32));
hmac.update(`${salt.toString("hex")}:${iv.toString("hex")}:${encrypted}`);
const digest = hmac.digest("hex");
return `${salt.toString("hex")}:${iv.toString("hex")}:${encrypted}:${digest}`;
}
decipherHardSymmetric(encryptedContent: string, encryptionKey: string | Buffer, timeCost = 200000){
const [saltString, ivString, encryptedString, digest] = encryptedContent.split(":");
const salt = Buffer.from(saltString, "hex");
const key = crypto.pbkdf2Sync(encryptionKey, salt, timeCost, 64, "sha512");
const iv = Buffer.from(ivString, "hex");
const hmac = crypto.createHmac("sha256", key.subarray(32));
hmac.update(`${saltString}:${ivString}:${encryptedString}`);
const calculatedDigest = hmac.digest("hex");
if(calculatedDigest !== digest)
throw new Error("Integrity check failed");
const decipher = crypto.createDecipheriv("aes-256-cbc", key.subarray(0, 32), iv);
let decrypted = decipher.update(encryptedString, "hex", "utf-8");
decrypted += decipher.final("utf-8");
return decrypted;
}
// Asymmetric functions
generateKeyPair(modulusLength = 4096, privateEncryptionKey = null): crypto.KeyPairSyncResult<string, string>{
if(!privateEncryptionKey)
console.warn("No private encryption key provided, the private key will not be encrypted");
let options = undefined;
if(privateEncryptionKey){
options = {
cipher: "aes-256-cbc",
passphrase: privateEncryptionKey,
};
}
return crypto.generateKeyPairSync("rsa", {
modulusLength: modulusLength,
publicKeyEncoding: {
type: "spki",
format: "pem",
},
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
...options,
},
});
}
encryptAsymmetric(content: string, publicKey: string | Buffer): string{
if(!content) content = "";
const buffer = Buffer.from(content, "utf-8");
const encrypted = crypto.publicEncrypt({
key: publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
}, buffer);
return encrypted.toString("base64");
}
decryptAsymmetric(encryptedContent: string, privateKey: string | Buffer, privateEncryptionKey = undefined): string{
const buffer = Buffer.from(encryptedContent, "base64");
if(!privateEncryptionKey)
return crypto.privateDecrypt({
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
}, buffer).toString("utf-8");
else
return crypto.privateDecrypt({
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
passphrase: privateEncryptionKey,
}, buffer).toString("utf-8");
}
// Secret functions
/**
* Generate a random string
* @param bytes Number of bytes to generate
*/
generateRandomBytes(bytes = 32): string{
return crypto.randomBytes(bytes).toString("hex");
}
/**
* Generate a random number
* @param numbersNumber Number of numbers to generate
*/
generateRandomNumbers(numbersNumber = 6): string{
return Array.from({length: numbersNumber}, () => Math.floor(Math.random() * 10)).join("");
}
maskSensitiveInfo(str: string, visibleCount: number = 4, maskChar: string = "*"){
return str.slice(0, visibleCount) + maskChar.repeat(Math.max(0, str.length - visibleCount));
}
maskEmail(email: string){
const [username, domain] = email.split("@");
return `${this.maskSensitiveInfo(username)}@${domain}`;
}
}

View file

@ -0,0 +1,10 @@
import { CipherService } from './cipher.service';
import { PrismaService } from './prisma.service';
import { Global, Module } from '@nestjs/common';
@Global()
@Module({
providers: [CipherService, PrismaService],
exports: [CipherService, PrismaService],
})
export class HelperModule {}

View file

@ -0,0 +1,32 @@
import {Injectable, Logger, OnModuleInit} from "@nestjs/common";
import {PrismaClient} from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit{
static readonly logger = new Logger(PrismaService.name);
constructor(){
super();
return this.$extends({
query: {
async $allOperations({operation, model, args, query}): Promise<any>{
const startTime: number = Date.now();
const result: any = await query(args);
const duration: number = Date.now() - startTime;
const requestCount: number = args.length || 1;
const resultCount: number = !result ? 0 : result.length || 1;
PrismaService.logger.log(`${model.toUpperCase()} ${operation.toLowerCase()} ${duration}ms ${requestCount} ${resultCount}`);
return result;
},
},
}) as PrismaService;
}
async onModuleInit(){
await this.$connect();
}
async onModuleDestroy(){
await this.$disconnect();
}
}

View file

@ -0,0 +1,26 @@
import {BadRequestException, Logger, ValidationError, ValidationPipe} from "@nestjs/common";
export class CustomValidationPipe extends ValidationPipe{
private readonly logger = new Logger(CustomValidationPipe.name);
constructor(){
super({
transform: true,
transformOptions: {enableImplicitConversion: true},
});
}
createExceptionFactory(){
return (validationErrors: ValidationError[] = []) => {
if(this.isDetailedOutputDisabled){
return new BadRequestException();
}
const messages = validationErrors.map(error => ({
property: error.property,
constraints: error.constraints,
}));
// this.logger.error(messages);
return new BadRequestException(messages);
};
}
}

View file

@ -0,0 +1,16 @@
import {Controller, Get, Req, UseGuards} from "@nestjs/common";
import {JwtAuthGuard} from "../../common/modules/auth/guards/jwt-auth.guard";
import {UserEntity} from "../../common/modules/auth/models/entities/user.entity";
import {ApiBearerAuth} from "@nestjs/swagger";
@Controller("users")
export class UsersController{
constructor(){}
@Get("me")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
getMyself(@Req() req: any): UserEntity{
return req.user;
}
}

View file

@ -0,0 +1,10 @@
import {Module} from "@nestjs/common";
import {UsersService} from "./users.service";
import {UsersController} from "./users.controller";
@Module({
providers: [UsersService],
exports: [UsersService],
controllers: [UsersController],
})
export class UsersModule{}

View file

@ -0,0 +1,56 @@
import {Injectable, NotFoundException} from "@nestjs/common";
import {UserEntity} from "../../common/modules/auth/models/entities/user.entity";
import {PrismaService} from "../../common/modules/helper/prisma.service";
@Injectable()
export class UsersService{
constructor(
private readonly prismaService: PrismaService,
){}
async getUserById(id: string){
const user = await this.prismaService.users.findUnique({
where: {
id,
},
include: {
email_verifications: true,
},
});
if(!user)
throw new NotFoundException("User not found");
return new UserEntity({
id: user.id,
username: user.username,
email: user.email,
verified: !user.email_verifications,
password: user.password,
tokenId: user.token_id,
createdAt: user.created_at,
updatedAt: user.updated_at,
});
}
async getUserByEmail(email: string): Promise<UserEntity>{
const user = await this.prismaService.users.findUnique({
where: {
email,
},
include: {
email_verifications: true,
},
});
if(!user)
throw new NotFoundException("User not found");
return new UserEntity({
id: user.id,
username: user.username,
email: user.email,
verified: !user.email_verifications,
password: user.password,
tokenId: user.token_id,
createdAt: user.created_at,
updatedAt: user.updated_at,
});
}
}

4
tsconfig.build.json Normal file
View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}