Refacto oauth2 granted authorities (#2684)

* refacto: oauth2

* feat: clear user from cache on logout
This commit is contained in:
Florian BEZANNIER
2025-10-23 11:45:50 +02:00
committed by GitHub
parent a3cf835ae2
commit 4306548b4a
8 changed files with 181 additions and 145 deletions

View File

@@ -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"));
}

View File

@@ -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()) {

View File

@@ -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<OAuth2TokenManager, RequestInterceptor> 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,16 +83,13 @@ 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)
(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<OidcUserRequest, OidcUser> 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<SimpleGrantedAuthority> 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<SimpleGrantedAuthority> 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<SimpleGrantedAuthority> getGrantedAuthorities(final HawkbitMgmtClient hawkbitClient, Authentication authentication) {
final List<String> 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");

View File

@@ -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<String> getName() {
@@ -28,6 +30,7 @@ public class AuthenticatedUser {
}
public void logout() {
this.getName().ifPresent(grantedAuthoritiesService::evictUserFromCache);
authenticationContext.logout();
}
}

View File

@@ -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<SimpleGrantedAuthority> getGrantedAuthorities(Authentication authentication) {
final List<String> 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);
}
}

View File

@@ -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();
}
}

View File

@@ -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<OAuth2LoginConfigurer<HttpSecurity>> 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();
}

View File

@@ -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<? extends GrantedAuthority> 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);
}
}