diff --git a/package.json b/package.json index b72a720..a4a0d1e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.28", "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.28", "@nestjs/swagger": "^11.4.6", "@nestjs/typeorm": "^11.0.3", @@ -34,6 +35,7 @@ "class-validator": "^0.15.1", "joi": "^18.2.3", "ms": "^2.1.3", + "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.22.0", "reflect-metadata": "^0.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe3777c..b0db64f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@nestjs/jwt': specifier: ^11.0.2 version: 11.0.2(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-express': specifier: ^11.1.28 version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) @@ -50,6 +53,9 @@ importers: ms: specifier: ^2.1.3 version: 2.1.3 + passport: + specifier: ^0.7.0 + version: 0.7.0 passport-jwt: specifier: ^4.0.1 version: 4.0.1 @@ -788,6 +794,12 @@ packages: class-validator: optional: true + '@nestjs/passport@11.0.5': + resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 + '@nestjs/platform-express@11.1.28': resolution: {integrity: sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==} peerDependencies: @@ -2650,6 +2662,10 @@ packages: resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} engines: {node: '>= 0.4.0'} + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2677,6 +2693,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -3303,6 +3322,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -4237,6 +4260,11 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.15.1 + '@nestjs/passport@11.0.5(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + '@nestjs/platform-express@11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)': dependencies: '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -6306,6 +6334,12 @@ snapshots: passport-strategy@1.0.0: {} + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -6326,6 +6360,8 @@ snapshots: path-type@4.0.0: {} + pause@0.0.1: {} + pg-cloudflare@1.4.0: optional: true @@ -6893,6 +6929,8 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 648fa7e..bdb155b 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,58 +1,57 @@ -import { Body, Controller, Post } from "@nestjs/common"; -import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; -import { AuthService } from "./auth.service"; -import { SendOtpDto } from "./DTO/send-otp.dto"; -import { LoginCredentialDto } from "./DTO/login-credential.dto"; -import { VerifyOtpDto } from "./DTO/verify-otp.dto"; +import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { AuthService } from './auth.service'; +import { SendOtpDto } from './DTO/send-otp.dto'; +import { LoginCredentialDto } from './DTO/login-credential.dto'; +import { VerifyOtpDto } from './DTO/verify-otp.dto'; +import { AuthGuard } from 'src/common/decorators/auth-guard.decorator'; +import type { IRequest } from './interfaces/IRequest'; +import { UserDec } from 'src/common/decorators/user.decorator'; @ApiTags('Authentication') @Controller('auth') export class AuthController { - constructor( - private readonly authService: AuthService, - ) {} + constructor(private readonly authService: AuthService) {} @Post('send-otp') @ApiOperation({ summary: 'Send OTP to a phone number', }) - @ApiResponse({status: 200, description: 'Sent Otp Successfully!'}) - @ApiResponse({status: 400, description: 'Bad Request from User!'}) - async sendOtp( - @Body() dto: SendOtpDto - ) { + @ApiResponse({ status: 200, description: 'Sent Otp Successfully!' }) + @ApiResponse({ status: 400, description: 'Bad Request from User!' }) + async sendOtp(@Body() dto: SendOtpDto) { return this.authService.sendOTP(dto.phoneNumber); } @Post('verify-otp') @ApiOperation({ - summary: 'Verify OTP.' + summary: 'Verify OTP.', }) - @ApiResponse({status: 200, description: 'Verified Otp Successfully!'}) - @ApiResponse({status: 400, description: 'Bad Request from the user'}) - async verifyOtp( - @Body() dto: VerifyOtpDto - ) { + @ApiResponse({ status: 200, description: 'Verified Otp Successfully!' }) + @ApiResponse({ status: 400, description: 'Bad Request from the user' }) + async verifyOtp(@Body() dto: VerifyOtpDto) { return this.authService.verifyOTP(dto); } @Post('check-credential') - @ApiOperation({summary: 'Check Login Credentials. If Valid Sends OTP'}) - @ApiResponse({status: 200, description: 'Valid Credentials!'}) - @ApiResponse({status: 400, description: 'Credentials Invalid!'}) - async checkLoginCredential( - @Body() dto: LoginCredentialDto - ) { + @ApiOperation({ summary: 'Check Login Credentials. If Valid Sends OTP' }) + @ApiResponse({ status: 200, description: 'Valid Credentials!' }) + @ApiResponse({ status: 400, description: 'Credentials Invalid!' }) + async checkLoginCredential(@Body() dto: LoginCredentialDto) { return this.authService.checkLoginCredentials(dto); } @Post('verify-login') - @ApiOperation({summary: 'verifies OTP and then login'}) - @ApiResponse({status: 200, description: 'User Logged in successfully!'}) - @ApiResponse({status: 400, description: 'Bad Request From The User'}) - async verifyOtpAndLogin( - @Body() verifyOtpDto: VerifyOtpDto - ) { + @ApiOperation({ summary: 'verifies OTP and then login' }) + @ApiResponse({ status: 200, description: 'User Logged in successfully!' }) + @ApiResponse({ status: 400, description: 'Bad Request From The User' }) + async verifyOtpAndLogin(@Body() verifyOtpDto: VerifyOtpDto) { return this.authService.verifyOtpAndLogin(verifyOtpDto); } -} \ No newline at end of file + + @AuthGuard() + @Get('profile') + profile(@Req() req: IRequest, @UserDec('id') userId: string) { + return req.user; + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 9cb9bbe..578e6b7 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -10,15 +10,19 @@ import { OtpRepository } from './repositories/otp.repository'; import { UsersModule } from 'src/users/users.module'; import { ConfigService } from '@nestjs/config'; import { UserRole } from 'src/users/entities/user-role.entity'; +import { PassportModule } from '@nestjs/passport'; +import { JwtStrategy } from './strategies/jwt.strategy'; @Module({ imports: [ TypeOrmModule.forFeature([OTP]), + PassportModule, JwtModule.register({}), UtilsModule, UsersModule, ], controllers: [AuthController], - providers: [AuthService, OtpRepository], + providers: [AuthService, OtpRepository, JwtStrategy], + exports: [JwtModule, PassportModule] }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 549c6e5..b687bd0 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -133,7 +133,7 @@ export class AuthService { const roles = user.roles.map((r) => r.role); const payload = { - sub: user.id, + id: user.id, phone: user.phone, roles, }; diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..6216cc5 --- /dev/null +++ b/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,33 @@ +import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Observable } from 'rxjs'; +import { SKIP_AUTH_KEY } from 'src/common/decorators/skip-auth.decorator'; +import { AuthMessages } from 'src/common/enums/messages.enum'; +import { JWT_STRATEGY_NAME } from 'src/constants'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY_NAME) { + constructor(private reflector: Reflector) { + super(); + } + + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + const skipAuth = this.reflector.getAllAndOverride(SKIP_AUTH_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (skipAuth) return true; + + return super.canActivate(context); + } + + handleRequest(err: unknown, user: any, _info: unknown) { + if (err || !user) + throw new UnauthorizedException(AuthMessages.TOKEN_EXPIRED_OR_INVALID); + return user; + } +} diff --git a/src/auth/interfaces/IRequest.ts b/src/auth/interfaces/IRequest.ts new file mode 100644 index 0000000..c2b2bba --- /dev/null +++ b/src/auth/interfaces/IRequest.ts @@ -0,0 +1,7 @@ +import { ITokenPayload } from "./IToken-payload"; +import { IUserIpAndHeaders } from "./IUserIpAndHeader"; + +export interface IRequest { + user?: ITokenPayload, + ip_header?: IUserIpAndHeaders +} \ No newline at end of file diff --git a/src/auth/interfaces/IToken-payload.ts b/src/auth/interfaces/IToken-payload.ts new file mode 100644 index 0000000..88cc53e --- /dev/null +++ b/src/auth/interfaces/IToken-payload.ts @@ -0,0 +1,7 @@ +import { RoleType } from "src/users/enums/user-role.enum"; + +export interface ITokenPayload { + id: string; + phone: string; + roles: RoleType[]; +} \ No newline at end of file diff --git a/src/auth/interfaces/IUserIpAndHeader.ts b/src/auth/interfaces/IUserIpAndHeader.ts new file mode 100644 index 0000000..d67acd5 --- /dev/null +++ b/src/auth/interfaces/IUserIpAndHeader.ts @@ -0,0 +1,6 @@ +export interface IUserIpAndHeaders { + ip: string; + headers: Record; + userId: string; + } + \ No newline at end of file diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..6ddf711 --- /dev/null +++ b/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { RoleType } from 'src/users/enums/user-role.enum'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, ExtractJwt } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { JWT_STRATEGY_NAME } from 'src/constants'; +import { ITokenPayload } from '../interfaces/IToken-payload'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY_NAME) { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.getOrThrow('JWT_ACCESS_SECRET'), + }); + } + + async validate(payload: ITokenPayload) { + return { + id: payload.id, + phone: payload.phone, + roles: payload.roles + }; + } +} diff --git a/src/common/decorators/auth-guard.decorator.ts b/src/common/decorators/auth-guard.decorator.ts new file mode 100644 index 0000000..47df060 --- /dev/null +++ b/src/common/decorators/auth-guard.decorator.ts @@ -0,0 +1,10 @@ +import { applyDecorators, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth } from "@nestjs/swagger"; +import { JwtAuthGuard } from "src/auth/guards/jwt-auth.guard"; + +export function AuthGuard() { + return applyDecorators( + UseGuards(JwtAuthGuard), + ApiBearerAuth('authorization') + ); +} \ No newline at end of file diff --git a/src/common/decorators/skip-auth.decorator.ts b/src/common/decorators/skip-auth.decorator.ts new file mode 100644 index 0000000..9b3cdb7 --- /dev/null +++ b/src/common/decorators/skip-auth.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from "@nestjs/common"; + +export const SKIP_AUTH_KEY = "skipAuth"; + +export const skipAuth = () => SetMetadata(SKIP_AUTH_KEY, true); \ No newline at end of file diff --git a/src/common/decorators/user.decorator.ts b/src/common/decorators/user.decorator.ts new file mode 100644 index 0000000..871bc82 --- /dev/null +++ b/src/common/decorators/user.decorator.ts @@ -0,0 +1,22 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { IRequest } from 'src/auth/interfaces/IRequest'; +import { ITokenPayload } from 'src/auth/interfaces/IToken-payload'; + +export const UserDec = createParamDecorator( + (data: keyof ITokenPayload | undefined, ctx: ExecutionContext) => { + const req = ctx.switchToHttp().getRequest(); + const user = req.user; + + return data ? user?.[data] : user; + }, +); + +export const UserIpAndHeaders = createParamDecorator( + (_data: unknown, ctx: ExecutionContext) => { + const req = ctx.switchToHttp().getRequest(); + const user = req.user; + const ip_header = req.ip_header; + + return { ip: ip_header?.ip, headers: ip_header?.headers, userId: user?.id }; + }, +); diff --git a/src/common/enums/messages.enum.ts b/src/common/enums/messages.enum.ts index 0d42008..ac544c7 100644 --- a/src/common/enums/messages.enum.ts +++ b/src/common/enums/messages.enum.ts @@ -14,4 +14,8 @@ export enum OTPMessages { export enum UserRoleMessages { USER_ROLE_ALREADY_EXISTS = 'نقش مورد نظر برای کاربر مد نظر وجود دارد!' +} + +export enum AuthMessages { + TOKEN_EXPIRED_OR_INVALID = 'توکن منسوخ شده یا نامعتبر است!', } \ No newline at end of file diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..24fad55 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1 @@ +export const JWT_STRATEGY_NAME = "jwt_Strategy"; \ No newline at end of file