Compare commits

...

4 Commits

Author SHA1 Message Date
09333ccc0a test(auth): verify security context is stored in session after login and signup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:55:25 +02:00
93ce1eaeac refactor(auth): add comments, clearContext on logout, explain session auth
- Add comment to SecurityConfig explaining why CSRF is disabled
- Add SecurityContextHolder.clearContext() to logout for clean thread state
- Add Javadoc on authenticateInSession() explaining manual session setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:52:16 +02:00
61249af086 feat(auth): add secure flag to JSESSIONID cookie and test JSESSIONID cookie setting
- Add secure: true to cookies.set() in login and signup actions
- Add tests verifying JSESSIONID is forwarded to browser on successful
  login and signup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:50:34 +02:00
16f0feb8d5 fix(auth): fix mock responses in tests and block open redirect in login
- Add response object to mockSuccess() in login and signup tests so
  response.headers.get() no longer throws
- Validate ?redirect= param: must start with / and not // to prevent
  redirecting users to external domains

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:48:48 +02:00
7 changed files with 136 additions and 9 deletions

View File

@@ -50,6 +50,11 @@ public class AuthController {
return ResponseEntity.ok(ApiResponse.success(user));
}
/**
* Creates an authenticated Spring Security context and stores it in the HTTP session
* so that subsequent requests from the same session are recognised as authenticated.
* We do this manually because we are not using Spring Security's built-in form login.
*/
private void authenticateInSession(String email, String role, HttpServletRequest request) {
var auth = UsernamePasswordAuthenticationToken.authenticated(
email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())));
@@ -66,6 +71,7 @@ public class AuthController {
if (session != null) {
session.invalidate();
}
SecurityContextHolder.clearContext();
return ResponseEntity.noContent().build();
}

View File

@@ -18,6 +18,8 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// CSRF is disabled: SvelteKit is the only client and submits form actions
// server-side, so the browser never calls the backend directly.
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()

View File

@@ -10,16 +10,20 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.UUID;
import static org.hamcrest.Matchers.notNullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ExtendWith(MockitoExtension.class)
class AuthControllerTest {
@@ -95,6 +99,40 @@ class AuthControllerTest {
.andExpect(jsonPath("$.data.systemRole").value("user"));
}
@Test
void signupShouldStoreSecurityContextInSession() throws Exception {
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
when(authService.signup(any(SignupRequest.class))).thenReturn(response);
mockMvc.perform(post("/v1/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(request().sessionAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
notNullValue()));
}
@Test
void loginShouldStoreSecurityContextInSession() throws Exception {
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
var response = UserResponse.withHousehold(
UUID.randomUUID(), "sarah@example.com", "Sarah",
UUID.randomUUID(), "Smith family", "planner", "user");
when(authService.login(any(LoginRequest.class))).thenReturn(response);
mockMvc.perform(post("/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(request().sessionAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
notNullValue()));
}
@Test
void logoutShouldReturn204() throws Exception {
mockMvc.perform(post("/v1/auth/logout"))

View File

@@ -37,10 +37,11 @@ export const actions = {
const sessionId = response.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1];
if (sessionId) {
cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax' });
cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', secure: true });
}
const redirectTo = url.searchParams.get('redirect') || '/planner';
const raw = url.searchParams.get('redirect');
const redirectTo = raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/planner';
throw redirect(303, redirectTo);
}
} satisfies Actions;

View File

@@ -31,8 +31,16 @@ describe('login form action', () => {
} as any;
}
function mockSuccess() {
return {
data: { data: { id: '123' } },
error: undefined,
response: { headers: { get: vi.fn().mockReturnValue(null) } }
};
}
it('calls POST /v1/auth/login with form data', async () => {
mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent({
@@ -52,7 +60,7 @@ describe('login form action', () => {
});
it('redirects to /planner on success by default', async () => {
mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent({
@@ -67,7 +75,7 @@ describe('login form action', () => {
});
it('redirects to ?redirect param when present', async () => {
mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent(
@@ -81,6 +89,53 @@ describe('login form action', () => {
}
});
it('falls back to /planner when ?redirect= is an absolute URL', async () => {
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent(
{ email: 'sarah@example.com', password: 'password123' },
'?redirect=https%3A%2F%2Fevil.com'
));
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/planner');
}
});
it('falls back to /planner when ?redirect= is a protocol-relative URL', async () => {
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent(
{ email: 'sarah@example.com', password: 'password123' },
'?redirect=%2F%2Fevil.com'
));
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/planner');
}
});
it('sets JSESSIONID cookie on successful login', async () => {
mockPost.mockResolvedValue({
data: { data: { id: '123' } },
error: undefined,
response: { headers: { get: vi.fn().mockReturnValue('JSESSIONID=abc123; Path=/; HttpOnly') } }
});
const event = createEvent({ email: 'sarah@example.com', password: 'password123' });
try {
await actions.default(event);
} catch {
// redirect throws
}
expect(event.cookies.set).toHaveBeenCalledWith('JSESSIONID', 'abc123', expect.objectContaining({ path: '/', secure: true }));
});
it('rejects empty email with validation error', async () => {
const result = await actions.default(createEvent({
email: '',

View File

@@ -43,7 +43,7 @@ export const actions = {
const sessionId = response.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1];
if (sessionId) {
cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax' });
cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', secure: true });
}
throw redirect(303, '/household/setup');

View File

@@ -30,8 +30,16 @@ describe('signup form action', () => {
} as any;
}
function mockSuccess() {
return {
data: { data: { id: '123' } },
error: undefined,
response: { headers: { get: vi.fn().mockReturnValue(null) } }
};
}
it('calls POST /v1/auth/signup with form data', async () => {
mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createRequest({
@@ -53,7 +61,7 @@ describe('signup form action', () => {
});
it('redirects to /household/setup on success', async () => {
mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createRequest({
@@ -68,6 +76,23 @@ describe('signup form action', () => {
}
});
it('sets JSESSIONID cookie on successful signup', async () => {
mockPost.mockResolvedValue({
data: { data: { id: '123' } },
error: undefined,
response: { headers: { get: vi.fn().mockReturnValue('JSESSIONID=xyz789; Path=/; HttpOnly') } }
});
const event = createRequest({ displayName: 'Sarah', email: 'sarah@example.com', password: 'password123' });
try {
await actions.default(event);
} catch {
// redirect throws
}
expect(event.cookies.set).toHaveBeenCalledWith('JSESSIONID', 'xyz789', expect.objectContaining({ path: '/', secure: true }));
});
it('rejects empty displayName with validation error', async () => {
const result = await actions.default(createRequest({
displayName: '',