feat(login): cookie session with rotating refresh token
Build, Test & Push Auth Service / quality-check (push) Successful in 30s
Build, Test & Push Auth Service / docker (push) Successful in 1m23s

The login token moves out of localStorage into an HttpOnly cookie
(15 min), so a script on the page can no longer read it. A random
refresh token (30 days, sliding) renews it; only its SHA-256 is
stored. Each refresh replaces the token, and a replaced token that
comes back revokes every token of that login.

Unsafe requests need an X-TBP-Client or Authorization header, and
CORS allows only the exact frontend origin. A missing or expired
token now answers 401 instead of 403, so the frontend can refresh.
This commit is contained in:
lobo committed 2026-09-25 13:45:04 +02:00
1 parent 600b0b143f
commit 930e08e6dd
30 files changed
+1096 -151

No files matched your search

@@ -0,0 +1,65 @@
package auth_service.configuration.security;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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.status;
/**
* Every access token from this context is already expired when it is issued.
*/
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.auth.access-token-ttl=-1s")
class ExpiredAccessTokenTest extends AbstractIntegrationTest {
private static final String EMAIL = "review-findings@example.com";
private static final String PASSWORD = "correct horse battery";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PasswordEncoder passwordEncoder;
@BeforeEach
void seedUser() {
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, ?, true, NULL)",
EMAIL, passwordEncoder.encode(PASSWORD));
}
private String expiredAccessCookie() throws Exception {
Cookie cookie = mockMvc.perform(post("/api/auth/login")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"))
.andExpect(status().isOk())
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE);
assertNotNull(cookie);
return cookie.getValue();
}
@Test
void expiredAccessCookie_Is401SoTheFrontendRefreshes() throws Exception {
String expired = expiredAccessCookie();
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, expired)))
.andExpect(status().isUnauthorized());
}
}
@@ -1,5 +1,7 @@
package auth_service.configuration.security;
import auth_service.shared.security.SessionProperties;
import auth_service.shared.security.UserPrincipal;
import auth_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
@@ -15,6 +17,7 @@ import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -51,7 +54,7 @@ class JwtServiceTest extends AbstractUnitTest {
publicKey = KeyFactory.getInstance("Ed25519")
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicB64)));
jwtService = new JwtService(jwtConfig);
jwtService = new JwtService(jwtConfig, new SessionProperties());
jwtService.init();
}
@@ -78,39 +81,25 @@ class JwtServiceTest extends AbstractUnitTest {
}
@Test
void extractEmail_Success() {
var email = "test@example.com";
var token = jwtService.generateToken("0815", email, null);
void parseLoginToken_ReturnsTheUser() {
var token = jwtService.generateToken("0815", "test@example.com", 123456789L);
assertEquals(email, jwtService.extractEmail(token));
assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)),
jwtService.parseLoginToken(token));
}
@Test
void extractUserId_Success() {
var userId = "0815";
var token = jwtService.generateToken(userId, "test@example.com", null);
assertEquals(userId, jwtService.extractUserId(token));
void parseLoginToken_RejectsGarbage() {
assertEquals(Optional.empty(), jwtService.parseLoginToken("invalid-token"));
}
@Test
void extractSteamId_Success() {
var steamId = 123456789L;
var token = jwtService.generateToken("0815", "test@example.com", steamId);
assertEquals(steamId, jwtService.extractSteamId(token));
}
@Test
void isTokenValid_Success() {
void generateToken_ExpiresAfterTheAccessTokenTtl() {
var token = jwtService.generateToken("0815", "test@example.com", null);
assertTrue(jwtService.isTokenValid(token));
}
@Test
void isTokenValid_Failure() {
assertFalse(jwtService.isTokenValid("invalid-token"));
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token).getPayload();
long secondsLeft = (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000;
assertTrue(secondsLeft > 14 * 60 && secondsLeft <= 15 * 60, "expires in " + secondsLeft + "s");
}
@Test
@@ -126,14 +115,14 @@ class JwtServiceTest extends AbstractUnitTest {
}
@Test
void isTokenValid_ProfileTicketIsNoLoginToken() {
assertFalse(jwtService.isTokenValid(jwtService.generateProfileTicket(42L)));
void parseLoginToken_ProfileTicketIsNoLoginToken() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L)));
}
@Test
void init_BlankKid_FailsAtStartup() {
when(jwtConfig.getKid()).thenReturn(" ");
JwtService service = new JwtService(jwtConfig);
JwtService service = new JwtService(jwtConfig, new SessionProperties());
assertThrows(IllegalStateException.class, service::init);
}
@@ -0,0 +1,301 @@
package auth_service.configuration.security;
import auth_service.modules.login.model.Session;
import auth_service.modules.login.service.RefreshTokenService;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The whole cookie session against a real database: login, refresh rotation, reuse detection, logout, the CSRF
* header and CORS.
*/
@AutoConfigureMockMvc
class SessionFlowTest extends AbstractIntegrationTest {
private static final String EMAIL = "session-flow@example.com";
private static final String PASSWORD = "correct horse battery";
private static final String CLIENT = "web";
private static final String FRONTEND = "https://tbp.andreas-dahm.eu";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private RefreshTokenService refreshTokenService;
@BeforeEach
void seedUser() {
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, ?, true, NULL)",
EMAIL, passwordEncoder.encode(PASSWORD));
}
private MockHttpServletResponse login() throws Exception {
return mockMvc.perform(post("/api/auth/login")
.header(ClientHeaderFilter.CLIENT_HEADER, CLIENT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").doesNotExist())
.andReturn().getResponse();
}
private MockHttpServletResponse refresh(String refreshToken) throws Exception {
return mockMvc.perform(post("/api/auth/refresh")
.header(ClientHeaderFilter.CLIENT_HEADER, CLIENT)
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, refreshToken)))
.andReturn().getResponse();
}
private static String cookie(MockHttpServletResponse response, String name) {
Cookie cookie = response.getCookie(name);
assertNotNull(cookie, name + " missing");
return cookie.getValue();
}
@Test
void login_TheAccessCookieAuthenticates() throws Exception {
String access = cookie(login(), AuthCookies.ACCESS_COOKIE);
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, access)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value(EMAIL));
}
@Test
void noLogin_Is401() throws Exception {
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isUnauthorized());
}
@Test
void refresh_RotatesTheToken() throws Exception {
String first = cookie(login(), AuthCookies.REFRESH_COOKIE);
MockHttpServletResponse refreshed = refresh(first);
assertEquals(204, refreshed.getStatus());
String second = cookie(refreshed, AuthCookies.REFRESH_COOKIE);
assertNotEquals(first, second);
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE,
cookie(refreshed, AuthCookies.ACCESS_COOKIE))))
.andExpect(status().isOk());
assertEquals(204, refresh(second).getStatus());
}
@Test
void refresh_ReusedToken_RevokesTheWholeFamily() throws Exception {
String first = cookie(login(), AuthCookies.REFRESH_COOKIE);
String second = cookie(refresh(first), AuthCookies.REFRESH_COOKIE);
assertEquals(401, refresh(first).getStatus());
assertEquals(401, refresh(second).getStatus());
}
@Test
void refresh_OtherLoginsKeepWorkingAfterAReuse() throws Exception {
String stolen = cookie(login(), AuthCookies.REFRESH_COOKIE);
String otherDevice = cookie(login(), AuthCookies.REFRESH_COOKIE);
refresh(stolen);
refresh(stolen);
assertEquals(204, refresh(otherDevice).getStatus());
}
@Test
void refresh_TwoParallelUsesOfOneToken_OnlyOneWinsAndTheFamilyEnds() throws Exception {
String token = cookie(login(), AuthCookies.REFRESH_COOKIE);
List<Optional<Session>> results;
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
List<Future<Optional<Session>>> futures = pool.invokeAll(List.<Callable<Optional<Session>>>of(
() -> refreshTokenService.refresh(token), () -> refreshTokenService.refresh(token)));
results = new ArrayList<>();
for (Future<Optional<Session>> future : futures) {
results.add(future.get());
}
}
List<Session> winners = results.stream().flatMap(Optional::stream).toList();
assertEquals(1, winners.size());
assertEquals(401, refresh(winners.getFirst().refreshToken()).getStatus());
}
@Test
void login_WithAnOldRefreshCookie_RevokesTheOldLogin() throws Exception {
String old = cookie(login(), AuthCookies.REFRESH_COOKIE);
mockMvc.perform(post("/api/auth/login")
.header(ClientHeaderFilter.CLIENT_HEADER, CLIENT)
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, old))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"))
.andExpect(status().isOk());
assertEquals(401, refresh(old).getStatus());
}
@Test
void refresh_ExpiredToken_Is401() throws Exception {
String token = cookie(login(), AuthCookies.REFRESH_COOKIE);
jdbc.update("UPDATE refresh_tokens SET expires_at = now() - interval '1 second' "
+ "WHERE user_id = (SELECT id FROM users WHERE email = ?)", EMAIL);
assertEquals(401, refresh(token).getStatus());
}
@Test
void refresh_DisabledUser_Is401() throws Exception {
String token = cookie(login(), AuthCookies.REFRESH_COOKIE);
jdbc.update("UPDATE users SET enabled = false WHERE email = ?", EMAIL);
assertEquals(401, refresh(token).getStatus());
}
@Test
void refresh_UnknownToken_Is401AndClearsTheCookies() throws Exception {
MockHttpServletResponse response = refresh("made-up");
assertEquals(401, response.getStatus());
assertEquals(0, response.getCookie(AuthCookies.REFRESH_COOKIE).getMaxAge());
}
@Test
void refresh_WithoutCookie_Is401() throws Exception {
mockMvc.perform(post("/api/auth/refresh").header(ClientHeaderFilter.CLIENT_HEADER, CLIENT))
.andExpect(status().isUnauthorized());
}
@Test
void logout_WithoutCookie_Is204() throws Exception {
mockMvc.perform(post("/api/auth/logout").header(ClientHeaderFilter.CLIENT_HEADER, CLIENT))
.andExpect(status().isNoContent());
}
@Test
void cookiesAreHostOnlyByDefault() throws Exception {
MockHttpServletResponse response = login();
assertEquals(null, response.getCookie(AuthCookies.ACCESS_COOKIE).getDomain());
}
@Test
void logout_RevokesTheRefreshToken() throws Exception {
String token = cookie(login(), AuthCookies.REFRESH_COOKIE);
mockMvc.perform(post("/api/auth/logout")
.header(ClientHeaderFilter.CLIENT_HEADER, CLIENT)
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, token)))
.andExpect(status().isNoContent());
assertEquals(401, refresh(token).getStatus());
}
@Test
void deleteExpired_RemovesOnlyExpiredRows() throws Exception {
login();
login();
jdbc.update("UPDATE refresh_tokens SET expires_at = now() - interval '1 second' WHERE id = "
+ "(SELECT MIN(t.id) FROM refresh_tokens t JOIN users u ON u.id = t.user_id WHERE u.email = ?)", EMAIL);
refreshTokenService.deleteExpired();
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM refresh_tokens t JOIN users u ON u.id = t.user_id "
+ "WHERE u.email = ?", Integer.class, EMAIL));
}
@Test
void postWithoutClientHeader_Is403() throws Exception {
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("CLIENT_HEADER_MISSING"));
}
@Test
void putWithAccessCookieButWithoutClientHeader_Is403() throws Exception {
String access = cookie(login(), AuthCookies.ACCESS_COOKIE);
mockMvc.perform(put("/api/users/settings/appearance")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, access))
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("CLIENT_HEADER_MISSING"));
}
@Test
void bearerHeaderStillAuthenticatesWithoutClientHeader() throws Exception {
String access = cookie(login(), AuthCookies.ACCESS_COOKIE);
mockMvc.perform(put("/api/users/settings/appearance")
.header("Authorization", "Bearer " + access)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"accent\":\"blue\",\"corners\":\"round\",\"wideLayout\":false,\"showBanner\":true}"))
.andExpect(status().isNoContent());
}
@Test
void staleCookieNextToAValidBearer_Authenticates() throws Exception {
String access = cookie(login(), AuthCookies.ACCESS_COOKIE);
mockMvc.perform(get("/api/users/me")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, "expired-or-garbage"))
.header("Authorization", "Bearer " + access))
.andExpect(status().isOk());
}
@Test
void corsPreflight_AllowsOnlyTheFrontendWithCredentials() throws Exception {
mockMvc.perform(options("/api/auth/refresh")
.header("Origin", FRONTEND)
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "x-tbp-client"))
.andExpect(status().isOk())
.andExpect(header().string("Access-Control-Allow-Origin", FRONTEND))
.andExpect(header().string("Access-Control-Allow-Credentials", "true"));
mockMvc.perform(options("/api/auth/refresh")
.header("Origin", "https://evil.andreas-dahm.eu")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "x-tbp-client"))
.andExpect(status().isForbidden());
}
}
@@ -4,8 +4,13 @@ import auth_service.configuration.security.JwtAuthenticationFilter;
import auth_service.modules.login.api.dto.request.LoginRequest;
import auth_service.modules.login.exception.InvalidCredentialsException;
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
import auth_service.modules.login.model.Session;
import auth_service.modules.login.model.User;
import auth_service.modules.login.service.LoginService;
import auth_service.modules.login.service.RefreshTokenService;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.security.SessionProperties;
import jakarta.servlet.http.Cookie;
import auth_service.shared.test.AbstractWebMvcTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -13,15 +18,21 @@ import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.TestPropertySource;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
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.cookie;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -30,6 +41,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* Tests for LoginController.
*/
@WebMvcTest(LoginController.class)
@Import({AuthCookies.class, SessionProperties.class})
@TestPropertySource(properties = "app.auth.cookie-domain=tbp.andreas-dahm.eu")
@AutoConfigureMockMvc(addFilters = false) // Skip security filters for simple controller test
@AutoConfigureJsonTesters
class LoginControllerTest extends AbstractWebMvcTest {
@@ -40,6 +53,9 @@ class LoginControllerTest extends AbstractWebMvcTest {
@MockitoBean
private LoginService loginService;
@MockitoBean
private RefreshTokenService refreshTokenService;
@MockitoBean
private JwtAuthenticationFilter jwtAuthenticationFilter; // Needed because it's used in SecurityConfig
@@ -50,7 +66,7 @@ class LoginControllerTest extends AbstractWebMvcTest {
void login_Success() throws Exception {
// Arrange
var request = new LoginRequest("test@example.com", "password");
when(loginService.login(any(User.class), eq("127.0.0.1"))).thenReturn("mock-token");
when(loginService.login(any(User.class), eq("127.0.0.1"), any())).thenReturn(new Session("mock-access", "mock-refresh"));
// Act & Assert
mockMvc.perform(post("/api/auth/login")
@@ -58,14 +74,26 @@ class LoginControllerTest extends AbstractWebMvcTest {
.content(loginRequestJson.write(request).getJson()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Login successful"))
.andExpect(jsonPath("$.token").value("mock-token"));
.andExpect(jsonPath("$.token").doesNotExist())
.andExpect(cookie().value(AuthCookies.ACCESS_COOKIE, "mock-access"))
.andExpect(cookie().httpOnly(AuthCookies.ACCESS_COOKIE, true))
.andExpect(cookie().secure(AuthCookies.ACCESS_COOKIE, true))
.andExpect(cookie().sameSite(AuthCookies.ACCESS_COOKIE, "Lax"))
.andExpect(cookie().domain(AuthCookies.ACCESS_COOKIE, "tbp.andreas-dahm.eu"))
.andExpect(cookie().maxAge(AuthCookies.ACCESS_COOKIE, 15 * 60))
.andExpect(cookie().value(AuthCookies.REFRESH_COOKIE, "mock-refresh"))
.andExpect(cookie().httpOnly(AuthCookies.REFRESH_COOKIE, true))
.andExpect(cookie().path(AuthCookies.REFRESH_COOKIE, "/api/auth"))
.andExpect(cookie().sameSite(AuthCookies.REFRESH_COOKIE, "Strict"))
.andExpect(cookie().domain(AuthCookies.REFRESH_COOKIE, (String) null))
.andExpect(cookie().maxAge(AuthCookies.REFRESH_COOKIE, 30 * 24 * 60 * 60));
}
@Test
void login_Failure() throws Exception {
// Arrange
var request = new LoginRequest("test@example.com", "wrong-password");
when(loginService.login(any(User.class), any())).thenThrow(new InvalidCredentialsException("Invalid email or password"));
when(loginService.login(any(User.class), any(), any())).thenThrow(new InvalidCredentialsException("Invalid email or password"));
// Act & Assert
mockMvc.perform(post("/api/auth/login")
@@ -79,7 +107,7 @@ class LoginControllerTest extends AbstractWebMvcTest {
void login_UsesClientIpAddedByTraefik() throws Exception {
// Arrange
var request = new LoginRequest("test@example.com", "password");
when(loginService.login(any(User.class), eq("203.0.113.7"))).thenReturn("mock-token");
when(loginService.login(any(User.class), eq("203.0.113.7"), any())).thenReturn(new Session("mock-access", "mock-refresh"));
// Act & Assert
mockMvc.perform(post("/api/auth/login")
@@ -93,7 +121,7 @@ class LoginControllerTest extends AbstractWebMvcTest {
void login_TooManyAttempts() throws Exception {
// Arrange
var request = new LoginRequest("test@example.com", "password");
when(loginService.login(any(User.class), any())).thenThrow(new TooManyLoginAttemptsException(900));
when(loginService.login(any(User.class), any(), any())).thenThrow(new TooManyLoginAttemptsException(900));
// Act & Assert
mockMvc.perform(post("/api/auth/login")
@@ -114,4 +142,45 @@ class LoginControllerTest extends AbstractWebMvcTest {
verifyNoInteractions(loginService);
}
@Test
void refresh_ValidCookie_SetsNewCookies() throws Exception {
when(refreshTokenService.refresh("old-refresh")).thenReturn(Optional.of(new Session("new-access", "new-refresh")));
mockMvc.perform(post("/api/auth/refresh").cookie(new Cookie(AuthCookies.REFRESH_COOKIE, "old-refresh")))
.andExpect(status().isNoContent())
.andExpect(cookie().value(AuthCookies.ACCESS_COOKIE, "new-access"))
.andExpect(cookie().value(AuthCookies.REFRESH_COOKIE, "new-refresh"));
}
@Test
void refresh_RejectedCookie_ClearsBothCookies() throws Exception {
when(refreshTokenService.refresh("old-refresh")).thenReturn(Optional.empty());
mockMvc.perform(post("/api/auth/refresh").cookie(new Cookie(AuthCookies.REFRESH_COOKIE, "old-refresh")))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("SESSION_EXPIRED"))
.andExpect(cookie().maxAge(AuthCookies.ACCESS_COOKIE, 0))
.andExpect(cookie().maxAge(AuthCookies.REFRESH_COOKIE, 0))
.andExpect(cookie().path(AuthCookies.REFRESH_COOKIE, "/api/auth"))
.andExpect(cookie().domain(AuthCookies.ACCESS_COOKIE, "tbp.andreas-dahm.eu"));
}
@Test
void refresh_NoCookie_Is401() throws Exception {
when(refreshTokenService.refresh(null)).thenReturn(Optional.empty());
mockMvc.perform(post("/api/auth/refresh"))
.andExpect(status().isUnauthorized());
}
@Test
void logout_RevokesAndClearsBothCookies() throws Exception {
mockMvc.perform(post("/api/auth/logout").cookie(new Cookie(AuthCookies.REFRESH_COOKIE, "the-refresh")))
.andExpect(status().isNoContent())
.andExpect(cookie().maxAge(AuthCookies.ACCESS_COOKIE, 0))
.andExpect(cookie().maxAge(AuthCookies.REFRESH_COOKIE, 0));
verify(refreshTokenService).revoke("the-refresh");
}
}
@@ -3,6 +3,7 @@ package auth_service.modules.login.service;
import auth_service.shared.security.TokenGenerator;
import auth_service.modules.login.exception.InvalidCredentialsException;
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
import auth_service.modules.login.model.Session;
import auth_service.modules.login.model.User;
import auth_service.modules.login.persistence.MinimalUserRepository;
import auth_service.shared.test.AbstractUnitTest;
@@ -44,6 +45,9 @@ class LoginServiceTest extends AbstractUnitTest {
@Mock
private LoginAttemptService loginAttemptService;
@Mock
private RefreshTokenService refreshTokenService;
private LoginService loginService;
private User user;
@@ -51,7 +55,8 @@ class LoginServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
lenient().when(passwordEncoder.encode(anyString())).thenReturn("dummy-hash");
loginService = new LoginService(minimalUserRepository, passwordEncoder, tokenGenerator, loginAttemptService);
loginService = new LoginService(minimalUserRepository, passwordEncoder, tokenGenerator, loginAttemptService,
refreshTokenService);
user = User.builder()
.email("test@example.com")
.password("password")
@@ -70,23 +75,46 @@ class LoginServiceTest extends AbstractUnitTest {
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
when(tokenGenerator.generateToken("815", "test@example.com", null)).thenReturn("mock-token");
when(refreshTokenService.startFamily(815L)).thenReturn("mock-refresh");
// Act
var result = loginService.login(user, IP);
var result = loginService.login(user, IP, null);
// Assert
assertEquals("mock-token", result);
assertEquals(new Session("mock-token", "mock-refresh"), result);
verify(loginAttemptService).acquire("test@example.com", IP);
verify(loginAttemptService).recordSuccess("test@example.com", IP);
}
@Test
void login_Success_RevokesTheRefreshTokenThisBrowserStillSends() {
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true).build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
loginService.login(user, IP, "old-refresh");
verify(refreshTokenService).revoke("old-refresh");
}
@Test
void login_WrongPassword_RevokesNothing() {
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true).build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false);
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, "old-refresh"));
verify(refreshTokenService, never()).revoke(any());
}
@Test
void login_UnknownEmail_SameErrorAsWrongPassword() {
// Arrange
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.empty());
// Act & Assert
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP));
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
// bcrypt still runs, so the response time does not reveal that the email is unknown
verify(passwordEncoder).matches("password", "dummy-hash");
verify(loginAttemptService).acquire("test@example.com", IP);
@@ -105,7 +133,7 @@ class LoginServiceTest extends AbstractUnitTest {
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false);
// Act & Assert
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP));
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
verify(loginAttemptService).acquire("test@example.com", IP);
verify(loginAttemptService, never()).recordSuccess(any(), any());
}
@@ -123,7 +151,7 @@ class LoginServiceTest extends AbstractUnitTest {
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
// Act & Assert
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP));
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any());
}
@@ -133,7 +161,7 @@ class LoginServiceTest extends AbstractUnitTest {
doThrow(new TooManyLoginAttemptsException(900)).when(loginAttemptService).acquire("test@example.com", IP);
// Act & Assert
assertThrows(TooManyLoginAttemptsException.class, () -> loginService.login(user, IP));
assertThrows(TooManyLoginAttemptsException.class, () -> loginService.login(user, IP, null));
verifyNoInteractions(minimalUserRepository);
verify(passwordEncoder, never()).matches(any(), eq("encodedPassword"));
}
@@ -10,6 +10,8 @@ import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.service.UserService;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.security.SessionProperties;
import auth_service.shared.test.AbstractWebMvcTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -20,6 +22,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -35,10 +38,12 @@ import static org.mockito.Mockito.verifyNoInteractions;
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.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(UserController.class)
@Import({AuthCookies.class, SessionProperties.class})
@AutoConfigureMockMvc(addFilters = false)
@AutoConfigureJsonTesters
class UserControllerTest extends AbstractWebMvcTest {
@@ -148,8 +153,9 @@ class UserControllerTest extends AbstractWebMvcTest {
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("new-token"));
.andExpect(status().isNoContent())
.andExpect(cookie().value(AuthCookies.ACCESS_COOKIE, "new-token"))
.andExpect(cookie().httpOnly(AuthCookies.ACCESS_COOKIE, true));
}
@Test
@@ -166,7 +172,7 @@ class UserControllerTest extends AbstractWebMvcTest {
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"en","steamId":null}"""))
.andExpect(status().isOk());
.andExpect(status().isNoContent());
verify(userService).updateUserSettings(eq(1L), eq(email), isNull(), any(), any());
}
@@ -300,7 +306,7 @@ class UserControllerTest extends AbstractWebMvcTest {
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"en","steamId":null,"displayName":"Lobo Corp_1"}"""))
.andExpect(status().isOk());
.andExpect(status().isNoContent());
}
@ParameterizedTest
@@ -372,7 +378,7 @@ class UserControllerTest extends AbstractWebMvcTest {
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"useDarkTheme\":true,\"language\":\"en\",\"steamId\":null,\"displayName\":\"Lobo\"" + field + "}"))
.andExpect(status().isOk());
.andExpect(status().isNoContent());
verify(userService).updateUserSettings(eq(1L), eq("test@example.com"), isNull(), eq("Lobo"),
argThat(settings -> settings.isPublicProfile() == expected));