From 8a590b42f27b2a6251c832ac60debe0a2d2e5a24 Mon Sep 17 00:00:00 2001 From: MiguelMLorente Date: Sat, 6 Dec 2025 22:13:51 +0100 Subject: [PATCH] Implement authentication guard with JWT --- package-lock.json | 133 ++++++++++++++++++++++++++- package.json | 1 + src/app.module.ts | 13 +++ src/auth.guard.ts | 55 +++++++++++ src/controller/access.controller.ts | 23 +++-- src/controller/buy.controller.ts | 8 +- src/controller/session.controller.ts | 11 ++- src/controller/tokens.controller.ts | 5 +- src/service/auth.service.ts | 23 +++++ 9 files changed, 248 insertions(+), 24 deletions(-) create mode 100644 src/auth.guard.ts create mode 100644 src/service/auth.service.ts diff --git a/package-lock.json b/package-lock.json index d094b8f..422faca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^11.0.1", "@nestjs/typeorm": "^11.0.0", "dotenv": "^17.2.3", @@ -2368,6 +2369,19 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.9", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.9.tgz", @@ -2903,6 +2917,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2917,11 +2941,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4302,6 +4331,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5015,6 +5050,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7428,6 +7472,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7525,6 +7612,42 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7539,6 +7662,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -8897,7 +9026,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10369,7 +10497,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/universalify": { diff --git a/package.json b/package.json index 107aa5d..5c0310d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^11.0.1", "@nestjs/typeorm": "^11.0.0", "dotenv": "^17.2.3", diff --git a/src/app.module.ts b/src/app.module.ts index de70dd7..55fe76c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,9 @@ import { Session } from './dto/session'; import { TokensController } from './controller/tokens.controller'; import { TokenService } from './service/token.service'; import { Token } from './dto/token'; +import { JwtModule } from '@nestjs/jwt'; +import { AuthGuard } from './auth.guard'; +import { AuthService } from './service/auth.service'; @Module({ imports: [ @@ -28,6 +31,11 @@ import { Token } from './dto/token'; synchronize: true, }), TypeOrmModule.forFeature([User, Purchase, Session, Token]), + JwtModule.register({ + global: true, + secret: process.env.JWT_SECRET || 'jwt-key', + signOptions: { expiresIn: '4 WEEKS' }, + }), ], controllers: [ AccessController, @@ -41,6 +49,11 @@ import { Token } from './dto/token'; StripeService, SessionService, TokenService, + AuthService, + { + provide: 'APP_GUARD', + useClass: AuthGuard, + }, ], }) export class AppModule {} diff --git a/src/auth.guard.ts b/src/auth.guard.ts new file mode 100644 index 0000000..863f946 --- /dev/null +++ b/src/auth.guard.ts @@ -0,0 +1,55 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; +import { SetMetadata } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + +@Injectable() +export class AuthGuard implements CanActivate { + private readonly jwtSecret = process.env.JWT_SECRET || 'jwt-key'; + + constructor( + private jwtService: JwtService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + if (!token) { + throw new UnauthorizedException(); + } + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: this.jwtSecret, + }); + + request['user'] = payload; + } catch { + throw new UnauthorizedException(); + } + return true; + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/controller/access.controller.ts b/src/controller/access.controller.ts index 895f126..0a57988 100644 --- a/src/controller/access.controller.ts +++ b/src/controller/access.controller.ts @@ -6,37 +6,36 @@ import { UnauthorizedException, } from '@nestjs/common'; import { UserService } from '../service/user.service'; +import { AuthService } from 'src/service/auth.service'; +import { Public } from 'src/auth.guard'; @Controller('/access') export class AccessController { private readonly logger = new Logger(AccessController.name); - constructor(private readonly userService: UserService) {} + constructor( + private readonly userService: UserService, + private readonly authService: AuthService, + ) {} @Post('/login') + @Public() public async login( @Body('name') name: string, @Body('password') password: string, ) { this.logger.debug('Received login request'); - const user = await this.userService.getUserByName(name); - if (user.password !== password) { - throw new UnauthorizedException(); - } - return { - userId: user.id, - }; + return await this.authService.signIn(name, password); } @Post('/register') + @Public() public async new( @Body('name') name: string, @Body('password') password: string, ) { this.logger.debug('Received register request'); - const user = await this.userService.createUser(name, password); - return { - userId: user.id, - }; + await this.userService.createUser(name, password); + return await this.authService.signIn(name, password); } } diff --git a/src/controller/buy.controller.ts b/src/controller/buy.controller.ts index da138e4..71d027b 100644 --- a/src/controller/buy.controller.ts +++ b/src/controller/buy.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Logger, Post } from '@nestjs/common'; +import { Body, Controller, Logger, Post, Req } from '@nestjs/common'; import { PurchaseService } from '../service/purchase.service'; import { UserService } from '../service/user.service'; import { PurchaseItem } from '../dto/purchase-item'; @@ -23,9 +23,10 @@ export class BuyController { @Post('/start') public async startPurchaseFlow( - @Body('userId') userId: string, + @Req() request, @Body('quantity') quantity: number, ) { + const userId = request.userId; const user = await this.userService.getUserById(userId); const purchase = await this.purchaseService.createPurchase( user, @@ -41,7 +42,8 @@ export class BuyController { } @Post('/complete') - public async complete(@Body('userId') userId: string) { + public async complete(@Req() request) { + const userId = request.userId; const user = await this.userService.getUserById(userId); const purchases = await this.purchaseService.getPurchasesForUser(user); purchases diff --git a/src/controller/session.controller.ts b/src/controller/session.controller.ts index ca641ee..22a4735 100644 --- a/src/controller/session.controller.ts +++ b/src/controller/session.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, Req } from '@nestjs/common'; import { SessionService } from 'src/service/session.service'; import { UserService } from 'src/service/user.service'; @@ -18,7 +18,8 @@ export class SessionController { } @Get() - public async getAllSessions(@Query('userId') userId: string) { + public async getAllSessions(@Req() request) { + const userId = request.userId; const sessions = await this.sessionService.getAllSessions(); const user = await this.userService.getUserById(userId); return sessions.map((session) => ({ @@ -33,18 +34,20 @@ export class SessionController { @Post('/join') public async joinSession( - @Body('userId') userId: string, + @Req() request, @Body('sessionId') sessionId: string, ) { + const userId = request.userId; const user = await this.userService.getUserById(userId); await this.sessionService.joinSession(user, sessionId); } @Post('/leave') public async leaveSession( - @Body('userId') userId: string, + @Req() request, @Body('sessionId') sessionId: string, ) { + const userId = request.userId; const user = await this.userService.getUserById(userId); await this.sessionService.leaveSession(user, sessionId); } diff --git a/src/controller/tokens.controller.ts b/src/controller/tokens.controller.ts index 99092ec..b2a6297 100644 --- a/src/controller/tokens.controller.ts +++ b/src/controller/tokens.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Logger, Query } from '@nestjs/common'; +import { Controller, Get, Logger, Query, Req } from '@nestjs/common'; import { TokenService } from 'src/service/token.service'; import { UserService } from 'src/service/user.service'; @@ -12,7 +12,8 @@ export class TokensController { ) {} @Get() - public async getTokenCount(@Query('userId') userId: string) { + public async getTokenCount(@Req() request) { + const userId = request.userId; this.logger.debug(`Finding tokens for user: ${userId}`); const user = await this.userService.getUserById(userId); const token = await this.tokenService.getTokens(user); diff --git a/src/service/auth.service.ts b/src/service/auth.service.ts new file mode 100644 index 0000000..a0c1a2b --- /dev/null +++ b/src/service/auth.service.ts @@ -0,0 +1,23 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { UserService } from './user.service'; + +@Injectable() +export class AuthService { + constructor( + private usersService: UserService, + private jwtService: JwtService, + ) {} + + async signIn(name: string, password: string) { + const user = await this.usersService.getUserByName(name); + if (user?.password !== password) { + throw new UnauthorizedException(); + } + + const payload = { userId: user.id }; + return { + jwtToken: await this.jwtService.signAsync(payload), + }; + } +}