feat(auth): refactor to Keycloak OIDC issuer-uri (drop static PEM)
@nstart/auth = Keycloak. Уходим от RSA PEM в Vault на OIDC discovery. OrdinisAuthProperties: - DROP: publicKey (PEM) - ADD: issuerUri (https://auth.nstart.space/realms/nstart) - ADD: jwkSetUri (optional override) - ADD: audience (для aud валидации, когда узнаем client_id) - RENAME: scopeClaim → rolesClaim (default: realm_access.roles) SecurityConfig: - JwtDecoders.fromIssuerLocation() — auto JWK Set discovery через /.well-known/openid-configuration. Ротация ключей не требует ручных операций. - AudienceValidator (опц.) для проверки aud claim. - Permissive stub когда issuer-uri пуст (dev mode). ScopeContext: - readClaimByPath() поддерживает nested путь "realm_access.roles". - normalizeRoleToScope(): маппит Keycloak роли ("ordinis-public", "ORDINIS_INTERNAL", "x-RESTRICTED") в DataScope. - JWT всегда побеждает query ?as_scope= (защита от scope escalation).
This commit is contained in:
+28
-12
@@ -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).
|
||||
*
|
||||
* <p>{@code publicKey} — RSA public key (PEM, без BEGIN/END заголовков, base64).
|
||||
* Подбрасывается через Vault: secret/ordinis/cuod/jwt-public-key.</p>
|
||||
* <p>{@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).
|
||||
*
|
||||
* <p>{@code jwkSetUri} — опциональный override (если discovery недоступен).
|
||||
* Обычно не нужен.
|
||||
*
|
||||
* <p>{@code audience} — опциональная валидация {@code aud} claim. Когда узнаем
|
||||
* client_id, выставляем сюда (например {@code ordinis-cuod}).
|
||||
*
|
||||
* <p>{@code rolesClaim} — путь к ролям внутри JWT. Для Keycloak realm-роли
|
||||
* лежат в {@code realm_access.roles} (массив). Для resource-ролей —
|
||||
* {@code resource_access.{client_id}.roles}. Default: realm_access.roles.
|
||||
*
|
||||
* <p>{@code requireAuthentication=true} → 401 на любой запрос без валидного JWT.
|
||||
* При false работает permissive режим — анонимный запрос имеет только PUBLIC scope,
|
||||
* что подходит для read-api на raw публичных endpoint'ах.</p>
|
||||
* При false работает permissive режим — анонимный запрос имеет только PUBLIC scope.
|
||||
*
|
||||
* <p>{@code allowQueryScope=true} включает legacy {@code ?as_scope=...} query param
|
||||
* (для smoke-тестов и dev). В prod рекомендуется false.</p>
|
||||
* <p>{@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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 для текущего запроса.
|
||||
*
|
||||
* <p>Источник scope (приоритет сверху-вниз):
|
||||
* <ol>
|
||||
* <li>JWT claim {@code scopes} (см. {@link OrdinisAuthProperties#scopeClaim()}).</li>
|
||||
* <li>Legacy query param {@code as_scope} (если {@code allowQueryScope=true}). Контроллеры
|
||||
* сами читают и передают через {@link #fromQueryParam(String)}.</li>
|
||||
* <li>Anonymous → только {@link DataScope#PUBLIC} (если auth не требуется).</li>
|
||||
* <li>JWT roles claim (для Keycloak — {@code realm_access.roles}, путь
|
||||
* настраивается через {@link OrdinisAuthProperties#rolesClaim()}).
|
||||
* Роли мапятся в {@link DataScope} по имени:
|
||||
* <ul>
|
||||
* <li>{@code ordinis-public} / {@code public} → {@link DataScope#PUBLIC}</li>
|
||||
* <li>{@code ordinis-internal} / {@code internal} → {@link DataScope#INTERNAL}</li>
|
||||
* <li>{@code ordinis-restricted} / {@code restricted} → {@link DataScope#RESTRICTED}</li>
|
||||
* </ul>
|
||||
* Любая роль с суффиксом {@code -PUBLIC|INTERNAL|RESTRICTED} тоже работает.</li>
|
||||
* <li>Legacy query param {@code as_scope} (если {@code allowQueryScope=true}).</li>
|
||||
* <li>Anonymous → {@link DataScope#PUBLIC}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>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<DataScope> 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 с правильными правилами:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Если JWT присутствует — query param ИГНОРИРУЕТСЯ (защита от scope escalation
|
||||
* через URL).</li>
|
||||
* <li>Если JWT отсутствует и {@code allowQueryScope=true} — берём из query.</li>
|
||||
* <li>Иначе — PUBLIC.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public Set<DataScope> 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<DataScope> 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<DataScope> scopesFromJwt(Jwt token) {
|
||||
Object raw = token.getClaim(props.scopeClaim());
|
||||
Object raw = readClaimByPath(token, props.rolesClaim());
|
||||
if (raw == null) return EnumSet.of(DataScope.PUBLIC);
|
||||
EnumSet<DataScope> 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<DataScope> target, String s) {
|
||||
if (s == null || s.isBlank()) return;
|
||||
try {
|
||||
|
||||
+49
-30
@@ -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}).
|
||||
*
|
||||
* <p>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<Jwt> 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<Jwt> {
|
||||
@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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user