From 1323682d80c0593d9efbfddb7134d9fb956c7bcd Mon Sep 17 00:00:00 2001 From: Mads Apollo Lauridsen Date: Mon, 23 Sep 2024 15:20:32 +0200 Subject: [PATCH] Added setup of CSRF, and interceptor that adds/gets relevant token header. WithCredentials is needed to send/receive cookies. --- src/app/app.module.ts | 10 ++++- src/app/shared/constants/csrf-constants.ts | 2 + src/app/shared/helpers/csrf-interceptor.ts | 45 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/app/shared/constants/csrf-constants.ts create mode 100644 src/app/shared/helpers/csrf-interceptor.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 0647bca9..f3307f9f 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -2,7 +2,7 @@ import { BrowserModule, Title } from "@angular/platform-browser"; import { NgModule } from "@angular/core"; import { TranslateModule, TranslateLoader } from "@ngx-translate/core"; import { TranslateHttpLoader } from "@ngx-translate/http-loader"; -import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS } from "@angular/common/http"; +import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS, HttpClientXsrfModule } from "@angular/common/http"; import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; import { NavbarModule } from "./navbar/navbar.module"; @@ -31,6 +31,8 @@ import { UserPageComponent } from "./admin/users/user-page/user-page.component"; import { SharedModule } from "@shared/shared.module"; import { PipesModule } from "@shared/pipes/pipes.module"; import { CookieService } from "ngx-cookie-service"; +import { CsrfInterceptor } from "@shared/helpers/csrf-interceptor"; +import { CsrfCookieName, CsrfHeaderName } from "@shared/constants/csrf-constants"; export function HttpLoaderFactory(http: HttpClient) { return new TranslateHttpLoader(http, "./assets/i18n/", ".json"); @@ -79,6 +81,11 @@ export function tokenGetter() { MonacoEditorModule.forRoot(), WelcomeDialogModule, PipesModule, + HttpClientXsrfModule, + HttpClientXsrfModule.withOptions({ + cookieName: CsrfCookieName, // Match the cookie name used by the backend + headerName: CsrfHeaderName, // Match the header name expected by the backend + }), ], bootstrap: [AppComponent], exports: [TranslateModule], @@ -87,6 +94,7 @@ export function tokenGetter() { //{ provide: ErrorHandler, useClass: GlobalErrorHandler }, //{ provide: HTTP_INTERCEPTORS, useClass: ServerErrorInterceptor, multi: true }, Title, + { provide: HTTP_INTERCEPTORS, useClass: CsrfInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: AuthJwtInterceptor, multi: true }, { provide: SAVER, useFactory: getSaver }, { provide: MatPaginatorIntl, useClass: MatPaginatorIntlDa }, diff --git a/src/app/shared/constants/csrf-constants.ts b/src/app/shared/constants/csrf-constants.ts new file mode 100644 index 00000000..f9667de1 --- /dev/null +++ b/src/app/shared/constants/csrf-constants.ts @@ -0,0 +1,2 @@ +export const CsrfCookieName = "token-cookie-name"; +export const CsrfHeaderName = "x-csrf-token"; diff --git a/src/app/shared/helpers/csrf-interceptor.ts b/src/app/shared/helpers/csrf-interceptor.ts new file mode 100644 index 00000000..cd966f9e --- /dev/null +++ b/src/app/shared/helpers/csrf-interceptor.ts @@ -0,0 +1,45 @@ +import { HttpClient, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { environment } from "@environments/environment"; +import { CsrfHeaderName } from "@shared/constants/csrf-constants"; +import { Observable, of } from "rxjs"; +import { catchError, switchMap, tap } from "rxjs/operators"; + +@Injectable() +export class CsrfInterceptor implements HttpInterceptor { + private baseUrl = environment.baseUrl; + private tokenUrl = "csrf/token"; + private csrfToken: string; + constructor(private httpClient: HttpClient) {} + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + if (this.csrfToken || req.url.endsWith(this.tokenUrl)) { + const cloned = req.clone({ + headers: req.headers.set(CsrfHeaderName, this?.csrfToken ?? ""), + withCredentials: req.withCredentials || ["POST", "PUT", "DELETE"].includes(req.method), + }); + + return next.handle(cloned); + } + + // If no CSRF token, fetch it first + return this.fetchCsrfToken().pipe( + switchMap(() => { + return next.handle(req); + }), + catchError(err => { + console.error("CSRF token fetch failed", err); + return next.handle(req); + }) + ); + } + + private fetchCsrfToken(): Observable { + return this.httpClient.get<{ token: string }>(this.baseUrl + this.tokenUrl, { withCredentials: true }).pipe( + tap(response => { + this.csrfToken = response.token; + }), + switchMap(response => of(response.token)) + ); + } +}