From 9aec0103167264aa9ea47217237047820c92da6f Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Thu, 30 May 2024 14:34:18 +0200 Subject: [PATCH 1/7] added dashboard --- frontend/src/app/app.component.ts | 28 ++++-- frontend/src/app/app.routes.ts | 31 ++++++- .../dashboard-root.component.html | 1 + .../dashboard-root.component.scss | 0 .../dashboard-root.component.ts | 14 +++ .../register-root/register-root.component.ts | 2 +- frontend/src/app/shared/guard/auth.guard.ts | 32 +++++++ .../shared/interceptors/auth.interceptor.ts | 90 ++++++++++++------- .../src/app/shared/service/auth.service.ts | 34 +++---- 9 files changed, 171 insertions(+), 61 deletions(-) create mode 100644 frontend/src/app/pages/dashboard-root/dashboard-root.component.html create mode 100644 frontend/src/app/pages/dashboard-root/dashboard-root.component.scss create mode 100644 frontend/src/app/pages/dashboard-root/dashboard-root.component.ts create mode 100644 frontend/src/app/shared/guard/auth.guard.ts diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 2ffb389..763c832 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,15 +1,33 @@ -import { Component, inject } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { Component, OnInit } from '@angular/core'; +import { RouterOutlet, Router } from '@angular/router'; import { AuthService } from './shared/service'; @Component({ selector: 'app-root', standalone: true, - providers: [AuthService], + providers: [], imports: [RouterOutlet], templateUrl: './app.component.html', styleUrl: './app.component.scss', }) -export class AppComponent { - private readonly authService: AuthService = inject(AuthService); +export class AppComponent implements OnInit { + public constructor( + private readonly authService: AuthService, + private readonly router: Router + ) {} + + public ngOnInit(): void { + this.checkAuthentication(); + } + + private checkAuthentication(): void { + this.authService.isAuthenticated$.subscribe((isAuthenticated: boolean) => { + if (isAuthenticated) { + console.log('User is authenticated'); + this.router.navigateByUrl('dashboard'); + } else { + this.router.navigateByUrl('signup'); + } + }); + } } diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 5488cff..2608632 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,7 +1,12 @@ import { Routes } from '@angular/router'; -export const routes: Routes = [ - { path: '', pathMatch: 'full', redirectTo: '' }, +import { AuthGuard } from './shared/guard/auth.guard'; + +const publicRoutes: Routes = [ + { + path: '', + loadComponent: () => import('./app.component').then((m) => m.AppComponent), + }, { path: 'signup', loadComponent: () => @@ -17,3 +22,25 @@ export const routes: Routes = [ ), }, ]; + +const protectedRoutes: Routes = [ + { + path: 'dashboard', + loadComponent: () => + import('./pages/dashboard-root/dashboard-root.component').then( + (m) => m.DashboardRootComponent + ), + canActivate: [AuthGuard], + }, +]; + +export const routes: Routes = [ + { + path: '', + children: [ + ...publicRoutes, + ...protectedRoutes, + { path: '', redirectTo: '', pathMatch: 'full' }, + ], + }, +]; diff --git a/frontend/src/app/pages/dashboard-root/dashboard-root.component.html b/frontend/src/app/pages/dashboard-root/dashboard-root.component.html new file mode 100644 index 0000000..f3e333e --- /dev/null +++ b/frontend/src/app/pages/dashboard-root/dashboard-root.component.html @@ -0,0 +1 @@ +

Hello World

diff --git a/frontend/src/app/pages/dashboard-root/dashboard-root.component.scss b/frontend/src/app/pages/dashboard-root/dashboard-root.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/pages/dashboard-root/dashboard-root.component.ts b/frontend/src/app/pages/dashboard-root/dashboard-root.component.ts new file mode 100644 index 0000000..6a160af --- /dev/null +++ b/frontend/src/app/pages/dashboard-root/dashboard-root.component.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'app-dashboard-root', + standalone: true, + imports: [], + providers: [], + templateUrl: './dashboard-root.component.html', + styleUrl: './dashboard-root.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DashboardRootComponent { + public constructor() {} +} diff --git a/frontend/src/app/pages/register-root/register-root.component.ts b/frontend/src/app/pages/register-root/register-root.component.ts index ccf8788..f70ee29 100644 --- a/frontend/src/app/pages/register-root/register-root.component.ts +++ b/frontend/src/app/pages/register-root/register-root.component.ts @@ -47,7 +47,7 @@ type AuthAction = 'register' | 'signup'; PasswordModule, HttpClientModule, ], - providers: [AuthService], + providers: [], templateUrl: './register-root.component.html', styleUrl: './register-root.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/frontend/src/app/shared/guard/auth.guard.ts b/frontend/src/app/shared/guard/auth.guard.ts new file mode 100644 index 0000000..36fb5f3 --- /dev/null +++ b/frontend/src/app/shared/guard/auth.guard.ts @@ -0,0 +1,32 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; + +import { Observable } from 'rxjs'; + +import { AuthService } from '../service'; + +export const AuthGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot +): + | Observable + | Promise + | boolean + | UrlTree => { + const authService: AuthService = inject(AuthService); + const router: Router = inject(Router); + + authService.isAuthenticated$.subscribe((isAuthenticated: boolean) => { + if (!isAuthenticated) { + router.navigateByUrl('signup'); + } + }); + + return true; +}; diff --git a/frontend/src/app/shared/interceptors/auth.interceptor.ts b/frontend/src/app/shared/interceptors/auth.interceptor.ts index 7259b6c..00d0384 100644 --- a/frontend/src/app/shared/interceptors/auth.interceptor.ts +++ b/frontend/src/app/shared/interceptors/auth.interceptor.ts @@ -1,51 +1,79 @@ import { - HttpErrorResponse, - HttpEvent, - HttpHandlerFn, HttpInterceptorFn, HttpRequest, + HttpHandlerFn, + HttpEvent, + HttpErrorResponse, } from '@angular/common/http'; import { inject } from '@angular/core'; +import { Router } from '@angular/router'; -import { Observable, catchError, switchMap, throwError } from 'rxjs'; +import { Observable, throwError } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; import { AuthService } from '../service'; -import { Tokens } from '../types'; export const AuthInterceptor: HttpInterceptorFn = ( request: HttpRequest, next: HttpHandlerFn ): Observable> => { - const authService: AuthService = inject(AuthService); - const accessToken: string | null = authService.access_token; + const router = inject(Router); + const authService = inject(AuthService); - if (accessToken) { - request = request.clone({ + const handleRequest = ( + req: HttpRequest + ): Observable> => { + const accessToken = authService.access_token; + + if (accessToken) { + req = addAuthHeader(req, accessToken); + } + return next(req); + }; + + const addAuthHeader = ( + req: HttpRequest, + token: string + ): HttpRequest => { + return req.clone({ setHeaders: { - Authorization: `Bearer ${accessToken}`, + Authorization: `Bearer ${token}`, }, }); - } - return next(request).pipe( - catchError((error: HttpErrorResponse) => { - if (error.status === 401) { - return authService.refreshToken().pipe( - switchMap((tokens: Tokens) => { - request = request.clone({ - setHeaders: { - Authorization: `Bearer ${tokens.access_token}`, - }, - }); - return next(request); - }), - catchError((refreshError) => { - authService.signout(); - return throwError(() => new Error(refreshError)); - }) - ); - } + }; - return throwError(() => new Error()); - }) + const handle401Error = ( + req: HttpRequest + ): Observable> => { + console.log(authService.refresh_token); + if (!authService.refresh_token) { + router.navigateByUrl('signup'); + return throwError(() => new Error('Authentication required')); + } + + return authService.refreshToken().pipe( + switchMap((tokens) => { + req = addAuthHeader(req, tokens.access_token); + return next(req); + }), + catchError((refreshError) => { + router.navigateByUrl('signup'); + return throwError(() => new Error(refreshError)); + }) + ); + }; + + const handleError = ( + error: HttpErrorResponse, + req: HttpRequest + ): Observable> => { + if (error.status === 401) { + return handle401Error(req); + } + return throwError(() => new Error('Unhandled error')); + }; + + return handleRequest(request).pipe( + catchError((error) => handleError(error, request)) ); }; diff --git a/frontend/src/app/shared/service/auth.service.ts b/frontend/src/app/shared/service/auth.service.ts index c7a6ea3..381fed3 100644 --- a/frontend/src/app/shared/service/auth.service.ts +++ b/frontend/src/app/shared/service/auth.service.ts @@ -1,4 +1,3 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable, tap } from 'rxjs'; @@ -13,22 +12,28 @@ import { SessionStorageService } from './session-storage.service'; providedIn: 'root', }) export class AuthService { + public isAuthenticated$: BehaviorSubject = + new BehaviorSubject(false); private _access_token: string | null = null; private _refresh_token: string | null = null; - private _isAuthenticated$: BehaviorSubject = - new BehaviorSubject(false); public get access_token(): string | null { return this._access_token; } + public get refresh_token(): string | null { + return this._refresh_token; + } + public constructor( - private readonly httpClient: HttpClient, private readonly localStorageService: LocalStorageService, private readonly sessionStorageService: SessionStorageService, private readonly authenticationApiService: AuthenticationApiService ) { - //this.autoLogin(); + this._access_token = + this.localStorageService.getItem('access_token'); + this._refresh_token = + this.sessionStorageService.getItem('refresh_token'); } public signin(credentials: LoginCredentials): void { @@ -66,31 +71,16 @@ export class AuthService { this._refresh_token = null; this.localStorageService.removeItem('access_token'); this.sessionStorageService.removeItem('refresh_token'); - this._isAuthenticated$.next(false); + this.isAuthenticated$.next(false); } }); } - public autoLogin(): void { - const storedAccessToken: string | null = - this.localStorageService.getItem('access_token'); - const storedRefreshToken: string | null = - this.sessionStorageService.getItem('refresh_token'); - - if (storedAccessToken && storedRefreshToken) { - this._refresh_token = storedRefreshToken; - this._isAuthenticated$.next(true); - //TODO Validate tokens with backend or decode JWT to check expiration - } else { - this.signout(); - } - } - private handleSuccess(tokens: Tokens): void { this._access_token = tokens.access_token; this._refresh_token = tokens.refresh_token; this.localStorageService.setItem('access_token', tokens.access_token); this.sessionStorageService.setItem('refresh_token', tokens.refresh_token); - this._isAuthenticated$.next(true); + this.isAuthenticated$.next(true); } } From 5769cf4f5a9395912d862a1a9561e490a40a0549 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Thu, 30 May 2024 22:29:55 +0200 Subject: [PATCH 2/7] rebuild frontend to http only cookies for session --- backend/package.json | 5 +- backend/pnpm-lock.yaml | 28 +++++ backend/src/app.module.ts | 5 +- backend/src/entities/index.ts | 1 + backend/src/entities/session.entity.ts | 38 ++++++ backend/src/main.ts | 6 + .../src/modules/auth-module/auth.module.ts | 6 +- .../auth-module/controller/auth.controller.ts | 92 +++++++++----- .../{tokens.dto.ts => access-token.dto.ts} | 11 +- .../modules/auth-module/models/dto/index.ts | 3 +- .../models/dto/login-response.dto.ts | 31 +++++ .../auth-module/services/auth.service.ts | 118 +++++++++++++----- .../auth-module/services/session.service.ts | 82 ++++++++++++ .../services/token-management.service.ts | 23 +++- .../database-module/database-config.ts | 9 +- 15 files changed, 373 insertions(+), 85 deletions(-) create mode 100644 backend/src/entities/session.entity.ts rename backend/src/modules/auth-module/models/dto/{tokens.dto.ts => access-token.dto.ts} (58%) create mode 100644 backend/src/modules/auth-module/models/dto/login-response.dto.ts create mode 100644 backend/src/modules/auth-module/services/session.service.ts diff --git a/backend/package.json b/backend/package.json index e2b1cd4..ea55b01 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,19 +35,22 @@ "argon2": "^0.40.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie-parser": "^1.4.6", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.11.5", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.0", - "typeorm": "^0.3.20" + "typeorm": "^0.3.20", + "uuid": "^9.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/argon2": "^0.15.0", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 9f8d4b8..80cfa46 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -41,6 +41,9 @@ dependencies: class-validator: specifier: ^0.14.1 version: 0.14.1 + cookie-parser: + specifier: ^1.4.6 + version: 1.4.6 passport: specifier: ^0.7.0 version: 0.7.0 @@ -62,6 +65,9 @@ dependencies: typeorm: specifier: ^0.3.20 version: 0.3.20(pg@8.11.5)(ts-node@10.9.2) + uuid: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@nestjs/cli': @@ -76,6 +82,9 @@ devDependencies: '@types/argon2': specifier: ^0.15.0 version: 0.15.0 + '@types/cookie-parser': + specifier: ^1.4.7 + version: 1.4.7 '@types/express': specifier: ^4.17.17 version: 4.17.21 @@ -1288,6 +1297,12 @@ packages: '@types/node': 20.12.4 dev: true + /@types/cookie-parser@1.4.7: + resolution: {integrity: sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==} + dependencies: + '@types/express': 4.17.21 + dev: true + /@types/cookiejar@2.1.5: resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} dev: true @@ -2237,9 +2252,22 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /cookie-parser@1.4.6: + resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==} + engines: {node: '>= 0.8.0'} + dependencies: + cookie: 0.4.1 + cookie-signature: 1.0.6 + dev: false + /cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + /cookie@0.4.1: + resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==} + engines: {node: '>= 0.6'} + dev: false + /cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e6167c8..24c2884 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -3,7 +3,6 @@ import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { CorsMiddleware } from './middleware/cors-middleware/cors.middlware'; import { CspMiddleware } from './middleware/csp-middleware/csp.middleware'; import { HttpsRedirectMiddleware } from './middleware/https-middlware/https-redirect.middleware'; import { SecurityHeadersMiddleware } from './middleware/security-middleware/security.middleware'; @@ -35,8 +34,8 @@ export class AppModule { .apply( CspMiddleware, SecurityHeadersMiddleware, - HttpsRedirectMiddleware, - CorsMiddleware + HttpsRedirectMiddleware + //CorsMiddleware ) .forRoutes({ path: '*', method: RequestMethod.ALL }); } diff --git a/backend/src/entities/index.ts b/backend/src/entities/index.ts index 6d8bde7..cdebf32 100644 --- a/backend/src/entities/index.ts +++ b/backend/src/entities/index.ts @@ -1,3 +1,4 @@ export * from './user-credentials.entity'; export * from './user-data.entity'; export * from './email-verification.entity'; +export * from './session.entity'; diff --git a/backend/src/entities/session.entity.ts b/backend/src/entities/session.entity.ts new file mode 100644 index 0000000..80a3bfb --- /dev/null +++ b/backend/src/entities/session.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { UserCredentials } from './user-credentials.entity'; + +@Entity() +export class Session { + @PrimaryGeneratedColumn('uuid') + public id: string; + + @Column() + public sessionId: string; + + @Column({ type: 'timestamp' }) + public expiresAt: Date; + + @Column({}) + public userAgent: string; + + @ManyToOne(() => UserCredentials, (userCredentials) => userCredentials.id, { + nullable: false, + }) + @JoinColumn({ name: 'userCredentialsId' }) + public userCredentials: UserCredentials['id']; + + @CreateDateColumn() + public createdAt: Date; + + @UpdateDateColumn() + public updatedAt: Date; +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 7c72a88..b07d58f 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import * as cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; @@ -30,6 +31,10 @@ async function setupSwagger(app: INestApplication): Promise { ); } +async function setupCookieParser(app: INestApplication): Promise { + app.use(cookieParser()); +} + async function setupPrefix(app: INestApplication): Promise { app.setGlobalPrefix('api'); } @@ -41,6 +46,7 @@ async function setupClassValidator(app: INestApplication): Promise { async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); + await setupCookieParser(app); await setupSwagger(app); await setupPrefix(app); await setupClassValidator(app); diff --git a/backend/src/modules/auth-module/auth.module.ts b/backend/src/modules/auth-module/auth.module.ts index 5a1dccb..9f440c3 100644 --- a/backend/src/modules/auth-module/auth.module.ts +++ b/backend/src/modules/auth-module/auth.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { UserCredentials } from 'src/entities'; +import { Session, UserCredentials } from 'src/entities'; import { SendgridModule } from '../sendgrid-module/sendgrid.module'; import { UserModule } from '../user-module/user.module'; @@ -10,6 +10,7 @@ import { VerifyModule } from '../verify-module/verify.module'; import { AuthController } from './controller/auth.controller'; import { UserCredentialsRepository } from './repositories/user-credentials.repository'; import { AuthService } from './services/auth.service'; +import { SessionService } from './services/session.service'; import { TokenManagementService } from './services/token-management.service'; import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies'; @@ -19,10 +20,11 @@ import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies'; SendgridModule, VerifyModule, JwtModule.register({}), - TypeOrmModule.forFeature([UserCredentials]), + TypeOrmModule.forFeature([UserCredentials, Session]), ], providers: [ AuthService, + SessionService, TokenManagementService, UserCredentialsRepository, AccessTokenStrategy, diff --git a/backend/src/modules/auth-module/controller/auth.controller.ts b/backend/src/modules/auth-module/controller/auth.controller.ts index bc48206..2e25b21 100644 --- a/backend/src/modules/auth-module/controller/auth.controller.ts +++ b/backend/src/modules/auth-module/controller/auth.controller.ts @@ -4,14 +4,19 @@ import { Body, HttpCode, HttpStatus, - UseGuards, + Res, + Req, } from '@nestjs/common'; -import { ApiCreatedResponse, ApiHeader, ApiTags } from '@nestjs/swagger'; +import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; +import { Response, Request } from 'express'; import { Public } from 'src/shared/decorator'; -import { GetCurrentUser, GetCurrentUserId } from '../common/decorators'; -import { RefreshTokenGuard } from '../common/guards'; -import { TokensDto, UserCredentialsDto } from '../models/dto'; +import { GetCurrentUserId } from '../common/decorators'; +import { + AccessTokenDto, + LoginResponseDto, + UserCredentialsDto, +} from '../models/dto'; import { AuthService } from '../services/auth.service'; @ApiTags('Authentication') @@ -21,28 +26,36 @@ export class AuthController { @ApiCreatedResponse({ description: 'User signed up successfully', - type: TokensDto, + type: LoginResponseDto, }) @Public() @Post('signup') @HttpCode(HttpStatus.CREATED) public async signup( @Body() userCredentials: UserCredentialsDto - ): Promise { + ): Promise { return this.authService.signup(userCredentials); } @ApiCreatedResponse({ description: 'User signin successfully', - type: TokensDto, + type: LoginResponseDto, }) @Public() @Post('signin') @HttpCode(HttpStatus.OK) public async signin( + @Res({ passthrough: true }) response: Response, + @Req() request: Request, @Body() userCredentials: UserCredentialsDto - ): Promise { - return this.authService.signin(userCredentials); + ): Promise { + return await this.authService.signin(userCredentials, response, request); + } + + @Public() + @Post('refresh') + public async refreshToken(@Req() request: Request): Promise { + return await this.authService.refresh(request); } @ApiCreatedResponse({ @@ -55,25 +68,42 @@ export class AuthController { return this.authService.logout(userId); } - @ApiHeader({ - name: 'Authorization', - required: true, - schema: { - example: 'Bearer ', - }, - }) - @ApiCreatedResponse({ - description: 'User tokens refreshed successfully', - type: TokensDto, - }) - @Public() - @UseGuards(RefreshTokenGuard) - @Post('refresh') - @HttpCode(HttpStatus.OK) - public async refresh( - @GetCurrentUserId() userId: string, - @GetCurrentUser('refresh_token') refresh_token: string - ): Promise { - return this.authService.refresh(userId, refresh_token); - } + // @ApiHeader({ + // name: 'Authorization', + // required: true, + // schema: { + // example: 'Bearer ', + // }, + // }) + // @ApiCreatedResponse({ + // description: 'User tokens refreshed successfully', + // type: TokensDto, + // }) + // @Public() + // @UseGuards(RefreshTokenGuard) + // @Post('refresh') + // @HttpCode(HttpStatus.OK) + // public async refresh( + // @GetCurrentUserId() userId: string, + // @GetCurrentUser('refresh_token') refresh_token: string + // ): Promise { + // return this.authService.refresh(userId, refresh_token); + // } + + // @ApiHeader({ + // name: 'Authorization', + // required: true, + // schema: { + // example: 'Bearer ', + // }, + // }) + // @ApiCreatedResponse({ + // description: 'Token validity checked successfully', + // type: Boolean, + // }) + // @Post('check-token') + // @HttpCode(HttpStatus.OK) + // public checkTokenValidity(): Promise { + // return this.authService.checkTokenValidity(); + // } } diff --git a/backend/src/modules/auth-module/models/dto/tokens.dto.ts b/backend/src/modules/auth-module/models/dto/access-token.dto.ts similarity index 58% rename from backend/src/modules/auth-module/models/dto/tokens.dto.ts rename to backend/src/modules/auth-module/models/dto/access-token.dto.ts index 2d6e111..4a8ea9a 100644 --- a/backend/src/modules/auth-module/models/dto/tokens.dto.ts +++ b/backend/src/modules/auth-module/models/dto/access-token.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; -export class TokensDto { +export class AccessTokenDto { @ApiProperty({ title: 'Access token', description: 'Access token', @@ -10,13 +10,4 @@ export class TokensDto { @IsNotEmpty() @IsString() public access_token: string; - - @ApiProperty({ - title: 'Refresh token', - description: 'Refresh token', - example: 'eyJhbGci', - }) - @IsNotEmpty() - @IsString() - public refresh_token: string; } diff --git a/backend/src/modules/auth-module/models/dto/index.ts b/backend/src/modules/auth-module/models/dto/index.ts index bd47ab5..4b4a3fc 100644 --- a/backend/src/modules/auth-module/models/dto/index.ts +++ b/backend/src/modules/auth-module/models/dto/index.ts @@ -1,2 +1,3 @@ export * from './user-credentials.dto'; -export * from './tokens.dto'; +export * from './login-response.dto'; +export * from './access-token.dto'; diff --git a/backend/src/modules/auth-module/models/dto/login-response.dto.ts b/backend/src/modules/auth-module/models/dto/login-response.dto.ts new file mode 100644 index 0000000..1f908d5 --- /dev/null +++ b/backend/src/modules/auth-module/models/dto/login-response.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; + +export class LoginResponseDto { + @ApiProperty({ + title: 'Access token', + description: 'Access token', + example: 'eyJhbGci', + }) + @IsNotEmpty() + @IsString() + public access_token: string; + + @ApiProperty({ + title: 'Email', + description: 'User Email', + }) + @IsNotEmpty() + @IsString() + @IsEmail() + public email: string; + + @ApiProperty({ + title: 'User ID', + description: 'User ID', + }) + @IsNotEmpty() + @IsString() + @IsEmail() + public userId: string; +} diff --git a/backend/src/modules/auth-module/services/auth.service.ts b/backend/src/modules/auth-module/services/auth.service.ts index 414ecca..df0ecef 100644 --- a/backend/src/modules/auth-module/services/auth.service.ts +++ b/backend/src/modules/auth-module/services/auth.service.ts @@ -1,13 +1,23 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; +import { Response, Request } from 'express'; +import { Session } from 'src/entities'; import { EncryptionService } from 'src/shared'; import { PasswordConfirmationMailService } from '../../sendgrid-module/services/password-confirmation.mail.service'; import { UserDataRepository } from '../../user-module/repositories/user-data.repository'; import { EmailVerificationService } from '../../verify-module/services/email-verification.service'; -import { TokensDto, UserCredentialsDto } from '../models/dto'; +import { + AccessTokenDto, + LoginResponseDto, + UserCredentialsDto, +} from '../models/dto'; import { UserCredentialsRepository } from '../repositories/user-credentials.repository'; -import { TokenManagementService } from './token-management.service'; +import { SessionService } from './session.service'; +import { + TokenManagementService, + TokenPayload, +} from './token-management.service'; @Injectable() export class AuthService { @@ -16,10 +26,13 @@ export class AuthService { private readonly userDataRepository: UserDataRepository, private readonly tokenManagementService: TokenManagementService, private readonly passwordConfirmationMailService: PasswordConfirmationMailService, - private readonly emailVerificationService: EmailVerificationService + private readonly emailVerificationService: EmailVerificationService, + private readonly sessionService: SessionService ) {} - public async signup(userCredentials: UserCredentialsDto): Promise { + public async signup( + userCredentials: UserCredentialsDto + ): Promise { const passwordHashed = await EncryptionService.hashData( userCredentials.password ); @@ -44,7 +57,11 @@ export class AuthService { return this.generateAndPersistTokens(user.id, user.email); } - public async signin(userCredentials: UserCredentialsDto): Promise { + public async signin( + userCredentials: UserCredentialsDto, + response: Response, + request: Request + ): Promise { const user = await this.userCredentialsRepository.findUserByEmail( userCredentials.email ); @@ -62,32 +79,18 @@ export class AuthService { throw new ForbiddenException('Access Denied'); } - return this.generateAndPersistTokens(user.id, user.email); - } - - public async refresh( - userId: string, - refreshToken: string - ): Promise { - const user = await this.userCredentialsRepository.findUserById(userId); - - if (!user || !user.hashedRt) { - throw new ForbiddenException('Access Denied'); - } - - const refreshTokenMatch = await EncryptionService.compareHash( - refreshToken, - user.hashedRt + const sesseionId = await this.sessionService.createSession( + user.id, + request.headers['user-agent'] ); - if (!refreshTokenMatch) { - throw new ForbiddenException('Access Denied'); - } + this.sessionService.attachSessionToResponse(response, sesseionId.sessionId); return this.generateAndPersistTokens(user.id, user.email); } public async logout(userId: string): Promise { + // TODO Check if the user is logged out already const affected = await this.userCredentialsRepository.updateUserTokenHash( userId, null @@ -96,22 +99,77 @@ export class AuthService { return affected > 0; } + public async refresh(request: Request): Promise { + const sessionId = request.cookies['session_id']; + + if (!sessionId) { + throw new ForbiddenException('Session ID missing'); + } + + const session: Session = + await this.sessionService.findSessionBySessionId(sessionId); + + if (!session) { + throw new ForbiddenException('Invalid session'); + } + + const isUserAgentValid = await this.sessionService.validateSessionUserAgent( + sessionId, + request.headers['user-agent'] + ); + + // TODO expand session expiration + + if (!isUserAgentValid) { + throw new ForbiddenException('Invalid session - User agent mismatch'); + } + + const decodedToken: TokenPayload = await this.validateRefreshToken( + session.userCredentials['id'] + ); + + const newTokens = await this.generateAndPersistTokens( + decodedToken.sub, + decodedToken.email + ); + + return { access_token: newTokens.access_token }; + } + private async generateAndPersistTokens( userId: string, email: string - ): Promise { + ): Promise { const tokens = await this.tokenManagementService.generateTokens( userId, email ); - const hashedRefreshToken = await EncryptionService.hashData( - tokens.refresh_token - ); await this.userCredentialsRepository.updateUserTokenHash( userId, - hashedRefreshToken + tokens.refresh_token ); - return tokens; + return { access_token: tokens.access_token, email: email, userId: userId }; + } + + private async validateRefreshToken(userId: string): Promise { + const user = await this.userCredentialsRepository.findUserById(userId); + + if (!user || !user.hashedRt) { + throw new Error('No refresh token found'); + } + + const decodedToken = await this.tokenManagementService.verifyRefreshToken( + user.hashedRt + ); + + if (decodedToken.exp < Date.now() / 1000) { + throw new Error('Token expired'); + } + + if (decodedToken.sub !== user.id) { + throw new Error('Token subject mismatch'); + } + return decodedToken; } } diff --git a/backend/src/modules/auth-module/services/session.service.ts b/backend/src/modules/auth-module/services/session.service.ts new file mode 100644 index 0000000..49e14f5 --- /dev/null +++ b/backend/src/modules/auth-module/services/session.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Response } from 'express'; +import { Session } from 'src/entities'; +import { LessThan, Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class SessionService { + public constructor( + @InjectRepository(Session) + private sessionRepository: Repository + ) {} + + public async createSession( + userId: string, + userAgent: string + ): Promise { + const sessionId = uuidv4(); + const expirationDate = new Date(); + + expirationDate.setHours(expirationDate.getHours() + 1); + + const session = this.sessionRepository.create({ + userCredentials: userId, + sessionId: sessionId, + expiresAt: expirationDate, + userAgent: userAgent, + }); + + await this.sessionRepository.save(session); + return session; + } + + public async validateSessionUserAgent( + sessionId: string, + currentUserAgent: string + ): Promise { + const session = await this.sessionRepository.findOne({ + where: { sessionId: sessionId }, + select: ['userAgent'], + }); + + if (!session) { + return false; + } + + return session.userAgent === currentUserAgent; + } + + // TODO use method to invalidate session + public async invalidateSession(sessionId: string): Promise { + await this.sessionRepository.delete({ sessionId: sessionId }); + } + + // TODO use method to invalidate all sessions for user + public async invalidateAllSessionsForUser(userId: string): Promise { + await this.sessionRepository.delete({ userCredentials: userId }); + } + + // TODO use method to clear expired sessions + public async clearExpiredSessions(): Promise { + const now = new Date(); + + await this.sessionRepository.delete({ expiresAt: LessThan(now) }); + } + + public async findSessionBySessionId(sessionId: string): Promise { + return this.sessionRepository.findOne({ + where: { sessionId: sessionId }, + relations: ['userCredentials'], + }); + } + + public attachSessionToResponse(response: Response, sessionId: string): void { + response.cookie('session_id', sessionId, { + httpOnly: true, + secure: true, + sameSite: 'strict', + }); + } +} diff --git a/backend/src/modules/auth-module/services/token-management.service.ts b/backend/src/modules/auth-module/services/token-management.service.ts index fd91669..5e3e7b3 100644 --- a/backend/src/modules/auth-module/services/token-management.service.ts +++ b/backend/src/modules/auth-module/services/token-management.service.ts @@ -2,7 +2,17 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -import { TokensDto } from '../models/dto'; +type Tokens = { + access_token: string; + refresh_token: string; +}; + +export type TokenPayload = { + sub: string; + email: string; + iat: number; + exp: number; +}; @Injectable() export class TokenManagementService { @@ -25,16 +35,19 @@ export class TokenManagementService { this.JWT_SECRET_RT = this.configService.get('JWT_SECRET_RT'); } - public async generateTokens( - userId: string, - email: string - ): Promise { + public async generateTokens(userId: string, email: string): Promise { const access_token: string = await this.createAccessToken(userId, email); const refresh_token: string = await this.createRefreshToken(userId, email); return { access_token, refresh_token }; } + public async verifyRefreshToken(token: string): Promise { + return this.jwt.verifyAsync(token, { + secret: this.JWT_SECRET_RT, + }); + } + private async createAccessToken( userId: string, email: string diff --git a/backend/src/modules/database-module/database-config.ts b/backend/src/modules/database-module/database-config.ts index 066f829..301cd97 100644 --- a/backend/src/modules/database-module/database-config.ts +++ b/backend/src/modules/database-module/database-config.ts @@ -1,6 +1,11 @@ import { ConfigService } from '@nestjs/config'; import { TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { EmailVerification, UserCredentials, UserData } from 'src/entities'; +import { + EmailVerification, + UserCredentials, + UserData, + Session, +} from 'src/entities'; export const databaseConfigFactory = ( configService: ConfigService @@ -13,5 +18,5 @@ export const databaseConfigFactory = ( database: configService.get('DB_NAME'), synchronize: true, logging: true, - entities: [UserCredentials, UserData, EmailVerification], + entities: [UserCredentials, UserData, EmailVerification, Session], }); From a341196f37d49048f6a897ec8a1bf47e41512c04 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Fri, 31 May 2024 08:11:11 +0200 Subject: [PATCH 3/7] rename entity colum, divide repo and service --- .../src/entities/user-credentials.entity.ts | 2 +- .../src/modules/auth-module/auth.module.ts | 2 + .../repositories/session.repository.ts | 48 ++++++++++++++ .../user-credentials.repository.ts | 6 +- .../auth-module/services/auth.service.ts | 37 ++++++----- .../auth-module/services/session.service.ts | 65 ++++++++++--------- 6 files changed, 110 insertions(+), 50 deletions(-) create mode 100644 backend/src/modules/auth-module/repositories/session.repository.ts diff --git a/backend/src/entities/user-credentials.entity.ts b/backend/src/entities/user-credentials.entity.ts index fad91b1..2f8eff4 100644 --- a/backend/src/entities/user-credentials.entity.ts +++ b/backend/src/entities/user-credentials.entity.ts @@ -18,7 +18,7 @@ export class UserCredentials { public hash: string; @Column({ nullable: true }) - public hashedRt?: string; + public refreshToken?: string; @CreateDateColumn() public createdAt: Date; diff --git a/backend/src/modules/auth-module/auth.module.ts b/backend/src/modules/auth-module/auth.module.ts index 9f440c3..ff76261 100644 --- a/backend/src/modules/auth-module/auth.module.ts +++ b/backend/src/modules/auth-module/auth.module.ts @@ -8,6 +8,7 @@ import { UserModule } from '../user-module/user.module'; import { VerifyModule } from '../verify-module/verify.module'; import { AuthController } from './controller/auth.controller'; +import { SessionRepository } from './repositories/session.repository'; import { UserCredentialsRepository } from './repositories/user-credentials.repository'; import { AuthService } from './services/auth.service'; import { SessionService } from './services/session.service'; @@ -27,6 +28,7 @@ import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies'; SessionService, TokenManagementService, UserCredentialsRepository, + SessionRepository, AccessTokenStrategy, RefreshTokenStrategy, ], diff --git a/backend/src/modules/auth-module/repositories/session.repository.ts b/backend/src/modules/auth-module/repositories/session.repository.ts new file mode 100644 index 0000000..9cb7bb3 --- /dev/null +++ b/backend/src/modules/auth-module/repositories/session.repository.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Session } from 'src/entities'; +import { Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class SessionRepository { + public constructor( + @InjectRepository(Session) + private sessionRepository: Repository + ) {} + + public async createSession( + userId: string, + userAgent: string + ): Promise { + const sessionId = uuidv4(); + const expirationDate = new Date(); + + expirationDate.setHours(expirationDate.getHours() + 1); + const session = this.sessionRepository.create({ + userCredentials: userId, + sessionId, + expiresAt: expirationDate, + userAgent, + }); + + await this.sessionRepository.save(session); + return session; + } + + public async validateSessionUserAgent( + sessionId: string, + currentUserAgent: string + ): Promise { + const session = await this.sessionRepository.findOne({ + where: { sessionId: sessionId }, + select: ['userAgent'], + }); + + if (!session) { + return false; + } + + return session.userAgent === currentUserAgent; + } +} diff --git a/backend/src/modules/auth-module/repositories/user-credentials.repository.ts b/backend/src/modules/auth-module/repositories/user-credentials.repository.ts index 86e8cae..a2bf7f0 100644 --- a/backend/src/modules/auth-module/repositories/user-credentials.repository.ts +++ b/backend/src/modules/auth-module/repositories/user-credentials.repository.ts @@ -31,11 +31,11 @@ export class UserCredentialsRepository { return this.repository.findOne({ where: { id: userId } }); } - public async updateUserTokenHash( + public async updateUserRefreshToken( userId: string, - hashedRt: string | null + refreshToken: string | null ): Promise { - const result = await this.repository.update(userId, { hashedRt }); + const result = await this.repository.update(userId, { refreshToken }); return result.affected ?? 0; } diff --git a/backend/src/modules/auth-module/services/auth.service.ts b/backend/src/modules/auth-module/services/auth.service.ts index df0ecef..595c986 100644 --- a/backend/src/modules/auth-module/services/auth.service.ts +++ b/backend/src/modules/auth-module/services/auth.service.ts @@ -79,6 +79,8 @@ export class AuthService { throw new ForbiddenException('Access Denied'); } + await this.sessionService.checkSessionLimit(user.id); + const sesseionId = await this.sessionService.createSession( user.id, request.headers['user-agent'] @@ -90,11 +92,10 @@ export class AuthService { } public async logout(userId: string): Promise { - // TODO Check if the user is logged out already - const affected = await this.userCredentialsRepository.updateUserTokenHash( - userId, - null - ); + const affected = + await this.userCredentialsRepository.updateUserRefreshToken(userId, null); + + await this.sessionService.invalidateAllSessionsForUser(userId); return affected > 0; } @@ -118,19 +119,20 @@ export class AuthService { request.headers['user-agent'] ); - // TODO expand session expiration - if (!isUserAgentValid) { throw new ForbiddenException('Invalid session - User agent mismatch'); } + await this.sessionService.extendSessionExpiration(sessionId); + const decodedToken: TokenPayload = await this.validateRefreshToken( session.userCredentials['id'] ); const newTokens = await this.generateAndPersistTokens( decodedToken.sub, - decodedToken.email + decodedToken.email, + false ); return { access_token: newTokens.access_token }; @@ -138,29 +140,33 @@ export class AuthService { private async generateAndPersistTokens( userId: string, - email: string + email: string, + updateRefreshToken: boolean = false ): Promise { const tokens = await this.tokenManagementService.generateTokens( userId, email ); - await this.userCredentialsRepository.updateUserTokenHash( - userId, - tokens.refresh_token - ); + if (updateRefreshToken) { + await this.userCredentialsRepository.updateUserRefreshToken( + userId, + tokens.refresh_token + ); + } + return { access_token: tokens.access_token, email: email, userId: userId }; } private async validateRefreshToken(userId: string): Promise { const user = await this.userCredentialsRepository.findUserById(userId); - if (!user || !user.hashedRt) { + if (!user || !user.refreshToken) { throw new Error('No refresh token found'); } const decodedToken = await this.tokenManagementService.verifyRefreshToken( - user.hashedRt + user.refreshToken ); if (decodedToken.exp < Date.now() / 1000) { @@ -170,6 +176,7 @@ export class AuthService { if (decodedToken.sub !== user.id) { throw new Error('Token subject mismatch'); } + return decodedToken; } } diff --git a/backend/src/modules/auth-module/services/session.service.ts b/backend/src/modules/auth-module/services/session.service.ts index 49e14f5..fae9da1 100644 --- a/backend/src/modules/auth-module/services/session.service.ts +++ b/backend/src/modules/auth-module/services/session.service.ts @@ -3,68 +3,71 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Response } from 'express'; import { Session } from 'src/entities'; import { LessThan, Repository } from 'typeorm'; -import { v4 as uuidv4 } from 'uuid'; + +import { SessionRepository } from '../repositories/session.repository'; @Injectable() export class SessionService { public constructor( @InjectRepository(Session) - private sessionRepository: Repository + private sessionRepository: Repository, + private readonly sessionRepository2: SessionRepository ) {} public async createSession( userId: string, userAgent: string ): Promise { - const sessionId = uuidv4(); - const expirationDate = new Date(); - - expirationDate.setHours(expirationDate.getHours() + 1); - - const session = this.sessionRepository.create({ - userCredentials: userId, - sessionId: sessionId, - expiresAt: expirationDate, - userAgent: userAgent, - }); - - await this.sessionRepository.save(session); - return session; + return this.sessionRepository2.createSession(userId, userAgent); } public async validateSessionUserAgent( sessionId: string, currentUserAgent: string ): Promise { - const session = await this.sessionRepository.findOne({ - where: { sessionId: sessionId }, - select: ['userAgent'], - }); + return this.sessionRepository2.validateSessionUserAgent( + sessionId, + currentUserAgent + ); + } - if (!session) { - return false; + public async checkSessionLimit(userId: string): Promise { + const userSessions = await this.sessionRepository + .createQueryBuilder('session') + .leftJoinAndSelect('session.userCredentials', 'userCredentials') + .where('userCredentials.id = :userId', { userId }) + .orderBy('session.expiresAt', 'ASC') + .getMany(); + + if (userSessions.length >= 5) { + await this.sessionRepository.delete(userSessions[0].id); } - - return session.userAgent === currentUserAgent; } - // TODO use method to invalidate session - public async invalidateSession(sessionId: string): Promise { - await this.sessionRepository.delete({ sessionId: sessionId }); - } - - // TODO use method to invalidate all sessions for user public async invalidateAllSessionsForUser(userId: string): Promise { await this.sessionRepository.delete({ userCredentials: userId }); } - // TODO use method to clear expired sessions + // TODO Add cron job to clear expired sessions public async clearExpiredSessions(): Promise { const now = new Date(); await this.sessionRepository.delete({ expiresAt: LessThan(now) }); } + public async extendSessionExpiration(sessionId: string): Promise { + const session = await this.sessionRepository.findOne({ + where: { sessionId }, + }); + + if (session) { + session.expiresAt = new Date( + session.expiresAt.setMinutes(session.expiresAt.getMinutes() + 30) + ); + await this.sessionRepository.save(session); + } + } + public async findSessionBySessionId(sessionId: string): Promise { return this.sessionRepository.findOne({ where: { sessionId: sessionId }, From b5c850d178974c3a4ad2460994e0474d035b3640 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Fri, 31 May 2024 08:19:26 +0200 Subject: [PATCH 4/7] tidy up --- .../repositories/session.repository.ts | 55 ++++++++++++++++++- .../auth-module/services/auth.service.ts | 2 +- .../auth-module/services/session.service.ts | 48 +++------------- 3 files changed, 64 insertions(+), 41 deletions(-) diff --git a/backend/src/modules/auth-module/repositories/session.repository.ts b/backend/src/modules/auth-module/repositories/session.repository.ts index 9cb7bb3..d2de7ee 100644 --- a/backend/src/modules/auth-module/repositories/session.repository.ts +++ b/backend/src/modules/auth-module/repositories/session.repository.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { Response } from 'express'; import { Session } from 'src/entities'; -import { Repository } from 'typeorm'; +import { LessThan, Repository } from 'typeorm'; import { v4 as uuidv4 } from 'uuid'; @Injectable() @@ -30,6 +31,21 @@ export class SessionRepository { return session; } + public async findSessionBySessionId(sessionId: string): Promise { + return await this.sessionRepository.findOne({ + where: { sessionId: sessionId }, + relations: ['userCredentials'], + }); + } + + public attachSessionToResponse(response: Response, sessionId: string): void { + response.cookie('session_id', sessionId, { + httpOnly: true, + secure: true, + sameSite: 'strict', + }); + } + public async validateSessionUserAgent( sessionId: string, currentUserAgent: string @@ -45,4 +61,41 @@ export class SessionRepository { return session.userAgent === currentUserAgent; } + + public async checkSessionLimit(userId: string): Promise { + const userSessions = await this.sessionRepository + .createQueryBuilder('session') + .leftJoinAndSelect('session.userCredentials', 'userCredentials') + .where('userCredentials.id = :userId', { userId }) + .orderBy('session.expiresAt', 'ASC') + .getMany(); + + if (userSessions.length >= 5) { + await this.sessionRepository.delete(userSessions[0].id); + } + } + + public async invalidateAllSessionsForUser(userId: string): Promise { + await this.sessionRepository.delete({ userCredentials: userId }); + } + + public async extendSessionExpiration(sessionId: string): Promise { + const session = await this.sessionRepository.findOne({ + where: { sessionId }, + }); + + if (session) { + session.expiresAt = new Date( + session.expiresAt.setMinutes(session.expiresAt.getMinutes() + 30) + ); + await this.sessionRepository.save(session); + } + } + + // TODO Add cron job to clear expired sessions + public async clearExpiredSessions(): Promise { + const now = new Date(); + + await this.sessionRepository.delete({ expiresAt: LessThan(now) }); + } } diff --git a/backend/src/modules/auth-module/services/auth.service.ts b/backend/src/modules/auth-module/services/auth.service.ts index 595c986..cea61c0 100644 --- a/backend/src/modules/auth-module/services/auth.service.ts +++ b/backend/src/modules/auth-module/services/auth.service.ts @@ -88,7 +88,7 @@ export class AuthService { this.sessionService.attachSessionToResponse(response, sesseionId.sessionId); - return this.generateAndPersistTokens(user.id, user.email); + return this.generateAndPersistTokens(user.id, user.email, true); } public async logout(userId: string): Promise { diff --git a/backend/src/modules/auth-module/services/session.service.ts b/backend/src/modules/auth-module/services/session.service.ts index fae9da1..623c976 100644 --- a/backend/src/modules/auth-module/services/session.service.ts +++ b/backend/src/modules/auth-module/services/session.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Response } from 'express'; import { Session } from 'src/entities'; -import { LessThan, Repository } from 'typeorm'; import { SessionRepository } from '../repositories/session.repository'; @@ -10,76 +9,47 @@ import { SessionRepository } from '../repositories/session.repository'; export class SessionService { public constructor( @InjectRepository(Session) - private sessionRepository: Repository, - private readonly sessionRepository2: SessionRepository + private readonly sessionRepository: SessionRepository ) {} public async createSession( userId: string, userAgent: string ): Promise { - return this.sessionRepository2.createSession(userId, userAgent); + return await this.sessionRepository.createSession(userId, userAgent); } public async validateSessionUserAgent( sessionId: string, currentUserAgent: string ): Promise { - return this.sessionRepository2.validateSessionUserAgent( + return await this.sessionRepository.validateSessionUserAgent( sessionId, currentUserAgent ); } public async checkSessionLimit(userId: string): Promise { - const userSessions = await this.sessionRepository - .createQueryBuilder('session') - .leftJoinAndSelect('session.userCredentials', 'userCredentials') - .where('userCredentials.id = :userId', { userId }) - .orderBy('session.expiresAt', 'ASC') - .getMany(); - - if (userSessions.length >= 5) { - await this.sessionRepository.delete(userSessions[0].id); - } + await this.sessionRepository.checkSessionLimit(userId); } public async invalidateAllSessionsForUser(userId: string): Promise { - await this.sessionRepository.delete({ userCredentials: userId }); + await this.sessionRepository.invalidateAllSessionsForUser(userId); } - // TODO Add cron job to clear expired sessions public async clearExpiredSessions(): Promise { - const now = new Date(); - - await this.sessionRepository.delete({ expiresAt: LessThan(now) }); + await this.sessionRepository.clearExpiredSessions(); } public async extendSessionExpiration(sessionId: string): Promise { - const session = await this.sessionRepository.findOne({ - where: { sessionId }, - }); - - if (session) { - session.expiresAt = new Date( - session.expiresAt.setMinutes(session.expiresAt.getMinutes() + 30) - ); - await this.sessionRepository.save(session); - } + await this.sessionRepository.extendSessionExpiration(sessionId); } public async findSessionBySessionId(sessionId: string): Promise { - return this.sessionRepository.findOne({ - where: { sessionId: sessionId }, - relations: ['userCredentials'], - }); + return await this.sessionRepository.findSessionBySessionId(sessionId); } public attachSessionToResponse(response: Response, sessionId: string): void { - response.cookie('session_id', sessionId, { - httpOnly: true, - secure: true, - sameSite: 'strict', - }); + this.sessionRepository.attachSessionToResponse(response, sessionId); } } From 4f8d54ebd61cd1528f9bec89053a24f52c98de45 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Fri, 31 May 2024 08:19:35 +0200 Subject: [PATCH 5/7] tidy up --- .../auth-module/controller/auth.controller.ts | 48 +++---------------- 1 file changed, 7 insertions(+), 41 deletions(-) diff --git a/backend/src/modules/auth-module/controller/auth.controller.ts b/backend/src/modules/auth-module/controller/auth.controller.ts index 2e25b21..c4c96e6 100644 --- a/backend/src/modules/auth-module/controller/auth.controller.ts +++ b/backend/src/modules/auth-module/controller/auth.controller.ts @@ -41,9 +41,9 @@ export class AuthController { description: 'User signin successfully', type: LoginResponseDto, }) + @HttpCode(HttpStatus.OK) @Public() @Post('signin') - @HttpCode(HttpStatus.OK) public async signin( @Res({ passthrough: true }) response: Response, @Req() request: Request, @@ -52,6 +52,11 @@ export class AuthController { return await this.authService.signin(userCredentials, response, request); } + @ApiCreatedResponse({ + description: 'User tokens refreshed successfully', + type: AccessTokenDto, + }) + @HttpCode(HttpStatus.OK) @Public() @Post('refresh') public async refreshToken(@Req() request: Request): Promise { @@ -62,48 +67,9 @@ export class AuthController { description: 'User signed out successfully', type: Boolean, }) - @Post('logout') @HttpCode(HttpStatus.OK) + @Post('logout') public async logout(@GetCurrentUserId() userId: string): Promise { return this.authService.logout(userId); } - - // @ApiHeader({ - // name: 'Authorization', - // required: true, - // schema: { - // example: 'Bearer ', - // }, - // }) - // @ApiCreatedResponse({ - // description: 'User tokens refreshed successfully', - // type: TokensDto, - // }) - // @Public() - // @UseGuards(RefreshTokenGuard) - // @Post('refresh') - // @HttpCode(HttpStatus.OK) - // public async refresh( - // @GetCurrentUserId() userId: string, - // @GetCurrentUser('refresh_token') refresh_token: string - // ): Promise { - // return this.authService.refresh(userId, refresh_token); - // } - - // @ApiHeader({ - // name: 'Authorization', - // required: true, - // schema: { - // example: 'Bearer ', - // }, - // }) - // @ApiCreatedResponse({ - // description: 'Token validity checked successfully', - // type: Boolean, - // }) - // @Post('check-token') - // @HttpCode(HttpStatus.OK) - // public checkTokenValidity(): Promise { - // return this.authService.checkTokenValidity(); - // } } From 0a35fc6d264dfd9d58f3d57b8784393122f22313 Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Fri, 31 May 2024 08:32:44 +0200 Subject: [PATCH 6/7] commented out unused code, small refactorings --- .../common/decorators/get-user.decorator.ts | 23 +++++++++---------- .../auth-module/common/decorators/index.ts | 2 +- .../modules/auth-module/models/types/index.ts | 4 +++- .../jwt-payload-with-refresh-token.type.ts | 4 ++-- .../models/types/token-payload.type.ts | 6 +++++ .../auth-module/models/types/tokens.type.ts | 4 ++++ .../auth-module/services/auth.service.ts | 6 ++--- .../services/token-management.service.ts | 12 +--------- 8 files changed, 30 insertions(+), 31 deletions(-) create mode 100644 backend/src/modules/auth-module/models/types/token-payload.type.ts create mode 100644 backend/src/modules/auth-module/models/types/tokens.type.ts diff --git a/backend/src/modules/auth-module/common/decorators/get-user.decorator.ts b/backend/src/modules/auth-module/common/decorators/get-user.decorator.ts index a442ca6..f03ed49 100644 --- a/backend/src/modules/auth-module/common/decorators/get-user.decorator.ts +++ b/backend/src/modules/auth-module/common/decorators/get-user.decorator.ts @@ -1,14 +1,13 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { JwtPayloadWithRefreshToken } from 'src/modules/auth-module/models/types'; +//import { JwtPayloadWithRefreshToken } from 'src/modules/auth-module/models/types'; -export const GetCurrentUser = createParamDecorator( - ( - data: keyof JwtPayloadWithRefreshToken | undefined, - context: ExecutionContext - ) => { - const request = context.switchToHttp().getRequest(); +// export const GetCurrentUser = createParamDecorator( +// ( +// data: keyof JwtPayloadWithRefreshToken | undefined, +// context: ExecutionContext +// ) => { +// const request = context.switchToHttp().getRequest(); - if (!data) return request.user; - return request.user[data]; - } -); +// if (!data) return request.user; +// return request.user[data]; +// } +// ); diff --git a/backend/src/modules/auth-module/common/decorators/index.ts b/backend/src/modules/auth-module/common/decorators/index.ts index 2a36025..6e85616 100644 --- a/backend/src/modules/auth-module/common/decorators/index.ts +++ b/backend/src/modules/auth-module/common/decorators/index.ts @@ -1,2 +1,2 @@ export * from './get-user-id.decorator'; -export * from './get-user.decorator'; +// export * from './get-user.decorator'; diff --git a/backend/src/modules/auth-module/models/types/index.ts b/backend/src/modules/auth-module/models/types/index.ts index 0e70b68..697195c 100644 --- a/backend/src/modules/auth-module/models/types/index.ts +++ b/backend/src/modules/auth-module/models/types/index.ts @@ -1,2 +1,4 @@ export * from './jwt-payload.type'; -export * from './jwt-payload-with-refresh-token.type'; +// export * from './jwt-payload-with-refresh-token.type'; +export * from './token-payload.type'; +export * from './tokens.type'; diff --git a/backend/src/modules/auth-module/models/types/jwt-payload-with-refresh-token.type.ts b/backend/src/modules/auth-module/models/types/jwt-payload-with-refresh-token.type.ts index c2e9746..6b6c80a 100644 --- a/backend/src/modules/auth-module/models/types/jwt-payload-with-refresh-token.type.ts +++ b/backend/src/modules/auth-module/models/types/jwt-payload-with-refresh-token.type.ts @@ -1,3 +1,3 @@ -import { JwtPayload } from './jwt-payload.type'; +// import { JwtPayload } from './jwt-payload.type'; -export type JwtPayloadWithRefreshToken = JwtPayload & { refresh_token: string }; +// export type JwtPayloadWithRefreshToken = JwtPayload & { refresh_token: string }; diff --git a/backend/src/modules/auth-module/models/types/token-payload.type.ts b/backend/src/modules/auth-module/models/types/token-payload.type.ts new file mode 100644 index 0000000..911ef0d --- /dev/null +++ b/backend/src/modules/auth-module/models/types/token-payload.type.ts @@ -0,0 +1,6 @@ +export type TokenPayload = { + sub: string; + email: string; + iat: number; + exp: number; +}; diff --git a/backend/src/modules/auth-module/models/types/tokens.type.ts b/backend/src/modules/auth-module/models/types/tokens.type.ts new file mode 100644 index 0000000..1c0a510 --- /dev/null +++ b/backend/src/modules/auth-module/models/types/tokens.type.ts @@ -0,0 +1,4 @@ +export type Tokens = { + access_token: string; + refresh_token: string; +}; diff --git a/backend/src/modules/auth-module/services/auth.service.ts b/backend/src/modules/auth-module/services/auth.service.ts index cea61c0..c27bb3a 100644 --- a/backend/src/modules/auth-module/services/auth.service.ts +++ b/backend/src/modules/auth-module/services/auth.service.ts @@ -11,13 +11,11 @@ import { LoginResponseDto, UserCredentialsDto, } from '../models/dto'; +import { TokenPayload } from '../models/types'; import { UserCredentialsRepository } from '../repositories/user-credentials.repository'; import { SessionService } from './session.service'; -import { - TokenManagementService, - TokenPayload, -} from './token-management.service'; +import { TokenManagementService } from './token-management.service'; @Injectable() export class AuthService { diff --git a/backend/src/modules/auth-module/services/token-management.service.ts b/backend/src/modules/auth-module/services/token-management.service.ts index 5e3e7b3..6b56f8f 100644 --- a/backend/src/modules/auth-module/services/token-management.service.ts +++ b/backend/src/modules/auth-module/services/token-management.service.ts @@ -2,17 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -type Tokens = { - access_token: string; - refresh_token: string; -}; - -export type TokenPayload = { - sub: string; - email: string; - iat: number; - exp: number; -}; +import { TokenPayload, Tokens } from '../models/types'; @Injectable() export class TokenManagementService { From a78531c88a28098bc4d7840349c7259a03e7fc5e Mon Sep 17 00:00:00 2001 From: Igor Propisnov Date: Fri, 31 May 2024 08:47:08 +0200 Subject: [PATCH 7/7] added docs --- backend/src/modules/auth-module/models/dto/login-response.dto.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/modules/auth-module/models/dto/login-response.dto.ts b/backend/src/modules/auth-module/models/dto/login-response.dto.ts index 1f908d5..d1a5569 100644 --- a/backend/src/modules/auth-module/models/dto/login-response.dto.ts +++ b/backend/src/modules/auth-module/models/dto/login-response.dto.ts @@ -14,6 +14,7 @@ export class LoginResponseDto { @ApiProperty({ title: 'Email', description: 'User Email', + example: 'foo@bar.de', }) @IsNotEmpty() @IsString()