Compare commits
4 Commits
0aa65214fc
...
09333ccc0a
| Author | SHA1 | Date | |
|---|---|---|---|
| 09333ccc0a | |||
| 93ce1eaeac | |||
| 61249af086 | |||
| 16f0feb8d5 |
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user