Public Access
feat(login): cookie session with rotating refresh token
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:
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));
|
||||
|
||||
Reference in new issue
Block a user