diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 9bdc136..f5e3012 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -243,6 +243,47 @@ tasks.register('prepareStudioCodegenSpec') { } collapseNullableOneOf(doc) + // (4b) `type: [X, "null"]` 인 필드는 required 목록에서 뺀다. + // + // 계약이 이 필드들을 required 로 두는 뜻은 "키가 있어야 한다"이지 "값이 있어야 한다"가 + // 아니다 — WorkingCopyInputBase 의 주석이 그렇게 못박고 있다("불완전한 초안도 저장할 수 + // 있어야 하므로 필드는 required 이되 빈 값과 null 을 허용한다"). 그런데 생성기는 required + // 를 그대로 @NotNull 로 옮긴다. 그래서 topicId/projectId/lastVerifiedOn/verifiedOn/ + // decidedOn/decisionStatus/questionStatus 가 전부 non-null 강제가 되고, 초안 저장이 + // 400 NOT_NULL 로 거부됐다(실측: {"projectId": null} → NOT_NULL "Required value is missing"). + // + // 원본 계약은 건드리지 않는다 — 프론트엔드가 같은 파일을 읽고, 그쪽 해석은 옳다. 코드젠 + // 사본에서만 required 를 벗겨 @NotNull 이 붙지 않게 한다. 값 제약(형식·길이·enum)은 + // 그대로 남는다. + int[] relaxed = [0] + def relaxNullableRequired + relaxNullableRequired = { Object node -> + if (node instanceof Map) { + def props = node.get('properties') + def required = node.get('required') + if (props instanceof Map && required instanceof List) { + def drop = [] + props.each { Object name, Object schema -> + if (!(schema instanceof Map)) return + def type = schema.get('type') + if (type instanceof List && type.contains('null') && required.contains(name)) { + drop << name + } + } + if (!drop.isEmpty()) { + required.removeAll(drop) + relaxed[0] += drop.size() + if (required.isEmpty()) node.remove('required') + } + } + new ArrayList(node.values()).each { relaxNullableRequired(it) } + } else if (node instanceof List) { + node.each { relaxNullableRequired(it) } + } + } + relaxNullableRequired(doc) + logger.lifecycle("prepareStudioCodegenSpec: nullable required 해제 ${relaxed[0]}건") + // (1) x-implements 주입 + union 목록 수집 def unions = [:] schemas.each { String name, Object schema -> diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java index f2b4fd9..240ef01 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java @@ -161,7 +161,8 @@ public class SecurityConfig { .deleteCookies(securitySettings.session().cookieName()) .logoutSuccessHandler( (request, response, authentication) -> - response.setStatus(jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT))); + response.setStatus( + jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT))); } return http.build(); } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java index bd05a70..5c10a4f 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java @@ -6,11 +6,14 @@ import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.shared.response.Envelope; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -76,6 +79,46 @@ public class StudioExceptionHandler { return requestValidationFailed(ex.getName(), "Parameter value is invalid"); } + /** + * 요청 본문 bean validation 실패(예: {@code title} 120자 초과). {@code GlobalExceptionHandler}도 이 예외를 처리하지만 + * 400 {@code OperationalError.VALIDATION_FAILED}를 낸다 — Studio 계약에 없는 코드이고 (계약이 아는 것은 {@code + * REQUEST_VALIDATION_FAILED}와 {@code DOCUMENT_VALIDATION_FAILED}뿐이다), 상태도 계약이 본문 검증 실패에 배정한 422가 + * 아니다. 프론트엔드는 봉투의 {@code code}를 enum으로 검증하므로 계약 밖 코드는 응답 파싱 자체를 깨뜨린다. studio 스코프에서 계약 코드로 옮긴다. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleBodyValidation(MethodArgumentNotValidException ex) { + List> fieldErrors = + ex.getBindingResult().getFieldErrors().stream() + .map( + error -> + Map.of( + "path", + "/" + error.getField(), + "message", + error.getDefaultMessage() == null + ? "Value is invalid" + : error.getDefaultMessage())) + .collect(Collectors.toList()); + return ErrorResponseFactory.envelope( + StudioError.REQUEST_VALIDATION_FAILED, + StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED), + Map.of("fieldErrors", fieldErrors)); + } + + /** + * 권한 부족. 스켈레톤의 분류기는 {@code AUTHZ_INSUFFICIENT_PERMISSION}을 내지만 계약이 403에 배정한 코드는 {@code + * STUDIO_ACCESS_DENIED}다({@code responses.AccessDenied.x-error-codes}). 상태는 그대로 403이고 코드만 계약 쪽으로 + * 옮긴다. + */ + @ExceptionHandler(AuthorizationDeniedException.class) + public ResponseEntity> handleAccessDenied(AuthorizationDeniedException ex) { + log.warn("studio access denied: {}", ex.getMessage()); + return ErrorResponseFactory.envelope( + StudioError.STUDIO_ACCESS_DENIED, + StudioClientSafeMessages.forError(StudioError.STUDIO_ACCESS_DENIED), + null); + } + /** * {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에 * 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다. diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java index e7c131d..0d9c5ad 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java @@ -1,9 +1,6 @@ package dev.caskeleton.adapter.inbound.web.techlog.auth; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -15,6 +12,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -24,19 +23,20 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; /** * OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다. * *

{@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다. * 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link - * AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 - * 넘지 못하게 하는 의도적인 제약이다. 그래서 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} - * 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 ID/Access 토큰은 남지 않는다. + * AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 넘지 못하게 하는 의도적인 제약이다. 그래서 + * 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 + * ID/Access 토큰은 남지 않는다. * *

역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code - * realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 - * 같은 역할 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다. + * realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 같은 역할 + * 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다. */ @Component @ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") @@ -63,7 +63,10 @@ public class StudioOidcLoginSuccessHandler implements AuthenticationSuccessHandl new AuthenticatedPrincipal(user.getSubject(), user.getEmail(), roles); Collection authorities = roles.stream() - .map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT))) + .map( + r -> + (GrantedAuthority) + new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT))) .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication( diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java index 66c1960..c57c6c2 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java @@ -11,26 +11,26 @@ import org.springframework.session.data.redis.RedisSessionRepository; /** * {@code auth-mode=redis-session} 의 세션 저장소. * - *

이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation - * 은 추가로 {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF - * 구성이며, {@code SecurityConfig} 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code - * RedisSessionWebConfig}(서블릿 세션 필터 + host-only 쿠키)는 이미 그 전제로 쓰여 있었다. + *

이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation 은 추가로 + * {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF 구성이며, {@code SecurityConfig} + * 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code RedisSessionWebConfig}(서블릿 세션 필터 + + * host-only 쿠키)는 이미 그 전제로 쓰여 있었다. * - *

빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 - * {@code redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 이름으로 - * 요구하는데, 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 - * 있었고 앞의 것이 어디에도 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다. + *

빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 {@code + * redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 이름으로 요구하는데, + * 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 있었고 앞의 것이 어디에도 + * 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다. */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") public class StudioSessionInfrastructureConfig { /** - * 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 - * 바꾸면 부팅이 "Redis Session repository/filter is incomplete" 로 실패한다. + * 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 바꾸면 부팅이 + * "Redis Session repository/filter is incomplete" 로 실패한다. * - *

{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 - * {@code sessionRepository} 로 고정한다. + *

{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 {@code sessionRepository} 로 + * 고정한다. */ @Bean public RedisSessionRepository redisVersionedSessionRepository( @@ -39,9 +39,8 @@ public class StudioSessionInfrastructureConfig { } /** - * 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code - * PrimitiveSessionSecurityContextRepository} 가 만든 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 - * 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다. + * 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code PrimitiveSessionSecurityContextRepository} 가 만든 + * 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다. */ private static RedisTemplate sessionRedisTemplate( RedisConnectionFactory connectionFactory) {