diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/HawkbitMgmtClient.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/HawkbitMgmtClient.java index 7fe0e2e1c..ccea66ca4 100644 --- a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/HawkbitMgmtClient.java +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/HawkbitMgmtClient.java @@ -48,7 +48,7 @@ public class HawkbitMgmtClient { private final MgmtTenantManagementRestApi tenantManagementRestApi; private final MgmtActionRestApi actionRestApi; - HawkbitMgmtClient(final Tenant tenant, final HawkbitClient hawkbitClient) { + public HawkbitMgmtClient(final Tenant tenant, final HawkbitClient hawkbitClient) { this.tenant = tenant; this.hawkbitClient = hawkbitClient; @@ -66,23 +66,23 @@ public class HawkbitMgmtClient { actionRestApi = service(MgmtActionRestApi.class); } - boolean hasSoftwareModulesRead() { + public boolean hasSoftwareModulesRead() { return hasRead(() -> softwareModuleRestApi.getSoftwareModule(-1L)); } - boolean hasRolloutRead() { + public boolean hasRolloutRead() { return hasRead(() -> rolloutRestApi.getRollout(-1L)); } - boolean hasDistributionSetRead() { + public boolean hasDistributionSetRead() { return hasRead(() -> distributionSetRestApi.getDistributionSet(-1L)); } - boolean hasTargetRead() { + public boolean hasTargetRead() { return hasRead(() -> targetRestApi.getTarget("_#ETE$ER")); } - boolean hasConfigRead() { + public boolean hasConfigRead() { return hasRead(() -> tenantManagementRestApi.getTenantConfigurationValue("_#ETE$ER")); } diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/MainLayout.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/MainLayout.java index 6848a0e19..62f37e8b8 100644 --- a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/MainLayout.java +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/MainLayout.java @@ -71,7 +71,8 @@ public class MainLayout extends AppLayout { protected void afterNavigation() { super.afterNavigation(); viewTitle.setText( - Optional.ofNullable(getContent().getClass().getAnnotation(PageTitle.class)) + Optional.ofNullable(getContent()) + .map(c -> c.getClass().getAnnotation(PageTitle.class)) .map(PageTitle::value) .orElse("")); if (UI.getCurrent().getActiveViewLocation().getPath().isEmpty()) { 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 aba4da1e5..42b9385b9 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 @@ -21,52 +21,43 @@ import lombok.extern.slf4j.Slf4j; 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.cache.annotation.EnableCaching; import org.springframework.cloud.openfeign.FeignClientsConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 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.net.HttpURLConnection; import java.net.URL; import java.util.Base64; -import java.util.LinkedList; -import java.util.List; +import java.util.Collections; import java.util.Objects; -import java.util.function.Function; import static feign.Util.ISO_8859_1; -import static java.util.Collections.emptyList; @Slf4j @Theme("hawkbit") @PWA(name = "hawkBit UI", shortName = "hawkBit UI") +@EnableCaching +@EnableScheduling @SpringBootApplication @Import(FeignClientsConfiguration.class) public class SimpleUIApp implements AppShellConfigurator { private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final Function AUTHORIZATION = oAuth2TokenManager -> requestTemplate -> { + private static final RequestInterceptor AUTHORIZATION = requestTemplate -> { final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication instanceof OAuth2AuthenticationToken authenticationToken) { - String bearerToken = oAuth2TokenManager.getToken(authenticationToken); - requestTemplate.header(AUTHORIZATION_HEADER, "Bearer " + bearerToken); + if (authentication.getPrincipal() instanceof OidcUser oidcUser) { + requestTemplate.header(AUTHORIZATION_HEADER, "Bearer " + oidcUser.getIdToken().getTokenValue()); } else { requestTemplate.header( AUTHORIZATION_HEADER, "Basic " + Base64.getEncoder().encodeToString( @@ -92,17 +83,14 @@ public class SimpleUIApp implements AppShellConfigurator { final HawkbitServer hawkBitServer, final Encoder encoder, final Decoder decoder, - final Contract contract, - @Autowired(required = false) - final OAuth2TokenManager oAuth2TokenManager + final Contract contract ) { return new HawkbitClient( hawkBitServer, encoder, decoder, contract, ERROR_DECODER, - (tenant, controller) -> - controller == null - ? AUTHORIZATION.apply(oAuth2TokenManager) - : HawkbitClient.DEFAULT_REQUEST_INTERCEPTOR_FN.apply(tenant, controller) + (tenant, controller) -> controller == null + ? AUTHORIZATION + : HawkbitClient.DEFAULT_REQUEST_INTERCEPTOR_FN.apply(tenant, controller) ); } @@ -111,21 +99,6 @@ 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, final HawkbitServer server) { @@ -138,9 +111,7 @@ public class SimpleUIApp implements AppShellConfigurator { throw new BadCredentialsException("Incorrect username or password!"); } - final List grantedAuthorities = getGrantedAuthorities( - hawkbitClient, new UsernamePasswordAuthenticationToken(username, password)); - return new UsernamePasswordAuthenticationToken(username, password, grantedAuthorities) { + return new UsernamePasswordAuthenticationToken(username, password, Collections.emptyList()) { @Override public void eraseCredentials() { @@ -151,35 +122,6 @@ 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(); - } - public static boolean isAuthenticated(String username, String password, String mgmtUrl) { try { final URL url = new URL(mgmtUrl + "/rest/v1/rollouts"); diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/AuthenticatedUser.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/AuthenticatedUser.java index c679f70a7..cf8f100b8 100644 --- a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/AuthenticatedUser.java +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/AuthenticatedUser.java @@ -18,9 +18,11 @@ import org.springframework.stereotype.Component; public class AuthenticatedUser { private final AuthenticationContext authenticationContext; + private final GrantedAuthoritiesService grantedAuthoritiesService; - public AuthenticatedUser(final AuthenticationContext authenticationContext) { + public AuthenticatedUser(final AuthenticationContext authenticationContext, GrantedAuthoritiesService grantedAuthoritiesService) { this.authenticationContext = authenticationContext; + this.grantedAuthoritiesService = grantedAuthoritiesService; } public Optional getName() { @@ -28,6 +30,7 @@ public class AuthenticatedUser { } public void logout() { + this.getName().ifPresent(grantedAuthoritiesService::evictUserFromCache); authenticationContext.logout(); } } diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/GrantedAuthoritiesService.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/GrantedAuthoritiesService.java new file mode 100644 index 000000000..37caee76f --- /dev/null +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/GrantedAuthoritiesService.java @@ -0,0 +1,65 @@ +/** + * 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 java.util.LinkedList; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.hawkbit.ui.simple.HawkbitMgmtClient; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +@Slf4j +public class GrantedAuthoritiesService { + + public static final String USER_CACHE = "userDetails"; + private HawkbitMgmtClient hawkbitClient; + + @Cacheable(cacheNames = "userDetails", key = "#authentication.getName()") + public List getGrantedAuthorities(Authentication authentication) { + final List roles = new LinkedList<>(); + roles.add("ANONYMOUS"); + 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"); + } + return roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).toList(); + } + + @Scheduled(fixedRateString = "${caching.spring.userDetailsTTL:1h}") + @CacheEvict(cacheNames = USER_CACHE, allEntries = true) + public void emptyCache() { + log.debug("emptying userDetails cache"); + } + + @CacheEvict(cacheNames = "userDetails") + public void evictUserFromCache(String principalName) { + log.debug("remove user from {} cache", principalName); + } +} \ No newline at end of file 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 deleted file mode 100644 index 96017133f..000000000 --- a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/OAuth2TokenManager.java +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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.ConditionalOnProperty; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -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.core.oidc.user.DefaultOidcUser; -import org.springframework.stereotype.Component; - -@Component -@ConditionalOnProperty(prefix = "hawkbit.server.security.oauth2.client", name = "enabled") -public class OAuth2TokenManager { - - private final OAuth2AuthorizedClientService clientService; - private final OAuth2AuthorizedClientManager clientManager; - - OAuth2TokenManager( - final OAuth2AuthorizedClientService clientService, - final OAuth2AuthorizedClientManager clientManager - ) { - this.clientService = clientService; - this.clientManager = clientManager; - } - - public String getToken(final OAuth2AuthenticationToken authentication) { - final String currentToken = ((DefaultOidcUser) authentication.getPrincipal()).getIdToken().getTokenValue(); - String registrationId = authentication.getAuthorizedClientRegistrationId(); - - // 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 currentToken; - - // 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 - OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest.withClientRegistrationId(registrationId).principal(authentication).build(); - // since Spring Security 6.5 this will trigger a refresh of the id token - authorizedClient = clientManager.authorize(request); - if (authorizedClient == null) return currentToken; - - // we need to fetch the newly created context containing the matching token - SecurityContext securityContext = SecurityContextHolder.getContext(); - return ((DefaultOidcUser) securityContext.getAuthentication().getPrincipal()).getIdToken().getTokenValue(); - } -} 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 6dca06c84..5dec7a9ba 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,6 +11,7 @@ package org.eclipse.hawkbit.ui.simple.security; import com.vaadin.flow.spring.security.VaadinAwareSecurityContextHolderStrategyConfiguration; import com.vaadin.flow.spring.security.VaadinSecurityConfigurer; +import lombok.extern.slf4j.Slf4j; import org.eclipse.hawkbit.ui.simple.view.LoginView; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -24,19 +25,27 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe 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.oauth2.client.registration.InMemoryClientRegistrationRepository; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import org.springframework.security.web.access.intercept.AuthorizationFilter; @EnableWebSecurity @Configuration @EnableConfigurationProperties(OidcClientProperties.class) +@Slf4j @Import(VaadinAwareSecurityContextHolderStrategyConfiguration.class) public class SecurityConfiguration { private Customizer> oAuth2LoginConfigurerCustomizer; + @Autowired + private UserDetailsSetter userDetailsSetter; + + @Autowired(required = false) + private InMemoryClientRegistrationRepository clientRegistrationRepository; @Autowired(required = false) public void setOAuth2LoginConfigurerCustomizer( @@ -52,23 +61,24 @@ public class SecurityConfiguration { @Bean public AuthenticationFailureHandler customFailureHandler() { // Redirect back to login with your message - return (request, response, exception) -> - response.sendRedirect("/login?error=" + URLEncoder.encode(exception.getMessage(), StandardCharsets.UTF_8)); + return (request, response, exception) -> response.sendRedirect("/login?error=" + URLEncoder.encode(exception.getMessage(), + StandardCharsets.UTF_8)); } @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize -> authorize.requestMatchers("/images/*.png").permitAll()); - if (oAuth2LoginConfigurerCustomizer != null) { - http.oauth2Login(oAuth2LoginConfigurerCustomizer); - } else { - http.formLogin(form -> form - .loginPage("/login") - .failureHandler(customFailureHandler())); - } + http.addFilterAfter(userDetailsSetter, AuthorizationFilter.class); return http.with(VaadinSecurityConfigurer.vaadin(), configurer -> { if (oAuth2LoginConfigurerCustomizer == null) { configurer.loginView(LoginView.class); + + } else { + var defaultClientRegistration = clientRegistrationRepository.iterator().next(); + configurer.oauth2LoginPage( + "/oauth2/authorization/" + defaultClientRegistration.getRegistrationId(), + "{baseUrl}/" + ); } }).build(); } diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/UserDetailsSetter.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/UserDetailsSetter.java new file mode 100644 index 000000000..55ebc0b5f --- /dev/null +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/security/UserDetailsSetter.java @@ -0,0 +1,72 @@ +/** + * 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 java.io.IOException; +import java.time.Instant; +import java.util.Collection; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.security.authentication.AccountExpiredException; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +@Component +@Slf4j +class UserDetailsSetter extends OncePerRequestFilter { + + private final SecurityContextHolderStrategy securityContextHolderStrategy; + private final GrantedAuthoritiesService grantedAuthoritiesService; + + @Override + protected void doFilterInternal(@NotNull final HttpServletRequest request, @NotNull final HttpServletResponse response, + @NotNull final FilterChain filterChain) + throws ServletException, IOException { + + Authentication authentication = securityContextHolderStrategy.getContext().getAuthentication(); + Authentication newAuthentication; + + if (!(authentication instanceof AnonymousAuthenticationToken) && authentication.isAuthenticated()) { + Collection grantedAuthorities = grantedAuthoritiesService.getGrantedAuthorities(authentication); + + if (authentication instanceof OAuth2AuthenticationToken oAuth2AuthenticationToken) { + newAuthentication = new OAuth2AuthenticationToken(oAuth2AuthenticationToken.getPrincipal(), grantedAuthorities, + oAuth2AuthenticationToken.getAuthorizedClientRegistrationId()); + if (authentication.getPrincipal() instanceof OidcUser user) { + // if there is no refresh token and the access token is expired then relogin is required + if (user.getIdToken().getExpiresAt() != null && Instant.now().isAfter(user.getIdToken().getExpiresAt())) { + throw new AccountExpiredException("Token expired"); + } + } + } else { + newAuthentication = new UsernamePasswordAuthenticationToken(authentication.getName(), authentication.getCredentials(), + grantedAuthorities); + } + + securityContextHolderStrategy.getContext().setAuthentication(newAuthentication); + } + filterChain.doFilter(request, response); + } +}