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>
This commit is contained in:
@@ -7,9 +7,15 @@ import jakarta.servlet.http.HttpSession;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
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 org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/v1/auth")
|
@RequestMapping("/v1/auth")
|
||||||
@@ -26,7 +32,7 @@ public class AuthController {
|
|||||||
@Valid @RequestBody SignupRequest request,
|
@Valid @RequestBody SignupRequest request,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
UserResponse user = authService.signup(request);
|
UserResponse user = authService.signup(request);
|
||||||
httpRequest.getSession(true);
|
authenticateInSession(user.email(), "user", httpRequest);
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
|
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +41,25 @@ public class AuthController {
|
|||||||
@Valid @RequestBody LoginRequest request,
|
@Valid @RequestBody LoginRequest request,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
UserResponse user = authService.login(request);
|
UserResponse user = authService.login(request);
|
||||||
HttpSession session = httpRequest.getSession(true);
|
// Session fixation protection: invalidate old session before creating new one
|
||||||
session.setAttribute("user_email", user.email());
|
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));
|
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")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
|
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
|
||||||
HttpSession session = httpRequest.getSession(false);
|
HttpSession session = httpRequest.getSession(false);
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
|
||||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -20,9 +18,7 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(csrf -> csrf
|
.csrf(csrf -> csrf.disable())
|
||||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
|
||||||
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
|
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
|
||||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function isPublicRoute(pathname: string): boolean {
|
|||||||
|
|
||||||
function loginRedirect(pathname: string): never {
|
function loginRedirect(pathname: string): never {
|
||||||
const target = '/login?redirect=' + encodeURIComponent(pathname);
|
const target = '/login?redirect=' + encodeURIComponent(pathname);
|
||||||
redirect(302, target);
|
throw redirect(302, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { apiClient } from '$lib/server/api';
|
|||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, url, fetch }) => {
|
default: async ({ request, url, fetch, cookies }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const email = (formData.get('email') ?? '').toString().trim();
|
const email = (formData.get('email') ?? '').toString().trim();
|
||||||
const password = (formData.get('password') ?? '').toString();
|
const password = (formData.get('password') ?? '').toString();
|
||||||
@@ -24,7 +24,7 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = apiClient(fetch);
|
const api = apiClient(fetch);
|
||||||
const { error } = await api.POST('/v1/auth/login', {
|
const { error, response } = await api.POST('/v1/auth/login', {
|
||||||
body: { email, password }
|
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';
|
const redirectTo = url.searchParams.get('redirect') || '/planner';
|
||||||
redirect(303, redirectTo);
|
throw redirect(303, redirectTo);
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
} satisfies Actions;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { apiClient } from '$lib/server/api';
|
|||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, fetch }) => {
|
default: async ({ request, fetch, cookies }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const displayName = (formData.get('displayName') ?? '').toString().trim();
|
const displayName = (formData.get('displayName') ?? '').toString().trim();
|
||||||
const email = (formData.get('email') ?? '').toString().trim();
|
const email = (formData.get('email') ?? '').toString().trim();
|
||||||
@@ -29,7 +29,7 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = apiClient(fetch);
|
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 }
|
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;
|
} satisfies Actions;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<!-- Mobile: stacked, Desktop: side by side -->
|
<!-- Mobile: stacked, Desktop: side by side -->
|
||||||
<div class="flex min-h-screen flex-col md:flex-row">
|
<div class="flex min-h-screen flex-col md:flex-row">
|
||||||
<BrandPanel />
|
<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]">
|
<div class="w-full max-w-[380px]">
|
||||||
<SignupForm {form} />
|
<SignupForm {form} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user