feat: Basic setup with auth

This commit is contained in:
sauravdhakal12
2026-02-21 17:21:48 +05:45
parent f6bce78aee
commit f4c9174752
24 changed files with 418 additions and 18 deletions

View File

@@ -0,0 +1,8 @@
import { HttpException, HttpStatus } from '@nestjs/common';
// Base exception
export class BaseException extends HttpException {
protected constructor(code: string, message: string, status: HttpStatus) {
super({ code, message }, status);
}
}

View File

@@ -0,0 +1,32 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException) // What exception to catch
export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
constructor(private readonly logger: Logger) {}
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request: Request = ctx.getRequest();
const response: Response = ctx.getResponse();
const status = exception.getStatus();
if (status >= 500) {
this.logger.warn({
method: request.method,
url: request.url,
message: exception.message,
});
}
response.status(status).json({
message: exception.message,
statusCode: status,
});
}
}

View File

@@ -0,0 +1 @@
export * from './custom-exceptions';

1
common/http/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './response';

25
common/http/response.ts Normal file
View File

@@ -0,0 +1,25 @@
export class MessageResponse {
readonly success: boolean;
readonly message: string;
constructor(message?: string) {
this.success = true;
this.message = message ?? 'Success';
}
}
export class DataResponse<T> extends MessageResponse {
readonly data: T;
constructor(data: T, message?: string) {
super(message);
this.data = data;
}
}
// Skipped
export class GlobalErrorResponseDTO {
success: boolean;
message: string;
statusCode: number;
}

View File

View File

@@ -0,0 +1,72 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { DataResponse, MessageResponse } from 'common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, any> {
intercept(_: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
if (data instanceof MessageResponse) return data;
else if (data instanceof DataResponse) return data;
else if (typeof data === 'string') return new MessageResponse(data);
return new DataResponse(data);
}),
);
}
}
/* NOTE: How to access request
*
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, any> {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
req.userId = 'ram';
return next.handle();
// return next.handle().pipe(
// map((data) => {
// if (data instanceof MessageResponse) return data;
// else if (data instanceof DataResponse) return data;
// else if (typeof data === 'string') return new MessageResponse(data);
// return new DataResponse(data);
// }),
// );
}
}
REQUEST
Guards
Interceptors (before)
Pipes
Controller method
Interceptors (after) ← YOU ARE HERE
Response
3⃣ What is next.handle() really?
next.handle(): Observable<T>
This is:
An RxJS stream of whatever the controller returns
Not the request
Not the response object
Just the return value
* */

View File

@@ -31,6 +31,7 @@
"@nestjs/swagger": "^11.2.6", "@nestjs/swagger": "^11.2.6",
"@prisma/adapter-pg": "^7.3.0", "@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0", "@prisma/client": "^7.3.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"pg": "^8.18.0", "pg": "^8.18.0",
@@ -46,6 +47,7 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0", "@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7", "@swc/core": "^1.10.7",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",

34
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
'@prisma/client': '@prisma/client':
specifier: ^7.3.0 specifier: ^7.3.0
version: 7.3.0(prisma@7.3.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) version: 7.3.0(prisma@7.3.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
bcrypt:
specifier: ^6.0.0
version: 6.0.0
class-transformer: class-transformer:
specifier: ^0.5.1 specifier: ^0.5.1
version: 0.5.1 version: 0.5.1
@@ -69,6 +72,9 @@ importers:
'@swc/core': '@swc/core':
specifier: ^1.10.7 specifier: ^1.10.7
version: 1.15.11 version: 1.15.11
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
'@types/express': '@types/express':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.6 version: 5.0.6
@@ -1128,6 +1134,9 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -1537,6 +1546,10 @@ packages:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true hasBin: true
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
bin-version-check@5.1.0: bin-version-check@5.1.0:
resolution: {integrity: sha512-bYsvMqJ8yNGILLz1KP9zKLzQ6YpljV3ln1gqhuLkUtyfGi3qXKGuK2p+U4NAvjVFzDFiBBtOpCOSFNuYYEGZ5g==} resolution: {integrity: sha512-bYsvMqJ8yNGILLz1KP9zKLzQ6YpljV3ln1gqhuLkUtyfGi3qXKGuK2p+U4NAvjVFzDFiBBtOpCOSFNuYYEGZ5g==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2895,12 +2908,20 @@ packages:
node-abort-controller@3.1.1: node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-addon-api@8.5.0:
resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
engines: {node: ^18 || ^20 || >= 21}
node-emoji@1.11.0: node-emoji@1.11.0:
resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==}
node-fetch-native@1.6.7: node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-int64@0.4.0: node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
@@ -4958,6 +4979,10 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 22.19.10
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
dependencies: dependencies:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
@@ -5499,6 +5524,11 @@ snapshots:
baseline-browser-mapping@2.9.19: {} baseline-browser-mapping@2.9.19: {}
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.5.0
node-gyp-build: 4.8.4
bin-version-check@5.1.0: bin-version-check@5.1.0:
dependencies: dependencies:
bin-version: 6.0.0 bin-version: 6.0.0
@@ -7034,12 +7064,16 @@ snapshots:
node-abort-controller@3.1.1: {} node-abort-controller@3.1.1: {}
node-addon-api@8.5.0: {}
node-emoji@1.11.0: node-emoji@1.11.0:
dependencies: dependencies:
lodash: 4.17.23 lodash: 4.17.23
node-fetch-native@1.6.7: {} node-fetch-native@1.6.7: {}
node-gyp-build@4.8.4: {}
node-int64@0.4.0: {} node-int64@0.4.0: {}
node-releases@2.0.27: {} node-releases@2.0.27: {}

View File

@@ -1,7 +1,9 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { Public } from './auth/decorators';
@Controller() @Controller('')
@Public(true)
export class AppController { export class AppController {
constructor(private readonly appService: AppService) {} constructor(private readonly appService: AppService) {}

View File

@@ -1,4 +1,4 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { Logger, MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@@ -7,6 +7,10 @@ import { RequestContextMiddleware } from 'core/als/request-context.middleware';
import { RequestContextModule } from 'core/als/request-context.module'; import { RequestContextModule } from 'core/als/request-context.module';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { ResponseInterceptor } from 'common/interceptors/response.interceptor';
import { ExceptionsHandler } from '@nestjs/core/exceptions/exceptions-handler';
import { HttpExceptionFilter } from 'common/exceptions/exception-filter';
@Module({ @Module({
imports: [ imports: [
@@ -19,7 +23,18 @@ import { PrismaModule } from './prisma/prisma.module';
PrismaModule, PrismaModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [
AppService,
Logger,
{
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,
},
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {

View File

@@ -1,4 +1,55 @@
import { Controller } from '@nestjs/common'; import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Res,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { ApiOperation } from '@nestjs/swagger';
import {
LoginUserRequestDTO,
LoginUserResponseDTO,
RegisterUserRequestDTO,
} from './dto';
import { Response } from 'express';
import { DataResponse } from 'common/http';
@Controller('auth') @Controller('auth')
export class AuthController {} export class AuthController {
constructor(private readonly authService: AuthService) {}
@ApiOperation({ summary: 'User login' })
@HttpCode(HttpStatus.OK)
@Post('/login')
async login(
@Body() body: LoginUserRequestDTO,
@Res({ passthrough: true }) response: Response,
): Promise<DataResponse<LoginUserResponseDTO>> {
const { accessToken, refreshToken, user } =
await this.authService.login(body);
response.cookie('accessToken', accessToken);
return new DataResponse(
new LoginUserResponseDTO(user, accessToken, refreshToken),
'Login successfull',
);
}
@ApiOperation({ summary: 'User register' })
@HttpCode(HttpStatus.CREATED)
@Post('/register')
async register(@Body() body: RegisterUserRequestDTO): Promise<string> {
await this.authService.register(body);
return 'Registered successfully. Login to continue.';
}
logout() {}
forgotPassword() {}
regenTokens() {}
}

View File

@@ -1,17 +1,23 @@
import { Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD, Reflector } from '@nestjs/core';
import { AuthGuard } from './guards/auth.guard'; import { AuthGuard } from './guards/auth.guard';
import { UserModule } from 'src/user/user.module';
import { JwtModule } from '@nestjs/jwt';
import { RequestContextModule } from 'core/als/request-context.module';
@Global()
@Module({ @Module({
providers: [ providers: [
AuthService, AuthService,
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: AuthGuard, useFactory: () => AuthGuard,
inject: [Reflector],
}, },
], ],
controllers: [AuthController], controllers: [AuthController],
imports: [UserModule, JwtModule, RequestContextModule],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -1,13 +1,74 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Public } from './decorators'; import { Public } from './decorators';
import { LoginUserRequestDTO, RegisterUserRequestDTO } from './dto';
import * as bcrypt from 'bcrypt';
import { UserService } from 'src/user/user.service';
import { TokenInputType } from './types';
import { JwtService } from '@nestjs/jwt';
@Injectable() @Injectable()
@Public() @Public()
export class AuthService { export class AuthService {
async login() {} constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
) {}
async signup() {} async register(dto: RegisterUserRequestDTO) {
const hashedPassword = await bcrypt.hash(dto.password, 10);
await this.userService.createUserWithPassword({
...dto,
password: hashedPassword,
});
@Public(false) return true;
async logout() {} }
async login(dto: LoginUserRequestDTO) {
const user = await this.userService.findUserForAuth(dto.email);
if (!user) throw new UnauthorizedException('Invalid credentials.');
const passwordMatch = await bcrypt.compare(dto.password, user.password);
if (!passwordMatch) throw new UnauthorizedException('Invalid credentials.');
const token = {
userId: user.id,
email: user.email,
role: user.role,
};
// TODO: Store more info: orgId, orgRole, etc
const { accessToken, refreshToken } = await this.genSignedTokens(token);
const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
await this.userService.updateRefreshToken(user.id, hashedRefreshToken);
return {
accessToken,
refreshToken,
user,
};
}
logout() {}
resetPassword() {}
// TODO: Use nest jwt
private async genSignedTokens(token: TokenInputType) {
const accessToken = await this.jwtService.signAsync(token, {
secret: 'demo',
});
const refreshToken = await this.jwtService.signAsync(
{
userId: token.userId,
},
{
secret: 'demo',
},
);
return { accessToken, refreshToken };
}
} }

View File

@@ -1 +1,3 @@
export * from './register-user.dto'; export * from './register-user.dto';
export * from './login-user.dto';
export * from './login-response.dto';

View File

@@ -0,0 +1,14 @@
import { User } from 'prisma/generated/prisma/client';
import { UserDTO } from 'src/user/dtos';
export class LoginUserResponseDTO {
readonly accessToken: string;
readonly refreshToken: string;
readonly user: UserDTO;
constructor(user: User, accessToken: string, refreshToken: string) {
this.user = new UserDTO(user);
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class LoginUserRequestDTO {
@ApiProperty({
description: "User's email",
example: 'user@example.com',
type: 'string',
})
@IsEmail()
@IsNotEmpty()
email: string;
@ApiProperty({
description: "User's password",
example: '123456',
type: 'string',
minLength: 6,
})
@IsString()
@IsNotEmpty()
@MinLength(6)
password: string;
}

View File

@@ -12,9 +12,9 @@ import { PUBLIC_KEY } from 'common/keys';
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor( constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly requestContext: RequestContextService, private readonly requestContext: RequestContextService,
private readonly reflector: Reflector,
) {} ) {}
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {

View File

@@ -1,2 +1,3 @@
export * from './jwt'; export * from './jwt';
export * from './role'; export * from './role';
export * from './token';

View File

@@ -1,11 +1,12 @@
import { UserRoleType } from './role'; import { ORG_ROLE, USER_ROLE } from 'prisma/generated/prisma/enums';
export interface JwtPayload { export interface JwtPayload {
iat?: number; iat?: number;
exp?: number; exp?: number;
orgId?: string; orgId?: string;
orgRole?: ORG_ROLE;
userId: string; userId: string;
email: string; email: string;
role: UserRoleType; role: USER_ROLE;
permission?: string[]; permissions?: string[];
} }

11
src/auth/types/token.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface TokenInputType {
userId: string;
email: string;
role: string;
}
export interface AccessTokenPayloadType extends TokenInputType {}
export interface RefreshTokenPayloadType {
userId: string;
}

View File

@@ -1,10 +1,47 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const swaggerConfig = new DocumentBuilder()
.setTitle('Kaa Khane')
.setDescription(`API Documentation for Kaa Khane`)
.setVersion('0.0.1')
.addGlobalResponse(
{
status: 500,
description: 'Internal Server Error',
},
{
status: 401,
description: 'Unauthorized',
},
{
status: 403,
description: 'Forbidden',
},
)
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
'access-token',
)
.build();
const documentFactory = () =>
SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('/docs', app, documentFactory, {
swaggerOptions: {
persistAuthorization: true,
},
});
const config = app.get(ConfigService); const config = app.get(ConfigService);
const port = config.get<number>('PORT') ?? 3000; const port = config.get<number>('PORT') ?? 3000;

View File

@@ -24,7 +24,6 @@ export class PrismaService
const connectionPool = new Pool({ const connectionPool = new Pool({
connectionString, connectionString,
}); });
console.log(connectionString);
const adapter = new PrismaPg(connectionPool); const adapter = new PrismaPg(connectionPool);

View File

@@ -5,5 +5,6 @@ import { PrismaModule } from 'src/prisma/prisma.module';
@Module({ @Module({
providers: [UserService], providers: [UserService],
imports: [PrismaModule], imports: [PrismaModule],
exports: [UserService],
}) })
export class UserModule {} export class UserModule {}