From 6f2fab076d0f39f6026ac899d481c6f2738c79db Mon Sep 17 00:00:00 2001 From: andres Date: Tue, 13 Jun 2023 14:31:05 +0200 Subject: [PATCH] auth in progress --- package.json | 1 + prisma/schema.prisma | 9 +- src/app.module.ts | 2 +- .../common/jwt-payload-extractor.guard.ts | 1 + .../common/jwt-payload-extractor.strategy.ts | 7 +- src/modules/auth/auth.controller.ts | 5 +- src/modules/auth/auth.module.ts | 1 + src/modules/auth/auth.service.ts | 88 +++++++++++++++++-- .../auth/strategies/access-token.strategy.ts | 24 +++++ src/modules/auth/strategies/jwt.strategy.ts | 31 ++++++- src/modules/auth/strategies/local.strategy.ts | 6 +- .../auth/strategies/refresh-token.strategy.ts | 27 ++++++ .../users/infrastructure/users.repository.ts | 8 +- 13 files changed, 181 insertions(+), 29 deletions(-) create mode 100644 src/modules/auth/strategies/access-token.strategy.ts create mode 100644 src/modules/auth/strategies/refresh-token.strategy.ts diff --git a/package.json b/package.json index 3f5202a..3b444b1 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "dev": "nest start --watch", + "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8a69dad..e4c2f34 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,18 +38,17 @@ model User { grades Grade[] generalChatMessages GeneralChatMessage[] verification Verification? - AccessToken AccessToken[] + RevokedToken RevokedToken[] RefreshToken RefreshToken[] @@fulltext([name, email]) } -model AccessToken { +model RevokedToken { id String @id @default(cuid()) userId String token String @unique - expiresAt DateTime - isRevoked Boolean @default(false) + revokedAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) @@index([userId]) @@ -58,7 +57,7 @@ model AccessToken { model RefreshToken { id String @id @default(cuid()) userId String - token String @unique + token String @unique @db.VarChar(255) expiresAt DateTime isRevoked Boolean @default(false) user User @relation(fields: [userId], references: [id]) diff --git a/src/app.module.ts b/src/app.module.ts index d2a7f01..08c0464 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,8 +12,8 @@ import * as process from 'process' @Module({ imports: [ ConfigModule, - AuthModule, UsersModule, + AuthModule, PrismaModule, MailerModule.forRoot({ diff --git a/src/guards/common/jwt-payload-extractor.guard.ts b/src/guards/common/jwt-payload-extractor.guard.ts index ea69d4f..4229c57 100644 --- a/src/guards/common/jwt-payload-extractor.guard.ts +++ b/src/guards/common/jwt-payload-extractor.guard.ts @@ -6,6 +6,7 @@ export class JwtPayloadExtractorGuard extends AuthGuard('payloadExtractor') { canActivate(context: ExecutionContext) { // Add your custom authentication logic here // for example, call super.logIn(request) to establish a session. + console.log('JwtPayloadExtractorGuard') return super.canActivate(context) } diff --git a/src/guards/common/jwt-payload-extractor.strategy.ts b/src/guards/common/jwt-payload-extractor.strategy.ts index 87fe5ee..ad93eac 100644 --- a/src/guards/common/jwt-payload-extractor.strategy.ts +++ b/src/guards/common/jwt-payload-extractor.strategy.ts @@ -2,10 +2,14 @@ import { Inject, Injectable } from '@nestjs/common' import { PassportStrategy } from '@nestjs/passport' import { ExtractJwt, Strategy } from 'passport-jwt' import { AppSettings } from '../../settings/app-settings' +import { AuthService } from '../../modules/auth/auth.service' @Injectable() export class JwtPayloadExtractorStrategy extends PassportStrategy(Strategy, 'payloadExtractor') { - constructor(@Inject(AppSettings.name) private readonly appSettings: AppSettings) { + constructor( + @Inject(AppSettings.name) private readonly appSettings: AppSettings, + private readonly authService: AuthService + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, @@ -14,6 +18,7 @@ export class JwtPayloadExtractorStrategy extends PassportStrategy(Strategy, 'pay } async validate(payload: any) { + console.log(payload) const userId = payload.userId const name = payload.name if (payload) return { userId, name } diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 4ce548e..3afbe87 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -44,7 +44,6 @@ export class AuthController { @UseGuards(LocalAuthGuard) @Post('sign-in') async login(@Request() req, @Res({ passthrough: true }) res) { - console.log(req) const userData = req.user.data res.cookie('refreshToken', userData.refreshToken, { httpOnly: true, @@ -96,10 +95,12 @@ export class AuthController { async refreshToken(@Request() req, @Response() res) { if (!req.cookie?.refreshToken) throw new UnauthorizedException() const userId = req.user.id - const newTokens = this.authService.createJwtTokensPair(userId, null) + const newTokens = await this.authService.createJwtTokensPair(userId) res.cookie('refreshToken', newTokens.refreshToken, { httpOnly: true, secure: true, + path: '/refresh', + expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), }) return { accessToken: newTokens.accessToken } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 93ebd0f..9286042 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -8,5 +8,6 @@ import { LocalStrategy } from './strategies/local.strategy' imports: [UsersModule], controllers: [AuthController], providers: [AuthService, LocalStrategy], + exports: [AuthService], }) export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 4caec5e..3f1807e 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,32 +1,104 @@ -import { Injectable } from '@nestjs/common' -import { isAfter } from 'date-fns' +import { Injectable, UnauthorizedException } from '@nestjs/common' +import { addDays, isAfter } from 'date-fns' import * as jwt from 'jsonwebtoken' import * as bcrypt from 'bcrypt' import { UsersRepository } from '../users/infrastructure/users.repository' import * as process from 'process' +import { PrismaService } from '../../prisma.service' @Injectable() export class AuthService { - constructor(private usersRepository: UsersRepository) {} + constructor(private usersRepository: UsersRepository, private prisma: PrismaService) {} - createJwtTokensPair(userId: string, email: string | null) { + async revokeToken(token: string, userId: string): Promise { + await this.prisma.revokedToken.create({ + data: { + userId, + token, + }, + }) + } + + async isTokenRevoked(token: string): Promise { + const revokedToken = await this.prisma.revokedToken.findUnique({ + where: { token }, + }) + return !!revokedToken + } + + // Periodically remove old revoked tokens + async removeExpiredTokens(): Promise { + const hourAgo = new Date() + hourAgo.setHours(hourAgo.getHours() - 1) + await this.prisma.revokedToken.deleteMany({ + where: { revokedAt: { lt: hourAgo } }, + }) + } + + async createJwtTokensPair(userId: string) { const accessSecretKey = process.env.ACCESS_JWT_SECRET_KEY const refreshSecretKey = process.env.REFRESH_JWT_SECRET_KEY - const payload: { userId: string; date: Date; email: string | null } = { + const payload: { userId: string; date: Date } = { userId, date: new Date(), - email, } - const accessToken = jwt.sign(payload, accessSecretKey, { expiresIn: '1d' }) + const accessToken = jwt.sign(payload, accessSecretKey, { expiresIn: '10m' }) const refreshToken = jwt.sign(payload, refreshSecretKey, { expiresIn: '30d', }) + console.log(refreshToken.length) + // Save refresh token in the database + await this.prisma.refreshToken.create({ + data: { + userId: userId, + token: refreshToken, + expiresAt: addDays(new Date(), 30), + isRevoked: false, + }, + }) + return { accessToken, refreshToken, } } + async checkToken(accessToken: string, refreshToken: string) { + try { + await jwt.verify(accessToken, process.env.ACCESS_JWT_SECRET_KEY) + return true + } catch (err) { + if (err instanceof jwt.TokenExpiredError) { + const dbRefreshToken = await this.prisma.refreshToken.findUnique({ + where: { token: refreshToken }, + }) + const isTokenRevoked = await this.isTokenRevoked(accessToken) + if (isTokenRevoked) { + throw new UnauthorizedException() + } + if (dbRefreshToken && !dbRefreshToken.isRevoked && dbRefreshToken.expiresAt > new Date()) { + const newTokens = await this.createJwtTokensPair(dbRefreshToken.userId) + await this.prisma.refreshToken.update({ + where: { id: dbRefreshToken.id }, + data: { isRevoked: true }, + }) + return newTokens + } + } + throw err + } + } + + async logout(accessToken: string, refreshToken: string) { + // Revoke the access token + const decoded = jwt.verify(accessToken, process.env.ACCESS_JWT_SECRET_KEY) + await this.revokeToken(accessToken, decoded.userId) + await this.prisma.refreshToken.update({ + where: { token: refreshToken }, + data: { isRevoked: true }, + }) + } + async checkCredentials(email: string, password: string) { const user = await this.usersRepository.findUserByEmail(email) if (!user /*|| !user.emailConfirmation.isConfirmed*/) @@ -49,7 +121,7 @@ export class AuthService { }, } } - const tokensPair = this.createJwtTokensPair(user.id, user.email) + const tokensPair = await this.createJwtTokensPair(user.id) return { resultCode: 0, data: tokensPair, diff --git a/src/modules/auth/strategies/access-token.strategy.ts b/src/modules/auth/strategies/access-token.strategy.ts new file mode 100644 index 0000000..f3ec82c --- /dev/null +++ b/src/modules/auth/strategies/access-token.strategy.ts @@ -0,0 +1,24 @@ +import { Inject, Injectable } from '@nestjs/common' +import { PassportStrategy } from '@nestjs/passport' +import { ExtractJwt, Strategy } from 'passport-jwt' +import { AppSettings } from '../../../settings/app-settings' + +type JwtPayload = { + userId: string + username: string +} + +@Injectable() +export class AccessTokenStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor(@Inject(AppSettings.name) private readonly appSettings: AppSettings) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: appSettings.auth.ACCESS_JWT_SECRET_KEY, + }) + } + + async validate(payload: JwtPayload) { + return { userId: payload.userId } + } +} diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts index a4a638c..eda3c78 100644 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -1,19 +1,42 @@ -import { Inject, Injectable } from '@nestjs/common' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' import { PassportStrategy } from '@nestjs/passport' import { ExtractJwt, Strategy } from 'passport-jwt' import { AppSettings } from '../../../settings/app-settings' +import { Request } from 'express' @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(@Inject(AppSettings.name) private readonly appSettings: AppSettings) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - ignoreExpiration: true, + ignoreExpiration: false, secretOrKey: appSettings.auth.ACCESS_JWT_SECRET_KEY, }) } - async validate(payload: any) { - return { userId: payload.userId } + async validate(request: Request, payload: any) { + const accessToken = request.headers.authorization?.split(' ')[1] + const refreshToken = request.cookies.refreshToken // Extract refresh token from cookies + + // If there's no refresh token, simply validate the user based on payload + if (!refreshToken) { + return { userId: payload.userId } + } + + try { + const newAccessToken = await this.authService.checkToken(accessToken, refreshToken) + + // If new access token were issued, attach it to the response headers + if (newAccessToken) { + request.res.setHeader('Authorization', `Bearer ${newAccessToken.accessToken}`) + } + request.res.cookie('refreshToken', newAccessToken.refreshToken, { + httpOnly: true, + path: '/auth/refresh-token', + }) + return { userId: payload.userId } + } catch (error) { + throw new UnauthorizedException('Invalid tokens') + } } } diff --git a/src/modules/auth/strategies/local.strategy.ts b/src/modules/auth/strategies/local.strategy.ts index 4f820ab..a0deaf6 100644 --- a/src/modules/auth/strategies/local.strategy.ts +++ b/src/modules/auth/strategies/local.strategy.ts @@ -12,10 +12,10 @@ export class LocalStrategy extends PassportStrategy(Strategy) { } async validate(email: string, password: string): Promise { - const user = await this.authService.checkCredentials(email, password) - if (user.resultCode === 1) { + const credentials = await this.authService.checkCredentials(email, password) + if (credentials.resultCode === 1) { throw new UnauthorizedException() } - return user + return credentials } } diff --git a/src/modules/auth/strategies/refresh-token.strategy.ts b/src/modules/auth/strategies/refresh-token.strategy.ts new file mode 100644 index 0000000..47ee822 --- /dev/null +++ b/src/modules/auth/strategies/refresh-token.strategy.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from '@nestjs/common' +import { PassportStrategy } from '@nestjs/passport' +import { ExtractJwt, Strategy } from 'passport-jwt' +import { AppSettings } from '../../../settings/app-settings' +import { Request } from 'express' + +type JwtPayload = { + userId: string + username: string +} + +@Injectable() +export class RefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { + constructor(@Inject(AppSettings.name) private readonly appSettings: AppSettings) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: appSettings.auth.ACCESS_JWT_SECRET_KEY, + passReqToCallback: true, + }) + } + + validate(req: Request, payload: any) { + const refreshToken = req.get('Authorization').replace('Bearer', '').trim() + return { ...payload, refreshToken } + } +} diff --git a/src/modules/users/infrastructure/users.repository.ts b/src/modules/users/infrastructure/users.repository.ts index 60e3ffa..3d0920a 100644 --- a/src/modules/users/infrastructure/users.repository.ts +++ b/src/modules/users/infrastructure/users.repository.ts @@ -152,12 +152,10 @@ export class UsersRepository implements IUsersRepository { } async revokeToken(id: string, token: string): Promise { - const revokedToken = await this.prisma.accessToken.update({ - where: { - token: token, - }, + const revokedToken = await this.prisma.revokedToken.create({ data: { - isRevoked: true, + token: token, + userId: id, }, include: { user: true,