feat(auth): JWT scope authorization (@nstart/auth контракт)

Новый модуль ordinis-auth:
- OrdinisAuthProperties — public-key, issuer, requireAuthentication, allowQueryScope
- ScopeContext — резолвит current scopes из JWT claim 'scopes' (приоритет),
  потом legacy ?as_scope=, потом anonymous=PUBLIC
- SecurityConfig — Spring Security OAuth2 Resource Server + RSA public-key
  decoder. Если public-key пуст — JWT validation отключён (permissive
  fallback); rollout без auth-сервера не ломает API.

Read controller теперь делегирует scope resolution в ScopeContext вместо
прямого парсинга ?as_scope=. Backward compat: legacy query параметр
работает пока ordinis.auth.allow-query-scope=true (по умолчанию true в
dev/staging, false в prod после интеграции с @nstart/auth).

Vault: public-key читается из secret/ordinis/cuod/jwt-public-key (key=key).
Spring Cloud Vault flatten'ит в property 'key', resolved в
ordinis.auth.public-key=${key:}.
This commit is contained in:
Александр Зимин
2026-05-04 00:28:27 +03:00
parent dfb694a42f
commit 6ee7a70ad1
11 changed files with 289 additions and 18 deletions
@@ -0,0 +1,30 @@
package cloud.nstart.terravault.ordinis.auth;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Конфиг для @nstart/auth-style JWT auth.
*
* <p>{@code publicKey} — RSA public key (PEM, без BEGIN/END заголовков, base64).
* Подбрасывается через Vault: secret/ordinis/cuod/jwt-public-key.</p>
*
* <p>{@code requireAuthentication=true} → 401 на любой запрос без валидного JWT.
* При false работает permissive режим — анонимный запрос имеет только PUBLIC scope,
* что подходит для read-api на raw публичных endpoint'ах.</p>
*
* <p>{@code allowQueryScope=true} включает legacy {@code ?as_scope=...} query param
* (для smoke-тестов и dev). В prod рекомендуется false.</p>
*/
@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 = "";
}
}
@@ -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 для текущего запроса.
*
* <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>
* </ol>
*/
@Component
public class ScopeContext {
private final OrdinisAuthProperties props;
public ScopeContext(OrdinisAuthProperties props) {
this.props = props;
}
/** Достаёт scope текущей аутентифицированной сессии (из JWT) или PUBLIC если анонимна. */
public Set<DataScope> 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 с правильными правилами:
*
* <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) {
return scopesFromJwt(jwtAuth.getToken());
}
if (props.allowQueryScope() && queryAsScope != null && !queryAsScope.isBlank()) {
return fromQueryParam(queryAsScope);
}
return EnumSet.of(DataScope.PUBLIC);
}
public static Set<DataScope> fromQueryParam(String csv) {
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) {
}
}
return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result;
}
private Set<DataScope> scopesFromJwt(Jwt token) {
Object raw = token.getClaim(props.scopeClaim());
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));
} 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<DataScope> target, String s) {
if (s == null || s.isBlank()) return;
try {
target.add(DataScope.valueOf(s.trim().toUpperCase()));
} catch (IllegalArgumentException ignored) {
}
}
}
@@ -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;
}
}
@@ -0,0 +1,2 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
cloud.nstart.terravault.ordinis.auth.SecurityConfig