diff --git a/application/pom.xml b/application/pom.xml index 245ee9b7..6912d25c 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -12,7 +12,7 @@ org.springframework.boot spring-boot-starter-parent - 3.3.2 + 3.3.5 ../ it.smartcommunitylabdhub @@ -71,6 +71,11 @@ spring-security-oauth2-resource-server ${spring-security.version} + + org.springframework.security + spring-security-oauth2-client + ${spring-security.version} + org.springframework.security spring-security-oauth2-jose @@ -270,6 +275,16 @@ dh-authorization ${revision} + + it.smartcommunitylabdhub + credentials-provider-minio + ${revision} + + + it.smartcommunitylabdhub + credentials-provider-db + ${revision} + diff --git a/application/src/main/java/it/smartcommunitylabdhub/core/components/config/CoreConfig.java b/application/src/main/java/it/smartcommunitylabdhub/core/components/config/CoreConfig.java new file mode 100644 index 00000000..458b571c --- /dev/null +++ b/application/src/main/java/it/smartcommunitylabdhub/core/components/config/CoreConfig.java @@ -0,0 +1,52 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.core.components.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import it.smartcommunitylabdhub.commons.infrastructure.AbstractConfiguration; +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 CoreConfig extends AbstractConfiguration { + + @JsonProperty("endpoint") + private String endpoint; + + @JsonProperty("name") + private String name; + + @JsonProperty("version") + private String version; + + @JsonProperty("api_level") + private String level; + + @JsonProperty("api_version") + private String api; +} diff --git a/application/src/main/java/it/smartcommunitylabdhub/core/components/config/CoreConfigProvider.java b/application/src/main/java/it/smartcommunitylabdhub/core/components/config/CoreConfigProvider.java new file mode 100644 index 00000000..d9ee5506 --- /dev/null +++ b/application/src/main/java/it/smartcommunitylabdhub/core/components/config/CoreConfigProvider.java @@ -0,0 +1,57 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.core.components.config; + +import it.smartcommunitylabdhub.commons.config.ApplicationProperties; +import it.smartcommunitylabdhub.commons.infrastructure.ConfigurationProvider; +import it.smartcommunitylabdhub.core.components.config.CoreConfig.CoreConfigBuilder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +@Slf4j +public class CoreConfigProvider implements ConfigurationProvider { + + private CoreConfig config; + + public CoreConfigProvider(ApplicationProperties properties) { + Assert.notNull(properties, "properties can not be null"); + + log.debug("Build configuration for provider..."); + + //build config + CoreConfigBuilder builder = CoreConfig + .builder() + .endpoint(properties.getEndpoint()) + .name(properties.getName()) + .version(properties.getVersion()) + .level(properties.getLevel()) + .api(properties.getApi()); + + this.config = builder.build(); + + if (log.isTraceEnabled()) { + log.trace("config: {}", config.toJson()); + } + } + + @Override + public CoreConfig getConfig() { + return config; + } +} diff --git a/application/src/main/java/it/smartcommunitylabdhub/core/components/run/states/RunStateBuilt.java b/application/src/main/java/it/smartcommunitylabdhub/core/components/run/states/RunStateBuilt.java index 4705faa8..f72f26e1 100644 --- a/application/src/main/java/it/smartcommunitylabdhub/core/components/run/states/RunStateBuilt.java +++ b/application/src/main/java/it/smartcommunitylabdhub/core/components/run/states/RunStateBuilt.java @@ -1,22 +1,21 @@ package it.smartcommunitylabdhub.core.components.run.states; -import it.smartcommunitylabdhub.authorization.services.JwtTokenService; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.services.CredentialsService; import it.smartcommunitylabdhub.commons.accessors.spec.RunSpecAccessor; -import it.smartcommunitylabdhub.commons.config.SecurityProperties; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; import it.smartcommunitylabdhub.commons.infrastructure.RunRunnable; import it.smartcommunitylabdhub.commons.infrastructure.SecuredRunnable; import it.smartcommunitylabdhub.commons.models.enums.State; +import it.smartcommunitylabdhub.core.components.security.UserAuthenticationHelper; import it.smartcommunitylabdhub.core.fsm.RunContext; import it.smartcommunitylabdhub.core.fsm.RunEvent; import it.smartcommunitylabdhub.fsm.FsmState; import it.smartcommunitylabdhub.fsm.Transition; -import java.io.Serializable; import java.util.List; import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @Slf4j @@ -24,10 +23,7 @@ public class RunStateBuilt implements FsmState.Builder { @Autowired - JwtTokenService jwtTokenService; - - @Autowired - SecurityProperties securityProperties; + CredentialsService credentialsService; public FsmState build() { //define state @@ -49,16 +45,14 @@ public FsmState build() { Optional runnable = Optional.ofNullable(context.runtime.run(context.run)); runnable.ifPresent(r -> { //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 && securityProperties.isRequired()) { - Serializable credentials = jwtTokenService.generateCredentials(auth); - if (credentials != null) { - ((SecuredRunnable) r).setCredentials(credentials); - } - } + UserAuthentication auth = UserAuthenticationHelper.getUserAuthentication(); + if (auth != null && r instanceof SecuredRunnable) { + //get credentials from providers + List credentials = credentialsService.getCredentials( + (UserAuthentication) auth + ); + + ((SecuredRunnable) r).setCredentials(credentials); } }); diff --git a/application/src/main/java/it/smartcommunitylabdhub/core/components/security/UserAuthenticationHelper.java b/application/src/main/java/it/smartcommunitylabdhub/core/components/security/UserAuthenticationHelper.java new file mode 100644 index 00000000..c42fe058 --- /dev/null +++ b/application/src/main/java/it/smartcommunitylabdhub/core/components/security/UserAuthenticationHelper.java @@ -0,0 +1,39 @@ +package it.smartcommunitylabdhub.core.components.security; + +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public class UserAuthenticationHelper { + + public static UserAuthentication getUserAuthentication() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null) { + return null; + } + + if (auth instanceof UserAuthentication) { + return (UserAuthentication) auth; + } + + // //workaround: inflate basic auth tokens + // //TODO define authManager to produce proper authentication + // if (auth instanceof UsernamePasswordAuthenticationToken) { + // UserAuthentication user = new UserAuthentication<>( + // (UsernamePasswordAuthenticationToken) auth, + // auth.getName(), + // auth.getAuthorities() + // ); + + // //update context + // SecurityContextHolder.getContext().setAuthentication(user); + + // return user; + // } + + return null; + } + + private UserAuthenticationHelper() {} +} 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 d3c76326..36bf852d 100644 --- a/application/src/main/java/it/smartcommunitylabdhub/core/config/SecurityConfig.java +++ b/application/src/main/java/it/smartcommunitylabdhub/core/config/SecurityConfig.java @@ -1,20 +1,20 @@ 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.UserAuthenticationManager; +import it.smartcommunitylabdhub.authorization.UserAuthenticationManagerBuilder; import it.smartcommunitylabdhub.authorization.config.KeyStoreConfig; import it.smartcommunitylabdhub.authorization.services.AuthorizableAwareEntityService; +import it.smartcommunitylabdhub.authorization.services.JwtTokenService; import it.smartcommunitylabdhub.commons.config.ApplicationProperties; import it.smartcommunitylabdhub.commons.config.SecurityProperties; -import it.smartcommunitylabdhub.commons.config.SecurityProperties.JwtAuthenticationProperties; +import it.smartcommunitylabdhub.commons.config.SecurityProperties.OidcAuthenticationProperties; import it.smartcommunitylabdhub.commons.models.project.Project; import jakarta.servlet.http.HttpServletRequest; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.Set; import lombok.extern.slf4j.Slf4j; @@ -26,7 +26,8 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.data.domain.AuditorAware; import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 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; @@ -36,6 +37,12 @@ import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrations; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; @@ -45,13 +52,13 @@ 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.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandlerImpl; import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; +import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.StringUtils; @@ -91,8 +98,17 @@ public class SecurityConfig { @Autowired KeyStoreConfig keyStoreConfig; + @Autowired(required = false) + JwtTokenService jwtTokenService; + + // @Autowired + // AuthorizableAwareEntityService projectAuthHelper; + + // @Autowired + // List providers; + @Autowired - AuthorizableAwareEntityService projectAuthHelper; + UserAuthenticationManagerBuilder authenticationManagerBuilder; @Bean("apiSecurityFilterChain") public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { @@ -118,43 +134,32 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce }); //authentication (when configured) - if (properties.isRequired()) { - //always enable internal jwt auth provider - JwtAuthenticationProvider coreJwtAuthProvider = new JwtAuthenticationProvider( - coreJwtDecoder( - applicationProperties.getEndpoint(), - applicationProperties.getName(), - keyStoreConfig.getJWKSetKeyStore().getJwk() - ) - ); - coreJwtAuthProvider.setJwtAuthenticationConverter( - coreJwtAuthenticationConverter("authorities", projectAuthHelper) - ); + if (properties.isRequired() && jwtTokenService != null) { + List authProviders = new ArrayList<>(); - // Create authentication Manager - securityChain.oauth2ResourceServer(oauth2 -> - oauth2.jwt(jwt -> jwt.authenticationManager(new ProviderManager(coreJwtAuthProvider))) - ); - - if (properties.isJwtAuthEnabled()) { - JwtAuthenticationProperties jwtProps = properties.getJwt(); - // rebuild auth manager to include external jwt provider - JwtAuthenticationProvider externalJwtAuthProvider = new JwtAuthenticationProvider( - externalJwtDecoder(jwtProps.getIssuerUri(), jwtProps.getAudience()) - ); + // always enable internal jwt auth provider + JwtAuthenticationProvider coreJwtAuthProvider = new JwtAuthenticationProvider(jwtTokenService.getDecoder()); + coreJwtAuthProvider.setJwtAuthenticationConverter(jwtTokenService.getAuthenticationConverter()); + authProviders.add(coreJwtAuthProvider); - externalJwtAuthProvider.setJwtAuthenticationConverter( - externalJwtAuthenticationConverter(jwtProps.getUsername(), jwtProps.getClaim(), projectAuthHelper) - ); - - securityChain.oauth2ResourceServer(oauth2 -> - oauth2.jwt(jwt -> - jwt.authenticationManager(new ProviderManager(coreJwtAuthProvider, externalJwtAuthProvider)) - ) + //enable basic if required + if (properties.isBasicAuthEnabled()) { + DaoAuthenticationProvider daoProvider = new DaoAuthenticationProvider(); + daoProvider.setUserDetailsService( + userDetailsService(properties.getBasic().getUsername(), properties.getBasic().getPassword()) ); + daoProvider.setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); + authProviders.add(daoProvider); } + // Create authentication Manager + UserAuthenticationManager authManager = authenticationManagerBuilder.build(authProviders); + + securityChain.authenticationManager(authManager); + securityChain.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.authenticationManager(authManager))); + //enable basic if required + //NOTE: we need to it now to use our authenticationManager if (properties.isBasicAuthEnabled()) { securityChain .httpBasic(basic -> basic.authenticationEntryPoint(new Http403ForbiddenEntryPoint())) @@ -182,10 +187,11 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce return securityChain.build(); } - @Bean("authSecurityFilterChain") - public SecurityFilterChain authSecurityFilterChain(HttpSecurity http) throws Exception { + @Bean("tokenSecurityFilterChain") + public SecurityFilterChain tokenSecurityFilterChain(HttpSecurity http) throws Exception { + //token chain HttpSecurity securityChain = http - .securityMatcher(getAuthRequestMatcher()) + .securityMatcher(new AntPathRequestMatcher("/auth/token")) .authorizeHttpRequests(auth -> { auth.requestMatchers(getAuthRequestMatcher()).hasRole("USER").anyRequest().authenticated(); }) @@ -193,32 +199,69 @@ public SecurityFilterChain authSecurityFilterChain(HttpSecurity http) throws Exc .requestCache(requestCache -> requestCache.disable()) //disable csrf .csrf(csrf -> csrf.disable()) - // we don't want a session for these endpoints, each request should be evaluated + // disable session for token requests .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - // allow cors + // always allow cors securityChain.cors(cors -> { - if (StringUtils.hasText(corsOrigins)) { - cors.configurationSource(corsConfigurationSource(corsOrigins)); - } else { - cors.disable(); - } + cors.configurationSource(corsConfigurationSource("*")); }); - //authentication (when configured) - if (StringUtils.hasText(clientId) && StringUtils.hasText(clientSecret)) { - //enable basic + //enable anonymous auth, we'll double check auth in granters + securityChain.anonymous(anon -> anon.authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + //enable basic if required (client auth) + //NOTE: configure first to avoid injecting user auth manager for basic + if (StringUtils.hasText(clientId) && StringUtils.hasText(clientId)) { + //client basic auth flow securityChain .httpBasic(basic -> basic.authenticationEntryPoint(new Http403ForbiddenEntryPoint())) .userDetailsService(userDetailsService(clientId, clientSecret)); } - //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("userinfoSecurityFilterChain") + public SecurityFilterChain userinfoSecurityFilterChain(HttpSecurity http) throws Exception { + //userinfo chain + HttpSecurity securityChain = http + .securityMatcher(new AntPathRequestMatcher("/auth/userinfo")) + .authorizeHttpRequests(auth -> { + auth.requestMatchers(getAuthRequestMatcher()).hasRole("USER").anyRequest().authenticated(); + }) + // disable request cache + .requestCache(requestCache -> requestCache.disable()) + //disable csrf + .csrf(csrf -> csrf.disable()) + // disable session + .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + // always allow cors + securityChain.cors(cors -> { + cors.configurationSource(corsConfigurationSource("*")); + }); + + //disable anonymous auth + securityChain.anonymous(anon -> anon.disable()); + + //authentication (when configured) + if (properties.isOidcAuthEnabled() && jwtTokenService != null) { + // enable internal jwt auth provider + JwtAuthenticationProvider coreJwtAuthProvider = new JwtAuthenticationProvider(jwtTokenService.getDecoder()); + coreJwtAuthProvider.setJwtAuthenticationConverter(jwtTokenService.getAuthenticationConverter()); + UserAuthenticationManager authManager = authenticationManagerBuilder.build(coreJwtAuthProvider); + + securityChain.authenticationManager(authManager); + securityChain.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.authenticationManager(authManager))); + } + securityChain.exceptionHandling(handling -> { handling .authenticationEntryPoint(new Http403ForbiddenEntryPoint()) @@ -228,6 +271,110 @@ public SecurityFilterChain authSecurityFilterChain(HttpSecurity http) throws Exc return securityChain.build(); } + @Bean("authorizeSecurityFilterChain") + public SecurityFilterChain authorizeSecurityFilterChain(HttpSecurity http) throws Exception { + //token chain + HttpSecurity securityChain = http + .securityMatcher(getAuthRequestMatcher()) + .authorizeHttpRequests(auth -> { + auth.requestMatchers(getAuthRequestMatcher()).hasRole("USER").anyRequest().authenticated(); + }) + // enable request cache IN SESSION + .requestCache(requestCache -> requestCache.requestCache(new HttpSessionRequestCache())) + //disable csrf + .csrf(csrf -> csrf.disable()) + // we need session to handle auth flows + .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)); + + // allow cors + securityChain.cors(cors -> { + if (StringUtils.hasText(corsOrigins)) { + cors.configurationSource(corsConfigurationSource(corsOrigins)); + } else { + cors.disable(); + } + }); + + //disable anonymous auth, to authenticate we *need* valid credentials! + securityChain.anonymous(anon -> anon.disable()); + + //enable upstream oidc + if (properties.isOidcAuthEnabled()) { + OidcAuthenticationProperties props = properties.getOidc(); + //we support a single static client + String registrationId = "oidc"; + ClientRegistration.Builder client = ClientRegistrations + .fromIssuerLocation(props.getIssuerUri()) + .registrationId(registrationId) + .clientName(props.getClientName()) + .clientId(props.getClientId()) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/auth/code/" + registrationId) + .scope(props.getScope()) + .userNameAttributeName( + StringUtils.hasText(props.getUsernameAttributeName()) + ? props.getUsernameAttributeName() + : IdTokenClaimNames.SUB + ); + + if (StringUtils.hasText(props.getClientSecret())) { + //use secret + client + .clientSecret(clientSecret) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); + } else { + //use PKCE + client.clientAuthenticationMethod(ClientAuthenticationMethod.NONE); + } + + InMemoryClientRegistrationRepository repository = new InMemoryClientRegistrationRepository(client.build()); + + //register provider for authorize chain + securityChain.oauth2Login(oauth2 -> { + oauth2.clientRegistrationRepository(repository); + oauth2.authorizationEndpoint(endpoint -> endpoint.baseUri("/auth/authorization")); + oauth2.redirectionEndpoint(endpoint -> endpoint.baseUri("/auth/code/*")); + oauth2.userInfoEndpoint(userInfo -> + userInfo.userAuthoritiesMapper(authorities -> + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")) + ) + ); + }); + // //add entryPoint towards provider + // securityChain.exceptionHandling(handling -> { + // handling + // .authenticationEntryPoint( + // new LoginUrlAuthenticationEntryPoint("/auth/authorization/" + registrationId) + // ) + // .accessDeniedHandler(new AccessDeniedHandlerImpl()); // use 403 + // }); + } + + return securityChain.build(); + } + + @Bean("wellKnownSecurityFilterChain") + public SecurityFilterChain wellKnownSecurityFilterChain(HttpSecurity http) throws Exception { + return http + .securityMatcher(new AntPathRequestMatcher("/.well-known/**")) + .authorizeHttpRequests(auth -> { + auth.anyRequest().permitAll(); + }) + // 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)) + // enable frame options + .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())) + // always allow cors + .cors(cors -> { + cors.configurationSource(corsConfigurationSource("*")); + }) + .build(); + } + @Bean("h2SecurityFilterChain") public SecurityFilterChain h2SecurityFilterChain(HttpSecurity http) throws Exception { return http @@ -316,87 +463,88 @@ public static UserDetailsService userDetailsService(String username, String pass * Internal auth via JWT */ - public static JwtDecoder coreJwtDecoder(String issuer, String audience, JWK jwk) 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(issuer); - - OAuth2TokenValidator audienceValidator = new JwtClaimValidator>( - JwtClaimNames.AUD, - (aud -> aud != null && aud.contains(audience)) - ); - - //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; - } - - public static JwtAuthenticationConverter coreJwtAuthenticationConverter( - String claim, - AuthorizableAwareEntityService projectAuthHelper - ) { - JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); - converter.setJwtGrantedAuthoritiesConverter((Jwt source) -> { - if (source == null) return null; - - Set authorities = new HashSet<>(); - 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)); - }); - } - } - - //refresh project authorities via helper - if (projectAuthHelper != null && StringUtils.hasText(source.getSubject())) { - String username = source.getSubject(); - - //inject roles from ownership of projects - projectAuthHelper - .findIdsByCreatedBy(username) - .forEach(p -> { - //derive a scoped ADMIN role - authorities.add(new SimpleGrantedAuthority(p + ":ROLE_ADMIN")); - }); - - //inject roles from sharing of projects - projectAuthHelper - .findIdsBySharedTo(username) - .forEach(p -> { - //derive a scoped USER role - //TODO make configurable? - authorities.add(new SimpleGrantedAuthority(p + ":ROLE_USER")); - }); - } - - return authorities; - }); - return converter; - } + // public static JwtDecoder coreJwtDecoder(String issuer, String audience, JWK jwk) 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(issuer); + + // OAuth2TokenValidator audienceValidator = new JwtClaimValidator>( + // JwtClaimNames.AUD, + // (aud -> aud != null && aud.contains(audience)) + // ); + + // //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; + // } + + // public static JwtAuthenticationConverter coreJwtAuthenticationConverter( + // String claim, + // AuthorizableAwareEntityService projectAuthHelper + // ) { + // JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + // converter.setJwtGrantedAuthoritiesConverter((Jwt source) -> { + // if (source == null) return null; + + // Set authorities = new HashSet<>(); + // 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)); + // }); + // } + // } + + // //refresh project authorities via helper + // if (projectAuthHelper != null && StringUtils.hasText(source.getSubject())) { + // String username = source.getSubject(); + + // //inject roles from ownership of projects + // projectAuthHelper + // .findIdsByCreatedBy(username) + // .forEach(p -> { + // //derive a scoped ADMIN role + // authorities.add(new SimpleGrantedAuthority(p + ":ROLE_ADMIN")); + // }); + + // //inject roles from sharing of projects + // projectAuthHelper + // .findIdsBySharedTo(username) + // .forEach(p -> { + // //derive a scoped USER role + // //TODO make configurable? + // authorities.add(new SimpleGrantedAuthority(p + ":ROLE_USER")); + // }); + // } + + // return authorities; + // }); + // return converter; + // } /** * External auth via JWT + * TODO move config to service */ public static JwtDecoder externalJwtDecoder(String issuer, String audience) { NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); diff --git a/application/src/main/java/it/smartcommunitylabdhub/core/controllers/ConfigurationEndpoint.java b/application/src/main/java/it/smartcommunitylabdhub/core/controllers/ConfigurationEndpoint.java new file mode 100644 index 00000000..146c69ef --- /dev/null +++ b/application/src/main/java/it/smartcommunitylabdhub/core/controllers/ConfigurationEndpoint.java @@ -0,0 +1,56 @@ +package it.smartcommunitylabdhub.core.controllers; + +import it.smartcommunitylabdhub.commons.config.ApplicationProperties; +import it.smartcommunitylabdhub.commons.config.SecurityProperties; +import it.smartcommunitylabdhub.commons.infrastructure.ConfigurationProvider; +import java.io.Serializable; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +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 ConfigurationEndpoint { + + @Autowired + private List providers; + + @Autowired + private ApplicationProperties applicationProperties; + + @Autowired + private SecurityProperties securityProperties; + + @Value("${jwt.cache-control}") + private String cacheControl; + + //cache, we don't expect config to be mutable! + private Map config = null; + + @GetMapping(value = { "/.well-known/configuration" }) + public ResponseEntity> getConfiguration() { + if (config == null) { + config = generate(); + } + + return ResponseEntity.ok().header(HttpHeaders.CACHE_CONTROL, cacheControl).body(config); + } + + private Map generate() { + Map map = new HashMap<>(); + if (providers != null) { + providers.forEach(p -> map.putAll(p.getConfig().toMap())); + } + + //always override core props + map.put("endpoint", applicationProperties.getEndpoint()); + + return Collections.unmodifiableMap(map); + } +} diff --git a/application/src/main/resources/application.yml b/application/src/main/resources/application.yml index 9e389a94..8a2746c2 100644 --- a/application/src/main/resources/application.yml +++ b/application/src/main/resources/application.yml @@ -179,7 +179,10 @@ security: username: ${DH_AUTH_JWT_USERNAME:preferred_username} oidc: issuer-uri: ${DH_AUTH_OIDC_ISSUER_URI:} + client-name: ${DH_AUTH_OIDC_CLIENT_NAME:${application.name}} client-id: ${DH_AUTH_OIDC_CLIENT_ID:} + client-secret: ${DH_AUTH_OIDC_CLIENT_SECRET:} + username-attribute-name: ${DH_AUTH_OIDC_USERNAME:preferred_username} scope: ${DH_AUTH_OIDC_SCOPE:openid,email,profile} @@ -226,6 +229,8 @@ files: secret-key: ${AWS_SECRET_KEY:} endpoint: ${S3_ENDPOINT:} bucket: ${S3_BUCKET:} + signature-version: s3v4 + region: ${S3_REGION:us-east-1} max-column-size: ${FILES_MAX_COLUMN_SIZE:2097152} # JWT configuration @@ -239,9 +244,35 @@ jwt: duration: ${JWT_REFRESH_TOKEN_DURATION:} client-id: ${JWT_CLIENT_ID:${security.basic.username}} client-secret: ${JWT_CLIENT_SECRET:${security.basic.password}} + redirect-uris: ${JWT_REDIRECT_URIS:http://localhost:*,${application.endpoint}/console/auth-callback} cache-control: ${JWKS_CACHE_CONTROL:public, max-age=900, must-revalidate, no-transform} +# Credentials +credentials: + provider: + s3: + enable: ${S3_CREDENTIALS_PROVIDER:false} + db: + enable: ${DB_CREDENTIALS_PROVIDER:false} + database: ${DB_CREDENTIALS_DATABASE:} + endpoint: ${DB_CREDENTIALS_ENDPOINT:} + claim: ${DB_CREDENTIALS_CLAIM:db/role} + role: ${DB_CREDENTIALS_ROLE:} + duration: ${DB_CREDENTIALS_DURATION:86400} + user: ${DB_CREDENTIALS_USER:} + password: ${DB_CREDENTIALS_PASSWORD:} + minio: + enable: ${MINIO_CREDENTIALS_PROVIDER:false} + endpoint: ${MINIO_CREDENTIALS_ENDPOINT:${files.store.s3.endpoint}} + region: ${MINIO_CREDENTIALS_REGION:${files.store.s3.region}} + claim: ${MINIO_CREDENTIALS_CLAIM:minio/policy} + policy: ${MINIO_CREDENTIALS_POLICY:readwrite} + duration: ${MINIO_CREDENTIALS_DURATION:86400} + access-key: ${MINIO_CREDENTIALS_ACCESS_KEY:${files.store.s3.access-key}} + secret-key: ${MINIO_CREDENTIALS_SECRET_KEY:${files.store.s3.secret-key}} + + # Templates templates: path: ${TEMPLATES_PATH:classpath:/templates} \ No newline at end of file diff --git a/frontend/src/main/java/it/smartcommunitylabdhub/console/controllers/ConsoleController.java b/frontend/src/main/java/it/smartcommunitylabdhub/console/controllers/ConsoleController.java index 40b20df1..2650ec29 100644 --- a/frontend/src/main/java/it/smartcommunitylabdhub/console/controllers/ConsoleController.java +++ b/frontend/src/main/java/it/smartcommunitylabdhub/console/controllers/ConsoleController.java @@ -36,6 +36,9 @@ public class ConsoleController { @Value("${solr.url}") private String solrUrl; + @Value("${jwt.client-id}") + private String clientId; + public static final String CONSOLE_CONTEXT = Keys.CONSOLE_CONTEXT; @GetMapping(value = { "/", CONSOLE_CONTEXT }) @@ -79,11 +82,11 @@ public String console(Model model, HttpServletRequest request) { if (securityProperties.isOidcAuthEnabled()) { config.put("REACT_APP_AUTH_URL", "/api"); config.put("REACT_APP_LOGIN_URL", "/auth"); - config.put("REACT_APP_ISSUER_URI", securityProperties.getOidc().getIssuerUri()); - config.put("REACT_APP_CLIENT_ID", securityProperties.getOidc().getClientId()); - if (securityProperties.getOidc().getScope() != null) { - config.put("REACT_APP_SCOPE", String.join(" ", securityProperties.getOidc().getScope())); - } + config.put("REACT_APP_ISSUER_URI", applicationUrl); + config.put("REACT_APP_CLIENT_ID", clientId); + // if (securityProperties.getOidc().getScope() != null) { + // config.put("REACT_APP_SCOPE", String.join(" ", securityProperties.getOidc().getScope())); + // } } config.put("REACT_APP_ENABLE_SOLR", String.valueOf(StringUtils.hasText(solrUrl))); diff --git a/modules/authorization/pom.xml b/modules/authorization/pom.xml index 28ec0054..84316be5 100644 --- a/modules/authorization/pom.xml +++ b/modules/authorization/pom.xml @@ -30,6 +30,11 @@ spring-boot-starter-oauth2-resource-server ${spring-boot.version} + + org.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + org.springframework.boot spring-boot-starter-test diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/UserAuthenticationManager.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/UserAuthenticationManager.java new file mode 100644 index 00000000..8779fd59 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/UserAuthenticationManager.java @@ -0,0 +1,118 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization; + +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.services.AuthorizableAwareEntityService; +import it.smartcommunitylabdhub.authorization.services.CredentialsProvider; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; +import it.smartcommunitylabdhub.commons.models.project.Project; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.ProviderManager; +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; + +public class UserAuthenticationManager extends ProviderManager { + + private List providers = Collections.emptyList(); + private AuthorizableAwareEntityService projectAuthHelper; + + public UserAuthenticationManager(AuthenticationProvider... providers) { + this(Arrays.asList(providers)); + } + + public UserAuthenticationManager(List providers) { + super(providers, null); + } + + @Autowired + public void setProviders(List providers) { + if (providers != null) { + this.providers = providers; + } + } + + @Autowired + public void setProjectAuthHelper(AuthorizableAwareEntityService projectAuthHelper) { + this.projectAuthHelper = projectAuthHelper; + } + + @Override + public UserAuthentication authenticate(Authentication authentication) throws AuthenticationException { + //let providers resolve auth + Authentication auth = super.authenticate(authentication); + + return process(auth); + } + + public UserAuthentication process(Authentication auth) throws AuthenticationException { + if (auth != null && auth.isAuthenticated() && auth instanceof AbstractAuthenticationToken) { + Set authorities = new HashSet<>(auth.getAuthorities()); + String username = auth.getName(); + + //refresh project authorities via helper + if (projectAuthHelper != null) { + //inject roles from ownership of projects + projectAuthHelper + .findIdsByCreatedBy(username) + .forEach(p -> { + //derive a scoped ADMIN role + authorities.add(new SimpleGrantedAuthority(p + ":ROLE_ADMIN")); + }); + + //inject roles from sharing of projects + projectAuthHelper + .findIdsBySharedTo(username) + .forEach(p -> { + //derive a scoped USER role + //TODO make configurable? + authorities.add(new SimpleGrantedAuthority(p + ":ROLE_USER")); + }); + } + + //inflate credentials + List credentials = providers + .stream() + .map(p -> p.process((AbstractAuthenticationToken) auth)) + .filter(c -> c != null) + .collect(Collectors.toList()); + + //create user auth with details + UserAuthentication user = new UserAuthentication<>( + (AbstractAuthenticationToken) auth, + username, + authorities + ); + + user.setCredentials(credentials); + return user; + } + + throw new AuthenticationServiceException("invalid auth"); + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/UserAuthenticationManagerBuilder.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/UserAuthenticationManagerBuilder.java new file mode 100644 index 00000000..03528798 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/UserAuthenticationManagerBuilder.java @@ -0,0 +1,76 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization; + +import it.smartcommunitylabdhub.authorization.services.AuthorizableAwareEntityService; +import it.smartcommunitylabdhub.authorization.services.CredentialsProvider; +import it.smartcommunitylabdhub.commons.models.project.Project; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.MessageSource; +import org.springframework.security.authentication.AuthenticationEventPublisher; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class UserAuthenticationManagerBuilder { + + private List credentialsProviders = Collections.emptyList(); + private AuthorizableAwareEntityService projectAuthHelper; + private AuthenticationEventPublisher eventPublisher; + private MessageSource messageSource; + + @Autowired + public void setMessageSource(MessageSource messageSource) { + this.messageSource = messageSource; + } + + @Autowired + public void setEventPublisher(AuthenticationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + @Autowired + public void setCredentialsProviders(List providers) { + if (providers != null) { + this.credentialsProviders = providers; + } + } + + @Autowired + public void setProjectAuthHelper(AuthorizableAwareEntityService projectAuthHelper) { + this.projectAuthHelper = projectAuthHelper; + } + + public UserAuthenticationManager build(AuthenticationProvider... providers) { + return build(Arrays.asList(providers)); + } + + public UserAuthenticationManager build(List authProviders) { + UserAuthenticationManager manager = new UserAuthenticationManager(authProviders); + manager.setProviders(credentialsProviders); + manager.setProjectAuthHelper(projectAuthHelper); + + manager.setAuthenticationEventPublisher(eventPublisher); + manager.setMessageSource(messageSource); + return manager; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/config/OAuth2Config.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/config/OAuth2Config.java new file mode 100644 index 00000000..9a3b6dcc --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/config/OAuth2Config.java @@ -0,0 +1,35 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.config; + +import it.smartcommunitylabdhub.authorization.repositories.AuthorizationRequestStore; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.web.context.WebApplicationContext; + +@Configuration +public class OAuth2Config { + + @Bean + @Scope(value = WebApplicationContext.SCOPE_APPLICATION, proxyMode = ScopedProxyMode.TARGET_CLASS) + public AuthorizationRequestStore authorizationRequestRepository() { + // used an app scoped proxy for auth requests + return new AuthorizationRequestStore(); + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/AuthorizationEndpoint.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/AuthorizationEndpoint.java new file mode 100644 index 00000000..1b146ec1 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/AuthorizationEndpoint.java @@ -0,0 +1,257 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.controllers; + +import it.smartcommunitylabdhub.authorization.UserAuthenticationManager; +import it.smartcommunitylabdhub.authorization.UserAuthenticationManagerBuilder; +import it.smartcommunitylabdhub.authorization.model.AuthorizationRequest; +import it.smartcommunitylabdhub.authorization.model.AuthorizationResponse; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.providers.NoOpAuthenticationProvider; +import it.smartcommunitylabdhub.authorization.repositories.AuthorizationRequestStore; +import it.smartcommunitylabdhub.authorization.utils.SecureKeyGenerator; +import it.smartcommunitylabdhub.commons.config.ApplicationProperties; +import it.smartcommunitylabdhub.commons.config.SecurityProperties; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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.AbstractAuthenticationToken; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.CurrentSecurityContext; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +//TODO add error handler +@RestController +@Slf4j +public class AuthorizationEndpoint implements InitializingBean { + + public static final String AUTHORIZE_URL = "/auth/authorize"; + private static final int CODE_LENGTH = 12; + private static final int MIN_STATE_LENGTH = 5; + private static final int AUTH_REQUEST_DURATION = 300; + + @Value("${jwt.client-id}") + private String jwtClientId; + + @Value("${jwt.redirect-uris}") + private List jwtRedirectUris; + + @Autowired + private SecurityProperties securityProperties; + + @Autowired + private ApplicationProperties applicationProperties; + + @Autowired + private AuthorizationRequestStore requestStore; + + @Autowired + private UserAuthenticationManagerBuilder authenticationManagerBuilder; + + private UserAuthenticationManager authenticationManager; + + //keygen + private StringKeyGenerator keyGenerator = new SecureKeyGenerator(CODE_LENGTH); + private String issuer; + + private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + @Override + public void afterPropertiesSet() throws Exception { + Assert.notNull(keyGenerator, "code generator can not be null"); + Assert.notNull(applicationProperties, AUTHORIZE_URL); + Assert.notNull(authenticationManagerBuilder, "auth manager builder is required"); + if (securityProperties.isOidcAuthEnabled()) { + Assert.notNull(requestStore, "request store can not be null"); + } + + this.issuer = applicationProperties.getEndpoint(); + this.authenticationManager = this.authenticationManagerBuilder.build(new NoOpAuthenticationProvider()); + } + + @RequestMapping(value = "auth/test", method = { RequestMethod.POST, RequestMethod.GET }) + public AbstractAuthenticationToken debug(@RequestParam Map parameters, Authentication auth) { + if (auth instanceof AbstractAuthenticationToken) { + return (AbstractAuthenticationToken) auth; + } + + return null; + } + + @RequestMapping(value = AUTHORIZE_URL, method = { RequestMethod.POST, RequestMethod.GET }) + public void authorize( + @RequestParam Map parameters, + @CurrentSecurityContext SecurityContext securityContext, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse + ) throws IOException { + if (!securityProperties.isOidcAuthEnabled()) { + throw new UnsupportedOperationException(); + } + + Authentication authentication = securityContext.getAuthentication(); + + //resolve user authentication + if ( + authentication == null || + !(authentication.isAuthenticated()) || + !(authentication instanceof AbstractAuthenticationToken) + ) { + throw new InsufficientAuthenticationException("Invalid or missing authentication"); + } + + UserAuthentication user = authenticationManager.authenticate(authentication); + + //sanity check + String responseType = parameters.get(OAuth2ParameterNames.RESPONSE_TYPE); + if (!"code".equals(responseType)) { + throw new IllegalArgumentException("invalid response type"); + } + + log.debug("authorize request for {}", authentication.getName()); + + //read parameters + String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID); + if (!jwtClientId.equals(clientId)) { + throw new IllegalArgumentException("invalid client"); + } + + String state = parameters.get(OAuth2ParameterNames.STATE); + if (!StringUtils.hasText(state) || state.length() < MIN_STATE_LENGTH) { + throw new IllegalArgumentException("invalid state"); + } + + String redirectUri = parameters.get(OAuth2ParameterNames.REDIRECT_URI); + if (!StringUtils.hasText(redirectUri)) { + throw new IllegalArgumentException("missing redirect_uri"); + } + + //redirect must match allowed + boolean matches = matches(redirectUri); + if (!matches) { + throw new IllegalArgumentException("invalid redirect_uri"); + } + + //pkce + String codeChallenge = parameters.get(PkceParameterNames.CODE_CHALLENGE); + String codeChallengeMethod = parameters.get(PkceParameterNames.CODE_CHALLENGE_METHOD); + + if (codeChallengeMethod != null && !"S256".equals(codeChallengeMethod)) { + throw new IllegalArgumentException("invalid code challenge method"); + } + + //generate code and store request + String code = keyGenerator.generateKey(); + Instant now = Instant.now(); + + AuthorizationRequest request = AuthorizationRequest + .builder() + .clientId(clientId) + .redirectUri(redirectUri) + .code(code) + .state(state) + .codeChallenge(codeChallenge) + .codeChallengeMethod(codeChallengeMethod) + .username(user.getUsername()) + .issuedTime(Date.from(now)) + .expirationTime(Date.from(now.plusSeconds(AUTH_REQUEST_DURATION))) + .build(); + + if (log.isTraceEnabled()) { + log.trace("request: {}", request); + } + + String key = requestStore.store(request, user); + + log.debug("stored auth request for {} with key {}", authentication.getName(), key); + + //build response + AuthorizationResponse response = AuthorizationResponse + .builder() + .code(code) + .state(state) + .issuer(issuer) + .redirectUrl(redirectUri) + .build(); + if (log.isTraceEnabled()) { + log.trace("response: {}", response); + } + + //redirect + redirectStrategy.sendRedirect(httpRequest, httpResponse, response.buildRedirectUri()); + } + + private boolean matches(String redirectUri) { + //simple matcher to authorize requests + if (jwtRedirectUris == null || jwtRedirectUris.isEmpty() || redirectUri == null) { + return false; + } + + //valid uri + try { + URI uri = new URI(redirectUri); + + //exact match + Optional exact = jwtRedirectUris + .stream() + .filter(u -> redirectUri.toLowerCase().equals(u.toLowerCase())) + .findFirst(); + if (exact.isPresent()) { + return true; + } + + //localhost relaxed match: any port/path is valid + String localhost = "http://localhost:*"; + Optional localhostMatch = jwtRedirectUris + .stream() + .filter(u -> u.toLowerCase().equals(localhost)) + .findFirst(); + + if (localhostMatch.isPresent() && uri.getHost().equals("localhost")) { + return true; + } + + return false; + } catch (URISyntaxException e) { + //invalid uri + return false; + } + } +} 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 deleted file mode 100644 index ad957c45..00000000 --- a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/ConfigurationEndpoint.java +++ /dev/null @@ -1,70 +0,0 @@ -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 index dcf14211..12c03d5c 100644 --- a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/JWKSEndpoint.java +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/JWKSEndpoint.java @@ -14,7 +14,7 @@ @RestController public class JWKSEndpoint { - public static final String JWKS_URL = "/.well-known/jwks.json"; + public static final String JWKS_URL = "/auth/jwks.json"; @Autowired private JWKSetKeyStore jwkSetKeyStore; diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/OAuth2ConfigurationEndpoint.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/OAuth2ConfigurationEndpoint.java new file mode 100644 index 00000000..df7feba0 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/OAuth2ConfigurationEndpoint.java @@ -0,0 +1,106 @@ +package it.smartcommunitylabdhub.authorization.controllers; + +import it.smartcommunitylabdhub.authorization.model.OpenIdConfig; +import it.smartcommunitylabdhub.authorization.model.OpenIdConfig.OpenIdConfigBuilder; +import it.smartcommunitylabdhub.commons.config.ApplicationProperties; +import it.smartcommunitylabdhub.commons.config.SecurityProperties; +import it.smartcommunitylabdhub.commons.infrastructure.ConfigurationProvider; +import java.io.Serializable; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +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.lang.Nullable; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class OAuth2ConfigurationEndpoint implements ConfigurationProvider { + + @Autowired + private ApplicationProperties applicationProperties; + + @Autowired + private SecurityProperties securityProperties; + + @Value("${jwt.cache-control}") + private String cacheControl; + + private OpenIdConfig 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.toMap()); + } + + private OpenIdConfig generate() { + /* + * OpenID Provider Metadata + * https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + */ + + String baseUrl = applicationProperties.getEndpoint(); + OpenIdConfigBuilder builder = OpenIdConfig.builder(); + + builder.issuer(baseUrl); + builder.jwksUri(baseUrl + JWKSEndpoint.JWKS_URL); + builder.responseTypesSupported(Set.of("code")); + + List grantTypes = Stream + .of( + AuthorizationGrantType.CLIENT_CREDENTIALS, + AuthorizationGrantType.REFRESH_TOKEN, + AuthorizationGrantType.TOKEN_EXCHANGE + ) + .map(t -> t.getValue()) + .toList(); + + if (securityProperties.isOidcAuthEnabled()) { + grantTypes = + Stream + .of( + AuthorizationGrantType.CLIENT_CREDENTIALS, + AuthorizationGrantType.REFRESH_TOKEN, + AuthorizationGrantType.AUTHORIZATION_CODE, + AuthorizationGrantType.TOKEN_EXCHANGE + ) + .map(t -> t.getValue()) + .toList(); + + builder.authorizationEndpoint(baseUrl + AuthorizationEndpoint.AUTHORIZE_URL); + builder.userinfoEndpoint(baseUrl + UserInfoEndpoint.USERINFO_URL); + } + + builder.grantTypesSupported(new HashSet<>(grantTypes)); + + builder.tokenEndpoint(baseUrl + TokenEndpoint.TOKEN_URL); + Set authMethods = Set.of("client_secret_basic", "client_secret_post", "none"); + builder.tokenEndpointAuthMethodsSupported(authMethods); + + return builder.build(); + } + + @Override + @Nullable + public OpenIdConfig getConfig() { + if (config == null) { + config = generate(); + } + + return config; + } +} 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 index 1c427ff8..5aa89f00 100644 --- a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/TokenEndpoint.java +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/TokenEndpoint.java @@ -1,54 +1,22 @@ 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.exceptions.JwtTokenServiceException; +import it.smartcommunitylabdhub.authorization.grants.TokenGranter; import it.smartcommunitylabdhub.authorization.model.TokenResponse; -import it.smartcommunitylabdhub.authorization.services.AuthorizableAwareEntityService; -import it.smartcommunitylabdhub.authorization.services.JwtTokenService; -import it.smartcommunitylabdhub.commons.config.ApplicationProperties; import it.smartcommunitylabdhub.commons.config.SecurityProperties; -import it.smartcommunitylabdhub.commons.config.SecurityProperties.JwtAuthenticationProperties; -import it.smartcommunitylabdhub.commons.models.project.Project; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; 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.core.convert.converter.Converter; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.AbstractAuthenticationToken; 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.annotation.CurrentSecurityContext; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContext; -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.JwtAuthenticationToken; -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -69,51 +37,54 @@ public class TokenEndpoint implements InitializingBean { @Value("${jwt.client-secret}") private String clientSecret; - @Autowired - private JwtTokenService jwtTokenService; + // @Autowired + // private JwtTokenService jwtTokenService; - @Autowired - private JWKSetKeyStore jwkSetKeyStore; + // @Autowired + // private JWKSetKeyStore jwkSetKeyStore; - @Autowired - private ApplicationProperties applicationProperties; + // @Autowired + // private ApplicationProperties applicationProperties; @Autowired private SecurityProperties securityProperties; - @Autowired - AuthorizableAwareEntityService projectAuthHelper; + // @Autowired + // AuthorizableAwareEntityService projectAuthHelper; //TODO move to dedicated filter initalized via securityConfig! - private JwtAuthenticationProvider accessTokenAuthProvider; - private JwtAuthenticationProvider refreshTokenAuthProvider; - private JwtAuthenticationProvider externalTokenAuthProvider; + // private JwtAuthenticationProvider accessTokenAuthProvider; + // private JwtAuthenticationProvider refreshTokenAuthProvider; + // private JwtAuthenticationProvider externalTokenAuthProvider; + + @Autowired + private List granters; @Override public void afterPropertiesSet() throws Exception { - if (securityProperties.isRequired()) { - Assert.notNull(jwkSetKeyStore, "jwks store is required"); - Assert.notNull(jwkSetKeyStore.getJwk(), "jwk is required"); - - //build auth provider to validate tokens - JwtAuthenticationConverter jwtConverter = coreJwtAuthenticationConverter("authorities", projectAuthHelper); - accessTokenAuthProvider = new JwtAuthenticationProvider(coreJwtDecoder(jwkSetKeyStore.getJwk(), false)); - accessTokenAuthProvider.setJwtAuthenticationConverter(jwtConverter); - refreshTokenAuthProvider = new JwtAuthenticationProvider(coreJwtDecoder(jwkSetKeyStore.getJwk(), true)); - refreshTokenAuthProvider.setJwtAuthenticationConverter(jwtConverter); - - if (securityProperties.isJwtAuthEnabled()) { - JwtAuthenticationProperties jwtProps = securityProperties.getJwt(); - externalTokenAuthProvider = - new JwtAuthenticationProvider(externalJwtDecoder(jwtProps.getIssuerUri(), jwtProps.getAudience())); - - externalTokenAuthProvider.setJwtAuthenticationConverter( - externalJwtAuthenticationConverter(jwtProps.getUsername(), jwtProps.getClaim(), projectAuthHelper) - ); - } - } + // if (securityProperties.isRequired()) { + // Assert.notNull(jwkSetKeyStore, "jwks store is required"); + // Assert.notNull(jwkSetKeyStore.getJwk(), "jwk is required"); + // //build auth provider to validate tokens + // JwtAuthenticationConverter jwtConverter = coreJwtAuthenticationConverter("authorities", projectAuthHelper); + // accessTokenAuthProvider = new JwtAuthenticationProvider(coreJwtDecoder(jwkSetKeyStore.getJwk(), false)); + // accessTokenAuthProvider.setJwtAuthenticationConverter(jwtConverter); + // // refreshTokenAuthProvider = new JwtAuthenticationProvider(coreJwtDecoder(jwkSetKeyStore.getJwk(), true)); + // // refreshTokenAuthProvider.setJwtAuthenticationConverter(jwtConverter); + + // if (securityProperties.isJwtAuthEnabled()) { + // JwtAuthenticationProperties jwtProps = securityProperties.getJwt(); + // externalTokenAuthProvider = + // new JwtAuthenticationProvider(externalJwtDecoder(jwtProps.getIssuerUri(), jwtProps.getAudience())); + + // externalTokenAuthProvider.setJwtAuthenticationConverter( + // externalJwtAuthenticationConverter(jwtProps.getUsername(), jwtProps.getClaim(), projectAuthHelper) + // ); + // } + // } } + //TODO add errorHandling @RequestMapping(value = TOKEN_URL, method = { RequestMethod.POST, RequestMethod.GET }) public TokenResponse token( @RequestParam Map parameters, @@ -137,14 +108,25 @@ public TokenResponse token( 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); + //pick first matching granter + TokenGranter granter = granters + .stream() + .filter(g -> g.type().getValue().equals(grantType)) + .findFirst() + .orElse(null); + + if (granter != null) { + return granter.grant(parameters, authentication); } + // 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"); } @@ -152,291 +134,290 @@ public TokenResponse token( public ResponseEntity handleServiceException(JwtTokenServiceException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage()); } - - private TokenResponse refreshToken(Map parameters, Authentication authentication) { - if (refreshTokenAuthProvider == null) { - throw new UnsupportedOperationException(); - } - - //refresh token is usable without credentials - 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"); - } - - // Consume refresh token - jwtTokenService.consume(auth, 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"); - } - - String cid = parameters.get("client_id"); - if (cid != null && !clientId.equals(cid)) { - throw new IllegalArgumentException("invalid or missing client_id"); - } - - //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 external provider - 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 - * TODO! use SecurityConfig instead (move tokenEndpoint to app!) - * copied from SecurityConfig - */ - private static JwtDecoder externalJwtDecoder(String issuer, String audience) { - NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); - - OAuth2TokenValidator audienceValidator = new JwtClaimValidator>( - JwtClaimNames.AUD, - (aud -> aud != null && aud.contains(audience)) - ); - - OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer(issuer); - OAuth2TokenValidator withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator); - jwtDecoder.setJwtValidator(withAudience); - - return jwtDecoder; - } - - private static Converter externalJwtAuthenticationConverter( - String usernameClaimName, - String rolesClaimName, - AuthorizableAwareEntityService projectAuthHelper - ) { - return (Jwt jwt) -> { - Set authorities = new HashSet<>(); - authorities.add(new SimpleGrantedAuthority("ROLE_USER")); - - //read roles from token - if (StringUtils.hasText(rolesClaimName) && jwt.hasClaim(rolesClaimName)) { - List roles = jwt.getClaimAsStringList(rolesClaimName); - 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")); - } - }); - } - } - - //principalName - String username = jwt.getClaimAsString(usernameClaimName); - - //fallback to SUB if missing - if (!StringUtils.hasText(username)) { - username = jwt.getSubject(); - } - - if (projectAuthHelper != null) { - //inject roles from ownership of projects - //derive a scoped ADMIN role - projectAuthHelper - .findIdsByCreatedBy(username) - .forEach(p -> authorities.add(new SimpleGrantedAuthority(p + ":ROLE_ADMIN"))); - - //inject roles from sharing of projects - //derive a scoped USER role - //TODO make configurable? - projectAuthHelper - .findIdsBySharedTo(username) - .forEach(p -> authorities.add(new SimpleGrantedAuthority(p + ":ROLE_USER"))); - } - - return new JwtAuthenticationToken(jwt, authorities, username); - }; - } - - private static JwtAuthenticationConverter coreJwtAuthenticationConverter( - String claim, - AuthorizableAwareEntityService projectAuthHelper - ) { - JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); - converter.setJwtGrantedAuthoritiesConverter((Jwt source) -> { - if (source == null) return null; - - Set authorities = new HashSet<>(); - 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)); - }); - } - } - - //refresh project authorities via helper - if (projectAuthHelper != null && StringUtils.hasText(source.getSubject())) { - String username = source.getSubject(); - - //inject roles from ownership of projects - projectAuthHelper - .findIdsByCreatedBy(username) - .forEach(p -> { - //derive a scoped ADMIN role - authorities.add(new SimpleGrantedAuthority(p + ":ROLE_ADMIN")); - }); - - //inject roles from sharing of projects - projectAuthHelper - .findIdsBySharedTo(username) - .forEach(p -> { - //derive a scoped USER role - //TODO make configurable? - authorities.add(new SimpleGrantedAuthority(p + ":ROLE_USER")); - }); - } - - return authorities; - }); - return converter; - } + // private TokenResponse refreshToken(Map parameters, Authentication authentication) { + // if (refreshTokenAuthProvider == null) { + // throw new UnsupportedOperationException(); + // } + + // //refresh token is usable without credentials + // 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"); + // } + + // // Consume refresh token + // jwtTokenService.consume(auth, 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"); + // } + + // String cid = parameters.get("client_id"); + // if (cid != null && !clientId.equals(cid)) { + // throw new IllegalArgumentException("invalid or missing client_id"); + // } + + // //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 external provider + // 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 + // * TODO! use SecurityConfig instead (move tokenEndpoint to app!) + // * copied from SecurityConfig + // */ + // private static JwtDecoder externalJwtDecoder(String issuer, String audience) { + // NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); + + // OAuth2TokenValidator audienceValidator = new JwtClaimValidator>( + // JwtClaimNames.AUD, + // (aud -> aud != null && aud.contains(audience)) + // ); + + // OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer(issuer); + // OAuth2TokenValidator withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator); + // jwtDecoder.setJwtValidator(withAudience); + + // return jwtDecoder; + // } + + // private static Converter externalJwtAuthenticationConverter( + // String usernameClaimName, + // String rolesClaimName, + // AuthorizableAwareEntityService projectAuthHelper + // ) { + // return (Jwt jwt) -> { + // Set authorities = new HashSet<>(); + // authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + + // //read roles from token + // if (StringUtils.hasText(rolesClaimName) && jwt.hasClaim(rolesClaimName)) { + // List roles = jwt.getClaimAsStringList(rolesClaimName); + // 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")); + // } + // }); + // } + // } + + // //principalName + // String username = jwt.getClaimAsString(usernameClaimName); + + // //fallback to SUB if missing + // if (!StringUtils.hasText(username)) { + // username = jwt.getSubject(); + // } + + // if (projectAuthHelper != null) { + // //inject roles from ownership of projects + // //derive a scoped ADMIN role + // projectAuthHelper + // .findIdsByCreatedBy(username) + // .forEach(p -> authorities.add(new SimpleGrantedAuthority(p + ":ROLE_ADMIN"))); + + // //inject roles from sharing of projects + // //derive a scoped USER role + // //TODO make configurable? + // projectAuthHelper + // .findIdsBySharedTo(username) + // .forEach(p -> authorities.add(new SimpleGrantedAuthority(p + ":ROLE_USER"))); + // } + + // return new JwtAuthenticationToken(jwt, authorities, username); + // }; + // } + + // private static JwtAuthenticationConverter coreJwtAuthenticationConverter( + // String claim, + // AuthorizableAwareEntityService projectAuthHelper + // ) { + // JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + // converter.setJwtGrantedAuthoritiesConverter((Jwt source) -> { + // if (source == null) return null; + + // Set authorities = new HashSet<>(); + // 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)); + // }); + // } + // } + + // //refresh project authorities via helper + // if (projectAuthHelper != null && StringUtils.hasText(source.getSubject())) { + // String username = source.getSubject(); + + // //inject roles from ownership of projects + // projectAuthHelper + // .findIdsByCreatedBy(username) + // .forEach(p -> { + // //derive a scoped ADMIN role + // authorities.add(new SimpleGrantedAuthority(p + ":ROLE_ADMIN")); + // }); + + // //inject roles from sharing of projects + // projectAuthHelper + // .findIdsBySharedTo(username) + // .forEach(p -> { + // //derive a scoped USER role + // //TODO make configurable? + // authorities.add(new SimpleGrantedAuthority(p + ":ROLE_USER")); + // }); + // } + + // return authorities; + // }); + // return converter; + // } } diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/UserInfoEndpoint.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/UserInfoEndpoint.java new file mode 100644 index 00000000..51361d8f --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/controllers/UserInfoEndpoint.java @@ -0,0 +1,86 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.controllers; + +import com.nimbusds.jwt.SignedJWT; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.services.JwtTokenService; +import it.smartcommunitylabdhub.commons.config.SecurityProperties; +import java.text.ParseException; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.CurrentSecurityContext; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Slf4j +public class UserInfoEndpoint { + + public static final String USERINFO_URL = "/auth/userinfo"; + + @Autowired + private SecurityProperties securityProperties; + + @Autowired(required = false) + private JwtTokenService jwtTokenService; + + @RequestMapping(value = USERINFO_URL, method = { RequestMethod.POST, RequestMethod.GET }) + public Map userinfo( + @RequestParam Map parameters, + @CurrentSecurityContext SecurityContext securityContext + ) { + if (!securityProperties.isOidcAuthEnabled() || jwtTokenService == null) { + throw new UnsupportedOperationException(); + } + + Authentication authentication = securityContext.getAuthentication(); + + //resolve user authentication + if ( + authentication == null || + !(authentication.isAuthenticated()) || + !(authentication instanceof UserAuthentication) + ) { + throw new InsufficientAuthenticationException("Invalid or missing authentication"); + } + try { + UserAuthentication user = (UserAuthentication) authentication; + log.debug("read userinfo for {}", user.getUsername()); + + //fetch token + SignedJWT token = jwtTokenService.generateAccessToken(user); + Map claims; + + claims = token.getJWTClaimsSet().getClaims(); + + if (log.isTraceEnabled()) { + log.trace("userinfo: {}", claims); + } + + return claims; + } catch (ParseException e) { + throw new IllegalArgumentException(); + } + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/AuthorizationCodeGranter.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/AuthorizationCodeGranter.java new file mode 100644 index 00000000..96c74f1b --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/AuthorizationCodeGranter.java @@ -0,0 +1,229 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.grants; + +import it.smartcommunitylabdhub.authorization.model.AuthorizationRequest; +import it.smartcommunitylabdhub.authorization.model.TokenRequest; +import it.smartcommunitylabdhub.authorization.model.TokenResponse; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.repositories.AuthorizationRequestStore; +import it.smartcommunitylabdhub.authorization.services.JwtTokenService; +import it.smartcommunitylabdhub.authorization.services.TokenService; +import jakarta.validation.constraints.NotNull; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +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.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +@Component +@Slf4j +public class AuthorizationCodeGranter implements TokenGranter { + + private String clientId; + private String clientSecret; + + private AuthorizationRequestStore requestStore; + + private final TokenService tokenService; + + public AuthorizationCodeGranter(TokenService tokenService, JwtTokenService jwtTokenService) { + Assert.notNull(tokenService, "token service is required"); + Assert.notNull(jwtTokenService, "token service is required"); + this.tokenService = tokenService; + } + + @Autowired + public void setClientId(@Value("${jwt.client-id}") String clientId) { + this.clientId = clientId; + } + + @Autowired + public void setClientSecret(@Value("${jwt.client-secret}") String clientSecret) { + this.clientSecret = clientSecret; + } + + @Autowired + public void setRequestStore(AuthorizationRequestStore requestStore) { + this.requestStore = requestStore; + } + + @Override + public TokenResponse grant(@NotNull Map parameters, Authentication authentication) { + //auth token requires client authentication either basic or pkce + if ( + authentication == null || + (!(authentication instanceof UsernamePasswordAuthenticationToken) && + !(authentication instanceof AnonymousAuthenticationToken)) + ) { + throw new InsufficientAuthenticationException("Invalid or missing authentication"); + } + + String cId = null; + if (authentication instanceof UsernamePasswordAuthenticationToken) { + UsernamePasswordAuthenticationToken auth = (UsernamePasswordAuthenticationToken) authentication; + //validate only name + cId = auth.getName(); + } else if (authentication instanceof AnonymousAuthenticationToken) { + //validate client and secret + cId = parameters.get(OAuth2ParameterNames.CLIENT_ID); + + //require PKCE or client secret, we validate later + String pkce = parameters.get(PkceParameterNames.CODE_VERIFIER); + String cSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET); + + if (clientSecret != null && StringUtils.hasText(cSecret) && !clientSecret.equals(cSecret)) { + throw new InsufficientAuthenticationException("Invalid client authentication"); + } + + if (!StringUtils.hasText(cSecret) && !StringUtils.hasText(pkce)) { + throw new InsufficientAuthenticationException("Invalid client authentication"); + } + } + + if (clientId != null && !clientId.equals(cId)) { + throw new InsufficientAuthenticationException("Invalid client authentication"); + } + + //sanity check + String grantType = parameters.get(OAuth2ParameterNames.GRANT_TYPE); + if (!type().getValue().equals(grantType)) { + throw new IllegalArgumentException("invalid grant type"); + } + + String code = parameters.get(OAuth2ParameterNames.CODE); + if (!StringUtils.hasText(code)) { + throw new IllegalArgumentException("Invalid or missing code"); + } + + //secret auth + String cid = parameters.get(OAuth2ParameterNames.CLIENT_ID); + if (cid == null || !clientId.equals(cid)) { + throw new IllegalArgumentException("invalid or missing client_id"); + } + + String cSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET); + if (cSecret != null && !clientSecret.equals(cSecret)) { + throw new InsufficientAuthenticationException("Invalid client authentication"); + } + + String redirectUri = parameters.get(OAuth2ParameterNames.REDIRECT_URI); + if (redirectUri == null) { + throw new IllegalArgumentException("invalid or missing redirect_uri"); + } + + String codeVerifier = parameters.get(PkceParameterNames.CODE_VERIFIER); + + TokenRequest tokenRequest = TokenRequest + .builder() + .clientId(cid) + .code(code) + .redirectUri(redirectUri) + .codeVerifier(codeVerifier) + .build(); + + if (log.isTraceEnabled()) { + log.trace("token request: {}", tokenRequest); + } + + //recover request from key + AuthorizationRequest request = requestStore.find(tokenRequest); + if (request == null) { + throw new IllegalArgumentException("invalid request"); + } + + if (!request.getClientId().equals(cid) || !request.getRedirectUri().equals(redirectUri)) { + //mismatch + throw new IllegalArgumentException("invalid request"); + } + + //check code + if (!code.equals(request.getCode())) { + throw new IllegalArgumentException("invalid request"); + } + + //check PKCE + String codeChallenge = request.getCodeChallenge(); + String codeChallengeMethod = request.getCodeChallengeMethod(); + + if (codeChallengeMethod != null && !"S256".equals(codeChallengeMethod)) { + throw new IllegalArgumentException("invalid code challenge method"); + } + + if (StringUtils.hasText(codeVerifier) && !StringUtils.hasText(codeChallenge)) { + throw new IllegalArgumentException("invalid code challenge"); + } + + if ( + StringUtils.hasText(codeVerifier) && + StringUtils.hasText(codeChallenge) && + !createS256Hash(codeVerifier).equals(codeChallenge) + ) { + //invalid verifier + throw new IllegalArgumentException("invalid code verifier"); + } + + //valid request, consume + UserAuthentication user = requestStore.consume(tokenRequest); + if (user == null) { + //concurrently consumed? + throw new IllegalArgumentException("invalid request"); + } + + log.debug("auth code token request for {}", cid); + + try { + if (!user.isAuthenticated()) { + throw new IllegalArgumentException("invalid auth"); + } + + //generate full credentials + new refresh + return tokenService.generateToken(user, true); + } catch (AuthenticationException ae) { + throw new IllegalArgumentException("invalid request"); + } + } + + @Override + public AuthorizationGrantType type() { + return AuthorizationGrantType.AUTHORIZATION_CODE; + } + + private static String createS256Hash(String value) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException e) { + return "______ERROR"; + } + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/ClientCredentialsGranter.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/ClientCredentialsGranter.java new file mode 100644 index 00000000..f3e464f0 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/ClientCredentialsGranter.java @@ -0,0 +1,132 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.grants; + +import it.smartcommunitylabdhub.authorization.model.TokenResponse; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.services.TokenService; +import jakarta.validation.constraints.NotNull; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +@Component +@Slf4j +public class ClientCredentialsGranter implements TokenGranter { + + private String clientId; + private String clientSecret; + + private final TokenService tokenService; + + public ClientCredentialsGranter(TokenService jwtTokenService) { + Assert.notNull(jwtTokenService, "token service is required"); + this.tokenService = jwtTokenService; + } + + @Autowired + public void setClientId(@Value("${jwt.client-id}") String clientId) { + this.clientId = clientId; + } + + @Autowired + public void setClientSecret(@Value("${jwt.client-secret}") String clientSecret) { + this.clientSecret = clientSecret; + } + + @Override + public TokenResponse grant(@NotNull Map parameters, Authentication authentication) { + //client credentials *requires* basic auth or form auth + if ( + authentication == null || + (!(authentication instanceof UsernamePasswordAuthenticationToken) && + !(authentication instanceof AnonymousAuthenticationToken)) + ) { + throw new InsufficientAuthenticationException("Invalid or missing authentication"); + } + + //for client credentials to mimic admin user client *must* match authenticated user + String cId = null; + if (authentication instanceof UsernamePasswordAuthenticationToken) { + UsernamePasswordAuthenticationToken auth = (UsernamePasswordAuthenticationToken) authentication; + //validate only name + cId = auth.getName(); + } else if (authentication instanceof AnonymousAuthenticationToken) { + //validate client and secret + cId = parameters.get(OAuth2ParameterNames.CLIENT_ID); + String cSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET); + + if (clientSecret == null || !clientSecret.equals(cSecret)) { + throw new InsufficientAuthenticationException("Invalid client authentication"); + } + } + + if (clientId != null && !clientId.equals(cId)) { + throw new InsufficientAuthenticationException("Invalid client authentication"); + } + + //sanity check + String grantType = parameters.get(OAuth2ParameterNames.GRANT_TYPE); + if (!type().getValue().equals(grantType)) { + throw new IllegalArgumentException("invalid grant type"); + } + + log.debug("client token request for {}", authentication.getName()); + + //generate as admin user == client + UserAuthentication user = new UserAuthentication<>( + new UsernamePasswordAuthenticationToken(clientId, null, authentication.getAuthorities()), + clientId, + Collections.singleton(new SimpleGrantedAuthority("ROLE_ADMIN")) + ); + + //full credentials without refresh + //TODO skip generation, for now we exclude from response + TokenResponse token = tokenService.generateToken(user, true); + Optional + .ofNullable(token.getCredentials()) + .ifPresent(cred -> { + token.setCredentials( + cred + .entrySet() + .stream() + .filter(e -> !"refresh_token".equals(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)) + ); + }); + + return token; + } + + @Override + public AuthorizationGrantType type() { + return AuthorizationGrantType.CLIENT_CREDENTIALS; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/RefreshTokenGranter.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/RefreshTokenGranter.java new file mode 100644 index 00000000..7aa8d612 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/RefreshTokenGranter.java @@ -0,0 +1,111 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.grants; + +import it.smartcommunitylabdhub.authorization.model.TokenResponse; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.services.JwtTokenService; +import it.smartcommunitylabdhub.authorization.services.TokenService; +import jakarta.validation.constraints.NotNull; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +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.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +@Component +@Slf4j +public class RefreshTokenGranter implements TokenGranter { + + private String clientId; + + private final TokenService tokenService; + private final JwtTokenService jwtTokenService; + + public RefreshTokenGranter(TokenService tokenService, JwtTokenService jwtTokenService) { + Assert.notNull(tokenService, "token service is required"); + Assert.notNull(jwtTokenService, "token service is required"); + this.tokenService = tokenService; + this.jwtTokenService = jwtTokenService; + } + + @Autowired + public void setClientId(@Value("${jwt.client-id}") String clientId) { + this.clientId = clientId; + } + + @Override + public TokenResponse grant(@NotNull Map parameters, Authentication authentication) { + //refresh token is usable without credentials + //if provided client *must* match authenticated user + if (authentication != null && authentication instanceof UsernamePasswordAuthenticationToken) { + UsernamePasswordAuthenticationToken auth = (UsernamePasswordAuthenticationToken) authentication; + if (clientId != null && !clientId.equals(auth.getName())) { + throw new InsufficientAuthenticationException("Invalid client authentication"); + } + } + + //sanity check + String grantType = parameters.get(OAuth2ParameterNames.GRANT_TYPE); + if (!type().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); + } + + try { + // Consume refresh token + UserAuthentication user = jwtTokenService.consume(token); + if (user == null) { + throw new IllegalArgumentException("invalid token"); + } + if (!user.isAuthenticated()) { + throw new IllegalArgumentException("invalid token"); + } + + //generate full credentials + new refresh + return tokenService.generateToken(user, true); + } catch (AuthenticationException ae) { + throw new IllegalArgumentException("invalid or missing refresh_token"); + } + } + + @Override + public AuthorizationGrantType type() { + return AuthorizationGrantType.REFRESH_TOKEN; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/TokenExchangeGranter.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/TokenExchangeGranter.java new file mode 100644 index 00000000..7b9b6410 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/TokenExchangeGranter.java @@ -0,0 +1,229 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.grants; + +import it.smartcommunitylabdhub.authorization.UserAuthenticationManager; +import it.smartcommunitylabdhub.authorization.UserAuthenticationManagerBuilder; +import it.smartcommunitylabdhub.authorization.model.TokenResponse; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.services.TokenService; +import it.smartcommunitylabdhub.commons.config.SecurityProperties; +import it.smartcommunitylabdhub.commons.config.SecurityProperties.JwtAuthenticationProperties; +import jakarta.validation.constraints.NotNull; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +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.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.JwtAuthenticationProvider; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +@Component +@Slf4j +public class TokenExchangeGranter implements TokenGranter, InitializingBean { + + public static final String ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; + + private String clientId; + private SecurityProperties securityProperties; + private JwtAuthenticationProvider jwtAuthProvider; + private UserAuthenticationManager authenticationManager; + private UserAuthenticationManagerBuilder authenticationManagerBuilder; + + private final TokenService tokenService; + + public TokenExchangeGranter(TokenService jwtTokenService) { + Assert.notNull(jwtTokenService, "token service is required"); + this.tokenService = jwtTokenService; + } + + @Autowired + public void setSecurityProperties(SecurityProperties securityProperties) { + this.securityProperties = securityProperties; + } + + @Autowired + public void setClientId(@Value("${jwt.client-id}") String clientId) { + this.clientId = clientId; + } + + @Autowired + public void setAuthenticationManagerBuilder(UserAuthenticationManagerBuilder authenticationManagerBuilder) { + this.authenticationManagerBuilder = authenticationManagerBuilder; + } + + @Override + public void afterPropertiesSet() throws Exception { + Assert.notNull(securityProperties, "security properties are required"); + Assert.notNull(authenticationManagerBuilder, "auth manager builder is required"); + + //build provider when supported + if (securityProperties.isJwtAuthEnabled()) { + JwtAuthenticationProperties props = securityProperties.getJwt(); + + JwtDecoder decoder = jwtDecoder(props.getIssuerUri(), props.getAudience()); + JwtAuthenticationProvider provider = new JwtAuthenticationProvider(decoder); + provider.setJwtAuthenticationConverter((Jwt jwt) -> { + Set authorities = new HashSet<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + + //read roles from token + if (StringUtils.hasText(props.getClaim()) && jwt.hasClaim(props.getClaim())) { + List roles = jwt.getClaimAsStringList(props.getClaim()); + 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")); + } + }); + } + } + + //principalName + String username = jwt.getClaimAsString(props.getUsername()); + + //fallback to SUB if missing + if (!StringUtils.hasText(username)) { + username = jwt.getSubject(); + } + + return new JwtAuthenticationToken(jwt, authorities, username); + }); + + this.jwtAuthProvider = provider; + + //build manager + this.authenticationManager = authenticationManagerBuilder.build(jwtAuthProvider); + } + } + + private static JwtDecoder jwtDecoder(String issuer, String audience) { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); + + OAuth2TokenValidator audienceValidator = new JwtClaimValidator>( + JwtClaimNames.AUD, + (aud -> aud != null && aud.contains(audience)) + ); + + OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer(issuer); + OAuth2TokenValidator withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator); + jwtDecoder.setJwtValidator(withAudience); + + return jwtDecoder; + } + + @Override + public TokenResponse grant(@NotNull Map parameters, Authentication authentication) { + if (jwtAuthProvider == null) { + throw new UnsupportedOperationException(); + } + + //token exchange *requires* basic auth + if (authentication == null || !(authentication instanceof UsernamePasswordAuthenticationToken)) { + throw new InsufficientAuthenticationException("Invalid or missing authentication"); + } + + //client *must* match authenticated user + UsernamePasswordAuthenticationToken clientAuth = (UsernamePasswordAuthenticationToken) authentication; + if (clientId != null && !clientId.equals(clientAuth.getName())) { + throw new InsufficientAuthenticationException("Invalid client authentication"); + } + + //sanity check + String grantType = parameters.get(OAuth2ParameterNames.GRANT_TYPE); + if (!type().getValue().equals(grantType)) { + throw new IllegalArgumentException("invalid grant type"); + } + + String cid = parameters.get("client_id"); + if (cid != null && !clientId.equals(cid)) { + throw new IllegalArgumentException("invalid or missing client_id"); + } + + //validate token + String token = parameters.get("subject_token"); + if (token == null) { + throw new IllegalArgumentException("invalid or missing subject_token"); + } + + String tokenType = parameters.getOrDefault("subject_token_type", ACCESS_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); + } + + try { + BearerTokenAuthenticationToken request = new BearerTokenAuthenticationToken(token); + + Authentication auth = authenticationManager.authenticate(request); + if (!auth.isAuthenticated()) { + throw new IllegalArgumentException("invalid or missing subject_token"); + } + + log.debug( + "exchange token request from {} resolved for {} via external provider", + clientAuth.getName(), + auth.getName() + ); + + //token is valid, use as context for generation + //we expect a UserAuth + UserAuthentication user = (UserAuthentication) auth; + + //full credentials + refresh + return tokenService.generateToken(user, true); + } catch (AuthenticationException ae1) { + throw new IllegalArgumentException("invalid or missing subject_token"); + } + } + + @Override + public AuthorizationGrantType type() { + return AuthorizationGrantType.TOKEN_EXCHANGE; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/TokenGranter.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/TokenGranter.java new file mode 100644 index 00000000..2bcd8837 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/grants/TokenGranter.java @@ -0,0 +1,31 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.grants; + +import it.smartcommunitylabdhub.authorization.model.TokenResponse; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import java.util.Map; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; + +public interface TokenGranter { + AuthorizationGrantType type(); + + @Nullable + TokenResponse grant(@NotNull Map parameters, @Nullable Authentication authentication); +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/AbstractCredentials.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/AbstractCredentials.java new file mode 100644 index 00000000..f287f28e --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/AbstractCredentials.java @@ -0,0 +1,63 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; +import it.smartcommunitylabdhub.commons.jackson.JacksonMapper; +import java.util.HashMap; +import java.util.Map; +import lombok.NoArgsConstructor; +import org.springframework.security.core.CredentialsContainer; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@NoArgsConstructor +public abstract class AbstractCredentials implements Credentials, CredentialsContainer { + + @JsonIgnore + protected static final ObjectMapper mapper = JacksonMapper.CUSTOM_OBJECT_MAPPER; + + @JsonIgnore + protected static final TypeReference> typeRef = new TypeReference< + HashMap + >() {}; + + @Override + public void eraseCredentials() { + //nothing to do by default + } + + @Override + public Map toMap() { + return mapper.convertValue(this, typeRef); + } + + @Override + public String toJson() { + try { + return mapper.writeValueAsString(toMap()); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("error with serialization :" + e.getMessage()); + } + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/AuthorizationRequest.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/AuthorizationRequest.java new file mode 100644 index 00000000..f9363c0d --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/AuthorizationRequest.java @@ -0,0 +1,59 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Date; +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 AuthorizationRequest { + + @JsonProperty("client_id") + private String clientId; + + @JsonProperty("redirect_uri") + private String redirectUri; + + @JsonProperty("code_challenge") + private String codeChallenge; + + @JsonProperty("code_challenge_method") + private String codeChallengeMethod; + + @JsonProperty("state") + private String state; + + @JsonProperty("code") + private String code; + + private String username; + private Date issuedTime; + private Date expirationTime; +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/AuthorizationResponse.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/AuthorizationResponse.java new file mode 100644 index 00000000..5f23e16b --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/AuthorizationResponse.java @@ -0,0 +1,50 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.springframework.web.util.UriComponentsBuilder; + +@ToString +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AuthorizationResponse { + + private String issuer; + private String code; + private String state; + private String redirectUrl; + + public String buildRedirectUri() { + if (redirectUrl == null) { + return null; + } + + return UriComponentsBuilder + .fromUriString(redirectUrl) + .queryParam("code", code) + .queryParam("state", state) + .queryParam("issuer", issuer) + .toUriString(); + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/OpenIdConfig.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/OpenIdConfig.java new file mode 100644 index 00000000..a8a17f05 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/OpenIdConfig.java @@ -0,0 +1,62 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import it.smartcommunitylabdhub.commons.infrastructure.AbstractConfiguration; +import java.util.Set; +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 OpenIdConfig extends AbstractConfiguration { + + @JsonProperty("issuer") + private String issuer; + + @JsonProperty("jwks_uri") + private String jwksUri; + + @JsonProperty("authorization_endpoint") + private String authorizationEndpoint; + + @JsonProperty("userinfo_endpoint") + private String userinfoEndpoint; + + @JsonProperty("token_endpoint") + private String tokenEndpoint; + + @JsonProperty("token_endpoint_auth_methods_supported") + private Set tokenEndpointAuthMethodsSupported; + + @JsonProperty("response_types_supported") + private Set responseTypesSupported; + + @JsonProperty("grant_types_supported") + private Set grantTypesSupported; +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/RefreshTokenEntity.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/RefreshTokenEntity.java index b20cfc7f..a36424b6 100644 --- a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/RefreshTokenEntity.java +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/RefreshTokenEntity.java @@ -1,6 +1,5 @@ package it.smartcommunitylabdhub.authorization.model; -import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; @@ -27,9 +26,9 @@ public class RefreshTokenEntity { @Id private String id; - @JdbcTypeCode(Types.LONGVARCHAR) - @Column(columnDefinition = "text") - private String token; + @JdbcTypeCode(Types.LONGVARBINARY) + @ToString.Exclude + protected byte[] authentication; private String subject; private Date issuedTime; diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/TokenRequest.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/TokenRequest.java new file mode 100644 index 00000000..79765406 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/TokenRequest.java @@ -0,0 +1,48 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +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 TokenRequest { + + @JsonProperty("client_id") + private String clientId; + + @JsonProperty("redirect_uri") + private String redirectUri; + + @JsonProperty("code_verifier") + private String codeVerifier; + + @JsonProperty("code") + private String code; +} 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 index 3fb77f49..a6b71ae7 100644 --- a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/TokenResponse.java +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/TokenResponse.java @@ -1,52 +1,41 @@ package it.smartcommunitylabdhub.authorization.model; -import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.nimbusds.jwt.SignedJWT; -import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; 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 { +public class TokenResponse { - @JsonProperty("access_token") - private SignedJWT accessToken; + @JsonIgnore + private Map credentials; - @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; + @JsonAnyGetter + public Map getCredentials() { + return credentials; + } - @JsonGetter - public String getAccessToken() { - return accessToken != null ? accessToken.serialize() : null; + public void setCredentials(Map credentials) { + this.credentials = credentials; } - @JsonGetter - public String getRefreshToken() { - return refreshToken != null ? refreshToken.serialize() : null; + @JsonAnySetter + public void setCredentials(String key, String value) { + if (this.credentials == null) { + this.credentials = new HashMap<>(); + } + + this.credentials.put(key, value); } } diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/UserAuthentication.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/UserAuthentication.java new file mode 100644 index 00000000..882d27a3 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/model/UserAuthentication.java @@ -0,0 +1,86 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.model; + +import it.smartcommunitylabdhub.commons.Keys; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; +import jakarta.validation.constraints.NotNull; +import java.util.Collection; +import java.util.List; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.Assert; + +public class UserAuthentication extends AbstractAuthenticationToken { + + private static final long serialVersionUID = Keys.SERIAL_VERSION_UID; + + private final T token; + private final String username; + + private List credentials; + + public UserAuthentication(@NotNull T token) { + super(token.getAuthorities()); + Assert.notNull(token, "token cannot be null"); + this.token = token; + this.username = token.getName(); + if (token.isAuthenticated()) { + setAuthenticated(true); + } + } + + public UserAuthentication(T token, Collection authorities) { + super(authorities); + Assert.notNull(token, "token cannot be null"); + this.token = token; + this.username = token.getName(); + + setAuthenticated(true); + } + + public UserAuthentication(T token, String username, Collection authorities) { + super(authorities); + Assert.notNull(token, "token cannot be null"); + this.token = token; + this.username = username; + + setAuthenticated(true); + } + + @Override + public List getCredentials() { + return credentials; + } + + @Override + public Object getPrincipal() { + return token; + } + + public String getUsername() { + return username; + } + + public T getToken() { + return token; + } + + public void setCredentials(List credentials) { + this.credentials = credentials; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/CoreCredentials.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/CoreCredentials.java new file mode 100644 index 00000000..635f1ff7 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/CoreCredentials.java @@ -0,0 +1,81 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.providers; + +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.nimbusds.jwt.SignedJWT; +import it.smartcommunitylabdhub.authorization.model.AbstractCredentials; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.springframework.util.StringUtils; + +@Getter +@Setter +@ToString +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CoreCredentials extends AbstractCredentials { + + @JsonProperty("token_type") + @Builder.Default + private String tokenType = "Bearer"; + + @JsonProperty("access_token") + private SignedJWT accessToken; + + @JsonProperty("id_token") + private SignedJWT idToken; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("expires_in") + private Integer expiration; + + @JsonProperty("client_id") + private String clientId; + + @JsonProperty("issuer") + private String issuer; + + @JsonProperty("projects") + private Set projects; + + @JsonGetter("projects") + private String getProjectsAsString() { + return projects == null ? null : StringUtils.collectionToCommaDelimitedString(projects); + } + + @JsonGetter("access_token") + public String getAccessToken() { + return accessToken != null ? accessToken.serialize() : null; + } + + @JsonGetter("id_token") + public String getIdToken() { + return idToken != null ? idToken.serialize() : null; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/CoreCredentialsConfig.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/CoreCredentialsConfig.java new file mode 100644 index 00000000..a93fd7fd --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/CoreCredentialsConfig.java @@ -0,0 +1,45 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.providers; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import it.smartcommunitylabdhub.commons.infrastructure.AbstractConfiguration; +import java.util.Set; +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 CoreCredentialsConfig extends AbstractConfiguration { + + @JsonProperty("authentication_methods") + private Set authenticationMethods; + + //basic auth + @JsonProperty("realm") + private String realm; +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/CoreCredentialsProvider.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/CoreCredentialsProvider.java new file mode 100644 index 00000000..fd4f7ecd --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/CoreCredentialsProvider.java @@ -0,0 +1,167 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.providers; + +import com.nimbusds.jwt.SignedJWT; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.providers.CoreCredentials.CoreCredentialsBuilder; +import it.smartcommunitylabdhub.authorization.providers.CoreCredentialsConfig.CoreCredentialsConfigBuilder; +import it.smartcommunitylabdhub.authorization.services.AuthorizableAwareEntityService; +import it.smartcommunitylabdhub.authorization.services.CredentialsProvider; +import it.smartcommunitylabdhub.authorization.services.JwtTokenService; +import it.smartcommunitylabdhub.commons.config.ApplicationProperties; +import it.smartcommunitylabdhub.commons.config.SecurityProperties; +import it.smartcommunitylabdhub.commons.infrastructure.ConfigurationProvider; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; +import it.smartcommunitylabdhub.commons.models.project.Project; +import jakarta.validation.constraints.NotNull; +import java.util.HashSet; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +@Slf4j +public class CoreCredentialsProvider implements ConfigurationProvider, CredentialsProvider, InitializingBean { + + private JwtTokenService jwtTokenService; + private CoreCredentialsConfig config; + + AuthorizableAwareEntityService projectAuthHelper; + + public CoreCredentialsProvider( + JwtTokenService jwtTokenService, + ApplicationProperties properties, + SecurityProperties security + ) { + Assert.notNull(jwtTokenService, "token service is required"); + Assert.notNull(properties, "properties can not be null"); + Assert.notNull(security, "properties can not be null"); + + log.debug("Build configuration for provider..."); + if (security.isRequired()) { + this.jwtTokenService = jwtTokenService; + + String baseUrl = properties.getEndpoint(); + + //build config + CoreCredentialsConfigBuilder builder = CoreCredentialsConfig.builder(); + Set authMethods = new HashSet<>(); + + //basic + if (security.isBasicAuthEnabled()) { + authMethods.add("basic"); + builder.realm(baseUrl); + } + + //oauth2 + if (security.isJwtAuthEnabled()) { + authMethods.add("jwt"); + } + + if (security.isOidcAuthEnabled()) { + authMethods.add("oidc"); + } + + builder.authenticationMethods(authMethods); + + this.config = builder.build(); + + if (log.isTraceEnabled()) { + log.trace("config: {}", config.toJson()); + } + } + } + + @Autowired(required = false) + public void setProjectAuthHelper(AuthorizableAwareEntityService projectAuthHelper) { + this.projectAuthHelper = projectAuthHelper; + } + + @Override + public void afterPropertiesSet() throws Exception { + Assert.notNull(config, "config not initialized"); + } + + @Override + public CoreCredentials get(@NotNull UserAuthentication auth) { + if (jwtTokenService == null) { + return null; + } + + log.debug("generate credentials for user authentication {} via jwtToken service", auth.getName()); + //TODO evaluate caching responses + //NOTE: refresh token is bound to access and single use, we could cache only non-refreshable cred! + SignedJWT accessToken = jwtTokenService.generateAccessToken(auth); + String refreshToken = jwtTokenService.generateRefreshToken(auth, accessToken); + + Integer exp = jwtTokenService.getAccessTokenDuration(); + //response + CoreCredentialsBuilder response = CoreCredentials + .builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .idToken(accessToken) + .expiration(exp) + .clientId(jwtTokenService.getClientId()) + .issuer(jwtTokenService.getIssuer()); + + return response.build(); + } + + @Override + public CoreCredentialsConfig getConfig() { + return config; + } + + @Override + public Credentials process(@NotNull T token) { + //inflate token by adding project authorizations + String username = token.getName(); + + if (projectAuthHelper == null) { + return null; + } + + Set projects = new HashSet<>(); + + //inject roles from ownership of projects + //derive a scoped ADMIN role + projectAuthHelper + .findIdsByCreatedBy(username) + .forEach(p -> { + projects.add(p); + }); + + //inject roles from sharing of projects + //derive a scoped USER role + //TODO make configurable? + projectAuthHelper + .findIdsBySharedTo(username) + .forEach(p -> { + projects.add(p); + }); + + CoreCredentials cred = CoreCredentials.builder().projects(projects).build(); + + return cred; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/NoOpAuthenticationProvider.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/NoOpAuthenticationProvider.java new file mode 100644 index 00000000..d3c42705 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/providers/NoOpAuthenticationProvider.java @@ -0,0 +1,40 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.providers; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +public class NoOpAuthenticationProvider implements AuthenticationProvider { + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (authentication == null) { + throw new InsufficientAuthenticationException("missing authentication"); + } + + //no-op + return authentication; + } + + @Override + public boolean supports(Class authentication) { + return true; + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/repositories/AuthorizationRequestStore.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/repositories/AuthorizationRequestStore.java new file mode 100644 index 00000000..1bb8a354 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/repositories/AuthorizationRequestStore.java @@ -0,0 +1,94 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.repositories; + +import it.smartcommunitylabdhub.authorization.model.AuthorizationRequest; +import it.smartcommunitylabdhub.authorization.model.TokenRequest; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import java.io.Serializable; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.data.util.Pair; +import org.springframework.util.Assert; + +public class AuthorizationRequestStore implements Serializable { + + private final Map>> requests; + + public AuthorizationRequestStore() { + this.requests = new ConcurrentHashMap<>(); + } + + public AuthorizationRequest find(TokenRequest tokenRequest) { + Assert.notNull(tokenRequest, "tokenRequest can not be null or empty"); + return find(extractKey(tokenRequest)); + } + + public AuthorizationRequest find(String key) { + Assert.hasText(key, "key can not be null or empty"); + Pair> entry = requests.get(key); + + if (entry == null) { + return null; + } + + return entry.getFirst(); + } + + public Collection findAll() { + return Collections.unmodifiableCollection(requests.values().stream().map(p -> p.getFirst()).toList()); + } + + public String store(AuthorizationRequest request, UserAuthentication auth) { + String key = extractKey(request); + requests.put(key, Pair.of(request, auth)); + + return key; + } + + public UserAuthentication consume(TokenRequest tokenRequest) { + Assert.notNull(tokenRequest, "tokenRequest can not be null or empty"); + return consume(extractKey(tokenRequest)); + } + + public UserAuthentication consume(String key) { + Assert.hasText(key, "key can not be null or empty"); + Pair> entry = requests.remove(key); + + if (entry == null) { + throw new IllegalArgumentException(); + } + + return entry.getSecond(); + } + + public void remove(String key) { + requests.remove(key); + } + + private String extractKey(AuthorizationRequest request) { + //we use clientId+redirect as key because we receive those from token request as well + return request.getClientId() + "|" + request.getRedirectUri(); + } + + private String extractKey(TokenRequest request) { + //we use clientId+redirect as key because we receive those from token request as well + return request.getClientId() + "|" + request.getRedirectUri(); + } +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/CredentialsProvider.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/CredentialsProvider.java new file mode 100644 index 00000000..f231f61f --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/CredentialsProvider.java @@ -0,0 +1,39 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.services; + +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import org.springframework.security.authentication.AbstractAuthenticationToken; + +public interface CredentialsProvider { + /* + * Process the given authentication token to extract credentials + * that will be added to UserAuth by manager + */ + + @Nullable + Credentials process(@NotNull T token); + + /* + * Generate or get a set of credentials for the given authenticated user + */ + @Nullable + Credentials get(@NotNull UserAuthentication auth); +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/CredentialsService.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/CredentialsService.java new file mode 100644 index 00000000..ec72b3ca --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/CredentialsService.java @@ -0,0 +1,26 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.services; + +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public interface CredentialsService { + List getCredentials(@NotNull UserAuthentication auth); +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/CredentialsServiceImpl.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/CredentialsServiceImpl.java new file mode 100644 index 00000000..3c858944 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/CredentialsServiceImpl.java @@ -0,0 +1,126 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.services; + +import it.smartcommunitylabdhub.authorization.model.TokenResponse; +import it.smartcommunitylabdhub.authorization.model.TokenResponse.TokenResponseBuilder; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.providers.CoreCredentialsProvider; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; +import jakarta.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class CredentialsServiceImpl implements CredentialsService, TokenService { + + private final List providers; + + public CredentialsServiceImpl(Collection providers) { + log.debug("Initialize service with providers"); + + if (providers != null) { + if (log.isTraceEnabled()) { + log.trace("providers: {}", providers); + } + + this.providers = Collections.unmodifiableList(new ArrayList<>(providers)); + } else { + this.providers = Collections.emptyList(); + } + } + + public List getCredentials(@NotNull UserAuthentication auth) { + log.debug("get credentials from providers for user {}", auth.getName()); + List credentials = providers.stream().map(p -> p.get(auth)).toList(); + + if (log.isTraceEnabled()) { + log.trace("credentials: {}", credentials); + } + + return credentials; + } + + //DISABLED: do not store credentials in user auth because we may serialize it + // public List getCredentials(@NotNull UserAuthentication auth, boolean refresh) { + // if (refresh || auth.getCredentials() == null || auth.getCredentials().isEmpty()) { + // log.debug("get credentials from providers for user {}", auth.getName()); + // List credentials = providers.stream().map(p -> p.get(auth)).toList(); + + // //cache + // auth.setCredentials(credentials); + + // return credentials; + // } + + // //use cached values + // //note: we don't evaluate duration because core is stateless, so max duration of cache is call duration + // log.debug("use cached credentials for user {}", auth.getName()); + + // return auth.getCredentials(); + // } + + public TokenResponse generateToken(@NotNull UserAuthentication authentication, boolean withCredentials) { + if (withCredentials) { + //refresh credentials before token generation + List credentials = providers + .stream() + .map(p -> p.process(authentication)) + .filter(c -> c != null) + .collect(Collectors.toList()); + authentication.setCredentials(credentials); + } + + //response + TokenResponseBuilder response = TokenResponse.builder(); + + if (withCredentials) { + //derive full credentials as map + response.credentials( + providers + .stream() + .map(p -> p.get(authentication)) + .filter(c -> c != null) + .flatMap(c -> c.toMap().entrySet().stream()) + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())) + ); + } else { + //keep only core response, skip rest + response.credentials( + providers + .stream() + .filter(p -> p instanceof CoreCredentialsProvider) + .map(p -> p.get(authentication)) + .filter(c -> c != null) + .flatMap(c -> c.toMap().entrySet().stream()) + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())) + ); + } + + return response.build(); + } + + public TokenResponse generateToken(@NotNull UserAuthentication authentication) { + return generateToken(authentication, true); + } +} 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 index 5c652970..a2fb3eb9 100644 --- a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/JwtTokenService.java +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/JwtTokenService.java @@ -20,52 +20,69 @@ import it.smartcommunitylabdhub.authorization.components.JWKSetKeyStore; import it.smartcommunitylabdhub.authorization.exceptions.JwtTokenServiceException; import it.smartcommunitylabdhub.authorization.model.RefreshTokenEntity; -import it.smartcommunitylabdhub.authorization.model.TokenResponse; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; import it.smartcommunitylabdhub.authorization.repositories.RefreshTokenRepository; -import it.smartcommunitylabdhub.authorization.utils.JWKUtils; +import it.smartcommunitylabdhub.authorization.utils.SecureKeyGenerator; import it.smartcommunitylabdhub.commons.config.ApplicationProperties; -import it.smartcommunitylabdhub.commons.config.SecurityProperties; import jakarta.transaction.Transactional; +import jakarta.validation.constraints.NotNull; +import java.io.IOException; import java.sql.SQLTimeoutException; -import java.text.ParseException; import java.time.Instant; import java.util.Date; +import java.util.HashSet; import java.util.List; import java.util.Optional; -import java.util.UUID; +import java.util.Set; 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.core.serializer.support.SerializationDelegate; import org.springframework.dao.PessimisticLockingFailureException; -import org.springframework.security.core.Authentication; +import org.springframework.security.core.CredentialsContainer; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.crypto.keygen.StringKeyGenerator; +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.core.oidc.StandardClaimNames; +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.JwtAuthenticationConverter; import org.springframework.stereotype.Service; +import org.springframework.util.Assert; import org.springframework.util.StringUtils; @Service @Slf4j +//TODO extract an interface 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 + private static final int DEFAULT_KEY_LENGTH = 54; + + private static final String CLAIM_AUTHORITIES = "authorities"; @Autowired + //TODO move to JDBC! private RefreshTokenRepository refreshTokenRepository; @Autowired private JWKSetKeyStore keyStore; - @Autowired - private ApplicationProperties applicationProperties; - - @Autowired - private SecurityProperties securityProperties; - @Value("${jwt.client-id}") private String clientId; + private String audience; + private String issuer; + private int accessTokenDuration = DEFAULT_ACCESS_TOKEN_DURATION; private int refreshTokenDuration = DEFAULT_REFRESH_TOKEN_DURATION; @@ -74,6 +91,21 @@ public class JwtTokenService implements InitializingBean { private JWSSigner signer; private JWSVerifier verifier; + private JwtDecoder decoder; + + private JwtAuthenticationConverter authenticationConverter; + + //keygen + private StringKeyGenerator keyGenerator; + + //custom serialization + SerializationDelegate serializer = new SerializationDelegate(this.getClass().getClassLoader()); + + public JwtTokenService() { + log.debug("create jwks service"); + this.keyGenerator = new SecureKeyGenerator(DEFAULT_KEY_LENGTH); + } + @Autowired public void setAccessTokenDuration(@Value("${jwt.access-token.duration}") Integer accessTokenDuration) { if (accessTokenDuration != null) { @@ -88,41 +120,28 @@ public void setRefreshTokenDuration(@Value("${jwt.refresh-token.duration}") Inte } } + @Autowired + public void setApplicationProperties(ApplicationProperties applicationProperties) { + Assert.notNull(applicationProperties, "app properties are required"); + this.issuer = applicationProperties.getEndpoint(); + this.audience = applicationProperties.getName(); + } + @Override public void afterPropertiesSet() throws Exception { - if (securityProperties.isRequired()) { + Assert.hasText(audience, "audience can not be null"); + Assert.hasText(issuer, "issuer can not be null"); + + if (keyStore != null) { //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); - } + this.verifier = buildVerifier(jwk); + this.signer = buildSigner(jwk); + this.decoder = buildJwtDecoder(jwk); + this.authenticationConverter = buildAuthoritiesConverter(); } catch (JOSEException e) { log.warn("Exception loading signer/verifier", e); } @@ -130,22 +149,47 @@ public void afterPropertiesSet() throws Exception { } } - 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(); + /* + * Export config + */ + + public int getAccessTokenDuration() { + return accessTokenDuration; + } + + public int getRefreshTokenDuration() { + return refreshTokenDuration; + } + + public String getIssuer() { + return issuer; + } + + public String getAudience() { + return audience; } - public String generateAccessTokenAsString(Authentication authentication) throws JwtTokenServiceException { + public String getClientId() { + return clientId; + } + + public JWSVerifier getVerifier() { + return verifier; + } + + public JwtDecoder getDecoder() { + return decoder; + } + + public JwtAuthenticationConverter getAuthenticationConverter() { + return authenticationConverter; + } + + /* + * Access tokens + */ + public String generateAccessTokenAsString(@NotNull UserAuthentication authentication) + throws JwtTokenServiceException { // Serialize to compact form SignedJWT jwt = generateAccessToken(authentication); String jwtToken = jwt.serialize(); @@ -157,7 +201,8 @@ public String generateAccessTokenAsString(Authentication authentication) throws return jwtToken; } - public SignedJWT generateAccessToken(Authentication authentication) throws JwtTokenServiceException { + public SignedJWT generateAccessToken(@NotNull UserAuthentication authentication) + throws JwtTokenServiceException { if (signer == null) { throw new UnsupportedOperationException("signer not available"); } @@ -170,11 +215,12 @@ public SignedJWT generateAccessToken(Authentication authentication) throws JwtTo // build access token claims JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder() .subject(authentication.getName()) - .issuer(applicationProperties.getEndpoint()) + .issuer(issuer) .issueTime(Date.from(now)) - .audience(applicationProperties.getName()) - .jwtID(UUID.randomUUID().toString()) + .audience(audience) + .jwtID(keyGenerator.generateKey()) .expirationTime(Date.from(now.plusSeconds(accessTokenDuration))); + claims.claim(StandardClaimNames.PREFERRED_USERNAME, authentication.getUsername()); //define authorities as claims List authorities = authentication @@ -183,13 +229,30 @@ public SignedJWT generateAccessToken(Authentication authentication) throws JwtTo .map(GrantedAuthority::getAuthority) .toList(); - claims.claim("authorities", authorities); + claims.claim(CLAIM_AUTHORITIES, authorities); //add client if set if (StringUtils.hasText(clientId)) { claims.claim("client_id", clientId); } + //include any credential available + //NOTE: we expect claims to NOT clash + if (authentication.getCredentials() != null) { + authentication + .getCredentials() + .stream() + .filter(c -> c != null) + .map(c -> { + if (c instanceof CredentialsContainer) { + ((CredentialsContainer) c).eraseCredentials(); + } + return c; + }) + .flatMap(c -> c.toMap().entrySet().stream()) + .forEach(c -> claims.claim(c.getKey(), c.getValue())); + } + // build and sign JWTClaimsSet claimsSet = claims.build(); JWSHeader header = new JWSHeader.Builder(jwsAlgorithm).keyID(jwk.getKeyID()).build(); @@ -203,131 +266,210 @@ public SignedJWT generateAccessToken(Authentication authentication) throws JwtTo } } - public SignedJWT generateRefreshToken(Authentication authentication, SignedJWT accessToken) + /* + * Refresh tokens + */ + public String generateRefreshToken(@NotNull UserAuthentication authentication, @NotNull SignedJWT accessToken) throws JwtTokenServiceException { - if (signer == null) { - throw new UnsupportedOperationException("signer not available"); - } - log.debug("generate refresh token for {}", authentication.getName()); if (log.isTraceEnabled()) { log.trace("access token: {}", accessToken.serialize()); } + //refresh tokens are opaque + //use UUID as secret value + String jti = keyGenerator.generateKey(); + Instant now = Instant.now(); + + // //derive a new access token with different expiration + // JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder( + // JWTClaimsSet.parse(accessToken.getPayload().toJSONObject()) + // ); + // claims.expirationTime(Date.from(now.plusSeconds(refreshTokenDuration))); + // JWSAlgorithm jwsAlgorithm = JWSAlgorithm.parse(jwk.getAlgorithm().getName()); + // JWSHeader header = new JWSHeader.Builder(jwsAlgorithm).keyID(jwk.getKeyID()).build(); + // SignedJWT jwt = new SignedJWT(header, claims.build()); + // jwt.sign(signer); + + //store auth object serialized + // byte[] auth = SerializationUtils.serialize(authentication); try { - JWSAlgorithm jwsAlgorithm = JWSAlgorithm.parse(jwk.getAlgorithm().getName()); - - Instant now = Instant.now(); - String jti = UUID.randomUUID().toString().replace("-", ""); - - // build refresh token claims - JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder() - .subject(authentication.getName()) - .issuer(applicationProperties.getEndpoint()) - .issueTime(Date.from(now)) - .audience(applicationProperties.getName()) - .jwtID(jti) - .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); - - if (log.isTraceEnabled()) { - log.trace("token: {}", jwt.serialize()); - } + byte[] auth = serializer.serializeToByteArray(authentication); log.debug("store refresh token for {} with id {}", authentication.getName(), jti); + // store Refresh Token into db RefreshTokenEntity refreshToken = RefreshTokenEntity .builder() .id(jti) .subject(authentication.getName()) - .token(jwt.serialize()) - .issuedTime(claimsSet.getIssueTime()) - .expirationTime(claimsSet.getExpirationTime()) + .authentication(auth) + .issuedTime(Date.from(now)) + .expirationTime(Date.from(now.plusSeconds(refreshTokenDuration))) .build(); - refreshTokenRepository.save(refreshToken); - return jwt; - } catch (JOSEException e) { - log.error("Error generating JWT token", e); - return null; + //save + refreshToken = refreshTokenRepository.saveAndFlush(refreshToken); + + //id is the token value + return refreshToken.getId(); + } catch (IOException e) { + throw new JwtTokenServiceException(e.getMessage()); } } @Transactional(dontRollbackOn = { PessimisticLockingFailureException.class, SQLTimeoutException.class }) - public void consume(Authentication authentication, String refreshToken) { + public @NotNull UserAuthentication consume(String refreshToken) { + // try { + if (verifier == null) { + throw new UnsupportedOperationException("verifier not available"); + } + + log.debug("consume refresh token: {}", refreshToken); + + //value is the ID for the table + String id = refreshToken; + + // Lock the token + Optional tokenEntity = refreshTokenRepository.findByIdForUpdate(id); + if (tokenEntity.isEmpty()) { + log.debug("refresh token does not exists: {} id {}", refreshToken, id); + throw new JwtTokenServiceException("Refresh token does not exist"); + } + + RefreshTokenEntity token = tokenEntity.get(); + + if (log.isTraceEnabled()) { + log.trace("token: {}", token); + } + + // // Parse the access token + // String accessToken = token.getToken(); + // if (!StringUtils.hasText(accessToken)) { + // //no access token stored along refresh, nothing to use to rebuild context + // throw new JwtTokenServiceException("Missing access token"); + // } + + // SignedJWT signedJWT = SignedJWT.parse(accessToken); + + // // Verify the token signature + // if (!signedJWT.verify(verifier)) { + // throw new JwtTokenServiceException("Invalid access token"); + // } + + // Delete the token after usage: it matches the subject and should not be reused + refreshTokenRepository.deleteById(token.getId()); + + // Check expiration + if (token.getExpirationTime().before(Date.from(Instant.now()))) { + throw new JwtTokenServiceException("Refresh token has expired"); + } try { - if (verifier == null) { - throw new UnsupportedOperationException("verifier not available"); + //deserialize authentication + byte[] bytes = token.getAuthentication(); + if (bytes == null || bytes.length == 0) { + throw new JwtTokenServiceException("Missing authentication for token"); } - log.debug("consume refresh token: {}", refreshToken); + // @SuppressWarnings("deprecation") + // UserAuthentication user = (UserAuthentication) SerializationUtils.deserialize(bytes); - //decode jwt - SignedJWT jwt = SignedJWT.parse(refreshToken); - String id = jwt.getJWTClaimsSet().getJWTID(); + UserAuthentication user = (UserAuthentication) serializer.deserializeFromByteArray(bytes); - // Lock the token - Optional tokenEntity = refreshTokenRepository.findByIdForUpdate(id); - if (tokenEntity.isEmpty()) { - log.debug("refresh token does not exists: {} id {}", refreshToken, id); - throw new JwtTokenServiceException("Refresh token does not exist"); - } + log.debug("Refresh token successfully consumed and removed from repository"); + return user; + // } catch (ParseException e) { + // throw new JwtTokenServiceException("error parsing token", e); + // } catch (JOSEException e) { + // throw new JwtTokenServiceException("Error verifying JWT token", e); + // } + + } catch (IOException e) { + throw new JwtTokenServiceException(e.getMessage()); + } + } - RefreshTokenEntity token = tokenEntity.get(); + private JWSSigner buildSigner(@NotNull JWK jwk) throws JOSEException { + if (jwk.getAlgorithm() == null) { + log.warn("Unsupported key: " + jwk); + throw new JOSEException("key algorithm invalid"); + } - if (log.isTraceEnabled()) { - log.trace("token: {}", token); + if (jwk instanceof RSAKey && jwk.isPrivate()) { + // only add the signer if there's a private key + return new RSASSASigner((RSAKey) jwk); + } else if (jwk instanceof ECKey && jwk.isPrivate()) { + // build EC signers & verifiers + return new ECDSASigner((ECKey) jwk); + } else if (jwk instanceof OctetSequenceKey) { + // build HMAC signers & verifiers + if (jwk.isPrivate()) { // technically redundant check because all HMAC keys are private + return new MACSigner((OctetSequenceKey) jwk); } + } - // Parse the refresh token - SignedJWT signedJWT = SignedJWT.parse(refreshToken); + log.warn("Unknown key type: " + jwk); + return null; + } - // Verify the token signature - if (!signedJWT.verify(verifier)) { - throw new JwtTokenServiceException("Invalid refresh token"); - } + private JWSVerifier buildVerifier(@NotNull JWK jwk) throws JOSEException { + if (jwk.getAlgorithm() == null) { + log.warn("Unsupported key: " + jwk); + throw new JOSEException("key algorithm invalid"); + } - // Validate the token subject matches the current authentication - if (!token.getSubject().equals(authentication.getName())) { - throw new JwtTokenServiceException("Token subject does not match authentication subject"); - } + if (jwk instanceof RSAKey) { + return new RSASSAVerifier((RSAKey) jwk); + } else if (jwk instanceof ECKey) { + return new ECDSAVerifier((ECKey) jwk); + } else if (jwk instanceof OctetSequenceKey) { + return new MACVerifier((OctetSequenceKey) jwk); + } + + log.warn("Unknown key type: " + jwk); + return null; + } + + private JwtDecoder buildJwtDecoder(@NotNull JWK jwk) throws JOSEException { + //we support only RSA keys + if (!(jwk instanceof RSAKey)) { + log.warn("Unsupported key type: " + jwk); + throw new IllegalArgumentException("the provided key is not suitable for token authentication"); + } - // Delete the token after usage: it matches the subject and should not be reused - refreshTokenRepository.deleteById(token.getId()); + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(jwk.toRSAKey().toRSAPublicKey()).build(); + OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer(issuer); + OAuth2TokenValidator audienceValidator = new JwtClaimValidator>( + JwtClaimNames.AUD, + (aud -> aud != null && aud.contains(audience)) + ); - // Check expiration - if (token.getExpirationTime().before(Date.from(Instant.now()))) { - throw new JwtTokenServiceException("Refresh token has expired"); + OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator); + jwtDecoder.setJwtValidator(validator); + + return jwtDecoder; + } + + private JwtAuthenticationConverter buildAuthoritiesConverter() { + JwtAuthenticationConverter authConverter = new JwtAuthenticationConverter(); + authConverter.setPrincipalClaimName(JwtClaimNames.SUB); + authConverter.setJwtGrantedAuthoritiesConverter((Jwt source) -> { + if (source == null) return null; + + Set authorities = new HashSet<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + + List roles = source.getClaimAsStringList(CLAIM_AUTHORITIES); + if (roles != null) { + roles.forEach(r -> { + //use as is + authorities.add(new SimpleGrantedAuthority(r)); + }); } - log.debug("Refresh token successfully consumed and removed from repository"); - } catch (ParseException e) { - throw new JwtTokenServiceException("error parsing token", e); - } catch (JOSEException e) { - throw new JwtTokenServiceException("Error verifying JWT token", e); - } + return authorities; + }); + + return authConverter; } } diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/TokenService.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/TokenService.java new file mode 100644 index 00000000..8f4d1155 --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/services/TokenService.java @@ -0,0 +1,27 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.services; + +import it.smartcommunitylabdhub.authorization.model.TokenResponse; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import jakarta.validation.constraints.NotNull; + +public interface TokenService { + TokenResponse generateToken(@NotNull UserAuthentication authentication); + + TokenResponse generateToken(@NotNull UserAuthentication authentication, boolean withCredentials); +} diff --git a/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/utils/SecureKeyGenerator.java b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/utils/SecureKeyGenerator.java new file mode 100644 index 00000000..f92938cc --- /dev/null +++ b/modules/authorization/src/main/java/it/smartcommunitylabdhub/authorization/utils/SecureKeyGenerator.java @@ -0,0 +1,55 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.authorization.utils; + +import java.nio.charset.Charset; +import java.util.Base64; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.keygen.StringKeyGenerator; + +public class SecureKeyGenerator implements StringKeyGenerator { + + private static final int DEFAULT_KEY_LENGTH = 20; + private static final Charset DEFAULT_ENCODE_CHARSET = Charset.forName("US-ASCII"); + + private final Charset charset; + private final BytesKeyGenerator generator; + + public SecureKeyGenerator() { + this(DEFAULT_KEY_LENGTH); + } + + public SecureKeyGenerator(int keyLength) { + this(DEFAULT_KEY_LENGTH, DEFAULT_ENCODE_CHARSET); + } + + public SecureKeyGenerator(int keyLength, Charset charset) { + this.generator = KeyGenerators.secureRandom(keyLength); + this.charset = charset; + } + + @Override + public String generateKey() { + //random bytes array... + byte[] key = generator.generateKey(); + //encoded as url-safe base64 + byte[] encoded = Base64.getUrlEncoder().withoutPadding().encode(key); + // with US-ASCII + return new String(encoded, charset); + } +} diff --git a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/Keys.java b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/Keys.java index e9da8fcd..d8219eba 100644 --- a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/Keys.java +++ b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/Keys.java @@ -2,6 +2,7 @@ public class Keys { + public static final long SERIAL_VERSION_UID = 100L; public static final String SLUG_PATTERN = "^[a-zA-Z0-9._+-]+$"; public static final String FUNCTION_PATTERN = "([^:/]+)://([^/]+)/([^:]+):(.+)"; public static final String WORKFLOW_PATTERN = "([^:/]+)://([^/]+)/([^:]+):(.+)"; diff --git a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/config/ApplicationProperties.java b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/config/ApplicationProperties.java index f1405876..a47f37a5 100644 --- a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/config/ApplicationProperties.java +++ b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/config/ApplicationProperties.java @@ -1,5 +1,8 @@ package it.smartcommunitylabdhub.commons.config; +import it.smartcommunitylabdhub.commons.Keys; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import java.util.List; import lombok.Getter; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -10,8 +13,13 @@ @Getter public class ApplicationProperties { + @NotBlank private String endpoint; + + @NotBlank + @Pattern(regexp = Keys.SLUG_PATTERN) private String name; + private String description; private String version; private String level; diff --git a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/config/SecurityProperties.java b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/config/SecurityProperties.java index 6c08a288..fcabd14a 100644 --- a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/config/SecurityProperties.java +++ b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/config/SecurityProperties.java @@ -69,8 +69,11 @@ public boolean isEnabled() { public static class OidcAuthenticationProperties { private String issuerUri; + private String clientName; private String clientId; + private String clientSecret; private List scope; + private String usernameAttributeName; public boolean isEnabled() { return StringUtils.hasText(issuerUri) && StringUtils.hasText(clientId); diff --git a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/AbstractConfiguration.java b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/AbstractConfiguration.java new file mode 100644 index 00000000..7d926afa --- /dev/null +++ b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/AbstractConfiguration.java @@ -0,0 +1,57 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.commons.infrastructure; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import it.smartcommunitylabdhub.commons.jackson.JacksonMapper; +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import lombok.NoArgsConstructor; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@NoArgsConstructor +public abstract class AbstractConfiguration implements Configuration { + + @JsonIgnore + protected static final ObjectMapper mapper = JacksonMapper.CUSTOM_OBJECT_MAPPER; + + @JsonIgnore + protected static final TypeReference> typeRef = new TypeReference< + HashMap + >() {}; + + @Override + public Map toMap() { + return mapper.convertValue(this, typeRef); + } + + @Override + public String toJson() { + try { + return mapper.writeValueAsString(toMap()); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("error with serialization :" + e.getMessage()); + } + } +} diff --git a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/Configuration.java b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/Configuration.java new file mode 100644 index 00000000..9804b13e --- /dev/null +++ b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/Configuration.java @@ -0,0 +1,26 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.commons.infrastructure; + +import java.io.Serializable; +import java.util.Map; + +public interface Configuration extends Serializable { + //TODO evaluate namespacing + String toJson(); + Map toMap(); +} diff --git a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/ConfigurationProvider.java b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/ConfigurationProvider.java new file mode 100644 index 00000000..b24aab71 --- /dev/null +++ b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/ConfigurationProvider.java @@ -0,0 +1,24 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.commons.infrastructure; + +import org.springframework.lang.Nullable; + +public interface ConfigurationProvider { + @Nullable + Configuration getConfig(); +} diff --git a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/Credentials.java b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/Credentials.java new file mode 100644 index 00000000..2decac45 --- /dev/null +++ b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/Credentials.java @@ -0,0 +1,25 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.commons.infrastructure; + +import java.io.Serializable; +import java.util.Map; + +public interface Credentials extends Serializable { + String toJson(); + Map toMap(); +} diff --git a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/SecuredRunnable.java b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/SecuredRunnable.java index c311f450..79053b40 100644 --- a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/SecuredRunnable.java +++ b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/infrastructure/SecuredRunnable.java @@ -1,8 +1,8 @@ package it.smartcommunitylabdhub.commons.infrastructure; -import java.io.Serializable; +import java.util.Collection; public interface SecuredRunnable { - Serializable getCredentials(); - void setCredentials(Serializable credentials); + Collection getCredentials(); + void setCredentials(Collection credentials); } diff --git a/modules/credentials-provider-db/pom.xml b/modules/credentials-provider-db/pom.xml new file mode 100644 index 00000000..d64401d4 --- /dev/null +++ b/modules/credentials-provider-db/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + it.smartcommunitylabdhub + digitalhub-core + ${revision} + ../../ + + it.smartcommunitylabdhub + credentials-provider-db + credentials-provider-db + credentials-provider-db + + + + + + + it.smartcommunitylabdhub + dh-commons + ${revision} + + + it.smartcommunitylabdhub + dh-authorization + ${revision} + + + + com.google.guava + guava + ${guava.version} + + + org.projectlombok + lombok + ${lombok.version} + compile + true + + + org.springframework.boot + spring-boot-starter-test + ${spring-boot.version} + test + + + org.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.slf4j + log4j-over-slf4j + ${slf4j.version} + + + + \ No newline at end of file diff --git a/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/DbCredentials.java b/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/DbCredentials.java new file mode 100644 index 00000000..bdb6ae46 --- /dev/null +++ b/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/DbCredentials.java @@ -0,0 +1,62 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.credentials.db; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import it.smartcommunitylabdhub.authorization.model.AbstractCredentials; +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 DbCredentials extends AbstractCredentials { + + @JsonProperty("db_platform") + private String platform; + + @JsonProperty("db_host") + private String host; + + @JsonProperty("db_port") + private Integer port; + + @JsonProperty("db_username") + private String username; + + @JsonProperty("db_password") + private String password; + + @JsonProperty("db_database") + private String database; + + @Override + public void eraseCredentials() { + //clear credentials + this.username = null; + this.password = null; + } +} diff --git a/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/DbCredentialsProvider.java b/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/DbCredentialsProvider.java new file mode 100644 index 00000000..b8e8b588 --- /dev/null +++ b/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/DbCredentialsProvider.java @@ -0,0 +1,248 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.credentials.db; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.services.CredentialsProvider; +import it.smartcommunitylabdhub.commons.exceptions.StoreException; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; +import jakarta.validation.constraints.NotNull; +import java.nio.charset.Charset; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.util.Pair; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@Service +@Slf4j +public class DbCredentialsProvider implements CredentialsProvider, InitializingBean { + + private static final Integer DEFAULT_DURATION = 24 * 3600; //24 hour + private static final Integer MIN_DURATION = 300; //5 min + + @Value("${credentials.provider.db.user}") + private String user; + + @Value("${credentials.provider.db.password}") + private String secret; + + @Value("${credentials.provider.db.database}") + private String database; + + @Value("${credentials.provider.db.claim}") + private String claim; + + @Value("${credentials.provider.db.role}") + private String defaultRole; + + @Value("${credentials.provider.db.duration}") + private Integer duration = DEFAULT_DURATION; + + @Value("${credentials.provider.db.endpoint}") + private String endpointUrl; + + @Value("${credentials.provider.db.enable}") + private Boolean enabled; + + private final RestTemplate restTemplate; + + // cache credentials for up to DURATION + LoadingCache, DbCredentials> cache; + + public DbCredentialsProvider() { + //TODO define a property bean + restTemplate = new RestTemplate(); + + //register message converter for form-encoded requests + List> converters = List.of( + new MappingJackson2HttpMessageConverter(), + new FormHttpMessageConverter() + ); + restTemplate.setMessageConverters(converters); + } + + @Override + public void afterPropertiesSet() throws Exception { + if (Boolean.TRUE.equals(enabled)) { + //check config + enabled = StringUtils.hasText(endpointUrl); + } + + //keep cache shorter than token duration to avoid releasing soon to be expired keys + int cacheDuration = Math.max((duration - MIN_DURATION), MIN_DURATION); + + //initialize cache + cache = + CacheBuilder + .newBuilder() + .expireAfterWrite(cacheDuration, TimeUnit.SECONDS) + .build( + new CacheLoader, DbCredentials>() { + @Override + public DbCredentials load(@Nonnull Pair key) throws Exception { + log.debug("load credentials for {} role {}", key.getFirst(), key.getSecond()); + return generate(key.getFirst(), key.getSecond()); + } + } + ); + } + + public DbCredentials generate(@NotNull String username, @NotNull String role) throws StoreException { + log.debug("generate credentials for user authentication {} via STS service", username); + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + if (StringUtils.hasText(user) && StringUtils.hasText(secret)) { + //basic auth is required + byte[] basicAuth = Base64 + .getEncoder() + .encode((user + ":" + secret).getBytes(Charset.forName("US-ASCII"))); + headers.add("Authorization", "Basic " + new String(basicAuth)); + } + + //we need to convert request to MultiValueMap otherwise restTemplate won't handle a form-urlrequest... + // TokenRequest request = TokenRequest + // .builder() + // .username(username) + // .database(database) + // .roles(Collections.singleton(role)) + // .duration(duration) + // .build(); + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("username", username); + map.add("database", database); + map.add("roles", role); + map.add("duration", String.valueOf(duration)); + + if (log.isTraceEnabled()) { + log.trace("request: {}", map); + } + + //call web sts + String url = endpointUrl + "/sts/web"; + + log.debug("call STS endpoint for exchange {}", url); + ResponseEntity response = restTemplate.postForEntity( + url, + new HttpEntity<>(map, headers), + TokenResponse.class + ); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + //error, no recovery + log.error("Error with provider, status code {}", response.getStatusCode().toString()); + return null; + } + + TokenResponse token = response.getBody(); + if (log.isTraceEnabled()) { + log.trace("response: {}", token); + } + + return DbCredentials + .builder() + .platform(token.getPlatform()) + .host(token.getHost()) + .port(token.getPort()) + .username(token.getUsername()) + .password(token.getPassword()) + .database(token.getDatabase()) + .build(); + } catch (RestClientException e) { + //error, no recovery + log.error("Error with provider {}", e); + throw new StoreException(e.getMessage()); + } + } + + @Override + public Credentials get(@NotNull UserAuthentication auth) { + if (Boolean.TRUE.equals(enabled) && cache != null) { + //we expect a role credentials in context + DbRole role = Optional + .ofNullable(auth.getCredentials()) + .map(creds -> + creds.stream().filter(DbRole.class::isInstance).map(c -> (DbRole) c).findFirst().orElse(null) + ) + .orElse(null); + + if (role != null && StringUtils.hasText(role.getRole())) { + //get from cache + String username = auth.getName(); + log.debug("get credentials for user authentication {} via STS service", username); + + try { + return cache.get(Pair.of(username, role.getRole())); + } catch (ExecutionException e) { + //error, no recovery + log.error("Error with provider {}", e); + } + } + } + + return null; + } + + @Override + public Credentials process(@NotNull T token) { + if (Boolean.TRUE.equals(enabled)) { + //extract a role from jwt tokens + if (token instanceof JwtAuthenticationToken && StringUtils.hasText(claim)) { + String role = ((JwtAuthenticationToken) token).getToken().getClaimAsString(claim); + if (StringUtils.hasText(role)) { + return DbRole.builder().claim(claim).role(role).build(); + } + } + + //fallback to default + if (StringUtils.hasText(defaultRole)) { + return DbRole.builder().claim(claim).role(defaultRole).build(); + } + } + + return null; + } +} diff --git a/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/DbRole.java b/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/DbRole.java new file mode 100644 index 00000000..3f2c1367 --- /dev/null +++ b/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/DbRole.java @@ -0,0 +1,51 @@ +// Copyright 2025 the original author or authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package it.smartcommunitylabdhub.credentials.db; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import it.smartcommunitylabdhub.authorization.model.AbstractCredentials; +import java.util.Collections; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.springframework.util.StringUtils; + +@Getter +@Setter +@ToString +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DbRole extends AbstractCredentials { + + @JsonIgnore + private String claim; + + @JsonIgnore + private String role; + + @Override + public Map toMap() { + //write claim under requested key + String k = StringUtils.hasText(claim) ? claim : "db/role"; + return Collections.singletonMap(k, role); + } +} diff --git a/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/TokenRequest.java b/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/TokenRequest.java new file mode 100644 index 00000000..28ab7614 --- /dev/null +++ b/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/TokenRequest.java @@ -0,0 +1,46 @@ +/** + * Copyright 2024 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.credentials.db; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.io.Serializable; +import java.util.Set; +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 TokenRequest implements Serializable { + + private String token; + + private String username; + private String database; + + private Integer duration; + + private Set roles; +} diff --git a/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/TokenResponse.java b/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/TokenResponse.java new file mode 100644 index 00000000..a1f334ec --- /dev/null +++ b/modules/credentials-provider-db/src/main/java/it/smartcommunitylabdhub/credentials/db/TokenResponse.java @@ -0,0 +1,63 @@ +/** + * Copyright 2024 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.credentials.db; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +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) +@JsonIgnoreProperties(ignoreUnknown = true) +public class TokenResponse implements Serializable { + + @JsonProperty("platform") + private String platform; + + @JsonProperty("host") + private String host; + + @JsonProperty("port") + private Integer port; + + @JsonProperty("database") + private String database; + + @JsonProperty("username") + private String username; + + @JsonProperty("password") + private String password; + + @JsonProperty("expires_in") + private Long expiration; + + @JsonProperty("issuer") + private String issuer; +} diff --git a/modules/credentials-provider-minio/.flattened-pom.xml b/modules/credentials-provider-minio/.flattened-pom.xml new file mode 100644 index 00000000..ffb2c13d --- /dev/null +++ b/modules/credentials-provider-minio/.flattened-pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + it.smartcommunitylabdhub + credentials-provider-minio + 0.10.0-SNAPSHOT + + + it.smartcommunitylabdhub + dh-commons + 0.10.0-SNAPSHOT + compile + + + it.smartcommunitylabdhub + dh-authorization + 0.10.0-SNAPSHOT + compile + + + io.minio + minio + 8.5.16 + compile + + + org.projectlombok + lombok + 1.18.34 + compile + true + + + org.springframework.boot + spring-boot-starter-web + 3.3.5 + compile + + + org.slf4j + slf4j-api + 2.0.9 + compile + + + org.slf4j + log4j-over-slf4j + 2.0.9 + compile + + + diff --git a/modules/credentials-provider-minio/pom.xml b/modules/credentials-provider-minio/pom.xml new file mode 100644 index 00000000..05947a44 --- /dev/null +++ b/modules/credentials-provider-minio/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + it.smartcommunitylabdhub + digitalhub-core + ${revision} + ../../ + + it.smartcommunitylabdhub + credentials-provider-minio + credentials-provider-minio + credentials-provider-minio + + + 8.5.16 + + + + + it.smartcommunitylabdhub + dh-commons + ${revision} + + + it.smartcommunitylabdhub + dh-authorization + ${revision} + + + io.minio + minio + ${minio.version} + + + + com.google.guava + guava + ${guava.version} + + + org.projectlombok + lombok + ${lombok.version} + compile + true + + + org.springframework.boot + spring-boot-starter-test + ${spring-boot.version} + test + + + org.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.slf4j + log4j-over-slf4j + ${slf4j.version} + + + + \ No newline at end of file diff --git a/modules/credentials-provider-minio/src/main/java/it/smartcommunitylabdhub/credentials/minio/MinioPolicy.java b/modules/credentials-provider-minio/src/main/java/it/smartcommunitylabdhub/credentials/minio/MinioPolicy.java new file mode 100644 index 00000000..9a0dfb0b --- /dev/null +++ b/modules/credentials-provider-minio/src/main/java/it/smartcommunitylabdhub/credentials/minio/MinioPolicy.java @@ -0,0 +1,51 @@ +// Copyright 2025 the original author or authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package it.smartcommunitylabdhub.credentials.minio; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import it.smartcommunitylabdhub.authorization.model.AbstractCredentials; +import java.util.Collections; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.springframework.util.StringUtils; + +@Getter +@Setter +@ToString +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MinioPolicy extends AbstractCredentials { + + @JsonIgnore + private String claim; + + @JsonIgnore + private String policy; + + @Override + public Map toMap() { + //write claim under requested key + String k = StringUtils.hasText(claim) ? claim : "minio/policy"; + return Collections.singletonMap(k, policy); + } +} diff --git a/modules/credentials-provider-minio/src/main/java/it/smartcommunitylabdhub/credentials/minio/MinioProvider.java b/modules/credentials-provider-minio/src/main/java/it/smartcommunitylabdhub/credentials/minio/MinioProvider.java new file mode 100644 index 00000000..b108dd3b --- /dev/null +++ b/modules/credentials-provider-minio/src/main/java/it/smartcommunitylabdhub/credentials/minio/MinioProvider.java @@ -0,0 +1,232 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.credentials.minio; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import io.minio.credentials.AssumeRoleProvider; +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.services.CredentialsProvider; +import it.smartcommunitylabdhub.commons.exceptions.StoreException; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; +import jakarta.validation.constraints.NotNull; +import java.security.NoSuchAlgorithmException; +import java.security.ProviderException; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.util.Pair; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +@Slf4j +public class MinioProvider implements CredentialsProvider, InitializingBean { + + private static final Integer DEFAULT_DURATION = 24 * 3600; //24 hour + private static final Integer MIN_DURATION = 300; //5 min + + @Value("${credentials.provider.minio.access-key}") + private String accessKey; + + @Value("${credentials.provider.minio.secret-key}") + private String secretKey; + + @Value("${credentials.provider.minio.claim}") + private String claim; + + @Value("${credentials.provider.minio.policy}") + private String defaultPolicy; + + @Value("${credentials.provider.minio.duration}") + private Integer duration = DEFAULT_DURATION; + + @Value("${credentials.provider.minio.endpoint}") + private String endpointUrl; + + @Value("${credentials.provider.minio.region}") + private String region; + + @Value("${credentials.provider.minio.enable}") + private Boolean enabled; + + // cache credentials for up to DURATION + LoadingCache, MinioSessionCredentials> cache; + + public MinioProvider() { + //TODO define a property bean + } + + @Override + public void afterPropertiesSet() throws Exception { + if (Boolean.TRUE.equals(enabled)) { + //check config + enabled = + StringUtils.hasText(accessKey) && StringUtils.hasText(secretKey) && StringUtils.hasText(endpointUrl); + } + + if (duration == null || duration < MIN_DURATION) { + duration = DEFAULT_DURATION; + } + + //keep cache shorter than token duration to avoid releasing soon to be expired keys + int cacheDuration = Math.max((duration - MIN_DURATION), MIN_DURATION); + + //initialize cache + cache = + CacheBuilder + .newBuilder() + .expireAfterWrite(cacheDuration, TimeUnit.SECONDS) + .build( + new CacheLoader, MinioSessionCredentials>() { + @Override + public MinioSessionCredentials load(@Nonnull Pair key) throws Exception { + log.debug("load credentials for {} policy {}", key.getFirst(), key.getSecond()); + return generate(key.getFirst(), key.getSecond()); + } + } + ); + } + + private MinioSessionCredentials generate(@NotNull String username, @NotNull String policy) throws StoreException { + log.debug("generate credentials for user authentication {} policy {} via STS service", username, policy); + + try { + //assume role as user + //NOTE: roleArn or policy scoping does NOT work via assumeRole, only with external OIDC provider + //credentials will receive the same set of privileges as the accessKey used to sign the request! + AssumeRoleProvider provider = new AssumeRoleProvider( + endpointUrl, + accessKey, + secretKey, + duration, + null, + region, + null, + null, + null, + null + ); + + io.minio.credentials.Credentials response = provider.fetch(); + + return MinioSessionCredentials + .builder() + .accessKey(response.accessKey()) + .secretKey(response.secretKey()) + .sessionToken(response.sessionToken()) + .endpoint(endpointUrl) + .region(region) + .signatureVersion("s3v4") + .build(); + } catch (NoSuchAlgorithmException | ProviderException e) { + //error, no recovery + log.error("Error with provider {}", e); + throw new StoreException(e.getMessage()); + } + } + + @Override + public Credentials get(@NotNull UserAuthentication auth) { + if (Boolean.TRUE.equals(enabled) && cache != null) { + //we expect a policy credentials in context + MinioPolicy policy = Optional + .ofNullable(auth.getCredentials()) + .map(creds -> + creds + .stream() + .filter(MinioPolicy.class::isInstance) + .map(c -> (MinioPolicy) c) + .findFirst() + .orElse(null) + ) + .orElse(null); + + if (policy != null && StringUtils.hasText(policy.getPolicy())) { + String username = auth.getName(); + log.debug("get credentials for user authentication {} from cache", username); + try { + return cache.get(Pair.of(username, policy.getPolicy())); + } catch (ExecutionException e) { + //error, no recovery + log.error("Error with provider {}", e); + } + // //TODO cache on username+policy for EXPIRE-skew + // log.debug("generate credentials for user authentication {} via STS service", auth.getName()); + + // try { + // AssumeRoleProvider provider = new AssumeRoleProvider( + // endpointUrl, + // accessKey, + // secretKey, + // defaultDuration, + // policy.getPolicy(), + // region, + // null, + // null, + // null, + // null + // ); + + // io.minio.credentials.Credentials response = provider.fetch(); + + // return MinioSessionCredentials + // .builder() + // .accessKey(response.accessKey()) + // .secretKey(response.secretKey()) + // .sessionToken(response.sessionToken()) + // .endpoint(endpointUrl) + // .region(region) + // .signatureVersion("s3v4") + // .build(); + // } catch (NoSuchAlgorithmException | ProviderException e) { + // //error, no recovery + // log.error("Error with provider {}", e); + // } + } + } + + return null; + } + + @Override + public Credentials process(@NotNull T token) { + if (Boolean.TRUE.equals(enabled)) { + //extract a policy from jwt tokens + if (token instanceof JwtAuthenticationToken && StringUtils.hasText(claim)) { + String policy = ((JwtAuthenticationToken) token).getToken().getClaimAsString(claim); + if (StringUtils.hasText(policy)) { + return MinioPolicy.builder().claim(claim).policy(policy).build(); + } + } + + //fallback to default + if (StringUtils.hasText(defaultPolicy)) { + return MinioPolicy.builder().claim(claim).policy(defaultPolicy).build(); + } + } + + return null; + } +} diff --git a/modules/credentials-provider-minio/src/main/java/it/smartcommunitylabdhub/credentials/minio/MinioSessionCredentials.java b/modules/credentials-provider-minio/src/main/java/it/smartcommunitylabdhub/credentials/minio/MinioSessionCredentials.java new file mode 100644 index 00000000..ac4deb5a --- /dev/null +++ b/modules/credentials-provider-minio/src/main/java/it/smartcommunitylabdhub/credentials/minio/MinioSessionCredentials.java @@ -0,0 +1,63 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.credentials.minio; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import it.smartcommunitylabdhub.authorization.model.AbstractCredentials; +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 MinioSessionCredentials extends AbstractCredentials { + + @JsonProperty("aws_access_key_id") + private String accessKey; + + @JsonProperty("aws_secret_access_key") + private String secretKey; + + @JsonProperty("aws_session_token") + private String sessionToken; + + @JsonProperty("s3_endpoint") + private String endpoint; + + @JsonProperty("s3_region") + private String region; + + @JsonProperty("s3_signature_version") + private String signatureVersion; + + @Override + public void eraseCredentials() { + //clear credentials + this.accessKey = null; + this.secretKey = null; + this.sessionToken = null; + } +} diff --git a/modules/files/pom.xml b/modules/files/pom.xml index 8c18051b..3f7cfa61 100644 --- a/modules/files/pom.xml +++ b/modules/files/pom.xml @@ -24,6 +24,11 @@ dh-commons ${revision} + + it.smartcommunitylabdhub + dh-authorization + ${revision} + org.projectlombok lombok diff --git a/modules/files/src/main/java/it/smartcommunitylabdhub/files/provider/S3Config.java b/modules/files/src/main/java/it/smartcommunitylabdhub/files/provider/S3Config.java new file mode 100644 index 00000000..f6b25c3c --- /dev/null +++ b/modules/files/src/main/java/it/smartcommunitylabdhub/files/provider/S3Config.java @@ -0,0 +1,49 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.files.provider; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import it.smartcommunitylabdhub.commons.infrastructure.AbstractConfiguration; +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 S3Config extends AbstractConfiguration { + + @JsonProperty("s3_bucket") + private String bucket; + + @JsonProperty("s3_endpoint") + private String endpoint; + + @JsonProperty("s3_region") + private String region; + + @JsonProperty("s3_signature_version") + private String signatureVersion; +} diff --git a/modules/files/src/main/java/it/smartcommunitylabdhub/files/provider/S3Credentials.java b/modules/files/src/main/java/it/smartcommunitylabdhub/files/provider/S3Credentials.java new file mode 100644 index 00000000..7450a61d --- /dev/null +++ b/modules/files/src/main/java/it/smartcommunitylabdhub/files/provider/S3Credentials.java @@ -0,0 +1,50 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.files.provider; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import it.smartcommunitylabdhub.authorization.model.AbstractCredentials; +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 S3Credentials extends AbstractCredentials { + + @JsonProperty("aws_access_key_id") + private String accessKey; + + @JsonProperty("aws_secret_access_key") + private String secretKey; + + @Override + public void eraseCredentials() { + //clear credentials + this.accessKey = null; + this.secretKey = null; + } +} diff --git a/modules/files/src/main/java/it/smartcommunitylabdhub/files/provider/S3Provider.java b/modules/files/src/main/java/it/smartcommunitylabdhub/files/provider/S3Provider.java new file mode 100644 index 00000000..57823354 --- /dev/null +++ b/modules/files/src/main/java/it/smartcommunitylabdhub/files/provider/S3Provider.java @@ -0,0 +1,109 @@ +/** + * Copyright 2025 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package it.smartcommunitylabdhub.files.provider; + +import it.smartcommunitylabdhub.authorization.model.UserAuthentication; +import it.smartcommunitylabdhub.authorization.services.CredentialsProvider; +import it.smartcommunitylabdhub.commons.infrastructure.ConfigurationProvider; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; +import it.smartcommunitylabdhub.files.provider.S3Config.S3ConfigBuilder; +import jakarta.validation.constraints.NotNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class S3Provider implements ConfigurationProvider, CredentialsProvider, InitializingBean { + + @Value("${files.store.s3.access-key}") + private String accessKey; + + @Value("${files.store.s3.secret-key}") + private String secretKey; + + @Value("${files.store.s3.endpoint}") + private String endpoint; + + @Value("${files.store.s3.bucket}") + private String bucket; + + @Value("${files.store.s3.signature-version}") + private String signatureVersion; + + @Value("${files.store.s3.region}") + private String region; + + @Value("${credentials.provider.s3.enable}") + private Boolean enabled; + + private S3Config config; + + public S3Provider() { + //TODO define a property bean + } + + @Override + public void afterPropertiesSet() throws Exception { + if (endpoint != null) { + log.debug("Build configuration for provider..."); + + //build config + S3ConfigBuilder builder = S3Config + .builder() + .endpoint(endpoint) + .bucket(bucket) + .region(region) + .signatureVersion(signatureVersion); + + this.config = builder.build(); + + if (log.isTraceEnabled()) { + log.trace("config: {}", config.toJson()); + } + } + } + + @Override + public Credentials get(@NotNull UserAuthentication auth) { + if (config == null) { + return null; + } + + if (Boolean.TRUE.equals(enabled)) { + log.debug("generate credentials for user authentication {} via STS service", auth.getName()); + + //static credentials shared + return S3Credentials.builder().accessKey(accessKey).secretKey(secretKey).build(); + } + + return null; + } + + @Override + public S3Config getConfig() { + return config; + } + + @Override + public Credentials process(@NotNull T token) { + //nothing to do, this provider is static + return null; + } +} 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 652ca2db..7eec41ad 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 @@ -919,7 +919,8 @@ protected V1Secret buildRunSecret(T runnable) { String envsPrefix = k8sSecretHelper.getEnvsPrefix(); runnable .getCredentials() - .entrySet() + .stream() + .flatMap(c -> c.toMap().entrySet().stream()) .forEach(e -> { if (envsPrefix != null) { data.put(envsPrefix.toUpperCase() + "_" + e.getKey().toUpperCase(), e.getValue()); 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 04c6c7d4..7fde37ad 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,10 +2,9 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonProcessingException; +import it.smartcommunitylabdhub.commons.infrastructure.Credentials; 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; @@ -18,11 +17,9 @@ 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.Collection; import java.util.List; import java.util.Map; -import java.util.Map.Entry; -import java.util.stream.Collectors; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -98,7 +95,7 @@ public class K8sRunnable implements RunRunnable, SecuredRunnable, CredentialsCon private List metrics; @ToString.Exclude - private HashMap credentials; + private Collection credentials; @JsonProperty("context_refs") private List contextRefs; @@ -115,35 +112,34 @@ public String getFramework() { public void eraseCredentials() { this.credentials = null; } - - @Override - public void setCredentials(Serializable 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)); - } - } + // @Override + // public void setCredentials(Collection 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 52b12cc1..0dd86170 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,7 @@ 21.0.0-legacy 21 1.18.34 + 33.3.1-jre UTF-8 checkstyle/checkstyle.xml false @@ -40,6 +41,8 @@ modules/runtime-model-serve modules/openmetadata-integration modules/files + modules/credentials-provider-db + modules/credentials-provider-minio frontend application