Frontend: design system, navigation, auth guard, signup screen #33
@@ -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