feat: give redis-session mode a way to authenticate — the BFF login path
auth-mode=redis-session was unreachable: getStudioSession answered 503 on
every call because the CSRF token is null when CsrfFilter never runs, and
CsrfFilter only runs in the session branch, which could not be selected
because AuthenticationModeCompositionConfig requires a
`redisVersionedSessionRepository` / `springSessionRepositoryFilter` pair
and only the second existed. Even with the pair present nothing could
create a session — there was no login endpoint and no client registration.
This is the surface the contract already describes: securitySchemes
declares a session cookie plus X-CSRF-TOKEN on mutations, not a bearer
token, and SecurityConfig's session branch (cookie CSRF repository,
session-fixation migration) plus RedisSessionWebConfig (servlet filter,
host-only cookie) were already written for it. The SPA never holds a
token; the backend owns the session.
- StudioSessionInfrastructureConfig supplies the missing repository
under the name the composition validator looks for. @EnableRedisHttp
Session is not used because it pins the bean name to sessionRepository.
- StudioOidcLoginSuccessHandler converts the OidcUser into an
AuthenticatedPrincipal. PrimitiveSessionSecurityContextRepository
rejects anything else on save — deliberately, so credentials and
framework object graphs never cross the session boundary — and it
restores the same type on load. Roles are unioned from realm_access
and resource_access exactly as the JWT converter does, so both modes
resolve the same set and the studio:read / studio:write mapping
behaves identically.
- SecurityConfig wires oauth2Login (only when a success handler bean is
present, so JWT mode is untouched) and a /logout that invalidates the
session. The envelope 401 stays the entry point: an unauthenticated
API call must not answer 302, which an XHR cannot follow. The SPA
navigates the browser to /oauth2/authorization/{id} instead.
Verified in a browser against a real Keycloak realm:
/oauth2/authorization/keycloak → Keycloak → callback
TECHLOG_SESSION cookie set, httpOnly
GET /api/v1/studio/session 200 {authenticated, displayName, roles,
csrfToken, csrfHeaderName}
POST /api/v1/studio/documents 403 without the CSRF header
201 with it
GET /api/v1/studio/documents 200
Also removes the same broken placeholder-in-map-key role mapping from the
dev profile that the previous commit fixed in local and prod.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
37d5614129
commit
a828b5d9fe
+26
-1
@@ -70,7 +70,10 @@ public class SecurityConfig {
|
||||
AccessDeniedHandler accessDeniedHandler,
|
||||
org.springframework.beans.factory.ObjectProvider<PrimitiveSessionSecurityContextRepository>
|
||||
sessionSecurityContextRepository,
|
||||
org.springframework.beans.factory.ObjectProvider<RestrictedPathRule> restrictedPaths)
|
||||
org.springframework.beans.factory.ObjectProvider<RestrictedPathRule> restrictedPaths,
|
||||
org.springframework.beans.factory.ObjectProvider<
|
||||
org.springframework.security.web.authentication.AuthenticationSuccessHandler>
|
||||
loginSuccessHandler)
|
||||
throws Exception {
|
||||
String[] publicPaths = securitySettings.publicPaths().toArray(new String[0]);
|
||||
java.util.List<RestrictedPathRule> restricted = restrictedPaths.orderedStream().toList();
|
||||
@@ -137,6 +140,28 @@ public class SecurityConfig {
|
||||
securityContext
|
||||
.securityContextRepository(sessionSecurityContextRepository.getObject())
|
||||
.requireExplicitSave(false));
|
||||
|
||||
// BFF 로그인. 세션을 만들 수 있는 유일한 경로다 — 이것이 없으면 auth-mode=redis-session 은
|
||||
// 아무도 인증할 수 없는 모드가 된다. SPA 는 401 을 받으면 브라우저를 /oauth2/authorization/{id}
|
||||
// 로 이동시키고, 콜백이 세션 쿠키를 심은 뒤 SPA 진입점으로 되돌린다.
|
||||
//
|
||||
// 진입점은 바꾸지 않는다: API 요청이 302 로 답하면 XHR 이 따라갈 수 없으므로, 미인증 API 호출은
|
||||
// 그대로 봉투 401 이어야 한다. 아래 defaultSuccessUrl 대신 주입된 핸들러를 쓰는 이유는
|
||||
// OidcUser 를 세션이 담을 수 있는 AuthenticatedPrincipal 로 바꿔야 하기 때문이다.
|
||||
org.springframework.security.web.authentication.AuthenticationSuccessHandler onSuccess =
|
||||
loginSuccessHandler.getIfAvailable();
|
||||
if (onSuccess != null) {
|
||||
http.oauth2Login(login -> login.successHandler(onSuccess));
|
||||
}
|
||||
http.logout(
|
||||
logout ->
|
||||
logout
|
||||
.logoutUrl("/logout")
|
||||
.invalidateHttpSession(true)
|
||||
.deleteCookies(securitySettings.session().cookieName())
|
||||
.logoutSuccessHandler(
|
||||
(request, response, authentication) ->
|
||||
response.setStatus(jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT)));
|
||||
}
|
||||
return http.build();
|
||||
}
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
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;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
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.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
||||
|
||||
/**
|
||||
* OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다.
|
||||
*
|
||||
* <p>{@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다.
|
||||
* 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link
|
||||
* AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를
|
||||
* 넘지 못하게 하는 의도적인 제약이다. 그래서 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal}
|
||||
* 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 ID/Access 토큰은 남지 않는다.
|
||||
*
|
||||
* <p>역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code
|
||||
* 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")
|
||||
public class StudioOidcLoginSuccessHandler implements AuthenticationSuccessHandler {
|
||||
|
||||
private final SimpleUrlAuthenticationSuccessHandler redirect =
|
||||
new SimpleUrlAuthenticationSuccessHandler();
|
||||
|
||||
public StudioOidcLoginSuccessHandler(
|
||||
@Value("${app.studio.post-login-redirect:/}") String defaultTargetUrl) {
|
||||
redirect.setDefaultTargetUrl(defaultTargetUrl);
|
||||
// SPA 가 라우팅을 소유한다. 프레임워크의 SavedRequest 는 SecurityConfig 가 이미 꺼두었으므로
|
||||
// 로그인 후에는 항상 SPA 진입점으로 보내고, 원래 가려던 화면 복원은 SPA 가 한다.
|
||||
redirect.setAlwaysUseDefaultTargetUrl(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(
|
||||
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
||||
throws IOException, ServletException {
|
||||
if (authentication.getPrincipal() instanceof OidcUser user) {
|
||||
Set<String> roles = extractRoles(user);
|
||||
AuthenticatedPrincipal principal =
|
||||
new AuthenticatedPrincipal(user.getSubject(), user.getEmail(), roles);
|
||||
Collection<GrantedAuthority> authorities =
|
||||
roles.stream()
|
||||
.map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT)))
|
||||
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(
|
||||
UsernamePasswordAuthenticationToken.authenticated(principal, null, authorities));
|
||||
SecurityContextHolder.setContext(context);
|
||||
// requireExplicitSave(false) 이므로 SecurityContextHolderFilter 가 응답 커밋 시 저장한다.
|
||||
authentication = context.getAuthentication();
|
||||
}
|
||||
redirect.onAuthenticationSuccess(request, response, authentication);
|
||||
}
|
||||
|
||||
private static Set<String> extractRoles(OidcUser user) {
|
||||
Set<String> roles = new HashSet<>();
|
||||
addRoles(roles, user.getClaimAsMap("realm_access"));
|
||||
Map<String, Object> resourceAccess = user.getClaimAsMap("resource_access");
|
||||
if (resourceAccess != null) {
|
||||
for (Object client : resourceAccess.values()) {
|
||||
if (client instanceof Map<?, ?> map) {
|
||||
addRoles(roles, map);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> generic = user.getClaimAsStringList("roles");
|
||||
if (generic != null) {
|
||||
roles.addAll(generic);
|
||||
}
|
||||
return Set.copyOf(roles);
|
||||
}
|
||||
|
||||
private static void addRoles(Set<String> sink, Map<?, ?> holder) {
|
||||
if (holder == null) {
|
||||
return;
|
||||
}
|
||||
if (holder.get("roles") instanceof Collection<?> values) {
|
||||
values.forEach(value -> sink.add(String.valueOf(value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user