diff --git a/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/OrdinisAuthProperties.java b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/OrdinisAuthProperties.java index 07d1e40..0baa886 100644 --- a/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/OrdinisAuthProperties.java +++ b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/OrdinisAuthProperties.java @@ -3,28 +3,44 @@ package cloud.nstart.terravault.ordinis.auth; import org.springframework.boot.context.properties.ConfigurationProperties; /** - * Конфиг для @nstart/auth-style JWT auth. + * Конфиг JWT auth для интеграции с Keycloak (@nstart/auth). * - *

{@code publicKey} — RSA public key (PEM, без BEGIN/END заголовков, base64). - * Подбрасывается через Vault: secret/ordinis/cuod/jwt-public-key.

+ *

{@code issuerUri} — OIDC issuer URL, например + * {@code https://auth.nstart.space/realms/nstart}. Spring Security сам тянет + * JWK Set через {@code .well-known/openid-configuration}, поддерживает rotation + * ключей. Если не задан — JWT validation выключен (permissive mode для dev). + * + *

{@code jwkSetUri} — опциональный override (если discovery недоступен). + * Обычно не нужен. + * + *

{@code audience} — опциональная валидация {@code aud} claim. Когда узнаем + * client_id, выставляем сюда (например {@code ordinis-cuod}). + * + *

{@code rolesClaim} — путь к ролям внутри JWT. Для Keycloak realm-роли + * лежат в {@code realm_access.roles} (массив). Для resource-ролей — + * {@code resource_access.{client_id}.roles}. Default: realm_access.roles. * *

{@code requireAuthentication=true} → 401 на любой запрос без валидного JWT. - * При false работает permissive режим — анонимный запрос имеет только PUBLIC scope, - * что подходит для read-api на raw публичных endpoint'ах.

+ * При false работает permissive режим — анонимный запрос имеет только PUBLIC scope. * - *

{@code allowQueryScope=true} включает legacy {@code ?as_scope=...} query param - * (для smoke-тестов и dev). В prod рекомендуется false.

+ *

{@code allowQueryScope=true} включает legacy {@code ?as_scope=...} + * (для smoke-тестов и dev). В prod — false. */ @ConfigurationProperties(prefix = "ordinis.auth") public record OrdinisAuthProperties( - String publicKey, - String issuer, - String scopeClaim, + String issuerUri, + String jwkSetUri, + String audience, + String rolesClaim, boolean requireAuthentication, boolean allowQueryScope) { public OrdinisAuthProperties { - if (scopeClaim == null) scopeClaim = "scopes"; - if (issuer == null) issuer = ""; + if (rolesClaim == null || rolesClaim.isBlank()) rolesClaim = "realm_access.roles"; + } + + public boolean enabled() { + return (issuerUri != null && !issuerUri.isBlank()) + || (jwkSetUri != null && !jwkSetUri.isBlank()); } } diff --git a/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java index c386d38..7e7ea8c 100644 --- a/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java +++ b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java @@ -9,19 +9,28 @@ import org.springframework.stereotype.Component; import java.util.Collection; import java.util.EnumSet; -import java.util.List; +import java.util.Map; import java.util.Set; /** - * Резолвер активных scope для текущего запроса. + * Резолвер scope для текущего запроса. * *

Источник scope (приоритет сверху-вниз): *

    - *
  1. JWT claim {@code scopes} (см. {@link OrdinisAuthProperties#scopeClaim()}).
  2. - *
  3. Legacy query param {@code as_scope} (если {@code allowQueryScope=true}). Контроллеры - * сами читают и передают через {@link #fromQueryParam(String)}.
  4. - *
  5. Anonymous → только {@link DataScope#PUBLIC} (если auth не требуется).
  6. + *
  7. JWT roles claim (для Keycloak — {@code realm_access.roles}, путь + * настраивается через {@link OrdinisAuthProperties#rolesClaim()}). + * Роли мапятся в {@link DataScope} по имени: + * + * Любая роль с суффиксом {@code -PUBLIC|INTERNAL|RESTRICTED} тоже работает.
  8. + *
  9. Legacy query param {@code as_scope} (если {@code allowQueryScope=true}).
  10. + *
  11. Anonymous → {@link DataScope#PUBLIC}.
  12. *
+ * + *

JWT всегда побеждает query param — защита от scope escalation через URL. */ @Component public class ScopeContext { @@ -32,7 +41,6 @@ public class ScopeContext { this.props = props; } - /** Достаёт scope текущей аутентифицированной сессии (из JWT) или PUBLIC если анонимна. */ public Set currentScopes() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth instanceof JwtAuthenticationToken jwtAuth) { @@ -41,15 +49,6 @@ public class ScopeContext { return EnumSet.of(DataScope.PUBLIC); } - /** Объединяет JWT-scope и query-as_scope с правильными правилами: - * - *

- */ public Set resolveForRead(String queryAsScope) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth instanceof JwtAuthenticationToken jwtAuth) { @@ -65,28 +64,54 @@ public class ScopeContext { if (csv == null || csv.isBlank()) return EnumSet.of(DataScope.PUBLIC); EnumSet result = EnumSet.noneOf(DataScope.class); for (String part : csv.split(",")) { - String s = part.trim(); - if (s.isEmpty()) continue; - try { - result.add(DataScope.valueOf(s.toUpperCase())); - } catch (IllegalArgumentException ignored) { - } + addScope(result, part); } return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result; } private Set scopesFromJwt(Jwt token) { - Object raw = token.getClaim(props.scopeClaim()); + Object raw = readClaimByPath(token, props.rolesClaim()); if (raw == null) return EnumSet.of(DataScope.PUBLIC); EnumSet result = EnumSet.noneOf(DataScope.class); if (raw instanceof Collection col) { - for (Object o : col) addScope(result, String.valueOf(o)); + for (Object o : col) addScope(result, normalizeRoleToScope(String.valueOf(o))); } else { - for (String part : String.valueOf(raw).split("[,\\s]+")) addScope(result, part); + for (String part : String.valueOf(raw).split("[,\\s]+")) { + addScope(result, normalizeRoleToScope(part)); + } } return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result; } + /** Поддерживает nested путь "realm_access.roles" или просто "scopes". */ + private static Object readClaimByPath(Jwt token, String path) { + String[] parts = path.split("\\."); + Object current = token.getClaim(parts[0]); + for (int i = 1; i < parts.length && current != null; i++) { + if (current instanceof Map m) { + current = m.get(parts[i]); + } else { + return null; + } + } + return current; + } + + /** + * Маппинг имени роли в scope. Поддерживает: + * "ordinis-public" / "public" → PUBLIC + * "ORDINIS_INTERNAL" → INTERNAL + * суффикс после "-" или "_": "x-RESTRICTED" → RESTRICTED + * просто "PUBLIC"|"INTERNAL"|"RESTRICTED" + */ + private static String normalizeRoleToScope(String role) { + if (role == null) return null; + String s = role.trim().toUpperCase().replace("ORDINIS-", "").replace("ORDINIS_", ""); + int dash = Math.max(s.lastIndexOf('-'), s.lastIndexOf('_')); + if (dash >= 0) s = s.substring(dash + 1); + return s; + } + private static void addScope(EnumSet target, String s) { if (s == null || s.isBlank()) return; try { diff --git a/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/SecurityConfig.java b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/SecurityConfig.java index 6be812d..fa2c7b1 100644 --- a/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/SecurityConfig.java +++ b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/SecurityConfig.java @@ -7,19 +7,25 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoders; +import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.web.SecurityFilterChain; -import java.security.KeyFactory; -import java.security.interfaces.RSAPublicKey; -import java.security.spec.X509EncodedKeySpec; -import java.util.Base64; - /** - * Spring Security конфиг: actuator/health и пути pre-flight открыты, остальные - * либо требуют JWT (если {@code requireAuthentication=true}), либо доступны - * анонимно (с автоматическим назначением PUBLIC scope в {@link ScopeContext}). + * Spring Security: actuator открыт, остальное — либо JWT-required, либо + * permissive (anonymous → PUBLIC scope в {@link ScopeContext}). + * + *

JWT verification через OIDC issuer-uri (Keycloak): JWK Set автоматически + * discover'ится с {@code {issuer}/.well-known/openid-configuration}, ротация + * ключей бесплатна. */ @Configuration @EnableConfigurationProperties(OrdinisAuthProperties.class) @@ -32,50 +38,63 @@ public class SecurityConfig { OrdinisAuthProperties props) throws Exception { http .csrf(csrf -> csrf.disable()) - .sessionManagement(sm -> sm.sessionCreationPolicy( - org.springframework.security.config.http.SessionCreationPolicy.STATELESS)) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> { auth.requestMatchers("/actuator/**").permitAll(); auth.requestMatchers("/error").permitAll(); if (props.requireAuthentication()) { auth.anyRequest().authenticated(); } else { - // Permissive: запрос без JWT → анонимный, но scope-фильтр оставит только PUBLIC. auth.anyRequest().permitAll(); } }); - // Resource server только если есть public key (иначе nimbus сломается). - if (props.publicKey() != null && !props.publicKey().isBlank()) { + if (props.enabled()) { http.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults())); } else { - log.warn("ordinis.auth.public-key не задан — JWT validation отключён, scope только из query/anonymous"); + log.warn("ordinis.auth.issuer-uri не задан — JWT validation отключён, scope только из query/anonymous"); } return http.build(); } @Bean - public JwtDecoder jwtDecoder(OrdinisAuthProperties props) throws Exception { - if (props.publicKey() == null || props.publicKey().isBlank()) { - // Nimbus требует ключ; возвращаем decoder который всегда падает — он не - // будет вызван, потому что oauth2ResourceServer не подключён. + public JwtDecoder jwtDecoder(OrdinisAuthProperties props) { + if (!props.enabled()) { return token -> { throw new org.springframework.security.oauth2.jwt.BadJwtException( - "JWT validation disabled: ordinis.auth.public-key не сконфигурирован"); + "JWT validation disabled: ordinis.auth.issuer-uri не сконфигурирован"); }; } - String pem = props.publicKey() - .replaceAll("-----BEGIN PUBLIC KEY-----", "") - .replaceAll("-----END PUBLIC KEY-----", "") - .replaceAll("\\s+", ""); - byte[] der = Base64.getDecoder().decode(pem); - RSAPublicKey publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA") - .generatePublic(new X509EncodedKeySpec(der)); - NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build(); - if (props.issuer() != null && !props.issuer().isBlank()) { - decoder.setJwtValidator( - org.springframework.security.oauth2.jwt.JwtValidators.createDefaultWithIssuer(props.issuer())); + + NimbusJwtDecoder decoder; + if (props.jwkSetUri() != null && !props.jwkSetUri().isBlank()) { + decoder = NimbusJwtDecoder.withJwkSetUri(props.jwkSetUri()).build(); + } else { + // OIDC discovery: загружает /.well-known/openid-configuration → jwks_uri. + decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(props.issuerUri()); } + + OAuth2TokenValidator validator = props.issuerUri() != null && !props.issuerUri().isBlank() + ? JwtValidators.createDefaultWithIssuer(props.issuerUri()) + : JwtValidators.createDefault(); + + if (props.audience() != null && !props.audience().isBlank()) { + validator = new DelegatingOAuth2TokenValidator<>(validator, new AudienceValidator(props.audience())); + } + decoder.setJwtValidator(validator); return decoder; } + + /** Валидирует {@code aud} claim. Keycloak кладёт client_id туда. */ + private record AudienceValidator(String expected) implements OAuth2TokenValidator { + @Override + public OAuth2TokenValidatorResult validate(Jwt token) { + if (token.getAudience() != null && token.getAudience().contains(expected)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure( + new OAuth2Error("invalid_audience", + "JWT aud claim не содержит ожидаемого значения " + expected, null)); + } + } }