Compare commits

..

2 Commits

Author SHA1 Message Date
0aa65214fc fix(auth): resolve broken signup/login flow end-to-end
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 <noreply@anthropic.com>
2026-04-02 17:31:29 +02:00
ab3363eeec refactor(auth): use shared BrandPanel on login page
Login page now uses the same BrandPanel component as signup
instead of an inline brand panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 16:45:22 +02:00
7 changed files with 44 additions and 23 deletions

View File

@@ -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<Void> logout(HttpServletRequest httpRequest) {
HttpSession session = httpRequest.getSession(false);

View File

@@ -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()

View File

@@ -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 }) => {

View File

@@ -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;

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import BrandPanel from '$lib/auth/BrandPanel.svelte';
import LoginForm from '$lib/auth/LoginForm.svelte';
let { form } = $props();
@@ -10,13 +11,7 @@
<!-- Mobile: stacked, Desktop: side by side -->
<div class="flex min-h-screen flex-col md:flex-row">
<div
class="bg-[var(--green)] flex flex-col items-center justify-center
px-6 py-7 text-center
md:w-1/2 md:min-h-screen md:px-10 md:py-12"
>
<span class="font-[var(--font-display)] text-4xl text-white font-medium">Mealprep</span>
</div>
<BrandPanel />
<div class="flex flex-1 flex-col items-start justify-center px-[20px] py-[24px] md:items-center md:px-[56px] md:py-[48px]">
<div class="w-full max-w-[380px]">
<LoginForm {form} />

View File

@@ -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;

View File

@@ -12,7 +12,7 @@
<!-- Mobile: stacked, Desktop: side by side -->
<div class="flex min-h-screen flex-col md:flex-row">
<BrandPanel />
<div class="flex flex-1 flex-col items-start justify-center px-[20px] py-[24px] md:items-center md:px-[56px] md:py-[48px]">
<div class="flex flex-1 flex-col items-center justify-center px-[20px] py-[24px] md:px-[56px] md:py-[48px]">
<div class="w-full max-w-[380px]">
<SignupForm {form} />
</div>