Spring Security 7.1 JWT authentication on Spring Boot 4.1
Runnable companion for https://ankurm.com/spring-security-7-1-jwt-authentication-guide/ - login -> token issue -> OncePerRequestFilter -> SecurityContext, end to end - HS256 and RS256 variants (RS256 publishes a real JWKS endpoint) - the same API secured by the built-in oauth2ResourceServer().jwt(), for comparison - 11 documentation chapters under docs/, interlinked with the code - docs/output/ is real captured output, regenerated by scripts/run-all.sh - 13 passing tests pinning the 401-vs-403 contract and the CSRF failure Verified against Spring Boot 4.1.1, Spring Security 7.1.1, JDK 25.0.4.1.
This commit is contained in:
142
src/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java
Normal file
142
src/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java
Normal file
@@ -0,0 +1,142 @@
|
||||
package com.ankurm.jwtauth;
|
||||
|
||||
import com.ankurm.jwtauth.auth.RevokedTokenStore;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* The 401-vs-403 contract, pinned as tests.
|
||||
*
|
||||
* @see docs/03-401-vs-403.md
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("hs256")
|
||||
class AuthenticationFlowTests {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
@Autowired RevokedTokenStore revokedTokens;
|
||||
|
||||
private static final tools.jackson.databind.ObjectMapper JSON =
|
||||
new tools.jackson.databind.ObjectMapper();
|
||||
|
||||
private Map<String, String> login(String user, String password) throws Exception {
|
||||
MvcResult result = this.mvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"%s\",\"password\":\"%s\"}".formatted(user, password)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
return JSON.readValue(result.getResponse().getContentAsString(),
|
||||
new tools.jackson.core.type.TypeReference<Map<String, String>>() { });
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicEndpointNeedsNoToken() throws Exception {
|
||||
this.mvc.perform(get("/api/public/ping")).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTokenIs401NotA403() throws Exception {
|
||||
this.mvc.perform(get("/api/me"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().string("WWW-Authenticate",
|
||||
org.hamcrest.Matchers.containsString("Bearer")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validTokenWithoutTheRoleIs403NotA401() throws Exception {
|
||||
String token = login("alice", "alice-password").get("accessToken");
|
||||
this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(header().string("WWW-Authenticate",
|
||||
org.hamcrest.Matchers.containsString("insufficient_scope")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminTokenReachesAdminEndpoint() throws Exception {
|
||||
String token = login("root", "root-password").get("accessToken");
|
||||
this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedSignatureIs401WithInvalidToken() throws Exception {
|
||||
String token = login("alice", "alice-password").get("accessToken");
|
||||
String tampered = token.substring(0, token.length() - 4) + "AAAA";
|
||||
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + tampered))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().string("WWW-Authenticate",
|
||||
org.hamcrest.Matchers.containsString("invalid_token")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshTokenIsNotAnAccessToken() throws Exception {
|
||||
String refresh = login("alice", "alice-password").get("refreshToken");
|
||||
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + refresh))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badPasswordIs401AndSaysNothingUseful() throws Exception {
|
||||
this.mvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"alice\",\"password\":\"nope\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockedAccountIsIndistinguishableFromABadPassword() throws Exception {
|
||||
MvcResult locked = this.mvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"locked\",\"password\":\"locked-password\"}"))
|
||||
.andExpect(status().isUnauthorized()).andReturn();
|
||||
MvcResult wrong = this.mvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"alice\",\"password\":\"nope\"}"))
|
||||
.andExpect(status().isUnauthorized()).andReturn();
|
||||
|
||||
assertThat(locked.getResponse().getContentAsString())
|
||||
.isEqualTo(wrong.getResponse().getContentAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokedTokenIsRefusedEvenThoughTheSignatureIsStillValid() throws Exception {
|
||||
String token = login("alice", "alice-password").get("accessToken");
|
||||
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
this.mvc.perform(post("/api/auth/logout").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void spentRefreshTokenCannotBeReplayed() throws Exception {
|
||||
String refresh = login("alice", "alice-password").get("refreshToken");
|
||||
this.mvc.perform(post("/api/auth/refresh")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"refreshToken\":\"%s\"}".formatted(refresh)))
|
||||
.andExpect(status().isOk());
|
||||
this.mvc.perform(post("/api/auth/refresh")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"refreshToken\":\"%s\"}".formatted(refresh)))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ankurm.jwtauth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.web.FilterChainProxy;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Pins the failure described in docs/04-csrf-permitall-403.md, and the two
|
||||
* facts that explain it.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles({"hs256", "csrfon"})
|
||||
class CsrfBreaksPermitAllTests {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
@Autowired FilterChainProxy filterChainProxy;
|
||||
|
||||
@Test
|
||||
void permitAllLoginStillReturns403WhenCsrfIsOn() throws Exception {
|
||||
this.mvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"alice\",\"password\":\"alice-password\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSameRequestWithACsrfTokenSucceeds() throws Exception {
|
||||
this.mvc.perform(post("/api/auth/login").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"alice\",\"password\":\"alice-password\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void csrfFilterRunsLongBeforeAuthorizationFilter() {
|
||||
List<String> filters = this.filterChainProxy.getFilterChains().getFirst()
|
||||
.getFilters().stream().map(f -> f.getClass().getSimpleName()).toList();
|
||||
|
||||
int csrf = filters.indexOf("CsrfFilter");
|
||||
int authorization = filters.indexOf("AuthorizationFilter");
|
||||
|
||||
assertThat(csrf).isNotNegative();
|
||||
assertThat(authorization).isEqualTo(filters.size() - 1);
|
||||
assertThat(csrf).isLessThan(authorization);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user