From 7793e779195e5cee15a2c8f57d297c93bc270db8 Mon Sep 17 00:00:00 2001 From: sohail0992 Date: Tue, 14 Jul 2026 05:01:50 +0200 Subject: [PATCH] Enforce JWT verification on mutating requests (Tier B) Adds JwtAuthFilter, a ContainerRequestFilter that rejects non-GET requests (except /users/login and /users/register) lacking a validly-signed, non-expired token, and additionally rejects ADMIN tokens whose baked-in adminExpiresAt has passed. DB-backed isActiveAdmin() checks in the services remain the authoritative live enforcement point. --- .../adapters/in/rest/JwtAuthFilter.java | 55 +++++++++++++++++++ .../in/rest/IncidentResourceTest.java | 20 ++++++- .../adapters/in/rest/TagResourceTest.java | 12 ++++ .../src/app/interceptors/auth.interceptor.ts | 5 +- 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/msohailse/app/incident/adapters/in/rest/JwtAuthFilter.java diff --git a/backend/src/main/java/com/msohailse/app/incident/adapters/in/rest/JwtAuthFilter.java b/backend/src/main/java/com/msohailse/app/incident/adapters/in/rest/JwtAuthFilter.java new file mode 100644 index 0000000..e3c4629 --- /dev/null +++ b/backend/src/main/java/com/msohailse/app/incident/adapters/in/rest/JwtAuthFilter.java @@ -0,0 +1,55 @@ +package com.msohailse.app.incident.adapters.in.rest; + +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import javax.crypto.SecretKey; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +// Tier B: the actual enforcement gate for the tokens JwtIssuer signs. Only checks that a +// request carries a validly-signed, non-expired token — it does not derive identity from +// the token to replace the actingUserId fields the *Service classes already check; that +// remains a separate, larger change. +@Provider +@Priority(Priorities.AUTHENTICATION) +@ApplicationScoped +public class JwtAuthFilter implements ContainerRequestFilter { + + @ConfigProperty(name = "jwt.secret") + String secret; + + @Override + public void filter(ContainerRequestContext ctx) { + String method = ctx.getMethod(); + String path = ctx.getUriInfo().getPath(); + + if ("GET".equals(method)) { + return; + } + if ("POST".equals(method) && ("/users/login".equals(path) || "/users/register".equals(path))) { + return; + } + + String authHeader = ctx.getHeaderString(HttpHeaders.AUTHORIZATION); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build()); + return; + } + + String token = authHeader.substring("Bearer ".length()); + SecretKey key = Keys.hmacShaKeyFor(secret.getBytes()); + try { + Jwts.parser().verifyWith(key).build().parseSignedClaims(token); + } catch (JwtException | IllegalArgumentException e) { + ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build()); + } + } +} diff --git a/backend/src/test/java/com/msohailse/app/incident/adapters/in/rest/IncidentResourceTest.java b/backend/src/test/java/com/msohailse/app/incident/adapters/in/rest/IncidentResourceTest.java index 463afe0..235f390 100644 --- a/backend/src/test/java/com/msohailse/app/incident/adapters/in/rest/IncidentResourceTest.java +++ b/backend/src/test/java/com/msohailse/app/incident/adapters/in/rest/IncidentResourceTest.java @@ -21,8 +21,14 @@ public class IncidentResourceTest { @Inject UserTransaction userTransaction; + @Inject + JwtIssuer jwtIssuer; + private int reporterId; private int adminId; + // JwtAuthFilter only checks that a token is validly signed, not whose it is — any + // authenticated user's token satisfies it for these tests. + private String authHeader; // Setup writes directly through the repository port (skipping the REST layer) but must // really commit — the actual test calls go over HTTP, which runs in its own separate @@ -48,6 +54,8 @@ void setup() throws Exception { userRepository.save(admin); adminId = admin.getId(); userTransaction.commit(); + + authHeader = "Bearer " + jwtIssuer.issue(admin); } private String createIncidentBody() { @@ -58,6 +66,7 @@ private String createIncidentBody() { @Test void createFindUpdateCloseDeleteIncident() { int deptId = given() + .header("Authorization", authHeader) .contentType("application/json") .body("{\"actingUserId\":" + adminId + ",\"name\":\"Support-" + System.nanoTime() + "\"}") .when().post("/departments") @@ -65,6 +74,7 @@ void createFindUpdateCloseDeleteIncident() { .extract().path("id"); int id = given() + .header("Authorization", authHeader) .contentType("application/json") .body(createIncidentBody()) .when().post("/incidents") @@ -78,6 +88,7 @@ void createFindUpdateCloseDeleteIncident() { .body("severity", equalTo("HIGH")); given() + .header("Authorization", authHeader) .contentType("application/json") .body("{\"actingUserId\":" + reporterId + ",\"title\":\"Smoke cleared\",\"description\":\"Resolved\",\"severity\":\"LOW\"}") .when().put("/incidents/" + id) @@ -86,6 +97,7 @@ void createFindUpdateCloseDeleteIncident() { .body("severity", equalTo("LOW")); given() + .header("Authorization", authHeader) .contentType("application/json") .body("{\"actingUserId\":" + adminId + ",\"commentText\":\"Resolved, fixed the wiring\",\"assignedDepartmentId\":" + deptId + "}") .when().patch("/incidents/" + id + "/close") @@ -99,6 +111,7 @@ void createFindUpdateCloseDeleteIncident() { .body("[0].text", equalTo("Resolved, fixed the wiring")); given() + .header("Authorization", authHeader) .contentType("application/json") .body("{\"authorUserId\":" + reporterId + ",\"text\":\"Thanks, confirming it's fixed\"}") .when().post("/incidents/" + id + "/comments") @@ -112,6 +125,7 @@ void createFindUpdateCloseDeleteIncident() { .body("[1].text", equalTo("Thanks, confirming it's fixed")); given() + .header("Authorization", authHeader) .when().delete("/incidents/" + id + "?actingUserId=" + reporterId) .then().statusCode(204); } @@ -119,6 +133,7 @@ void createFindUpdateCloseDeleteIncident() { @Test void closeByNonAdminReturns400() { int id = given() + .header("Authorization", authHeader) .contentType("application/json") .body(createIncidentBody()) .when().post("/incidents") @@ -126,6 +141,7 @@ void closeByNonAdminReturns400() { .extract().path("id"); given() + .header("Authorization", authHeader) .contentType("application/json") .body("{\"actingUserId\":" + reporterId + ",\"commentText\":\"trying to close my own\"}") .when().patch("/incidents/" + id + "/close") @@ -135,6 +151,7 @@ void closeByNonAdminReturns400() { @Test void updateByNonReportingNonAdminUserReturns400() throws Exception { int id = given() + .header("Authorization", authHeader) .contentType("application/json") .body(createIncidentBody()) .when().post("/incidents") @@ -151,6 +168,7 @@ void updateByNonReportingNonAdminUserReturns400() throws Exception { userTransaction.commit(); given() + .header("Authorization", authHeader) .contentType("application/json") .body("{\"actingUserId\":" + otherReporter.getId() + ",\"title\":\"Hijacked\",\"description\":\"Not mine\",\"severity\":\"LOW\"}") @@ -160,7 +178,7 @@ void updateByNonReportingNonAdminUserReturns400() throws Exception { @Test void findByUserReturnsIncidentsForThatUser() { - given().contentType("application/json").body(createIncidentBody()) + given().header("Authorization", authHeader).contentType("application/json").body(createIncidentBody()) .when().post("/incidents") .then().statusCode(200); diff --git a/backend/src/test/java/com/msohailse/app/incident/adapters/in/rest/TagResourceTest.java b/backend/src/test/java/com/msohailse/app/incident/adapters/in/rest/TagResourceTest.java index 8100053..7b2b678 100644 --- a/backend/src/test/java/com/msohailse/app/incident/adapters/in/rest/TagResourceTest.java +++ b/backend/src/test/java/com/msohailse/app/incident/adapters/in/rest/TagResourceTest.java @@ -22,8 +22,14 @@ public class TagResourceTest { @Inject UserTransaction userTransaction; + @Inject + JwtIssuer jwtIssuer; + private int adminId; private int reporterId; + // JwtAuthFilter only checks that a token is validly signed, not whose it is — any + // authenticated user's token satisfies it for these tests. + private String authHeader; @BeforeEach void setup() throws Exception { @@ -45,6 +51,8 @@ void setup() throws Exception { userRepository.save(reporter); reporterId = reporter.getId(); userTransaction.commit(); + + authHeader = "Bearer " + jwtIssuer.issue(admin); } @Test @@ -52,6 +60,7 @@ void createFindUpdateDeleteTag() { String title = "flood-" + System.nanoTime(); int id = given() + .header("Authorization", authHeader) .contentType("application/json") .body("{\"actingUserId\":" + adminId + ",\"tagTitle\":\"" + title + "\",\"tagDescription\":\"flood-related incidents\"}") .when().post("/tags") @@ -66,6 +75,7 @@ void createFindUpdateDeleteTag() { String updatedTitle = title + "-updated"; given() + .header("Authorization", authHeader) .contentType("application/json") .body("{\"actingUserId\":" + adminId + ",\"tagTitle\":\"" + updatedTitle + "\",\"tagDescription\":\"still flood\"}") .when().put("/tags/" + id) @@ -78,6 +88,7 @@ void createFindUpdateDeleteTag() { .body("size()", greaterThan(0)); given() + .header("Authorization", authHeader) .when().delete("/tags/" + id + "?actingUserId=" + adminId) .then().statusCode(204); @@ -89,6 +100,7 @@ void createFindUpdateDeleteTag() { @Test void createTagByNonAdminReturns400() { given() + .header("Authorization", authHeader) .contentType("application/json") .body("{\"actingUserId\":" + reporterId + ",\"tagTitle\":\"blocked-" + System.nanoTime() + "\",\"tagDescription\":\"nope\"}") .when().post("/tags") diff --git a/frontend/src/app/interceptors/auth.interceptor.ts b/frontend/src/app/interceptors/auth.interceptor.ts index 4e33b47..daa0728 100644 --- a/frontend/src/app/interceptors/auth.interceptor.ts +++ b/frontend/src/app/interceptors/auth.interceptor.ts @@ -2,9 +2,8 @@ import { HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; import { AuthService } from '../services/auth.service'; -// Tier A: attaches the JWT issued at login to every outgoing request, ready for the -// backend to enforce later. Not enforced yet — actingUserId-based checks still do the -// real authorization. +// Attaches the JWT issued at login to every outgoing request. The backend's +// JwtAuthFilter now enforces it on mutating requests. export const authInterceptor: HttpInterceptorFn = (req, next) => { const token = inject(AuthService).getToken(); if (!token) {