Fix authentication errors handling.

This commit is contained in:
Florian THIERRY
2024-09-03 11:45:47 +02:00
parent 4d44b6f53c
commit b091dc52b7
8 changed files with 120 additions and 44 deletions

View File

@@ -7,10 +7,7 @@ import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import org.codiki.domain.category.exception.CategoryDeletionException; import org.codiki.domain.category.exception.CategoryDeletionException;
import org.codiki.domain.category.exception.CategoryEditionException; import org.codiki.domain.category.exception.CategoryEditionException;
import org.codiki.domain.category.exception.CategoryNotFoundException; import org.codiki.domain.category.exception.CategoryNotFoundException;
import org.codiki.domain.exception.LoginFailureException; import org.codiki.domain.exception.*;
import org.codiki.domain.exception.RefreshTokenDoesNotExistException;
import org.codiki.domain.exception.RefreshTokenExpiredException;
import org.codiki.domain.exception.UserDoesNotExistException;
import org.codiki.domain.picture.exception.PictureNotFoundException; import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.picture.exception.PictureUploadException; import org.codiki.domain.picture.exception.PictureUploadException;
import org.codiki.domain.publication.exception.*; import org.codiki.domain.publication.exception.*;
@@ -51,7 +48,8 @@ public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHan
} }
@ExceptionHandler({ @ExceptionHandler({
RefreshTokenExpiredException.class RefreshTokenExpiredException.class,
AuthenticationRequiredException.class,
}) })
public ProblemDetail handleUnauthorizedExceptions(Exception exception) { public ProblemDetail handleUnauthorizedExceptions(Exception exception) {
return buildProblemDetail(UNAUTHORIZED, exception); return buildProblemDetail(UNAUTHORIZED, exception);

View File

@@ -19,7 +19,7 @@ export const routes: Routes = [
}, },
{ {
path: 'publications/:publicationId/edit', 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', path: 'publications',

View File

@@ -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;
}
}

View File

@@ -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 { 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 { AuthenticationService } from '../service/authentication.service';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
@Injectable() @Injectable()
export class JwtInterceptor implements HttpInterceptor { export class JwtInterceptor implements HttpInterceptor {
private readonly authenticationService = inject(AuthenticationService); 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<string | undefined>();
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (this.authenticationService.isTokenExpired()) { let requestWithAuthentication = request;
this.authenticationService.renewToken();
}
const jwt = this.authenticationService.getToken(); const jwt = this.authenticationService.getToken();
if (jwt) { if (jwt) {
const cloned = request.clone({ requestWithAuthentication = this.addTokenInHeaders(request, jwt);
headers: request.headers.set('Authorization', `Bearer ${jwt}`) }
});
return next.handle(cloned); 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<any>, next: HttpHandler, initialError: any): Observable<HttpEvent<any>> {
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 { } else {
this.authenticationService.unauthenticate(); return this.handleNoRefreshToken(initialError);
}
} }
return next.handle(request); 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<any>, token: string): HttpRequest<any> {
return request.clone({
headers: request.headers.set('Authorization', `Bearer ${token}`)
});
}
private handleNoRefreshToken(initialError: any): Observable<HttpEvent<any>> {
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);
} }
} }

View File

@@ -18,7 +18,6 @@ interface UserDetails {
providedIn: 'root' providedIn: 'root'
}) })
export class AuthenticationService { export class AuthenticationService {
private userRestService = inject(UserRestService);
authenticate(token: string, refreshToken: string): void { authenticate(token: string, refreshToken: string): void {
localStorage.setItem(JWT_PARAM, token); localStorage.setItem(JWT_PARAM, token);
@@ -50,6 +49,10 @@ export class AuthenticationService {
return localStorage.getItem(JWT_PARAM) ?? undefined; return localStorage.getItem(JWT_PARAM) ?? undefined;
} }
getRefreshToken(): string | undefined {
return localStorage.getItem(REFRESH_TOKEN_PARAM) ?? undefined;
}
isTokenExpired(): boolean { isTokenExpired(): boolean {
let result = false; let result = false;
@@ -65,19 +68,6 @@ export class AuthenticationService {
return result; 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 { private extractUserFromLocalStorage(): User | undefined {
let result: User | undefined = undefined; let result: User | undefined = undefined;

View File

@@ -31,9 +31,13 @@ export class PictureSelectionDialog implements OnInit {
this.pictures = pictures; this.pictures = pictures;
}) })
.catch(error => { .catch(error => {
if (error.status === 401) {
this.dialogRef.close();
} else {
const errorMessage = 'An error occured while loading pictures.'; const errorMessage = 'An error occured while loading pictures.';
console.error(errorMessage, error); console.error(errorMessage, error);
this.snackBar.open(errorMessage, 'Close', { duration: 5000 }); this.snackBar.open(errorMessage, 'Close', { duration: 5000 });
}
}) })
.finally(() => { .finally(() => {
this.isLoading = false; this.isLoading = false;

View File

@@ -1,17 +1,17 @@
import { CommonModule, Location } from '@angular/common'; import { CommonModule, Location } from '@angular/common';
import { Component, inject, OnDestroy, OnInit } from '@angular/core'; import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; 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 { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTabsModule } from '@angular/material/tabs'; 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 { MatTooltipModule } from '@angular/material/tooltip';
import { map, Observable, Subscription } from 'rxjs';
import { SubmitButtonComponent } from '../../components/submit-button/submit-button.component'; 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({ @Component({
selector: 'app-publication-edition', selector: 'app-publication-edition',

View File

@@ -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] }
]