From 0aa65214fce1835c51b95bc16e4656efabbc5af0 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 17:31:29 +0200 Subject: [PATCH] fix(auth): resolve broken signup/login flow end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes fixed: 1. CSRF blocked all backend POSTs — Spring Security's CSRF filter ran before permitAll() authorization, returning 401 for signup and login. Disabled CSRF since SvelteKit is the only client (never the browser directly) and handles its own CSRF via Origin header checks. 2. Login/signup didn't establish Spring Security authentication — they stored email in the HTTP session manually but never set the SecurityContext, so Principal in /v1/auth/me was always null and hooks.server.ts redirected every authenticated request to /login. Fixed with authenticateInSession() helper that sets and persists the SecurityContext under SPRING_SECURITY_CONTEXT_KEY. Login also now invalidates the old session before creating a new one to prevent session fixation. 3. redirect() missing throw in hooks.server.ts, signup action, and login action — SvelteKit never saw the redirect, so pages silently reloaded with no navigation. Also forward JSESSIONID from backend response to browser explicitly, since SvelteKit does not auto-forward Set-Cookie for cross-origin server-side fetches. Co-Authored-By: Claude Sonnet 4.6 --- .../com/recipeapp/auth/AuthController.java | 26 ++++++++++++++++--- .../com/recipeapp/auth/SecurityConfig.java | 6 +---- frontend/src/hooks.server.ts | 2 +- .../src/routes/(public)/login/+page.server.ts | 11 +++++--- .../routes/(public)/signup/+page.server.ts | 11 +++++--- .../src/routes/(public)/signup/+page.svelte | 2 +- 6 files changed, 42 insertions(+), 16 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/auth/AuthController.java b/backend/src/main/java/com/recipeapp/auth/AuthController.java index 3e5e9ee..51f5fa7 100644 --- a/backend/src/main/java/com/recipeapp/auth/AuthController.java +++ b/backend/src/main/java/com/recipeapp/auth/AuthController.java @@ -7,9 +7,15 @@ import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +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.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.*; import java.security.Principal; +import java.util.List; @RestController @RequestMapping("/v1/auth") @@ -26,7 +32,7 @@ public class AuthController { @Valid @RequestBody SignupRequest request, HttpServletRequest httpRequest) { UserResponse user = authService.signup(request); - httpRequest.getSession(true); + authenticateInSession(user.email(), "user", httpRequest); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user)); } @@ -35,11 +41,25 @@ public class AuthController { @Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) { UserResponse user = authService.login(request); - HttpSession session = httpRequest.getSession(true); - session.setAttribute("user_email", user.email()); + // Session fixation protection: invalidate old session before creating new one + var oldSession = httpRequest.getSession(false); + if (oldSession != null) { + oldSession.invalidate(); + } + authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest); return ResponseEntity.ok(ApiResponse.success(user)); } + private void authenticateInSession(String email, String role, HttpServletRequest request) { + var auth = UsernamePasswordAuthenticationToken.authenticated( + email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + request.getSession(true).setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + } + @PostMapping("/logout") public ResponseEntity logout(HttpServletRequest httpRequest) { HttpSession session = httpRequest.getSession(false); diff --git a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java index c084836..7c1a6bd 100644 --- a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java +++ b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java @@ -10,8 +10,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; @Configuration @EnableWebSecurity @@ -20,9 +18,7 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf - .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())) + .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index df32645..9fbd150 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -15,7 +15,7 @@ function isPublicRoute(pathname: string): boolean { function loginRedirect(pathname: string): never { const target = '/login?redirect=' + encodeURIComponent(pathname); - redirect(302, target); + throw redirect(302, target); } export const handle: Handle = async ({ event, resolve }) => { diff --git a/frontend/src/routes/(public)/login/+page.server.ts b/frontend/src/routes/(public)/login/+page.server.ts index dd6912b..5b4a45b 100644 --- a/frontend/src/routes/(public)/login/+page.server.ts +++ b/frontend/src/routes/(public)/login/+page.server.ts @@ -3,7 +3,7 @@ import { apiClient } from '$lib/server/api'; import type { Actions } from './$types'; export const actions = { - default: async ({ request, url, fetch }) => { + default: async ({ request, url, fetch, cookies }) => { const formData = await request.formData(); const email = (formData.get('email') ?? '').toString().trim(); const password = (formData.get('password') ?? '').toString(); @@ -24,7 +24,7 @@ export const actions = { } const api = apiClient(fetch); - const { error } = await api.POST('/v1/auth/login', { + const { error, response } = await api.POST('/v1/auth/login', { body: { email, password } }); @@ -35,7 +35,12 @@ 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' }); + } + const redirectTo = url.searchParams.get('redirect') || '/planner'; - redirect(303, redirectTo); + throw redirect(303, redirectTo); } } satisfies Actions; diff --git a/frontend/src/routes/(public)/signup/+page.server.ts b/frontend/src/routes/(public)/signup/+page.server.ts index 8fa6ff5..f158209 100644 --- a/frontend/src/routes/(public)/signup/+page.server.ts +++ b/frontend/src/routes/(public)/signup/+page.server.ts @@ -3,7 +3,7 @@ import { apiClient } from '$lib/server/api'; import type { Actions } from './$types'; export const actions = { - default: async ({ request, fetch }) => { + default: async ({ request, fetch, cookies }) => { const formData = await request.formData(); const displayName = (formData.get('displayName') ?? '').toString().trim(); const email = (formData.get('email') ?? '').toString().trim(); @@ -29,7 +29,7 @@ export const actions = { } const api = apiClient(fetch); - const { error } = await api.POST('/v1/auth/signup', { + const { error, response } = await api.POST('/v1/auth/signup', { body: { displayName, email, password } }); @@ -41,6 +41,11 @@ export const actions = { }); } - redirect(303, '/household/setup'); + const sessionId = response.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1]; + if (sessionId) { + cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax' }); + } + + throw redirect(303, '/household/setup'); } } satisfies Actions; diff --git a/frontend/src/routes/(public)/signup/+page.svelte b/frontend/src/routes/(public)/signup/+page.svelte index 8140c3a..dd9dda0 100644 --- a/frontend/src/routes/(public)/signup/+page.svelte +++ b/frontend/src/routes/(public)/signup/+page.svelte @@ -12,7 +12,7 @@
-
+