diff --git a/application/pom.xml b/application/pom.xml index a7554cb2..6d2334eb 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -271,7 +271,12 @@ it.smartcommunitylabdhub dh-files ${revision} - + + + it.smartcommunitylabdhub + dh-authorization + ${revision} + diff --git a/application/src/main/java/it/smartcommunitylabdhub/core/components/run/RunManager.java b/application/src/main/java/it/smartcommunitylabdhub/core/components/run/RunManager.java index 4aac7ec8..5b9a466a 100644 --- a/application/src/main/java/it/smartcommunitylabdhub/core/components/run/RunManager.java +++ b/application/src/main/java/it/smartcommunitylabdhub/core/components/run/RunManager.java @@ -1,7 +1,9 @@ package it.smartcommunitylabdhub.core.components.run; +import it.smartcommunitylabdhub.authorization.services.JwtTokenService; import it.smartcommunitylabdhub.commons.accessors.fields.StatusFieldAccessor; import it.smartcommunitylabdhub.commons.accessors.spec.RunSpecAccessor; +import it.smartcommunitylabdhub.commons.config.SecurityProperties; import it.smartcommunitylabdhub.commons.events.RunnableChangedEvent; import it.smartcommunitylabdhub.commons.events.RunnableMonitorObject; import it.smartcommunitylabdhub.commons.exceptions.NoSuchEntityException; @@ -78,6 +80,12 @@ public class RunManager { @Autowired ProcessorRegistry processorRegistry; + @Autowired + JwtTokenService jwtTokenService; + + @Autowired + SecurityProperties securityProperties; + public Run build(@NotNull Run run) throws NoSuchEntityException { // GET state machine, init state machine with status RunBaseSpec runBaseSpec = new RunBaseSpec(); @@ -196,9 +204,13 @@ public Run run(@NotNull Run run) throws NoSuchEntityException, InvalidTransactio //extract auth from security context to inflate secured credentials //TODO refactor properly if (r instanceof SecuredRunnable) { + // check that auth is enabled via securityProperties Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth != null) { - ((SecuredRunnable) r).setCredentials(auth); + if (auth != null && securityProperties.isRequired()) { + Serializable credentials = jwtTokenService.generateCredentials(auth); + if (credentials != null) { + ((SecuredRunnable) r).setCredentials(credentials); + } } } diff --git a/application/src/main/java/it/smartcommunitylabdhub/core/config/SecurityConfig.java b/application/src/main/java/it/smartcommunitylabdhub/core/config/SecurityConfig.java index 1e5d0f0a..bbd48e96 100644 --- a/application/src/main/java/it/smartcommunitylabdhub/core/config/SecurityConfig.java +++ b/application/src/main/java/it/smartcommunitylabdhub/core/config/SecurityConfig.java @@ -1,10 +1,16 @@ package it.smartcommunitylabdhub.core.config; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.RSAKey; +import it.smartcommunitylabdhub.authorization.config.KeyStoreConfig; +import it.smartcommunitylabdhub.commons.config.ApplicationProperties; import it.smartcommunitylabdhub.commons.config.SecurityProperties; import jakarta.servlet.http.HttpServletRequest; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -12,6 +18,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.domain.AuditorAware; +import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -23,6 +30,7 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.oauth2.jwt.JwtClaimValidator; @@ -30,6 +38,7 @@ import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandlerImpl; @@ -48,6 +57,9 @@ public class SecurityConfig { public static final String API_PREFIX = "/api"; + @Autowired + ApplicationProperties applicationProperties; + @Autowired SecurityProperties properties; @@ -60,6 +72,15 @@ public class SecurityConfig { @Value("${management.endpoints.web.base-path}") private String managementBasePath; + @Value("${jwt.client-id}") + private String clientId; + + @Value("${jwt.client-secret}") + private String clientSecret; + + @Autowired + KeyStoreConfig keyStoreConfig; + @Bean("apiSecurityFilterChain") public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { HttpSecurity securityChain = http @@ -85,18 +106,37 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce //authentication (when configured) if (properties.isRequired()) { - if (properties.isBasicAuthEnabled()) { - securityChain - .httpBasic(basic -> basic.authenticationEntryPoint(new Http403ForbiddenEntryPoint())) - .userDetailsService(userDetailsService()); - } + //always enable internal jwt auth provider + JwtAuthenticationProvider coreJwtAuthProvider = new JwtAuthenticationProvider(coreJwtDecoder()); + coreJwtAuthProvider.setJwtAuthenticationConverter(coreJwtAuthenticationConverter()); + + // Create authentication Manager + securityChain.oauth2ResourceServer(oauth2 -> + oauth2.jwt(jwt -> jwt.authenticationManager(new ProviderManager(coreJwtAuthProvider))) + ); + if (properties.isJwtAuthEnabled()) { + // rebuild auth manager to include external jwt provider + JwtAuthenticationProvider externalJwtAuthProvider = new JwtAuthenticationProvider(externalJwtDecoder()); + + externalJwtAuthProvider.setJwtAuthenticationConverter(externalJwtAuthenticationConverter()); + securityChain.oauth2ResourceServer(oauth2 -> - oauth2.jwt(jwt -> jwt.decoder(jwtDecoder()).jwtAuthenticationConverter(jwtAuthenticationConverter()) + oauth2.jwt(jwt -> + jwt.authenticationManager(new ProviderManager(coreJwtAuthProvider, externalJwtAuthProvider)) ) ); } + //enable basic if required + if (properties.isBasicAuthEnabled()) { + securityChain + .httpBasic(basic -> basic.authenticationEntryPoint(new Http403ForbiddenEntryPoint())) + .userDetailsService( + userDetailsService(properties.getBasic().getUsername(), properties.getBasic().getPassword()) + ); + } + //disable anonymous securityChain.anonymous(anon -> anon.disable()); } else { @@ -116,19 +156,88 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce return securityChain.build(); } - public UserDetailsService userDetailsService() { + /** + * Internal basic auth + */ + public UserDetailsService userDetailsService(String username, String password) { //create admin user with full permissions UserDetails admin = User .withDefaultPasswordEncoder() - .username(properties.getBasic().getUsername()) - .password(properties.getBasic().getPassword()) + .username(username) + .password(password) .roles("ADMIN", "USER") .build(); return new InMemoryUserDetailsManager(admin); } - private JwtDecoder jwtDecoder() { + /** + * Internal auth via JWT + */ + + private JwtDecoder coreJwtDecoder() throws JOSEException { + JWK jwk = keyStoreConfig.getJWKSetKeyStore().getJwk(); + + //we support only RSA keys + if (!(jwk instanceof RSAKey)) { + throw new IllegalArgumentException("the provided key is not suitable for token authentication"); + } + + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(jwk.toRSAKey().toRSAPublicKey()).build(); + + OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer( + applicationProperties.getEndpoint() + ); + + OAuth2TokenValidator audienceValidator = new JwtClaimValidator>( + JwtClaimNames.AUD, + (aud -> aud != null && aud.contains(applicationProperties.getName())) + ); + + //access tokens *do not contain* at_hash, those are refresh + OAuth2TokenValidator accessTokenValidator = new JwtClaimValidator( + IdTokenClaimNames.AT_HASH, + (Objects::isNull) + ); + + OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>( + withIssuer, + audienceValidator, + accessTokenValidator + ); + jwtDecoder.setJwtValidator(validator); + + return jwtDecoder; + } + + private JwtAuthenticationConverter coreJwtAuthenticationConverter() { + String claim = "authorities"; + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter((Jwt source) -> { + if (source == null) return null; + + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + + if (StringUtils.hasText(claim) && source.hasClaim(claim)) { + List roles = source.getClaimAsStringList(claim); + if (roles != null) { + roles.forEach(r -> { + //use as is + authorities.add(new SimpleGrantedAuthority(r)); + }); + } + } + + return authorities; + }); + return converter; + } + + /** + * External auth via JWT + */ + private JwtDecoder externalJwtDecoder() { SecurityProperties.JwtAuthenticationProperties jwtProps = properties.getJwt(); NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(jwtProps.getIssuerUri()).build(); @@ -144,7 +253,7 @@ private JwtDecoder jwtDecoder() { return jwtDecoder; } - private JwtAuthenticationConverter jwtAuthenticationConverter() { + private JwtAuthenticationConverter externalJwtAuthenticationConverter() { SecurityProperties.JwtAuthenticationProperties jwtProps = properties.getJwt(); String claim = jwtProps.getClaim(); JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); @@ -174,6 +283,55 @@ private JwtAuthenticationConverter jwtAuthenticationConverter() { return converter; } + @Bean("authSecurityFilterChain") + public SecurityFilterChain authSecurityFilterChain(HttpSecurity http) throws Exception { + HttpSecurity securityChain = http + .securityMatcher(getAuthRequestMatcher()) + .authorizeHttpRequests(auth -> { + auth.requestMatchers(getAuthRequestMatcher()).hasRole("USER").anyRequest().authenticated(); + }) + // disable request cache + .requestCache(requestCache -> requestCache.disable()) + //disable csrf + .csrf(csrf -> csrf.disable()) + // we don't want a session for these endpoints, each request should be evaluated + .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + // allow cors + securityChain.cors(cors -> { + if (StringUtils.hasText(corsOrigins)) { + cors.configurationSource(corsConfigurationSource(corsOrigins)); + } else { + cors.disable(); + } + }); + + //authentication (when configured) + if (StringUtils.hasText(clientId) && StringUtils.hasText(clientSecret)) { + //enable basic + securityChain + .httpBasic(basic -> basic.authenticationEntryPoint(new Http403ForbiddenEntryPoint())) + .userDetailsService(userDetailsService(clientId, clientSecret)); + + //disable anonymous + securityChain.anonymous(anon -> anon.disable()); + } else { + //assign both USER and ADMIN to anon user to bypass all scoped permission checks + securityChain.anonymous(anon -> { + anon.authorities("ROLE_USER", "ROLE_ADMIN"); + anon.principal("anonymous"); + }); + } + + securityChain.exceptionHandling(handling -> { + handling + .authenticationEntryPoint(new Http403ForbiddenEntryPoint()) + .accessDeniedHandler(new AccessDeniedHandlerImpl()); // use 403 + }); + + return securityChain.build(); + } + @Bean("h2SecurityFilterChain") public SecurityFilterChain h2SecurityFilterChain(HttpSecurity http) throws Exception { return http @@ -222,6 +380,10 @@ public RequestMatcher getApiRequestMatcher() { return new AntPathRequestMatcher(API_PREFIX + "/**"); } + public RequestMatcher getAuthRequestMatcher() { + return new AntPathRequestMatcher("/auth/**"); + } + private CorsConfigurationSource corsConfigurationSource(String origins) { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns(new ArrayList<>(StringUtils.commaDelimitedListToSet(origins))); diff --git a/application/src/main/resources/application.yml b/application/src/main/resources/application.yml index 71ba4929..298cd87f 100644 --- a/application/src/main/resources/application.yml +++ b/application/src/main/resources/application.yml @@ -107,6 +107,8 @@ kubernetes: results: ${K8S_ENABLE_RESULTS:default} security: disable-root: ${K8S_SEC_DISABLE_ROOT:false} + envs: + prefix: ${K8S_ENVS_PREFIX:${application.name}} image-pull-policy: ${K8S_IMAGE_PULL_POLICY:IfNotPresent} init-image: ${K8S_INIT_IMAGE:ghcr.io/scc-digitalhub/digitalhub-core-builder-tool:latest} resources: @@ -193,4 +195,16 @@ files: secret-key: ${AWS_SECRET_KEY:} endpoint: ${S3_ENDPOINT:} bucket: ${S3_BUCKET:} - \ No newline at end of file + +# JWT configuration +jwt: + keystore: + path: ${JWT_KEYSTORE_PATH:classpath:/keystore.jwks} + kid: ${JWT_KEYSTORE_KID:} + access-token: + duration: ${JWT_ACCESS_TOKEN_DURATION:} + refresh-token: + duration: ${JWT_REFRESH_TOKEN_DURATION:} + client-id: ${JWT_CLIENT_ID:${security.basic.username}} + client-secret: ${JWT_CLIENT_SECRET:${security.basic.password}} + cache-control: ${JWKS_CACHE_CONTROL:public, max-age=900, must-revalidate, no-transform} \ No newline at end of file diff --git a/modules/authorization/.flattened-pom.xml b/modules/authorization/.flattened-pom.xml new file mode 100644 index 00000000..a85a99c4 --- /dev/null +++ b/modules/authorization/.flattened-pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + it.smartcommunitylabdhub + dh-authorization + 0.6.0-SNAPSHOT + + + it.smartcommunitylabdhub + dh-commons + 0.6.0-SNAPSHOT + compile + + + org.springframework.boot + spring-boot-starter-security + 3.2.0 + compile + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + 3.2.0 + compile + + + org.projectlombok + lombok + 1.18.30 + compile + true + + + org.slf4j + slf4j-api + 2.0.9 + compile + + + org.slf4j + log4j-over-slf4j + 2.0.9 + compile + + + diff --git a/modules/authorization/pom.xml b/modules/authorization/pom.xml new file mode 100644 index 00000000..09a31625 --- /dev/null +++ b/modules/authorization/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + it.smartcommunitylabdhub + digitalhub-core + ${revision} + ../../ + + it.smartcommunitylabdhub + dh-authorization + authorization + DHCore authorization + + + + it.smartcommunitylabdhub + dh-commons + ${revision} + + + org.springframework.boot + spring-boot-starter-security + 3.2.0 + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + 3.2.0 + + + org.springframework.boot + spring-boot-starter-test + ${spring-boot.version} + test + + + + org.projectlombok + lombok + ${lombok.version} + compile + true + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.slf4j + log4j-over-slf4j + ${slf4j.version} + + + + \ No newline at end of file diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/components/JWKSetKeyStore.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/components/JWKSetKeyStore.java new file mode 100644 index 00000000..2b20dea2 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/components/JWKSetKeyStore.java @@ -0,0 +1,83 @@ +package it.smartcommunitylabdhub.authorization.components; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import it.smartcommunitylabdhub.authorization.utils.JWKUtils; +import jakarta.annotation.Nullable; +import java.util.List; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +@Slf4j +public class JWKSetKeyStore { + + @Getter + private final JWKSet jwkSet; + + @Getter + private final String kid; + + public JWKSetKeyStore(Resource location, @Nullable String kid) throws IllegalArgumentException { + Assert.notNull(location, "location can not be null"); + + //load + log.debug("load keyStore from {}", location); + this.jwkSet = JWKUtils.loadJwkSet(location); + JWK jwk = load(jwkSet, kid); + this.kid = jwk.getKeyID(); + + log.debug("use key {} for signing", kid); + } + + public JWKSetKeyStore() throws JOSEException { + //create + log.debug("create temporary keyStore"); + this.jwkSet = JWKUtils.createJwkSet(); + JWK jwk = load(jwkSet, null); + this.kid = jwk.getKeyID(); + + log.debug("use key {} for signing", kid); + } + + public JWK getJwk() { + return jwkSet.getKeyByKeyId(kid); + } + + private JWK load(JWKSet jwkSet, String kid) { + //if specified, kid must be in set + if (StringUtils.hasText(kid)) { + JWK key = jwkSet.getKeyByKeyId(kid); + Assert.notNull(key, "Provided key_id is not in the set"); + + //validate key usage + Assert.isTrue( + (key.getKeyUse() == KeyUse.SIGNATURE || key.getKeyUse() == null), + "key should be usable for signing" + ); + + //use + return key; + } else { + List keys = jwkSet.getKeys(); + Assert.notNull(keys, "keystore must contain at least one valid key"); + Assert.isTrue(!keys.isEmpty(), "keystore must contain at least one valid key"); + + //fetch the first signing key + JWK key = keys + .stream() + .filter(k -> (k.getKeyUse() == KeyUse.SIGNATURE || k.getKeyUse() == null)) + .findFirst() + .orElse(null); + + Assert.notNull(key, "No suitable key found in store"); + + //use + return key; + } + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/config/KeyStoreConfig.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/config/KeyStoreConfig.java new file mode 100644 index 00000000..1bc33a74 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/config/KeyStoreConfig.java @@ -0,0 +1,50 @@ +package it.smartcommunitylabdhub.authorization.config; + +import com.nimbusds.jose.JOSEException; +import it.smartcommunitylabdhub.authorization.components.JWKSetKeyStore; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.util.StringUtils; + +@Configuration +public class KeyStoreConfig { + + @Value("${jwt.keystore.path}") + private String path; + + @Value("${jwt.keystore.kid}") + private String kid; + + @Autowired + private ResourceLoader resourceLoader; + + private JWKSetKeyStore keyStore; + + @Bean + public JWKSetKeyStore getJWKSetKeyStore() throws JOSEException { + if (keyStore != null) { + //re-use because we load from config *before* services are built + return keyStore; + } + + if (StringUtils.hasText(path) && !path.contains(":")) { + //no protocol specified, try as file by default + this.path = "file:" + path; + } + + Resource location = resourceLoader.getResource(path); + if (location != null && location.exists() && location.isReadable()) { + // Load from resource + keyStore = new JWKSetKeyStore(location, kid); + } else { + // Generate new in-memory keystore + keyStore = new JWKSetKeyStore(); + } + + return keyStore; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/ConfigurationEndpoint.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/ConfigurationEndpoint.java new file mode 100644 index 00000000..ad957c45 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/ConfigurationEndpoint.java @@ -0,0 +1,70 @@ +package it.smartcommunitylabdhub.authorization.controllers; + +import it.smartcommunitylabdhub.commons.config.ApplicationProperties; +import it.smartcommunitylabdhub.commons.config.SecurityProperties; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ConfigurationEndpoint { + + @Autowired + private ApplicationProperties applicationProperties; + + @Autowired + private SecurityProperties securityProperties; + + @Value("${jwt.cache-control}") + private String cacheControl; + + private Map config = null; + + @GetMapping(value = { "/.well-known/openid-configuration", "/.well-known/oauth-authorization-server" }) + public ResponseEntity> getCOnfiguration() { + if (!securityProperties.isRequired()) { + throw new UnsupportedOperationException(); + } + + if (config == null) { + config = generate(); + } + + return ResponseEntity.ok().header(HttpHeaders.CACHE_CONTROL, cacheControl).body(config); + } + + private Map generate() { + /* + * OpenID Provider Metadata + * https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + */ + + String baseUrl = applicationProperties.getEndpoint(); + Map m = new HashMap<>(); + + m.put("issuer", baseUrl); + m.put("jwks_uri", baseUrl + JWKSEndpoint.JWKS_URL); + m.put("response_types_supported", Collections.emptyList()); + + List grantTypes = Stream + .of(AuthorizationGrantType.CLIENT_CREDENTIALS, AuthorizationGrantType.REFRESH_TOKEN) + .map(t -> t.getValue()) + .toList(); + m.put("grant_types_supported", grantTypes); + + m.put("token_endpoint", baseUrl + TokenEndpoint.TOKEN_URL); + List authMethods = Collections.singletonList("client_secret_basic"); + m.put("token_endpoint_auth_methods_supported", authMethods); + + return m; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/JWKSEndpoint.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/JWKSEndpoint.java new file mode 100644 index 00000000..dcf14211 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/JWKSEndpoint.java @@ -0,0 +1,40 @@ +package it.smartcommunitylabdhub.authorization.controllers; + +import com.nimbusds.jose.jwk.JWKSet; +import it.smartcommunitylabdhub.authorization.components.JWKSetKeyStore; +import it.smartcommunitylabdhub.commons.config.SecurityProperties; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class JWKSEndpoint { + + public static final String JWKS_URL = "/.well-known/jwks.json"; + + @Autowired + private JWKSetKeyStore jwkSetKeyStore; + + @Autowired + private SecurityProperties securityProperties; + + @Value("${jwt.cache-control}") + private String cacheControl; + + @GetMapping(JWKS_URL) + public ResponseEntity> getJWKInfo() { + if (!securityProperties.isRequired()) { + throw new UnsupportedOperationException(); + } + + //expose the entire jwkSet as JSON + JWKSet jwkSet = jwkSetKeyStore.getJwkSet(); + Map jwkSetMap = jwkSet.toJSONObject(); + + return ResponseEntity.ok().header(HttpHeaders.CACHE_CONTROL, cacheControl).body(jwkSetMap); + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/TokenEndpoint.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/TokenEndpoint.java new file mode 100644 index 00000000..49fb9e1f --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/TokenEndpoint.java @@ -0,0 +1,353 @@ +package it.smartcommunitylabdhub.authorization.controllers; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.RSAKey; +import it.smartcommunitylabdhub.authorization.components.JWKSetKeyStore; +import it.smartcommunitylabdhub.authorization.model.TokenResponse; +import it.smartcommunitylabdhub.authorization.services.JwtTokenService; +import it.smartcommunitylabdhub.commons.config.ApplicationProperties; +import it.smartcommunitylabdhub.commons.config.SecurityProperties; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Slf4j +public class TokenEndpoint implements InitializingBean { + + public static final String TOKEN_URL = "/auth/token"; + public static final String TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; + public static final String ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; + + @Value("${jwt.client-id}") + private String clientId; + + @Value("${jwt.client-secret}") + private String clientSecret; + + @Autowired + private JwtTokenService jwtTokenService; + + @Autowired + private JWKSetKeyStore jwkSetKeyStore; + + @Autowired + private ApplicationProperties applicationProperties; + + @Autowired + private SecurityProperties securityProperties; + + //TODO move to dedicated filter initalized via securityConfig! + private JwtAuthenticationProvider accessTokenAuthProvider; + private JwtAuthenticationProvider refreshTokenAuthProvider; + private JwtAuthenticationProvider externalTokenAuthProvider; + + @Override + public void afterPropertiesSet() throws Exception { + if (securityProperties.isRequired()) { + Assert.notNull(jwkSetKeyStore, "jwks store is required"); + Assert.notNull(jwkSetKeyStore.getJwk(), "jwk is required"); + + JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter(); + JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); + authoritiesConverter.setAuthoritiesClaimName("authorities"); + authoritiesConverter.setAuthorityPrefix(""); + jwtConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); + + //build auth provider to validate tokens + accessTokenAuthProvider = new JwtAuthenticationProvider(coreJwtDecoder(jwkSetKeyStore.getJwk(), false)); + accessTokenAuthProvider.setJwtAuthenticationConverter(jwtConverter); + refreshTokenAuthProvider = new JwtAuthenticationProvider(coreJwtDecoder(jwkSetKeyStore.getJwk(), true)); + refreshTokenAuthProvider.setJwtAuthenticationConverter(jwtConverter); + + if (securityProperties.isJwtAuthEnabled()) { + externalTokenAuthProvider = new JwtAuthenticationProvider(externalJwtDecoder()); + externalTokenAuthProvider.setJwtAuthenticationConverter(externalJwtAuthenticationConverter()); + } + } + } + + @PostMapping(TOKEN_URL) + public TokenResponse token(@RequestParam Map parameters, Authentication authentication) { + if (!securityProperties.isRequired()) { + throw new UnsupportedOperationException(); + } + + //resolve client authentication + if (authentication == null || !(authentication.isAuthenticated())) { + throw new InsufficientAuthenticationException("Invalid or missing authentication"); + } + + //select flow + String grantType = parameters.get(OAuth2ParameterNames.GRANT_TYPE); + log.debug("token request for {}", grantType); + if (log.isTraceEnabled()) { + log.trace("authentication name {}", authentication.getName()); + } + + if (AuthorizationGrantType.CLIENT_CREDENTIALS.getValue().equals(grantType)) { + return clientCredentials(parameters, authentication); + } else if (AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(grantType)) { + return refreshToken(parameters, authentication); + } else if (TOKEN_EXCHANGE_GRANT_TYPE.equals(grantType)) { + return tokenExchange(parameters, authentication); + } + + throw new IllegalArgumentException("invalid or unsupported grant type"); + } + + private TokenResponse refreshToken(Map parameters, Authentication authentication) { + if (refreshTokenAuthProvider == null) { + throw new UnsupportedOperationException(); + } + + //refresh token is usable without credentials + //TODO add rotation by storing refresh tokens in db! + + String grantType = parameters.get(OAuth2ParameterNames.GRANT_TYPE); + if (!AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(grantType)) { + throw new IllegalArgumentException("invalid grant type"); + } + + String token = parameters.get("refresh_token"); + if (token == null) { + throw new IllegalArgumentException("invalid or missing refresh_token"); + } + + String cid = parameters.get("client_id"); + if (cid == null || !clientId.equals(cid)) { + throw new IllegalArgumentException("invalid or missing client_id"); + } + + log.debug("refresh token request for {}", cid); + if (log.isTraceEnabled()) { + log.trace("refresh token {}", token); + } + + //validate via provider + try { + BearerTokenAuthenticationToken request = new BearerTokenAuthenticationToken(token); + Authentication auth = refreshTokenAuthProvider.authenticate(request); + if (!auth.isAuthenticated()) { + throw new IllegalArgumentException("invalid or missing refresh_token"); + } + + //token is valid, use as context for generation + return jwtTokenService.generateCredentials(auth); + } catch (AuthenticationException ae) { + throw new IllegalArgumentException("invalid or missing refresh_token"); + } + } + + private TokenResponse clientCredentials(Map parameters, Authentication authentication) { + //client credentials *requires* basic auth + if (authentication == null || !(authentication instanceof UsernamePasswordAuthenticationToken)) { + throw new InsufficientAuthenticationException("Invalid or missing authentication"); + } + + //for client credentials to mimic admin user client *must* match authenticated user + UsernamePasswordAuthenticationToken auth = (UsernamePasswordAuthenticationToken) authentication; + if (clientId != null && !clientId.equals(auth.getName())) { + throw new InsufficientAuthenticationException("Invalid client authentication"); + } + + String grantType = parameters.get(OAuth2ParameterNames.GRANT_TYPE); + if (!AuthorizationGrantType.CLIENT_CREDENTIALS.getValue().equals(grantType)) { + throw new IllegalArgumentException("invalid grant type"); + } + + log.debug("client token request for {}", auth.getName()); + + //generate as per user + return jwtTokenService.generateCredentials(authentication); + } + + private TokenResponse tokenExchange(Map parameters, Authentication authentication) { + if (accessTokenAuthProvider == null) { + throw new UnsupportedOperationException(); + } + + //token exchange *requires* basic auth + if (authentication == null || !(authentication instanceof UsernamePasswordAuthenticationToken)) { + throw new InsufficientAuthenticationException("Invalid or missing authentication"); + } + + //for client credentials to mimic admin user client *must* match authenticated user + UsernamePasswordAuthenticationToken clientAuth = (UsernamePasswordAuthenticationToken) authentication; + if (clientId != null && !clientId.equals(clientAuth.getName())) { + throw new InsufficientAuthenticationException("Invalid client authentication"); + } + + String grantType = parameters.get(OAuth2ParameterNames.GRANT_TYPE); + if (!TOKEN_EXCHANGE_GRANT_TYPE.equals(grantType)) { + throw new IllegalArgumentException("invalid grant type"); + } + + //validate token as well + String token = parameters.get("subject_token"); + if (token == null) { + throw new IllegalArgumentException("invalid or missing subject_token"); + } + + String tokenType = parameters.get("subject_token_type"); + if (!ACCESS_TOKEN_TYPE.equals(tokenType)) { + throw new IllegalArgumentException("invalid or missing subject_token_type"); + } + + log.debug("exchange token request from {}", clientAuth.getName()); + if (log.isTraceEnabled()) { + log.trace("subject token {}", token); + } + + //validate via provider + try { + BearerTokenAuthenticationToken request = new BearerTokenAuthenticationToken(token); + Authentication userAuth = accessTokenAuthProvider.authenticate(request); + if (!userAuth.isAuthenticated()) { + throw new IllegalArgumentException("invalid or missing subject_token"); + } + + log.debug("exchange token request from {} resolved for {} via internal provider", clientAuth.getName(), userAuth.getName()); + + //token is valid, use as context for generation + return jwtTokenService.generateCredentials(userAuth); + } catch (AuthenticationException ae) { + //fall back to external if available + if (externalTokenAuthProvider != null) { + try { + BearerTokenAuthenticationToken request = new BearerTokenAuthenticationToken(token); + Authentication userAuth = externalTokenAuthProvider.authenticate(request); + if (!userAuth.isAuthenticated()) { + throw new IllegalArgumentException("invalid or missing subject_token"); + } + + log.debug( + "exchange token request from {} resolved for {} via external provider", + clientAuth.getName(), + userAuth.getName() + ); + + //token is valid, use as context for generation + return jwtTokenService.generateCredentials(userAuth); + } catch (AuthenticationException ae1) { + throw new IllegalArgumentException("invalid or missing subject_token"); + } + } + + throw new IllegalArgumentException("invalid or missing subject_token"); + } + } + + //TODO move to filter + config! + private JwtDecoder coreJwtDecoder(JWK jwk, boolean asRefresh) throws JOSEException { + //we support only RSA keys + if (!(jwk instanceof RSAKey)) { + throw new IllegalArgumentException("the provided key is not suitable for token authentication"); + } + + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(jwk.toRSAKey().toRSAPublicKey()).build(); + + OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer( + applicationProperties.getEndpoint() + ); + OAuth2TokenValidator audienceValidator = new JwtClaimValidator>( + JwtClaimNames.AUD, + (aud -> aud != null && aud.contains(applicationProperties.getName())) + ); + + //refresh tokens *must contain* at_hash, access token *not* + OAuth2TokenValidator tokenValidator = new JwtClaimValidator( + IdTokenClaimNames.AT_HASH, + (hash -> (asRefresh ? hash != null : hash == null)) + ); + + OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>( + withIssuer, + audienceValidator, + tokenValidator + ); + jwtDecoder.setJwtValidator(validator); + + return jwtDecoder; + } + + /** + * External auth via JWT + */ + private JwtDecoder externalJwtDecoder() { + SecurityProperties.JwtAuthenticationProperties jwtProps = securityProperties.getJwt(); + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(jwtProps.getIssuerUri()).build(); + + OAuth2TokenValidator audienceValidator = new JwtClaimValidator>( + JwtClaimNames.AUD, + (aud -> aud != null && aud.contains(jwtProps.getAudience())) + ); + + OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer(jwtProps.getIssuerUri()); + OAuth2TokenValidator withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator); + jwtDecoder.setJwtValidator(withAudience); + + return jwtDecoder; + } + + private JwtAuthenticationConverter externalJwtAuthenticationConverter() { + SecurityProperties.JwtAuthenticationProperties jwtProps = securityProperties.getJwt(); + String claim = jwtProps.getClaim(); + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter((Jwt source) -> { + if (source == null) return null; + + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + + if (StringUtils.hasText(claim) && source.hasClaim(claim)) { + List roles = source.getClaimAsStringList(claim); + if (roles != null) { + roles.forEach(r -> { + if ("ROLE_ADMIN".equals(r) || r.contains(":")) { + //use as is + authorities.add(new SimpleGrantedAuthority(r)); + } else { + //derive a scoped USER role + authorities.add(new SimpleGrantedAuthority(r + ":ROLE_USER")); + } + }); + } + } + + return authorities; + }); + return converter; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/exceptions/JwtTokenServiceException.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/exceptions/JwtTokenServiceException.java new file mode 100644 index 00000000..4ee9449e --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/exceptions/JwtTokenServiceException.java @@ -0,0 +1,8 @@ +package it.smartcommunitylabdhub.authorization.exceptions; + +public class JwtTokenServiceException extends RuntimeException { + + public JwtTokenServiceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/TokenResponse.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/TokenResponse.java new file mode 100644 index 00000000..3fb77f49 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/TokenResponse.java @@ -0,0 +1,52 @@ +package it.smartcommunitylabdhub.authorization.model; + +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nimbusds.jwt.SignedJWT; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TokenResponse implements Serializable { + + @JsonProperty("access_token") + private SignedJWT accessToken; + + @JsonProperty("token_type") + @Builder.Default + private String tokenType = "Bearer"; + + @JsonProperty("refresh_token") + private SignedJWT refreshToken; + + @JsonProperty("expires_in") + private Integer expiration; + + @JsonProperty("client_id") + private String clientId; + + @JsonProperty("issuer") + private String issuer; + + @JsonGetter + public String getAccessToken() { + return accessToken != null ? accessToken.serialize() : null; + } + + @JsonGetter + public String getRefreshToken() { + return refreshToken != null ? refreshToken.serialize() : null; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/JwtTokenService.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/JwtTokenService.java new file mode 100644 index 00000000..e14e2a8b --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/JwtTokenService.java @@ -0,0 +1,246 @@ +package it.smartcommunitylabdhub.authorization.services; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.ECDSAVerifier; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.OctetSequenceKey; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import it.smartcommunitylabdhub.authorization.components.JWKSetKeyStore; +import it.smartcommunitylabdhub.authorization.exceptions.JwtTokenServiceException; +import it.smartcommunitylabdhub.authorization.model.TokenResponse; +import it.smartcommunitylabdhub.authorization.utils.JWKUtils; +import it.smartcommunitylabdhub.commons.config.ApplicationProperties; +import it.smartcommunitylabdhub.commons.config.SecurityProperties; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +@Slf4j +public class JwtTokenService implements InitializingBean { + + private static final int DEFAULT_ACCESS_TOKEN_DURATION = 3600 * 8; //8 hours + private static final int DEFAULT_REFRESH_TOKEN_DURATION = 3600 * 24 * 30; //30 days + + @Autowired + private JWKSetKeyStore keyStore; + + @Autowired + private ApplicationProperties applicationProperties; + + @Autowired + private SecurityProperties securityProperties; + + @Value("${jwt.client-id}") + private String clientId; + + private int accessTokenDuration = DEFAULT_ACCESS_TOKEN_DURATION; + private int refreshTokenDuration = DEFAULT_REFRESH_TOKEN_DURATION; + + //we need to keep the key along with singer/verifier + private JWK jwk; + private JWSSigner signer; + private JWSVerifier verifier; + + @Autowired + public void setAccessTokenDuration(@Value("${jwt.access-token.duration}") Integer accessTokenDuration) { + if (accessTokenDuration != null) { + this.accessTokenDuration = accessTokenDuration.intValue(); + } + } + + @Autowired + public void setRefreshTokenDuration(@Value("${jwt.access-token.duration}") Integer refreshTokenDuration) { + if (refreshTokenDuration != null) { + this.refreshTokenDuration = refreshTokenDuration.intValue(); + } + } + + @Override + public void afterPropertiesSet() throws Exception { + if (securityProperties.isRequired()) { + //build signer for the given keys + this.jwk = keyStore.getJwk(); + + if (jwk != null) { + try { + if (jwk.getAlgorithm() == null) { + throw new JOSEException("key algorithm invalid"); + } + if (jwk instanceof RSAKey) { + // build RSA signers & verifiers + if (jwk.isPrivate()) { // only add the signer if there's a private key + signer = new RSASSASigner((RSAKey) jwk); + } + verifier = new RSASSAVerifier((RSAKey) jwk); + } else if (jwk instanceof ECKey) { + // build EC signers & verifiers + if (jwk.isPrivate()) { + signer = new ECDSASigner((ECKey) jwk); + } + + verifier = new ECDSAVerifier((ECKey) jwk); + } else if (jwk instanceof OctetSequenceKey) { + // build HMAC signers & verifiers + + if (jwk.isPrivate()) { // technically redundant check because all HMAC keys are private + signer = new MACSigner((OctetSequenceKey) jwk); + } + + verifier = new MACVerifier((OctetSequenceKey) jwk); + } else { + log.warn("Unknown key type: " + jwk); + } + } catch (JOSEException e) { + log.warn("Exception loading signer/verifier", e); + } + } + } + } + + public TokenResponse generateCredentials(Authentication authentication) { + // Serialize to compact form + SignedJWT accessToken = generateAccessToken(authentication); + SignedJWT refreshToken = generateRefreshToken(authentication, accessToken); + + return TokenResponse + .builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiration(accessTokenDuration) + .clientId(clientId) + .issuer(applicationProperties.getEndpoint()) + .build(); + } + + public String generateAccessTokenAsString(Authentication authentication) throws JwtTokenServiceException { + // Serialize to compact form + SignedJWT jwt = generateAccessToken(authentication); + String jwtToken = jwt.serialize(); + + if (log.isTraceEnabled()) { + log.trace("Generated JWT token: {}", jwtToken); + } + + return jwtToken; + } + + public SignedJWT generateAccessToken(Authentication authentication) throws JwtTokenServiceException { + if (signer == null) { + throw new UnsupportedOperationException("signer not available"); + } + + try { + JWSAlgorithm jwsAlgorithm = JWSAlgorithm.parse(jwk.getAlgorithm().getName()); + + Instant now = Instant.now(); + + // build access token claims + JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder() + .subject(authentication.getName()) + .issuer(applicationProperties.getEndpoint()) + .issueTime(Date.from(now)) + .audience(applicationProperties.getName()) + .jwtID(UUID.randomUUID().toString()) + .expirationTime(Date.from(now.plusSeconds(accessTokenDuration))); + + //define authorities as claims + List authorities = authentication + .getAuthorities() + .stream() + .map(GrantedAuthority::getAuthority) + .toList(); + + claims.claim("authorities", authorities); + + //add client if set + if (StringUtils.hasText(clientId)) { + claims.claim("client_id", clientId); + } + + // build and sign + JWTClaimsSet claimsSet = claims.build(); + JWSHeader header = new JWSHeader.Builder(jwsAlgorithm).keyID(jwk.getKeyID()).build(); + SignedJWT jwt = new SignedJWT(header, claimsSet); + jwt.sign(signer); + + return jwt; + } catch (JOSEException e) { + log.error("Error generating JWT token", e); + return null; + } + } + + public SignedJWT generateRefreshToken(Authentication authentication, SignedJWT accessToken) + throws JwtTokenServiceException { + if (signer == null) { + throw new UnsupportedOperationException("signer not available"); + } + + try { + JWSAlgorithm jwsAlgorithm = JWSAlgorithm.parse(jwk.getAlgorithm().getName()); + + Instant now = Instant.now(); + + // build refresh token claims + JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder() + .subject(authentication.getName()) + .issuer(applicationProperties.getEndpoint()) + .issueTime(Date.from(now)) + .audience(applicationProperties.getName()) + .jwtID(UUID.randomUUID().toString()) + .expirationTime(Date.from(now.plusSeconds(refreshTokenDuration))); + + //associate access token via hash binding + String hash = JWKUtils.getAccessTokenHash(jwsAlgorithm, accessToken); + claims.claim(IdTokenClaimNames.AT_HASH, hash); + + //define authorities as claims + List authorities = authentication + .getAuthorities() + .stream() + .map(GrantedAuthority::getAuthority) + .toList(); + + claims.claim("authorities", authorities); + + //add client if set + if (StringUtils.hasText(clientId)) { + claims.claim("client_id", clientId); + } + + // build and sign + JWTClaimsSet claimsSet = claims.build(); + JWSHeader header = new JWSHeader.Builder(jwsAlgorithm).keyID(jwk.getKeyID()).build(); + SignedJWT jwt = new SignedJWT(header, claimsSet); + jwt.sign(signer); + + return jwt; + } catch (JOSEException e) { + log.error("Error generating JWT token", e); + return null; + } + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/utils/JWKUtils.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/utils/JWKUtils.java new file mode 100644 index 00000000..611d9e83 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/utils/JWKUtils.java @@ -0,0 +1,118 @@ +package it.smartcommunitylabdhub.authorization.utils; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jose.util.Base64URL; +import com.nimbusds.jwt.SignedJWT; +import jakarta.annotation.Nullable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.ParseException; +import java.util.Arrays; +import java.util.UUID; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; + +public class JWKUtils { + + private static final Integer DEFAULT_RSA_KEY_LENGTH = 2048; + + public static JWK generateRsaJWK(@Nullable String id) throws IllegalArgumentException, JOSEException { + return generateRsaJWK(id, KeyUse.SIGNATURE, JWSAlgorithm.RS256, DEFAULT_RSA_KEY_LENGTH); + } + + public static JWK generateRsaJWK( + @Nullable String id, + @Nullable KeyUse usage, + @Nullable JWSAlgorithm algorithm, + @Nullable Integer length + ) throws IllegalArgumentException, JOSEException { + if (id == null || id.isEmpty()) { + id = UUID.randomUUID().toString(); + } + + return new RSAKeyGenerator(length).keyUse(usage).keyID(id).algorithm(algorithm).generate(); + } + + public static JWKSet loadJwkSet(Resource location) throws IllegalArgumentException { + Assert.notNull(location, "Key Set resource cannot be null"); + + //read from file + if (location.exists() && location.isReadable()) { + try { + return JWKSet.parse(location.getContentAsString(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalArgumentException("Key Set resource could not be read: " + location); + } catch (ParseException e) { + throw new IllegalArgumentException("Key Set resource could not be parsed: " + location); + } + } else { + throw new IllegalArgumentException("Key Set resource could not be read: " + location); + } + } + + public static JWKSet createJwkSet() throws JOSEException { + //generate a set with a single RSA key for signing + return new JWKSet(JWKUtils.generateRsaJWK(null)); + } + + public static String getAccessTokenHash(JWSAlgorithm signingAlg, SignedJWT token) { + byte[] tokenBytes = token.serialize().getBytes(); + Base64URL base64 = getHash(signingAlg, tokenBytes); + return base64.toString(); + } + + public static Base64URL getHash(JWSAlgorithm signingAlg, byte[] bytes) { + //guess hash algorithm from signing algo + String alg = null; + if ( + signingAlg.equals(JWSAlgorithm.HS256) || + signingAlg.equals(JWSAlgorithm.ES256) || + signingAlg.equals(JWSAlgorithm.RS256) || + signingAlg.equals(JWSAlgorithm.PS256) + ) { + alg = "SHA-256"; + } else if ( + signingAlg.equals(JWSAlgorithm.ES384) || + signingAlg.equals(JWSAlgorithm.HS384) || + signingAlg.equals(JWSAlgorithm.RS384) || + signingAlg.equals(JWSAlgorithm.PS384) + ) { + alg = "SHA-384"; + } else if ( + signingAlg.equals(JWSAlgorithm.ES512) || + signingAlg.equals(JWSAlgorithm.HS512) || + signingAlg.equals(JWSAlgorithm.RS512) || + signingAlg.equals(JWSAlgorithm.PS512) + ) { + alg = "SHA-512"; + } + if (alg == null) { + return null; + } + + try { + MessageDigest hash = MessageDigest.getInstance(alg); + hash.reset(); + hash.update(bytes); + + //keep left-most half as per spec + byte[] hashBytes = hash.digest(); + byte[] hashBytesLeftHalf = Arrays.copyOf(hashBytes, hashBytes.length / 2); + + //encode as base64 url + return Base64URL.encode(hashBytesLeftHalf); + } catch (NoSuchAlgorithmException e) { + //shouldn't happen + return null; + } + } + + private JWKUtils() {} +} diff --git a/modules/authorization/src/test/java/it/smartcommunitylabdhub/authorization/JwkTests.java b/modules/authorization/src/test/java/it/smartcommunitylabdhub/authorization/JwkTests.java new file mode 100644 index 00000000..ee25fa1f --- /dev/null +++ b/modules/authorization/src/test/java/it/smartcommunitylabdhub/authorization/JwkTests.java @@ -0,0 +1,22 @@ +package it.smartcommunitylabdhub.authorization; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.KeyUse; +import it.smartcommunitylabdhub.authorization.utils.JWKUtils; +import org.junit.jupiter.api.Test; + +public class JwkTests { + + @Test + public void generateRsaKey() throws Exception { + JWK jwk = JWKUtils.generateRsaJWK(null); + assertThat(jwk).isNotNull(); + //validate content + assertThat(jwk.getKeyID()).isNotBlank(); + assertThat(jwk.getAlgorithm()).isEqualTo(JWSAlgorithm.RS256); + assertThat(jwk.getKeyUse()).isEqualTo(KeyUse.SIGNATURE); + } +} diff --git a/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/infrastructure/k8s/K8sBaseFramework.java b/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/infrastructure/k8s/K8sBaseFramework.java index ab9ce798..67274e31 100644 --- a/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/infrastructure/k8s/K8sBaseFramework.java +++ b/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/infrastructure/k8s/K8sBaseFramework.java @@ -804,7 +804,7 @@ protected List buildImagePullSecrets(T runnable) { protected V1Secret buildRunSecret(T runnable) { if (runnable.getCredentials() != null) { - V1Secret secret = k8sSecretHelper.convertAuthentication( + V1Secret secret = k8sSecretHelper.convertCredentials( k8sSecretHelper.getSecretName(runnable.getRuntime(), runnable.getTask(), runnable.getId()), runnable.getCredentials() ); diff --git a/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/kubernetes/K8sBuilderHelper.java b/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/kubernetes/K8sBuilderHelper.java index eb08521d..11e0f281 100644 --- a/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/kubernetes/K8sBuilderHelper.java +++ b/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/kubernetes/K8sBuilderHelper.java @@ -63,6 +63,9 @@ public class K8sBuilderHelper implements InitializingBean { @Value("${kubernetes.namespace}") private String namespace; + @Value("${kubernetes.envs.prefix}") + private String envsPrefix; + @Override public void afterPropertiesSet() { // Retrieve CoreV1Api @@ -124,7 +127,7 @@ public List getV1EnvVar() { List vars = new ArrayList<>(); //if no configMap build a minimal config if (sharedConfigMaps == null || sharedConfigMaps.isEmpty()) { - vars.add(new V1EnvVar().name("DIGITALHUB_CORE_ENDPOINT").value(coreEndpoint)); + vars.add(new V1EnvVar().name(sanitizeNames(envsPrefix).toUpperCase() + "_ENDPOINT").value(coreEndpoint)); } return vars; diff --git a/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/kubernetes/K8sSecretHelper.java b/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/kubernetes/K8sSecretHelper.java index fadcb26d..79bb9f21 100644 --- a/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/kubernetes/K8sSecretHelper.java +++ b/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/kubernetes/K8sSecretHelper.java @@ -42,6 +42,9 @@ public class K8sSecretHelper { @Value("${kubernetes.namespace}") private String namespace; + @Value("${kubernetes.envs.prefix}") + private String envsPrefix; + public K8sSecretHelper(ApiClient client) { api = new CoreV1Api(client); } @@ -183,6 +186,30 @@ public void storeSecretData(@NotNull String secretName, Map data } } + public @Nullable V1Secret convertCredentials(String name, Map credentials) { + if (credentials != null) { + //map to secret as envs under declared prefix + Map data = credentials + .entrySet() + .stream() + .collect( + Collectors.toMap( + e -> K8sBuilderHelper.sanitizeNames(envsPrefix).toUpperCase() + "_" + e.getKey().toUpperCase(), + Entry::getValue + ) + ); + + return new V1Secret() + .metadata(new V1ObjectMeta().name(name).namespace(namespace)) + .apiVersion("v1") + .kind("Secret") + .stringData(data); + } + + return null; + } + + @Deprecated public @Nullable V1Secret convertAuthentication(String name, AbstractAuthenticationToken auth) { if (auth instanceof JwtAuthenticationToken) { Jwt token = ((JwtAuthenticationToken) auth).getToken(); diff --git a/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/runnables/K8sRunnable.java b/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/runnables/K8sRunnable.java index 87e48f26..abb0a896 100644 --- a/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/runnables/K8sRunnable.java +++ b/modules/framework-k8s/src/main/java/it/smartcommunitylabdhub/framework/k8s/runnables/K8sRunnable.java @@ -2,8 +2,10 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; import it.smartcommunitylabdhub.commons.infrastructure.RunRunnable; import it.smartcommunitylabdhub.commons.infrastructure.SecuredRunnable; +import it.smartcommunitylabdhub.commons.jackson.JacksonMapper; import it.smartcommunitylabdhub.framework.k8s.model.ContextRef; import it.smartcommunitylabdhub.framework.k8s.model.ContextSource; import it.smartcommunitylabdhub.framework.k8s.objects.CoreAffinity; @@ -16,15 +18,17 @@ import it.smartcommunitylabdhub.framework.k8s.objects.CoreToleration; import it.smartcommunitylabdhub.framework.k8s.objects.CoreVolume; import java.io.Serializable; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Map.Entry; import java.util.Set; +import java.util.stream.Collectors; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.experimental.SuperBuilder; -import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.CredentialsContainer; @SuperBuilder @@ -82,7 +86,7 @@ public class K8sRunnable implements RunRunnable, SecuredRunnable, CredentialsCon @JsonIgnore private List metrics; - private AbstractAuthenticationToken credentials; + private HashMap credentials; @JsonProperty("context_refs") private List contextRefs; @@ -102,8 +106,32 @@ public void eraseCredentials() { @Override public void setCredentials(Serializable credentials) { - if (credentials instanceof AbstractAuthenticationToken) { - this.credentials = (AbstractAuthenticationToken) credentials; + if (credentials != null) { + //try to coerce into map + HashMap map = JacksonMapper.CUSTOM_OBJECT_MAPPER.convertValue( + credentials, + JacksonMapper.typeRef + ); + + this.credentials = + map + .entrySet() + .stream() + .filter(e -> e.getValue() != null) + .map(e -> { + if (e.getValue() instanceof String) { + return Map.entry(e.getKey(), (String) e.getValue()); + } + + try { + String value = JacksonMapper.CUSTOM_OBJECT_MAPPER.writeValueAsString(e.getValue()); + return Map.entry(e.getKey(), value); + } catch (JsonProcessingException je) { + return null; + } + }) + .filter(e -> e.getValue() != null) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue, (o1, o2) -> o1, HashMap::new)); } } } diff --git a/pom.xml b/pom.xml index 7439f784..9a627d4e 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ modules/commons + modules/authorization modules/fsm modules/framework-k8s modules/framework-kaniko