Frontend: design system, navigation, auth guard, signup screen #33

Merged
marcel merged 25 commits from feat/issue-16-design-system into master 2026-04-02 19:00:19 +02:00
6 changed files with 42 additions and 16 deletions
Showing only changes of commit 0aa65214fc - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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