diff --git a/ordinis-app/src/main/resources/application.yml b/ordinis-app/src/main/resources/application.yml index 1b5078f..954f913 100644 --- a/ordinis-app/src/main/resources/application.yml +++ b/ordinis-app/src/main/resources/application.yml @@ -158,3 +158,11 @@ spring: hibernate: ddl-auto: none open-in-view: false + +# Auth (см. ordinis-auth: SecurityConfig). public-key из Vault. +ordinis: + auth: + public-key: ${key:} + issuer: ${jwt-issuer:} + require-authentication: ${ORDINIS_AUTH_REQUIRED:false} + allow-query-scope: ${ORDINIS_AUTH_ALLOW_QUERY_SCOPE:true} diff --git a/ordinis-auth/pom.xml b/ordinis-auth/pom.xml new file mode 100644 index 0000000..e1d50ba --- /dev/null +++ b/ordinis-auth/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + + cloud.nstart.terravault.ordinis + ordinis-parent + 0.1.0-SNAPSHOT + + + ordinis-auth + Ordinis :: Auth + JWT-based scope authorization (@nstart/auth контракт). Используется writer и read-api. + + + + cloud.nstart.terravault.ordinis + ordinis-domain + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-web + + + 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 new file mode 100644 index 0000000..07d1e40 --- /dev/null +++ b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/OrdinisAuthProperties.java @@ -0,0 +1,30 @@ +package cloud.nstart.terravault.ordinis.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Конфиг для @nstart/auth-style JWT auth. + * + *

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

+ * + *

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

+ * + *

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

+ */ +@ConfigurationProperties(prefix = "ordinis.auth") +public record OrdinisAuthProperties( + String publicKey, + String issuer, + String scopeClaim, + boolean requireAuthentication, + boolean allowQueryScope) { + + public OrdinisAuthProperties { + if (scopeClaim == null) scopeClaim = "scopes"; + if (issuer == null) issuer = ""; + } +} 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 new file mode 100644 index 0000000..c386d38 --- /dev/null +++ b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java @@ -0,0 +1,97 @@ +package cloud.nstart.terravault.ordinis.auth; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +/** + * Резолвер активных 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. + *
+ */ +@Component +public class ScopeContext { + + private final OrdinisAuthProperties props; + + public ScopeContext(OrdinisAuthProperties props) { + this.props = props; + } + + /** Достаёт scope текущей аутентифицированной сессии (из JWT) или PUBLIC если анонимна. */ + public Set currentScopes() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof JwtAuthenticationToken jwtAuth) { + return scopesFromJwt(jwtAuth.getToken()); + } + 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) { + return scopesFromJwt(jwtAuth.getToken()); + } + if (props.allowQueryScope() && queryAsScope != null && !queryAsScope.isBlank()) { + return fromQueryParam(queryAsScope); + } + return EnumSet.of(DataScope.PUBLIC); + } + + public static Set fromQueryParam(String csv) { + 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) { + } + } + return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result; + } + + private Set scopesFromJwt(Jwt token) { + Object raw = token.getClaim(props.scopeClaim()); + 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)); + } else { + for (String part : String.valueOf(raw).split("[,\\s]+")) addScope(result, part); + } + return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result; + } + + private static void addScope(EnumSet target, String s) { + if (s == null || s.isBlank()) return; + try { + target.add(DataScope.valueOf(s.trim().toUpperCase())); + } catch (IllegalArgumentException ignored) { + } + } +} 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 new file mode 100644 index 0000000..6be812d --- /dev/null +++ b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/SecurityConfig.java @@ -0,0 +1,81 @@ +package cloud.nstart.terravault.ordinis.auth; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +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.oauth2.jwt.JwtDecoder; +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}). + */ +@Configuration +@EnableConfigurationProperties(OrdinisAuthProperties.class) +public class SecurityConfig { + + private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + OrdinisAuthProperties props) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy( + org.springframework.security.config.http.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()) { + http.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults())); + } else { + log.warn("ordinis.auth.public-key не задан — 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 не подключён. + return token -> { + throw new org.springframework.security.oauth2.jwt.BadJwtException( + "JWT validation disabled: ordinis.auth.public-key не сконфигурирован"); + }; + } + 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())); + } + return decoder; + } +} diff --git a/ordinis-auth/src/main/resources/META-INF/spring.factories b/ordinis-auth/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..31299b6 --- /dev/null +++ b/ordinis-auth/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + cloud.nstart.terravault.ordinis.auth.SecurityConfig diff --git a/ordinis-read-api/pom.xml b/ordinis-read-api/pom.xml index 03cbc61..998480c 100644 --- a/ordinis-read-api/pom.xml +++ b/ordinis-read-api/pom.xml @@ -19,6 +19,10 @@ cloud.nstart.terravault.ordinis ordinis-domain + + cloud.nstart.terravault.ordinis + ordinis-auth + org.springframework.boot diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java index 4811107..0ca95d4 100644 --- a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java @@ -1,5 +1,6 @@ package cloud.nstart.terravault.ordinis.readapi.web; +import cloud.nstart.terravault.ordinis.auth.ScopeContext; import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse; import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService; @@ -16,27 +17,29 @@ import org.springframework.web.bind.annotation.RestController; import java.time.OffsetDateTime; import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; /** * Read API для consumers (геопортал, Альтум). Фронт публичный — здесь * locale negotiation + scope filtering. * - *

Scope authorisation: в v1 берём из query param {@code as_scope} (для - * dev/curl). В prod — из JWT (@nstart/auth) — сделаем через filter позже. + *

Scope authorisation: JWT claim {@code scopes} через {@link ScopeContext}. + * Если запрос без JWT и {@code ordinis.auth.allow-query-scope=true} — fallback + * на legacy query param {@code ?as_scope=}; иначе только PUBLIC. */ @RestController @RequestMapping("/api/v1/{dictionaryName}/records") public class DictionaryReadController { private final RecordReadService service; + private final ScopeContext scopeContext; private final Counter scopeDeniedCounter; - public DictionaryReadController(RecordReadService service, MeterRegistry meterRegistry) { + public DictionaryReadController( + RecordReadService service, ScopeContext scopeContext, MeterRegistry meterRegistry) { this.service = service; + this.scopeContext = scopeContext; this.scopeDeniedCounter = Counter.builder("ordinis_scope_access_denied_total") - .description("Попытки чтения недоступного scope (v1 — из query param)") + .description("Попытки чтения недоступного scope") .register(meterRegistry); } @@ -45,10 +48,10 @@ public class DictionaryReadController { @PathVariable String dictionaryName, @PathVariable String businessKey, @RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage, - @RequestParam(value = "as_scope", defaultValue = "PUBLIC") String asScope, + @RequestParam(value = "as_scope", required = false) String asScope, @RequestParam(value = "at", required = false) OffsetDateTime at) { - List scopes = parseScopes(asScope); + List scopes = scopeContext.resolveForRead(asScope).stream().toList(); var result = service.readActive(dictionaryName, businessKey, acceptLanguage, scopes, at); HttpHeaders headers = new HttpHeaders(); @@ -63,10 +66,10 @@ public class DictionaryReadController { public ResponseEntity> list( @PathVariable String dictionaryName, @RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage, - @RequestParam(value = "as_scope", defaultValue = "PUBLIC") String asScope, + @RequestParam(value = "as_scope", required = false) String asScope, @RequestParam(value = "at", required = false) OffsetDateTime at) { - List scopes = parseScopes(asScope); + List scopes = scopeContext.resolveForRead(asScope).stream().toList(); var items = service.listActive(dictionaryName, acceptLanguage, scopes, at); HttpHeaders headers = new HttpHeaders(); @@ -76,12 +79,4 @@ public class DictionaryReadController { headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE); return new ResponseEntity<>(items, headers, 200); } - - private List parseScopes(String asScope) { - return Stream.of(asScope.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .map(s -> DataScope.valueOf(s.toUpperCase())) - .collect(Collectors.toList()); - } } diff --git a/ordinis-read-api/src/main/resources/application.yml b/ordinis-read-api/src/main/resources/application.yml index 438f216..647d521 100644 --- a/ordinis-read-api/src/main/resources/application.yml +++ b/ordinis-read-api/src/main/resources/application.yml @@ -121,3 +121,12 @@ spring: hibernate: ddl-auto: none open-in-view: false + +# Auth — JWT public key из Vault (secret/ordinis/cuod/jwt-public-key, key=key). +# Если public-key пуст — резолвер scope падает на anonymous PUBLIC + ?as_scope= legacy. +ordinis: + auth: + public-key: ${key:} + issuer: ${jwt-issuer:} + require-authentication: ${ORDINIS_AUTH_REQUIRED:false} + allow-query-scope: ${ORDINIS_AUTH_ALLOW_QUERY_SCOPE:true} diff --git a/ordinis-rest-api/pom.xml b/ordinis-rest-api/pom.xml index 5c161d5..9469e95 100644 --- a/ordinis-rest-api/pom.xml +++ b/ordinis-rest-api/pom.xml @@ -39,6 +39,10 @@ cloud.nstart.terravault.ordinis ordinis-events-api + + cloud.nstart.terravault.ordinis + ordinis-auth + com.fasterxml.jackson.core jackson-databind diff --git a/pom.xml b/pom.xml index c2e9e37..520f3b4 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,7 @@ ordinis-validation ordinis-events-api ordinis-outbox + ordinis-auth ordinis-rest-api ordinis-app ordinis-read-api @@ -78,6 +79,11 @@ ordinis-outbox ${project.version} + + cloud.nstart.terravault.ordinis + ordinis-auth + ${project.version} + cloud.nstart.terravault.ordinis ordinis-rest-api