From 37bcb7f48f921bd170b1d59d3971117e47ab1acf Mon Sep 17 00:00:00 2001 From: Randy Dean Date: Mon, 6 Jul 2026 12:22:31 -0400 Subject: [PATCH] Auth: wire Spring Security + Spring Session JDBC end to end - AuthController: POST /api/auth/request-link|verify|logout and GET /api/session; verify performs programmatic login (session id rotation + SecurityContextRepository save), session reads the member fresh so profile completion is never stale - SecurityConfig moved to auth/security and rewired: patchats_session cookie (HttpOnly, SameSite=Lax, Secure per profile, 30d) via DefaultCookieSerializer; dev/prod chains share CSRF-off posture documented in the javadoc; prod email rule kept fail-closed - ApiAuthenticationEntryPoint answers 401s in the ApiResponder envelope - AuthenticatedMember principal is Serializable (persisted by Spring Session) and exposes email as the indexed principal name - SecurityWiringTest exercises the real filter chain: anonymous 401, session-carried auth across requests, logout invalidation Co-Authored-By: Claude Fable 5 --- .../patchats/auth/AuthController.java | 113 +++++++++++++++++ .../security/ApiAuthenticationEntryPoint.java | 31 +++++ .../auth/security/AuthenticatedMember.java | 23 ++++ .../auth/security/SecurityConfig.java | 105 ++++++++++++++++ .../patchats/auth/AuthControllerTest.java | 118 ++++++++++++++++++ .../auth/security/SecurityWiringTest.java | 106 ++++++++++++++++ 6 files changed, 496 insertions(+) create mode 100644 src/main/java/org/patinanetwork/patchats/auth/AuthController.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/security/ApiAuthenticationEntryPoint.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/security/AuthenticatedMember.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/security/SecurityConfig.java create mode 100644 src/test/java/org/patinanetwork/patchats/auth/AuthControllerTest.java create mode 100644 src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java diff --git a/src/main/java/org/patinanetwork/patchats/auth/AuthController.java b/src/main/java/org/patinanetwork/patchats/auth/AuthController.java new file mode 100644 index 0000000..c143291 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/AuthController.java @@ -0,0 +1,113 @@ +package org.patinanetwork.patchats.auth; + +import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.auth.dto.RequestLinkRequest; +import org.patinanetwork.patchats.auth.dto.SessionResponse; +import org.patinanetwork.patchats.auth.dto.VerifyRequest; +import org.patinanetwork.patchats.auth.repo.MemberAccount; +import org.patinanetwork.patchats.auth.repo.MemberAccountRepository; +import org.patinanetwork.patchats.auth.security.AuthenticatedMember; +import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** REST endpoints for magic-link sign-in and the current session. */ +@RestController +@RequestMapping("/api") +@Tag(name = "Auth") +@Timed(value = "controller.execution") +@EnableConfigurationProperties(AuthProperties.class) +@RequiredArgsConstructor +public class AuthController { + + /** The response is identical whether or not the email has an account, so nothing can be enumerated. */ + private static final String GENERIC_REQUEST_MESSAGE = "Check your email for a sign-in link."; + + private final AuthService authService; + private final MemberAccountRepository members; + private final SecurityContextRepository securityContextRepository; + + @Operation(summary = "Email a single-use sign-in link") + @PostMapping("/auth/request-link") + public ResponseEntity> requestLink( + @Valid @RequestBody final RequestLinkRequest request, final HttpServletRequest httpRequest) { + authService.requestLink(request.email(), httpRequest.getRemoteAddr()); + return ResponseEntity.ok(ApiResponder.success(GENERIC_REQUEST_MESSAGE, null)); + } + + @Operation(summary = "Exchange a magic-link token for a session") + @PostMapping("/auth/verify") + public ResponseEntity> verify( + @Valid @RequestBody final VerifyRequest request, + final HttpServletRequest httpRequest, + final HttpServletResponse httpResponse) { + final MemberAccount member = authService.verify(request.token()); + login(member, httpRequest, httpResponse); + return ResponseEntity.ok(ApiResponder.success("Signed in.", SessionResponse.of(member))); + } + + /** Reads the member fresh from the database so profile completion is never stale session state. */ + @Operation(summary = "The currently signed-in member") + @GetMapping("/session") + public ResponseEntity> session( + @AuthenticationPrincipal final AuthenticatedMember principal, final HttpServletRequest httpRequest) { + return members.findById(principal.memberId()) + .map(account -> ResponseEntity.ok(ApiResponder.success("Signed in.", SessionResponse.of(account)))) + .orElseGet(() -> { + invalidateSession(httpRequest); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponder.failure("Not signed in")); + }); + } + + @Operation(summary = "Sign out") + @PostMapping("/auth/logout") + public ResponseEntity> logout(final HttpServletRequest httpRequest) { + invalidateSession(httpRequest); + SecurityContextHolder.clearContext(); + return ResponseEntity.ok(ApiResponder.success("Signed out.", null)); + } + + /** + * Programmatic login: store the authenticated principal in the {@link SecurityContextRepository}, which Spring + * Session persists and turns into the session cookie. {@code changeSessionId} rotates any pre-existing session so a + * client-supplied id can never survive into an authenticated session (fixation defense). + */ + private void login( + final MemberAccount member, final HttpServletRequest request, final HttpServletResponse response) { + if (request.getSession(false) != null) { + request.changeSessionId(); + } + final SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated( + AuthenticatedMember.of(member), null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")))); + SecurityContextHolder.setContext(context); + securityContextRepository.saveContext(context, request, response); + } + + private void invalidateSession(final HttpServletRequest request) { + final HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/security/ApiAuthenticationEntryPoint.java b/src/main/java/org/patinanetwork/patchats/auth/security/ApiAuthenticationEntryPoint.java new file mode 100644 index 0000000..057cce6 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/security/ApiAuthenticationEntryPoint.java @@ -0,0 +1,31 @@ +package org.patinanetwork.patchats.auth.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +/** Answers unauthenticated requests to protected endpoints with a 401 in the standard JSON envelope. */ +@Component +@RequiredArgsConstructor +public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + final HttpServletRequest request, + final HttpServletResponse response, + final AuthenticationException authException) + throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getWriter(), ApiResponder.failure("Not signed in")); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/security/AuthenticatedMember.java b/src/main/java/org/patinanetwork/patchats/auth/security/AuthenticatedMember.java new file mode 100644 index 0000000..d7b5336 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/security/AuthenticatedMember.java @@ -0,0 +1,23 @@ +package org.patinanetwork.patchats.auth.security; + +import java.io.Serializable; +import java.security.Principal; +import java.util.UUID; +import org.patinanetwork.patchats.auth.repo.MemberAccount; + +/** + * The security principal stored in the session. Must stay {@link Serializable} (and small): Spring Session JDBC + * serializes the whole {@code SecurityContext} into {@code spring_session_attributes}. Implementing {@link Principal} + * gives {@code Authentication.getName()} — and Spring Session's {@code PRINCIPAL_NAME} index — the member's email. + */ +public record AuthenticatedMember(UUID memberId, String email) implements Principal, Serializable { + + public static AuthenticatedMember of(final MemberAccount account) { + return new AuthenticatedMember(account.id(), account.email()); + } + + @Override + public String getName() { + return email; + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/security/SecurityConfig.java b/src/main/java/org/patinanetwork/patchats/auth/security/SecurityConfig.java new file mode 100644 index 0000000..de2c8f6 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/security/SecurityConfig.java @@ -0,0 +1,105 @@ +package org.patinanetwork.patchats.auth.security; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.auth.AuthProperties; +import org.springframework.boot.autoconfigure.session.SessionProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.session.web.http.DefaultCookieSerializer; + +/** + * Wires cookie-session authentication on top of Spring Session JDBC. + * + *

How a request is authenticated. Spring Session's {@code SessionRepositoryFilter} resolves the + * {@code patchats_session} cookie into an {@code HttpSession} backed by the {@code spring_session} tables, and Spring + * Security's {@code SecurityContextHolderFilter} restores the {@link AuthenticatedMember} principal that + * {@code AuthController} saved at magic-link verification. There is no {@code JSESSIONID} and the server never adopts a + * client-supplied session id, so session fixation is prevented by construction (verify additionally rotates any + * pre-existing session id). + * + *

Why CSRF protection is disabled. Three layers make the classic attack a no-op here: the session cookie is + * {@code SameSite=Lax}, so browsers do not attach it to cross-site POSTs; every state-changing endpoint only consumes + * {@code application/json} request bodies, which cross-site forms cannot produce and cross-origin {@code fetch} cannot + * send without a CORS preflight (and no CORS mappings exist — the SPA is served same-origin, via the Vite proxy in + * dev); and the one anonymous cookie-consuming POST, logout, is idempotent and harmless. Revisit if the cookie ever + * needs {@code SameSite=None}. + */ +@Configuration +@EnableConfigurationProperties({AuthProperties.class, SessionProperties.class}) +@RequiredArgsConstructor +public class SecurityConfig { + + public static final String SESSION_COOKIE_NAME = "patchats_session"; + + private final ApiAuthenticationEntryPoint authenticationEntryPoint; + + /** Shapes the Spring Session cookie; picked up automatically by Spring Session's auto-configuration. */ + @Bean + public DefaultCookieSerializer cookieSerializer( + final AuthProperties authProperties, final SessionProperties sessionProperties) { + final DefaultCookieSerializer serializer = new DefaultCookieSerializer(); + serializer.setCookieName(SESSION_COOKIE_NAME); + serializer.setUseHttpOnlyCookie(true); + serializer.setUseSecureCookie(authProperties.isCookieSecure()); + serializer.setSameSite("Lax"); + serializer.setCookiePath("/"); + // Persistent cookie matching the server-side inactivity timeout (spring.session.timeout). + final Duration timeout = sessionProperties.getTimeout(); + serializer.setCookieMaxAge((int) timeout.toSeconds()); + return serializer; + } + + /** Shared by the filter chain (restore on request) and {@code AuthController} (save on login). */ + @Bean + public SecurityContextRepository securityContextRepository() { + return new HttpSessionSecurityContextRepository(); + } + + /** + * Default/production chain. + * + *

NOTE: the admin role is not yet assigned anywhere, so the email rule still fails closed — every caller is + * denied until an admin domain lands. Other endpoints keep their prior open posture. + */ + @Bean + @Profile("!dev") + SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { + return common(http) + .authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.POST, "/api/email/**") + .hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/api/session") + .authenticated() + .anyRequest() + .permitAll()) + .build(); + } + + /** Local-dev chain: only the session endpoint needs auth so the login flow can be exercised end to end. */ + @Bean + @Profile("dev") + SecurityFilterChain devSecurityFilterChain(final HttpSecurity http) throws Exception { + return common(http) + .authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.GET, "/api/session") + .authenticated() + .anyRequest() + .permitAll()) + .build(); + } + + private HttpSecurity common(final HttpSecurity http) throws Exception { + return http.csrf(AbstractHttpConfigurer::disable) + .requestCache(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .securityContext(context -> context.securityContextRepository(securityContextRepository())) + .exceptionHandling(handling -> handling.authenticationEntryPoint(authenticationEntryPoint)); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/auth/AuthControllerTest.java b/src/test/java/org/patinanetwork/patchats/auth/AuthControllerTest.java new file mode 100644 index 0000000..b3709cf --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/AuthControllerTest.java @@ -0,0 +1,118 @@ +package org.patinanetwork.patchats.auth; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.auth.repo.MemberAccount; +import org.patinanetwork.patchats.auth.repo.MemberAccountRepository; +import org.patinanetwork.patchats.common.web.ApiExceptionHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(AuthController.class) +@AutoConfigureMockMvc(addFilters = false) +@Import(ApiExceptionHandler.class) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AuthService authService; + + @MockitoBean + private MemberAccountRepository members; + + @MockitoBean + private SecurityContextRepository securityContextRepository; + + @Test + void requestLinkAlwaysReturnsGenericMessage() throws Exception { + mockMvc.perform(post("/api/auth/request-link") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"ann@example.com\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Check your email for a sign-in link.")); + + verify(authService).requestLink(eq("ann@example.com"), anyString()); + } + + @Test + void requestLinkRejectsMalformedEmail() throws Exception { + mockMvc.perform(post("/api/auth/request-link") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"not-an-email\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void verifyReturnsSessionPayloadAndSavesContext() throws Exception { + final MemberAccount member = + new MemberAccount(UUID.randomUUID(), "ann@example.com", "Ann", OffsetDateTime.now(ZoneOffset.UTC)); + when(authService.verify("raw-token")).thenReturn(member); + + mockMvc.perform(post("/api/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"raw-token\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.payload.email").value("ann@example.com")) + .andExpect(jsonPath("$.payload.name").value("Ann")) + .andExpect(jsonPath("$.payload.isAdmin").value(false)) + .andExpect(jsonPath("$.payload.profileCompleted").value(true)); + + verify(securityContextRepository).saveContext(any(), any(), any()); + } + + @Test + void verifyShellMemberHasNullNameAndIncompleteProfile() throws Exception { + when(authService.verify("raw-token")) + .thenReturn(new MemberAccount(UUID.randomUUID(), "new@example.com", null, null)); + + mockMvc.perform(post("/api/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"raw-token\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.payload.name").doesNotExist()) + .andExpect(jsonPath("$.payload.profileCompleted").value(false)); + } + + @Test + void verifyMapsInvalidTokenToBadRequestEnvelope() throws Exception { + when(authService.verify("spent")).thenThrow(new InvalidMagicLinkException()); + + mockMvc.perform(post("/api/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"spent\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.message").value("This sign-in link is invalid or has expired. Request a new one.")); + } + + @Test + void logoutIsIdempotent() throws Exception { + mockMvc.perform(post("/api/auth/logout")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Signed out.")); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java b/src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java new file mode 100644 index 0000000..757e53a --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java @@ -0,0 +1,106 @@ +package org.patinanetwork.patchats.auth.security; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.auth.AuthController; +import org.patinanetwork.patchats.auth.AuthService; +import org.patinanetwork.patchats.auth.repo.MemberAccount; +import org.patinanetwork.patchats.auth.repo.MemberAccountRepository; +import org.patinanetwork.patchats.common.web.ApiExceptionHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Exercises the default (non-dev) filter chain end to end with filters ON: anonymous rejection, programmatic login at + * verify, session-carried authentication, and logout. Runs without Spring Session's JDBC store — the servlet mock + * session stands in for it, which keeps the slice database-free while still proving the Spring Security wiring. + */ +@WebMvcTest(AuthController.class) +@Import({SecurityConfig.class, ApiAuthenticationEntryPoint.class, ApiExceptionHandler.class}) +class SecurityWiringTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AuthService authService; + + @MockitoBean + private MemberAccountRepository members; + + @Test + void sessionEndpointRejectsAnonymousWithJsonEnvelope() throws Exception { + mockMvc.perform(get("/api/session")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Not signed in")); + } + + @Test + void requestLinkIsReachableAnonymously() throws Exception { + mockMvc.perform(post("/api/auth/request-link") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"ann@example.com\"}")) + .andExpect(status().isOk()); + } + + @Test + void emailEndpointStaysAdminOnlyAndFailsClosed() throws Exception { + mockMvc.perform(post("/api/email/send") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void verifyEstablishesASessionThatAuthenticatesLaterRequests() throws Exception { + final MemberAccount member = new MemberAccount(UUID.randomUUID(), "ann@example.com", null, null); + when(authService.verify("raw-token")).thenReturn(member); + when(members.findById(member.id())).thenReturn(Optional.of(member)); + + final MvcResult login = mockMvc.perform(post("/api/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"raw-token\"}")) + .andExpect(status().isOk()) + .andReturn(); + final MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false); + assertNotNull(session, "verify must establish a session"); + + mockMvc.perform(get("/api/session").session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.payload.email").value("ann@example.com")) + .andExpect(jsonPath("$.payload.profileCompleted").value(false)); + } + + @Test + void logoutInvalidatesTheSession() throws Exception { + final MemberAccount member = new MemberAccount(UUID.randomUUID(), "ann@example.com", null, null); + when(authService.verify("raw-token")).thenReturn(member); + + final MvcResult login = mockMvc.perform(post("/api/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"raw-token\"}")) + .andReturn(); + final MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false); + assertNotNull(session); + + mockMvc.perform(post("/api/auth/logout").session(session)).andExpect(status().isOk()); + assertTrue(session.isInvalid()); + } +}