diff --git a/hawkbit-simple-ui/pom.xml b/hawkbit-simple-ui/pom.xml index 50244850c..0d6f337d9 100644 --- a/hawkbit-simple-ui/pom.xml +++ b/hawkbit-simple-ui/pom.xml @@ -81,6 +81,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-client + diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/SimpleUIApp.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/SimpleUIApp.java index 4c9fb81ac..7abb6092f 100644 --- a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/SimpleUIApp.java +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/SimpleUIApp.java @@ -9,13 +9,6 @@ */ package org.eclipse.hawkbit.ui.simple; -import static feign.Util.ISO_8859_1; - -import java.util.Base64; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; - import com.vaadin.flow.component.page.AppShellConfigurator; import com.vaadin.flow.server.PWA; import com.vaadin.flow.theme.Theme; @@ -28,7 +21,9 @@ import feign.codec.ErrorDecoder; import org.eclipse.hawkbit.sdk.HawkbitClient; import org.eclipse.hawkbit.sdk.HawkbitServer; import org.eclipse.hawkbit.sdk.Tenant; +import org.eclipse.hawkbit.ui.simple.security.OAuth2TokenManager; import org.eclipse.hawkbit.ui.simple.view.util.Utils; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.openfeign.FeignClientsConfiguration; @@ -40,6 +35,21 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import java.util.Base64; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +import static feign.Util.ISO_8859_1; +import static java.util.Collections.emptyList; @Theme(themeClass = Lumo.class) @PWA(name = "hawkBit UI", shortName = "hawkBit UI") @@ -47,11 +57,18 @@ import org.springframework.security.core.context.SecurityContextHolder; @Import(FeignClientsConfiguration.class) public class SimpleUIApp implements AppShellConfigurator { - private static final RequestInterceptor AUTHORIZATION = requestTemplate -> { + private static final Function AUTHORIZATION = (oAuth2TokenManager) -> requestTemplate -> { final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - requestTemplate.header("Authorization", "Basic " + Base64.getEncoder().encodeToString( - (Objects.requireNonNull(authentication.getPrincipal(), "User is null!") + ":" + Objects.requireNonNull( - authentication.getCredentials(), "Password is not available!")).getBytes(ISO_8859_1))); + if (oAuth2TokenManager != null && authentication instanceof OAuth2AuthenticationToken oAuth2AuthenticationToken) { + String bearerToken = oAuth2TokenManager.getToken(oAuth2AuthenticationToken); + requestTemplate.header("Authorization", "Bearer " + bearerToken); + } else { + requestTemplate.header( + "Authorization", "Basic " + Base64.getEncoder().encodeToString( + (Objects.requireNonNull(authentication.getPrincipal(), "User is null!") + ":" + Objects.requireNonNull( + authentication.getCredentials(), "Password is not available!")).getBytes(ISO_8859_1)) + ); + } }; private static final ErrorDecoder DEFAULT_ERROR_DECODER = new ErrorDecoder.Default(); @@ -67,13 +84,21 @@ public class SimpleUIApp implements AppShellConfigurator { @Bean HawkbitClient hawkbitClient( - final HawkbitServer hawkBitServer, final Encoder encoder, final Decoder decoder, final Contract contract) { + final HawkbitServer hawkBitServer, + final Encoder encoder, + final Decoder decoder, + final Contract contract, + @Autowired(required = false) + final OAuth2TokenManager oAuth2TokenManager + ) { return new HawkbitClient( hawkBitServer, encoder, decoder, contract, ERROR_DECODER, (tenant, controller) -> - controller == null ? - AUTHORIZATION : HawkbitClient.DEFAULT_REQUEST_INTERCEPTOR_FN.apply(tenant, controller)); + controller == null + ? AUTHORIZATION.apply(oAuth2TokenManager) + : HawkbitClient.DEFAULT_REQUEST_INTERCEPTOR_FN.apply(tenant, controller) + ); } @Bean @@ -81,6 +106,27 @@ public class SimpleUIApp implements AppShellConfigurator { return new HawkbitMgmtClient(tenant, hawkbitClient); } + @Bean + OAuth2UserService oidcUserService(final HawkbitMgmtClient hawkbitClient) { + final OidcUserService delegate = new OidcUserService(); + return (userRequest) -> { + OidcUser oidcUser = delegate.loadUser(userRequest); + + final OAuth2AuthenticationToken tempToken = new OAuth2AuthenticationToken( + oidcUser, + emptyList(), + userRequest.getClientRegistration().getRegistrationId() + ); + final List grantedAuthorities = + getGrantedAuthorities(hawkbitClient, tempToken); + return new DefaultOidcUser( + grantedAuthorities, + oidcUser.getIdToken(), + oidcUser.getUserInfo() + ); + }; + } + // accepts all user / pass, just delegating them to the feign client @Bean AuthenticationManager authenticationManager(final HawkbitMgmtClient hawkbitClient) { @@ -88,35 +134,9 @@ public class SimpleUIApp implements AppShellConfigurator { final String username = authentication.getName(); final String password = authentication.getCredentials().toString(); - final List roles = new LinkedList<>(); - roles.add("ANONYMOUS"); - final SecurityContext unauthorizedContext = SecurityContextHolder.createEmptyContext(); - unauthorizedContext.setAuthentication( - new UsernamePasswordAuthenticationToken(username, password)); - final SecurityContext currentContext = SecurityContextHolder.getContext(); - try { - SecurityContextHolder.setContext(unauthorizedContext); - if (hawkbitClient.hasSoftwareModulesRead()) { - roles.add("SOFTWARE_MODULE_READ"); - } - if (hawkbitClient.hasRolloutRead()) { - roles.add("ROLLOUT_READ"); - } - if (hawkbitClient.hasDistributionSetRead()) { - roles.add("DISTRIBUTION_SET_READ"); - } - if (hawkbitClient.hasTargetRead()) { - roles.add("TARGET_READ"); - } - if (hawkbitClient.hasConfigRead()) { - roles.add("CONFIG_READ"); - } - } finally { - SecurityContextHolder.setContext(currentContext); - } - return new UsernamePasswordAuthenticationToken( - username, password, - roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).toList()) { + final List grantedAuthorities = getGrantedAuthorities( + hawkbitClient, new UsernamePasswordAuthenticationToken(username, password)); + return new UsernamePasswordAuthenticationToken(username, password, grantedAuthorities) { @Override public void eraseCredentials() { @@ -126,4 +146,33 @@ public class SimpleUIApp implements AppShellConfigurator { }; }; } + + private List getGrantedAuthorities(final HawkbitMgmtClient hawkbitClient, Authentication authentication) { + final List roles = new LinkedList<>(); + roles.add("ANONYMOUS"); + final SecurityContext unauthorizedContext = SecurityContextHolder.createEmptyContext(); + unauthorizedContext.setAuthentication(authentication); + final SecurityContext currentContext = SecurityContextHolder.getContext(); + try { + SecurityContextHolder.setContext(unauthorizedContext); + if (hawkbitClient.hasSoftwareModulesRead()) { + roles.add("SOFTWARE_MODULE_READ"); + } + if (hawkbitClient.hasRolloutRead()) { + roles.add("ROLLOUT_READ"); + } + if (hawkbitClient.hasDistributionSetRead()) { + roles.add("DISTRIBUTION_SET_READ"); + } + if (hawkbitClient.hasTargetRead()) { + roles.add("TARGET_READ"); + } + if (hawkbitClient.hasConfigRead()) { + roles.add("CONFIG_READ"); + } + } finally { + SecurityContextHolder.setContext(currentContext); + } + return roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).toList(); + } } diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/OAuth2TokenManager.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/OAuth2TokenManager.java new file mode 100644 index 000000000..e9c2924ad --- /dev/null +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/OAuth2TokenManager.java @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.ui.simple.security; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; +import org.springframework.security.oauth2.client.endpoint.RestClientRefreshTokenTokenResponseClient; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@ConditionalOnBean(name = "hawkbitOAuth2ClientCustomizer") +public class OAuth2TokenManager { + + private final OAuth2AuthorizedClientService clientService; + private final OAuth2AuthorizedClientManager clientManager; + private final OAuth2AccessTokenResponseClient tokenResponseClient; + + OAuth2TokenManager( + final OAuth2AuthorizedClientService clientService, + final OAuth2AuthorizedClientManager clientManager + ) { + this.clientService = clientService; + this.clientManager = clientManager; + this.tokenResponseClient = new RestClientRefreshTokenTokenResponseClient(); + } + + public String getToken(final OAuth2AuthenticationToken authentication) { + return Optional.ofNullable(authorizedToken(authentication)).orElse( + ((DefaultOidcUser) authentication.getPrincipal()).getIdToken().getTokenValue() + ); + } + + /** + * Tries to refresh the id token if it is expired and adds it to the request. + */ + private String authorizedToken(final OAuth2AuthenticationToken authentication) { + String registrationId = authentication.getAuthorizedClientRegistrationId(); + OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest.withClientRegistrationId(registrationId).principal(authentication).build(); + + // This ensures that there is a client already, otherwise we won't be able to call the manager for authorization + OAuth2AuthorizedClient authorizedClient = clientService.loadAuthorizedClient(registrationId, authentication.getName()); + if (authorizedClient == null) return null; + + // Will ensure that the token is refreshed if needed; do not rely on it being not null as it won't be available + // during the first calls made to get the rights and generate the authorities + OAuth2AuthorizedClient refreshClient = clientManager.authorize(request); + if (refreshClient == null) return null; + + // A small trick to refresh the token if it is expired; the current spring version does not refresh the ID Token when the Access Token is refreshed + // This won't be necessary after Spring Security 6.5; cf. https://github.com/spring-projects/spring-security/pull/16589 + OAuth2AccessToken accessToken = refreshClient.getAccessToken(); + OAuth2RefreshToken refreshToken = refreshClient.getRefreshToken(); + ClientRegistration clientRegistration = refreshClient.getClientRegistration(); + // if this is null, please request it via the scopes + if (refreshToken == null) return null; + + OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest( + clientRegistration, accessToken, refreshToken); + OAuth2AccessTokenResponse response = tokenResponseClient.getTokenResponse(refreshTokenGrantRequest); + return (String) response.getAdditionalParameters().get("id_token"); + } +} diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/Oauth2ClientConfig.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/Oauth2ClientConfig.java new file mode 100644 index 000000000..37792cd79 --- /dev/null +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/Oauth2ClientConfig.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.ui.simple.security; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; + +@Configuration +public class Oauth2ClientConfig { + @Bean(name = "hawkbitOAuth2ClientCustomizer") + @ConditionalOnProperty(prefix = "hawkbit.server.security.oauth2.client", name = "enabled") + @ConditionalOnMissingBean(name = "hawkbitOAuth2ClientCustomizer") + Customizer> defaultOAuth2ClientCustomizer() { + return Customizer.withDefaults(); + } +} diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/OidcClientProperties.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/OidcClientProperties.java new file mode 100644 index 000000000..8a47d1ad0 --- /dev/null +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/OidcClientProperties.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.ui.simple.security; + +import lombok.Data; +import lombok.ToString; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ToString +@ConfigurationProperties("hawkbit.server.security") +public class OidcClientProperties { + + private final OidcClientProperties.Oauth2 oauth2 = new OidcClientProperties.Oauth2(); + + @Data + public static class Oauth2 { + + private final OidcClientProperties.Oauth2.Client client = new OidcClientProperties.Oauth2.Client(); + + @Data + public static class Client { + + private boolean enabled = false; + } + } +} \ No newline at end of file diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/SecurityConfiguration.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/SecurityConfiguration.java index 12563a915..19696dd62 100644 --- a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/SecurityConfiguration.java +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/SecurityConfiguration.java @@ -11,18 +11,33 @@ package org.eclipse.hawkbit.ui.simple.security; import com.vaadin.flow.spring.security.VaadinWebSecurity; import org.eclipse.hawkbit.ui.simple.view.LoginView; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @EnableWebSecurity @Configuration +@EnableConfigurationProperties({ OidcClientProperties.class }) public class SecurityConfiguration extends VaadinWebSecurity { + private Customizer> oAuth2LoginConfigurerCustomizer; + + @Autowired(required = false) + public void setOAuth2LoginConfigurerCustomizer( + @Qualifier("hawkbitOAuth2ClientCustomizer") final Customizer> oauth2LoginConfigurerCustomizer + ) { + this.oAuth2LoginConfigurerCustomizer = oauth2LoginConfigurerCustomizer; + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -34,6 +49,11 @@ public class SecurityConfiguration extends VaadinWebSecurity { authorize -> authorize.requestMatchers(new AntPathRequestMatcher("/images/*.png")).permitAll()); super.configure(http); - setLoginView(http, LoginView.class); + + if (oAuth2LoginConfigurerCustomizer != null) { + http.oauth2Login(oAuth2LoginConfigurerCustomizer); + } else { + setLoginView(http, LoginView.class); + } } }