From b091dc52b7bc3ba901db6c9715cd37dec1b8ff99 Mon Sep 17 00:00:00 2001 From: Florian THIERRY Date: Tue, 3 Sep 2024 11:45:47 +0200 Subject: [PATCH] Fix authentication errors handling. --- .../GlobalControllerExceptionHandler.java | 8 +- frontend/src/app/app.routes.ts | 2 +- .../app/core/guard/authentication.guard.ts | 18 ++++ .../app/core/interceptor/jwt.interceptor.ts | 87 ++++++++++++++++--- .../core/service/authentication.service.ts | 18 +--- .../picture-selection-dialog.component.ts | 10 ++- .../publication-edition.component.ts | 14 +-- .../publication-edition.routes.ts | 7 ++ 8 files changed, 120 insertions(+), 44 deletions(-) create mode 100644 frontend/src/app/core/guard/authentication.guard.ts create mode 100644 frontend/src/app/pages/publication-edition/publication-edition.routes.ts diff --git a/backend/codiki-exposition/src/main/java/org/codiki/exposition/configuration/GlobalControllerExceptionHandler.java b/backend/codiki-exposition/src/main/java/org/codiki/exposition/configuration/GlobalControllerExceptionHandler.java index 7481453..57e3f20 100644 --- a/backend/codiki-exposition/src/main/java/org/codiki/exposition/configuration/GlobalControllerExceptionHandler.java +++ b/backend/codiki-exposition/src/main/java/org/codiki/exposition/configuration/GlobalControllerExceptionHandler.java @@ -7,10 +7,7 @@ import static org.springframework.http.HttpStatus.UNAUTHORIZED; import org.codiki.domain.category.exception.CategoryDeletionException; import org.codiki.domain.category.exception.CategoryEditionException; import org.codiki.domain.category.exception.CategoryNotFoundException; -import org.codiki.domain.exception.LoginFailureException; -import org.codiki.domain.exception.RefreshTokenDoesNotExistException; -import org.codiki.domain.exception.RefreshTokenExpiredException; -import org.codiki.domain.exception.UserDoesNotExistException; +import org.codiki.domain.exception.*; import org.codiki.domain.picture.exception.PictureNotFoundException; import org.codiki.domain.picture.exception.PictureUploadException; import org.codiki.domain.publication.exception.*; @@ -51,7 +48,8 @@ public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHan } @ExceptionHandler({ - RefreshTokenExpiredException.class + RefreshTokenExpiredException.class, + AuthenticationRequiredException.class, }) public ProblemDetail handleUnauthorizedExceptions(Exception exception) { return buildProblemDetail(UNAUTHORIZED, exception); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 1507ec2..d89116b 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -19,7 +19,7 @@ export const routes: Routes = [ }, { path: 'publications/:publicationId/edit', - loadComponent: () => import('./pages/publication-edition/publication-edition.component').then(module => module.PublicationEditionComponent) + loadChildren: () => import('./pages/publication-edition/publication-edition.routes').then(module => module.ROUTES) }, { path: 'publications', diff --git a/frontend/src/app/core/guard/authentication.guard.ts b/frontend/src/app/core/guard/authentication.guard.ts new file mode 100644 index 0000000..e6c0eb6 --- /dev/null +++ b/frontend/src/app/core/guard/authentication.guard.ts @@ -0,0 +1,18 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { AuthenticationService } from "../service/authentication.service"; +import { MatSnackBar } from "@angular/material/snack-bar"; + +export const authenticationGuard: CanActivateFn = () => { + const authenticationService = inject(AuthenticationService); + const router = inject(Router); + const snackBar = inject(MatSnackBar); + + if (authenticationService.isAuthenticated()) { + return true; + } else { + router.navigate(['/login']); + snackBar.open('You are unauthenticated. Please, log-in first.', 'Close', { duration: 5000 }); + return false; + } +} \ No newline at end of file diff --git a/frontend/src/app/core/interceptor/jwt.interceptor.ts b/frontend/src/app/core/interceptor/jwt.interceptor.ts index 6165609..893cbc0 100644 --- a/frontend/src/app/core/interceptor/jwt.interceptor.ts +++ b/frontend/src/app/core/interceptor/jwt.interceptor.ts @@ -1,29 +1,88 @@ -import { Observable } from 'rxjs'; +import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http'; +import { catchError, filter, Observable, Subject, switchMap, take, throwError } from 'rxjs'; +import { RefreshTokenRequest } from '../rest-services/user/model/refresh-token.model'; +import { UserRestService } from '../rest-services/user/user.rest-service'; import { AuthenticationService } from '../service/authentication.service'; +import { Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; @Injectable() export class JwtInterceptor implements HttpInterceptor { private readonly authenticationService = inject(AuthenticationService); + private readonly router = inject(Router); + private readonly userRestService = inject(UserRestService); + private readonly snackBar = inject(MatSnackBar); + private isRefreshingToken = false; + private refreshTokenSubject = new Subject(); intercept(request: HttpRequest, next: HttpHandler): Observable> { - if (this.authenticationService.isTokenExpired()) { - this.authenticationService.renewToken(); - } + let requestWithAuthentication = request; const jwt = this.authenticationService.getToken(); - if (jwt) { - const cloned = request.clone({ - headers: request.headers.set('Authorization', `Bearer ${jwt}`) - }); - - return next.handle(cloned); - } else { - this.authenticationService.unauthenticate(); + requestWithAuthentication = this.addTokenInHeaders(request, jwt); } - return next.handle(request); + return next.handle(requestWithAuthentication) + .pipe( + catchError(error => { + if (error instanceof HttpErrorResponse && error.status === 401) { + return this.handleAunauthorizedError(request, next, error); + } + + return throwError(() => error); + }) + ); + } + + private handleAunauthorizedError(request: HttpRequest, next: HttpHandler, initialError: any): Observable> { + if (!this.isRefreshingToken) { + this.isRefreshingToken = true; + this.refreshTokenSubject.next(undefined); + + const refreshToken = this.authenticationService.getRefreshToken(); + if (refreshToken) { + const refreshTokenRequest: RefreshTokenRequest = { + refreshTokenValue: refreshToken + }; + this.userRestService.refreshToken(refreshTokenRequest) + .then(refreshTokenResponse => { + this.authenticationService.authenticate(refreshTokenResponse.accessToken, refreshTokenResponse.refreshToken); + }) + .catch(() => { + return this.handleNoRefreshToken(initialError); + }) + .finally(() => this.isRefreshingToken = false); + } else { + return this.handleNoRefreshToken(initialError); + } + } + + return this.refreshTokenSubject.pipe( + filter(token => !!token), + take(1), + switchMap(token => { + let requestWithAuthentication = request; + if (token) { + requestWithAuthentication = this.addTokenInHeaders(request, token) + } + return next.handle(requestWithAuthentication); + }) + ); + } + + private addTokenInHeaders(request: HttpRequest, token: string): HttpRequest { + return request.clone({ + headers: request.headers.set('Authorization', `Bearer ${token}`) + }); + } + + private handleNoRefreshToken(initialError: any): Observable> { + this.router.navigate(['/login']); + this.refreshTokenSubject.next(undefined); + this.authenticationService.unauthenticate(); + this.snackBar.open('You are unauthenticated. Please, re-authenticate before retrying your action.', 'Close', { duration: 5000 }); + return throwError(() => initialError); } } \ No newline at end of file diff --git a/frontend/src/app/core/service/authentication.service.ts b/frontend/src/app/core/service/authentication.service.ts index c4b1fbd..a94476a 100644 --- a/frontend/src/app/core/service/authentication.service.ts +++ b/frontend/src/app/core/service/authentication.service.ts @@ -18,7 +18,6 @@ interface UserDetails { providedIn: 'root' }) export class AuthenticationService { - private userRestService = inject(UserRestService); authenticate(token: string, refreshToken: string): void { localStorage.setItem(JWT_PARAM, token); @@ -50,6 +49,10 @@ export class AuthenticationService { return localStorage.getItem(JWT_PARAM) ?? undefined; } + getRefreshToken(): string | undefined { + return localStorage.getItem(REFRESH_TOKEN_PARAM) ?? undefined; + } + isTokenExpired(): boolean { let result = false; @@ -65,19 +68,6 @@ export class AuthenticationService { return result; } - renewToken(): void { - const refreshToken = localStorage.getItem(REFRESH_TOKEN_PARAM); - if (refreshToken) { - const request: RefreshTokenRequest = { - refreshTokenValue: refreshToken - }; - this.userRestService.refreshToken(request) - .then(refreshTokenResponse => { - this.authenticate(refreshTokenResponse.accessToken, refreshTokenResponse.refreshToken); - }); - } - } - private extractUserFromLocalStorage(): User | undefined { let result: User | undefined = undefined; diff --git a/frontend/src/app/pages/publication-edition/picture-selection-dialog/picture-selection-dialog.component.ts b/frontend/src/app/pages/publication-edition/picture-selection-dialog/picture-selection-dialog.component.ts index 409bdfe..75ab064 100644 --- a/frontend/src/app/pages/publication-edition/picture-selection-dialog/picture-selection-dialog.component.ts +++ b/frontend/src/app/pages/publication-edition/picture-selection-dialog/picture-selection-dialog.component.ts @@ -31,9 +31,13 @@ export class PictureSelectionDialog implements OnInit { this.pictures = pictures; }) .catch(error => { - const errorMessage = 'An error occured while loading pictures.'; - console.error(errorMessage, error); - this.snackBar.open(errorMessage, 'Close', { duration: 5000 }); + if (error.status === 401) { + this.dialogRef.close(); + } else { + const errorMessage = 'An error occured while loading pictures.'; + console.error(errorMessage, error); + this.snackBar.open(errorMessage, 'Close', { duration: 5000 }); + } }) .finally(() => { this.isLoading = false; diff --git a/frontend/src/app/pages/publication-edition/publication-edition.component.ts b/frontend/src/app/pages/publication-edition/publication-edition.component.ts index 0703cbe..2219c26 100644 --- a/frontend/src/app/pages/publication-edition/publication-edition.component.ts +++ b/frontend/src/app/pages/publication-edition/publication-edition.component.ts @@ -1,17 +1,17 @@ import { CommonModule, Location } from '@angular/common'; import { Component, inject, OnDestroy, OnInit } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTabsModule } from '@angular/material/tabs'; -import { debounceTime, map, Observable, Subscription } from 'rxjs'; -import { Publication } from '../../core/rest-services/publications/model/publication'; -import { PublicationEditionService } from './publication-edition.service'; -import {MatDialogModule} from '@angular/material/dialog'; -import { PictureSelectionDialog } from './picture-selection-dialog/picture-selection-dialog.component'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { map, Observable, Subscription } from 'rxjs'; import { SubmitButtonComponent } from '../../components/submit-button/submit-button.component'; -import { MatIconModule } from '@angular/material/icon'; +import { Publication } from '../../core/rest-services/publications/model/publication'; +import { PictureSelectionDialog } from './picture-selection-dialog/picture-selection-dialog.component'; +import { PublicationEditionService } from './publication-edition.service'; @Component({ selector: 'app-publication-edition', @@ -137,4 +137,4 @@ export class PublicationEditionComponent implements OnInit, OnDestroy { } } -} +} \ No newline at end of file diff --git a/frontend/src/app/pages/publication-edition/publication-edition.routes.ts b/frontend/src/app/pages/publication-edition/publication-edition.routes.ts new file mode 100644 index 0000000..8a1839a --- /dev/null +++ b/frontend/src/app/pages/publication-edition/publication-edition.routes.ts @@ -0,0 +1,7 @@ +import { Route } from "@angular/router"; +import { PublicationEditionComponent } from "./publication-edition.component"; +import { authenticationGuard } from "../../core/guard/authentication.guard"; + +export const ROUTES: Route[] = [ + { path: '', component: PublicationEditionComponent, canActivate: [authenticationGuard] } +] \ No newline at end of file