diff --git a/pom.xml b/pom.xml index 4a184cdf0e..fe6131ab07 100644 --- a/pom.xml +++ b/pom.xml @@ -177,6 +177,7 @@ ${jackson.version} + diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/AppContextProvider.java b/project-management/src/main/java/life/qbic/projectmanagement/application/AppContextProvider.java index 80669ba3fb..6892422f5e 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/AppContextProvider.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/AppContextProvider.java @@ -27,4 +27,11 @@ public interface AppContextProvider { * @since 1.0.0 */ String urlToSamplePage(String projectId, String experimentId); + + /** + * Returns the base URL of the application including its context path. + * @return the base URL and the context path + * @since 1.8.0 + */ + String baseUrl(); } diff --git a/user-interface/auth-flow-zenodo.md b/user-interface/auth-flow-zenodo.md new file mode 100644 index 0000000000..815df69149 --- /dev/null +++ b/user-interface/auth-flow-zenodo.md @@ -0,0 +1,134 @@ +# Integration of external services + +Data Manager integrates services like ORCID (as identity provider) or Zenodo (as resource provider). + +This document describes the technical view on how it has been integrated in the application and its +important components +for developers. + +## OAuth 2.0 + +Data Manager uses the OAuth 2.0 protocol to acquire access resources on behalf of the resource +owner (e.g. the user in Data Manager). + +The Data Manager application acts as the client and for allowing it to perform actions against an +extern resource such as Zenodo, it needs to get the resource owner's (user's) allowance first. + +### Zenodo + +We assume, that the user has already logged-in their Data Manager account and want to interact with +Zenodo, in order to create a draft on Zenodo with some metadata available +in their Data Manager project. + +The current user Alice represents the resource owner and has an account on Zenodo and Data Manager. + +First, let us see on a very top level view what happens: + +```mermaid + +sequenceDiagram + actor Alice + participant Data Manager + participant DM Session + participant Zenodo + Alice ->> Data Manager: provides credentials + activate Data Manager + Data Manager ->> DM Session: creates session for Alice + Data Manager ->> Alice: diplays projects + deactivate Data Manager + note right of Alice: Alice is logged in + Alice ->> Data Manager: create result record on Zenodo + activate Data Manager + Data Manager ->> Zenodo: inits authorization challenge + activate Zenodo + Zenodo ->> Alice: asks to log into her account + Alice ->> Zenodo: provides credentials + Zenodo ->> Alice: asks to give Data Manager access + Alice ->> Zenodo: gives Data Manager access + Zenodo ->> Data Manager: provides authorization + Data Manager ->> Zenodo: requests access token + Zenodo ->> Data Manager: gives access token for account + deactivate Zenodo + Data Manager ->> DM Session: stores Zenodo access token + Data Manager ->> Alice: informs Alice to continue + deactivate Data Manager + note right of Alice: Alice is now able to create a Zenodo record
from within her data manager session + + +``` + +In order to make it work, we needed to tweak the default OAuth2.0 implementation flow of Spring in +order to make it +work for the Data Manager use case. + +We have some extra components + +- **Custom OAuth 2.0 callback controller**: intercepts the authorization challenge with Zenodo after + the authorization has been granted by the resource owner +- **Custom OAuth 2.0 access token response client**: turns out Zenodo does not like `client_id` and + `client_secret` being transferred in the HTTP header. So we need to put it in the HTTP message + body as form data. +- **Additional custom security context**: We don't want to interfere with the Spring security + context and the security implementation context of the logged-in principal. So we use an + additional one for the remote resource access only. + +#### Custom OAuth2.0 callback controller + +To the time of writing in the ``ZenodoOAuth2Controller.java`` class. As a Spring servlet controller, +the route `/zenodo/callback` will be registered by Spring and we can interecept all incoming http +requests. Exactly what we want when Zenodo grants Data Manager access on behalf of the user. + +_What is wrong with the default Spring OAuth 2.0 handling?_ + +Nothing. + +However: + +1. Zenodo does not accept the access token challenge as it would be done Springs default +implementation. ``client_id`` and ``client_secret`` are usually passed Base64-encoded as Basic +authentication in the http message header. If you do that against the Zenodo API, you will receive a status code ``404`` (not found). + +2. We need to enrich the user session with the acquired remote service access token. The default OAuth 2.0 flow wil otherwise replace the current user's existing security context. + +This magic happens in our custom controller implementation and only in the case of handling third party access tokens. This does not interfere with the OAuth 2.0 flow that we use for log in via ORCID. + +#### Additional custom security context + +Next to the primary Spring security context, we enrich the session with additional principals, that are secondary and only for accessing remote resource server (e.g. Zenodo). + +Although the access tokens have a longer life span (in case of Zenodo at the time of writing **2 months**), we decided against storing them +persistently on our side. It adds complexity to the Data Manager's implementation since access tokens are very sensitive material. + +So access tokens currently only live as long as the user's current Data Manager session. +After that, users need to authorize Data Manager again, which is fast and easy, especially if they use ORCID as identity provide in both cases. + +After successful access token retrieval, it is added to the Data Manager Security Context: + +```java +import life.qbic.datamanager.security.context.DMOAuth2BearerToken; +import life.qbic.datamanager.security.context.DMSecurityContext; + +DMOAuth2BearerToken token = new DMOAuth2BearerToken(accessToken, refreshToken, "zenodo"); +var contextBuilder = new DMSecurityContext.Builder(); +DMSecurityContext context = contextBuilder.addPrincipal(token).build(); + +// append context to the user session +session.addAttribute("DATA_MANAGER_SECURITY_CONTEXT", context); +``` + +So in any authenticated context in the Data Manager, we can now look for existing access tokens if the +user already granted access and do actions on behalf of them from the app. + + + + + + + + + + + + + + diff --git a/user-interface/pom.xml b/user-interface/pom.xml index b015d77a69..d8af84f214 100644 --- a/user-interface/pom.xml +++ b/user-interface/pom.xml @@ -154,6 +154,10 @@ org.springframework.boot spring-boot-starter-oauth2-client + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + diff --git a/user-interface/src/main/java/life/qbic/datamanager/DataManagerContextProvider.java b/user-interface/src/main/java/life/qbic/datamanager/DataManagerContextProvider.java index 54126ba658..34a549fc3e 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/DataManagerContextProvider.java +++ b/user-interface/src/main/java/life/qbic/datamanager/DataManagerContextProvider.java @@ -61,4 +61,9 @@ public String urlToSamplePage(String projectId, String experimentId) { throw new ApplicationException("Data Manager context creation failed.", e); } } + + @Override + public String baseUrl() { + return baseUrlApplication.toExternalForm(); + } } diff --git a/user-interface/src/main/java/life/qbic/datamanager/MyVaadinSessionInitListener.java b/user-interface/src/main/java/life/qbic/datamanager/MyVaadinSessionInitListener.java index 08ac65f23d..f252df2ab0 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/MyVaadinSessionInitListener.java +++ b/user-interface/src/main/java/life/qbic/datamanager/MyVaadinSessionInitListener.java @@ -6,14 +6,20 @@ import com.vaadin.flow.component.UI; import com.vaadin.flow.component.page.Page.ExtendedClientDetailsReceiver; import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.server.RequestHandler; import com.vaadin.flow.server.ServiceDestroyEvent; import com.vaadin.flow.server.ServiceInitEvent; import com.vaadin.flow.server.SessionDestroyEvent; import com.vaadin.flow.server.SessionInitEvent; import com.vaadin.flow.server.UIInitEvent; +import com.vaadin.flow.server.VaadinRequest; +import com.vaadin.flow.server.VaadinResponse; import com.vaadin.flow.server.VaadinServiceInitListener; +import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.server.WrappedSession; +import com.vaadin.flow.shared.ui.Transport; import com.vaadin.flow.spring.annotation.SpringComponent; +import java.io.IOException; import life.qbic.datamanager.exceptionhandling.UiExceptionHandler; import life.qbic.datamanager.security.LogoutService; import life.qbic.datamanager.views.AppRoutes; diff --git a/user-interface/src/main/java/life/qbic/datamanager/security/DMOAuth2AccessTokenResponseClient.java b/user-interface/src/main/java/life/qbic/datamanager/security/DMOAuth2AccessTokenResponseClient.java new file mode 100644 index 0000000000..552adf451e --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/security/DMOAuth2AccessTokenResponseClient.java @@ -0,0 +1,99 @@ +package life.qbic.datamanager.security; + +import static org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames.TOKEN_TYPE; +import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.ACCESS_TOKEN; +import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.EXPIRES_IN; + +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import org.springframework.core.ParameterizedTypeReference; +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.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +/** + * + * + *

+ * + * @since + */ +@Component +public class DMOAuth2AccessTokenResponseClient implements + OAuth2AccessTokenResponseClient { + + private static Map filterOptional(Map body) { + return body.entrySet().stream().filter(entry -> isOptionalParameter(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private static boolean isOptionalParameter(String value) { + return switch (value) { + case "access_token" -> false; + case "token_type" -> false; + case "expires_in" -> false; + default -> true; + }; + } + + private static Optional fromString(String tokenType) { + switch (tokenType.toLowerCase()) { + case "bearer": + return Optional.of(TokenType.BEARER); + default: + return Optional.empty(); + } + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest request) { + // Send the token request + String tokenUri = request.getClientRegistration().getProviderDetails().getTokenUri(); + ResponseEntity> response = new RestTemplate().exchange( + tokenUri, HttpMethod.POST, createRequestEntity(request), + new ParameterizedTypeReference<>() { + } + ); + + // Parse the response manually + Map body = response.getBody(); + String accessToken = (String) body.get(ACCESS_TOKEN); + var tokenTypeString = (String) body.get(TOKEN_TYPE); + TokenType tokenType = fromString((String) body.get(TOKEN_TYPE)).orElseThrow(() -> + new RuntimeException("Unknown token type: '%s'".formatted(tokenTypeString))); + long expiresIn = ((Number) body.get(EXPIRES_IN)).longValue(); + + return OAuth2AccessTokenResponse.withToken(accessToken) + .tokenType(tokenType) + .expiresIn(expiresIn) + .scopes(request.getClientRegistration().getScopes()) + .additionalParameters(filterOptional(body)) + .build(); + } + + private HttpEntity createRequestEntity(OAuth2AuthorizationCodeGrantRequest request) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("code", request.getAuthorizationExchange().getAuthorizationResponse().getCode()); + body.add("redirect_uri", + request.getAuthorizationExchange().getAuthorizationRequest().getRedirectUri()); + body.add("client_id", request.getClientRegistration().getClientId()); + body.add("client_secret", request.getClientRegistration().getClientSecret()); + + return new HttpEntity<>(body, headers); + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/security/FilterChainDebugger.java b/user-interface/src/main/java/life/qbic/datamanager/security/FilterChainDebugger.java new file mode 100644 index 0000000000..2f35d8d4fe --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/security/FilterChainDebugger.java @@ -0,0 +1,30 @@ +package life.qbic.datamanager.security; + +/** + * + * + *

+ * + * @since + */ +import org.springframework.context.annotation.Bean; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.stereotype.Component; + +@Component +public class FilterChainDebugger { + + private final FilterChainProxy filterChainProxy; + + public FilterChainDebugger(FilterChainProxy filterChainProxy) { + this.filterChainProxy = filterChainProxy; + } + + @Bean + public void printFilterChains() { + filterChainProxy.getFilterChains().forEach(chain -> { + System.out.println("Filter Chain for: " + chain.getFilters()); + chain.getFilters().forEach(filter -> System.out.println(" " + filter.getClass().getName())); + }); + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/security/OAuth2Config.java b/user-interface/src/main/java/life/qbic/datamanager/security/OAuth2Config.java new file mode 100644 index 0000000000..1bae7bc37c --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/security/OAuth2Config.java @@ -0,0 +1,27 @@ +package life.qbic.datamanager.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; + +/** + * + * + *

+ * + * @since + */ + +@Configuration +public class OAuth2Config { + + @Bean + public OAuth2AuthorizedClientManager authorizedClientManager( + ClientRegistrationRepository clientRegistrationRepository, + OAuth2AuthorizedClientRepository authorizedClientRepository) { + return new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository); + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java b/user-interface/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java index 55afcceec7..c170bc04fd 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java +++ b/user-interface/src/main/java/life/qbic/datamanager/security/SecurityConfiguration.java @@ -13,8 +13,9 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @EnableWebSecurity @Configuration @@ -22,6 +23,7 @@ public class SecurityConfiguration extends VaadinWebSecurity { final VaadinDefaultRequestCache defaultRequestCache; + private final OAuth2AccessTokenResponseClient accessTokenResponseClient; @Value("${routing.registration.oidc.orcid.endpoint}") String registrationOrcidEndpoint; @@ -30,9 +32,11 @@ public class SecurityConfiguration extends VaadinWebSecurity { String emailConfirmationEndpoint; public SecurityConfiguration( - @Autowired VaadinDefaultRequestCache defaultRequestCache) { + @Autowired VaadinDefaultRequestCache defaultRequestCache, + OAuth2AccessTokenResponseClient accessTokenResponseClient) { this.defaultRequestCache = requireNonNull(defaultRequestCache, "defaultRequestCache must not be null"); + this.accessTokenResponseClient = accessTokenResponseClient; } @Bean @@ -50,18 +54,41 @@ private AuthenticationSuccessHandler authenticationSuccessHandler() { @Override protected void configure(HttpSecurity http) throws Exception { - http.authorizeHttpRequests(v -> v.requestMatchers( + /*http.authorizeHttpRequests(v -> v.requestMatchers( new AntPathRequestMatcher("/oauth2/authorization/orcid"), + new AntPathRequestMatcher("/oauth2/authorization/zenodo"), + new AntPathRequestMatcher("/oauth2/callback/zenodo2"), new AntPathRequestMatcher("/oauth2/code/**"), new AntPathRequestMatcher("images/*.png")) .permitAll()); + http.oauth2Login(oAuth2Login -> { oAuth2Login.loginPage("/login").permitAll(); oAuth2Login.defaultSuccessUrl("/"); + oAuth2Login.failureHandler((request, response, e) -> { + System.out.println(e.getMessage()); + }); oAuth2Login.successHandler( authenticationSuccessHandler()); oAuth2Login.failureUrl("/login?errorOauth2=true&error"); }); super.configure(http); + setLoginView(http, LoginLayout.class);*/ + http.authorizeHttpRequests(v -> + v.requestMatchers("/oauth2/authorization/zenodo").permitAll() // Public paths + .requestMatchers("/oauth2/code/**").permitAll() + .requestMatchers("images/*png").permitAll() + .requestMatchers("/zenodo/callback").authenticated() + ) + .oauth2Login(oauth2 -> { + oauth2.loginPage("/login").permitAll(); + oauth2.defaultSuccessUrl("/", true) + .tokenEndpoint(v -> v.accessTokenResponseClient(accessTokenResponseClient)); + oauth2.failureUrl("/login?errorOauth2=true&error"); + oauth2.successHandler(authenticationSuccessHandler()); + } + ); + super.configure(http); setLoginView(http, LoginLayout.class); } + } diff --git a/user-interface/src/main/java/life/qbic/datamanager/security/ZenodoOAuth2Controller.java b/user-interface/src/main/java/life/qbic/datamanager/security/ZenodoOAuth2Controller.java new file mode 100644 index 0000000000..15e542a879 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/security/ZenodoOAuth2Controller.java @@ -0,0 +1,187 @@ +package life.qbic.datamanager.security; + +import static life.qbic.logging.service.LoggerFactory.logger; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.Objects; +import life.qbic.datamanager.security.context.DMOAuth2BearerToken; +import life.qbic.datamanager.security.context.DMSecurityContext; +import life.qbic.logging.api.Logger; +import life.qbic.projectmanagement.application.AppContextProvider; +import org.apache.catalina.util.URLEncoder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * + * + *

+ * + * @since + */ +@RestController +@RequestMapping("/zenodo") +public class ZenodoOAuth2Controller { + + public static final String ZENODO_CLIENT_ID = "zenodo"; + public static final int STATE_LENGTH = 32; + private static final Logger log = logger(ZenodoOAuth2Controller.class); + private final ClientRegistrationRepository clientRepo; + private final DMOAuth2AccessTokenResponseClient dmOAuth2AccessTokenResponseClient; + private final AppContextProvider appContextProvider; + + @Autowired + public ZenodoOAuth2Controller(ClientRegistrationRepository clientRegistrationRepository, + DMOAuth2AccessTokenResponseClient dmOAuth2AccessTokenResponseClient, + AppContextProvider appContextProvider) { + this.clientRepo = Objects.requireNonNull(clientRegistrationRepository); + this.dmOAuth2AccessTokenResponseClient = Objects.requireNonNull( + dmOAuth2AccessTokenResponseClient); + this.appContextProvider = Objects.requireNonNull(appContextProvider); + } + + private static boolean hasValidState(HttpSession session, String responseState) { + var request = (OAuth2AuthorizationRequest) session.getAttribute( + HttpSessionOAuth2AuthorizationRequestRepository.class.getName() + ".AUTHORIZATION_REQUEST"); + if (request == null) { + return false; + } + return request.getState().equals(responseState); + } + + private static String secureRandomString() { + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[STATE_LENGTH]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().encodeToString(bytes); + } + + @GetMapping("/callback") + public void handleZenodoCallback(HttpServletRequest request, @RequestParam("code") String code, + @RequestParam("state") String state, HttpServletResponse response) + throws IOException { + var session = request.getSession(false); + if (session == null) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + if (!hasValidState(session, state)) { // CSRF protection + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + // Retrieve the client registration for Zenodo + ClientRegistration clientRegistration = clientRepo.findByRegistrationId(ZENODO_CLIENT_ID); + if (clientRegistration == null) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, + "Invalid client registration: " + ZENODO_CLIENT_ID); + return; + } + + // Build the request for the access token + OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest = new OAuth2AuthorizationCodeGrantRequest( + clientRegistration, + new OAuth2AuthorizationExchange( + OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri()) + .clientId(clientRegistration.getClientId()) + .redirectUri(clientRegistration.getRedirectUri() + .replace("{baseUrl}", appContextProvider.baseUrl())) + .state(secureRandomString()) // Use a real state value + .build(), + OAuth2AuthorizationResponse.success(code) + .code(code) + .redirectUri(clientRegistration.getRedirectUri() + .replace("{baseUrl}", appContextProvider.baseUrl())) + .build() + ) + ); + + // Execute the request via our custom response client, in order to conform to Zenodo's auth API. + OAuth2AccessTokenResponse tokenResponse = null; + var round = 1; + while (round < 10) { + try { + tokenResponse = dmOAuth2AccessTokenResponseClient.getTokenResponse( + authorizationCodeGrantRequest); + } catch (Exception e) { + log.error(e.getMessage()); + } + if (tokenResponse != null) { + break; + } + round++; + try { + Thread.sleep(100 * 2 ^ round); // we increase the duration every time to cut the server some slack + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + var originalPath = session.getAttribute("datamanager.originalRoute"); + + if (tokenResponse == null) { + // it failed! + if (originalPath != null) { + response.sendRedirect("%s/%s?error=%s".formatted(request.getContextPath(), originalPath, new URLEncoder().encode( + OAuth2Error.AUTH_FAILED, StandardCharsets.UTF_8))); + } else { + response.sendRedirect("%s?error=%s".formatted(request.getContextPath(), new URLEncoder().encode( + OAuth2Error.AUTH_FAILED, StandardCharsets.UTF_8))); + } + return; + } + + DMSecurityContext context; + if (session.getAttribute(DMSecurityContext.NAME) != null) { + var existingContext = (DMSecurityContext) session.getAttribute(DMSecurityContext.NAME); + context = createSecurityContext(tokenResponse, existingContext.principals().toArray()); + } else { + context = createSecurityContext(tokenResponse); + } + + session.setAttribute(DMSecurityContext.NAME, context); + + if (originalPath == null) { + response.sendRedirect(request.getContextPath()); + } + response.sendRedirect("%s/%s".formatted(request.getContextPath(), originalPath)); + } + + private static DMSecurityContext createSecurityContext(OAuth2AccessTokenResponse tokenResponse) { + var dmToken = new DMOAuth2BearerToken(tokenResponse.getAccessToken(), tokenResponse.getRefreshToken(), "zenodo"); + var contextBuilder = new DMSecurityContext.Builder(); + return contextBuilder.addPrincipal(dmToken).build(); + } + + private static DMSecurityContext createSecurityContext(OAuth2AccessTokenResponse tokenResponse, Object... principals) { + var dmToken = new DMOAuth2BearerToken(tokenResponse.getAccessToken(), tokenResponse.getRefreshToken(), "zenodo"); + var contextBuilder = new DMSecurityContext.Builder(); + contextBuilder.addPrincipal(dmToken); + Arrays.stream(principals).forEach(contextBuilder::addPrincipal); + return contextBuilder.build(); + } + + private static class OAuth2Error { + static final String AUTH_FAILED = "auth_failed"; + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/security/context/DMOAuth2BearerToken.java b/user-interface/src/main/java/life/qbic/datamanager/security/context/DMOAuth2BearerToken.java new file mode 100644 index 0000000000..59584ff695 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/security/context/DMOAuth2BearerToken.java @@ -0,0 +1,15 @@ +package life.qbic.datamanager.security.context; + +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; + +/** + * + * + *

+ * + * @since + */ +public record DMOAuth2BearerToken(OAuth2AccessToken accessToken, OAuth2RefreshToken refreshToken, String issuer) { + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/security/context/DMSecurityContext.java b/user-interface/src/main/java/life/qbic/datamanager/security/context/DMSecurityContext.java new file mode 100644 index 0000000000..c64716bbcf --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/security/context/DMSecurityContext.java @@ -0,0 +1,67 @@ +package life.qbic.datamanager.security.context; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * + * + *

+ * + * @since + */ +public class DMSecurityContext { + + public static final String NAME = "DATA_MANAGER_SECURITY_CONTEXT"; + private final ArrayList principals; + + private DMSecurityContext(List principals) { + this.principals = new ArrayList<>(principals); + } + + public List principals() { + return principals; + } + + public boolean hasPrincipal(Class clazz) { + for (Object principal : principals) { + if (clazz.isAssignableFrom(principal.getClass())) { + return true; + } + } + return false; + } + + public Optional getPrincipal(Class clazz) { + for (Object principal : principals) { + if (clazz.isAssignableFrom(principal.getClass())) { + return Optional.of(principal); + } + } + return Optional.empty(); + } + + public static class Builder { + + List principals; + + public Builder() { + principals = new ArrayList<>(); + } + + public Builder addPrincipal(Object principal) { + principals.add(principal); + return this; + } + + public DMSecurityContext build() { + return new DMSecurityContext(principals); + } + + } + + +} + + diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/demo/AuthTest.java b/user-interface/src/main/java/life/qbic/datamanager/views/demo/AuthTest.java new file mode 100644 index 0000000000..f25e5a4f0e --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/views/demo/AuthTest.java @@ -0,0 +1,71 @@ +package life.qbic.datamanager.views.demo; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.BeforeEnterObserver; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.VaadinRequest; +import com.vaadin.flow.server.VaadinServletRequest; +import com.vaadin.flow.spring.annotation.UIScope; +import jakarta.annotation.security.PermitAll; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Profile; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +/** + * + * + *

+ * + * @since + */ +@Profile("test-ui") // This view will only be available when the "test-ui" profile is active +@Route("zenodo-auth") +@PermitAll +@UIScope +@Component +public class AuthTest extends Div implements BeforeEnterObserver { + + private final Div errorPlaceHolder = new Div(); + + @Autowired + public AuthTest(ApplicationContext app) { + Button button = new Button("Authorize Zenodo"); + button.addClickListener(e -> { + HttpServletRequest request = ((VaadinServletRequest) VaadinRequest.getCurrent()).getHttpServletRequest(); + saveOriginalRoute(request); // + UI.getCurrent().getPage().setLocation("/dev/oauth2/authorization/zenodo"); + }); + add(button); + add(errorPlaceHolder); + } + + private void saveOriginalRoute(HttpServletRequest request) { + String currentRoute = UI.getCurrent().getInternals().getActiveViewLocation().getPathWithQueryParameters(); + request.getSession().setAttribute("datamanager.originalRoute", currentRoute); + } + + + @Override + public void beforeEnter(BeforeEnterEvent event) { + var queryParameters = event.getLocation().getQueryParameters(); + queryParameters.getSingleParameter("error").ifPresentOrElse(error -> {errorPlaceHolder.setText("You are not authorized to access this resource");}, () -> errorPlaceHolder.setText("")); + + + var auth = SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || !auth.isAuthenticated()) { + throw new IllegalStateException("Authentication required"); + } + + if (auth instanceof Jwt jwt) { + add(new Div("JWT available: " + jwt.getTokenValue())); + } + } +} diff --git a/user-interface/src/main/resources/application.properties b/user-interface/src/main/resources/application.properties index 6340fd147a..f11da6355f 100644 --- a/user-interface/src/main/resources/application.properties +++ b/user-interface/src/main/resources/application.properties @@ -107,7 +107,24 @@ spring.security.oauth2.client.provider.orcid.authorization-uri=${ORCID_AUTHORIZA spring.security.oauth2.client.provider.orcid.token-uri=${ORCID_TOKEN_URI:https://sandbox.orcid.org/oauth/token} spring.security.oauth2.client.provider.orcid.user-info-uri=${ORCID_USERINFO_URI:https://sandbox.orcid.org/oauth/userinfo} spring.security.oauth2.client.provider.orcid.jwk-set-uri=${ORCID_JWK_SET_URI:https://sandbox.orcid.org/oauth/jwks} -#logging.level.org.springframework.security.web=DEBUG +################# zenodo ############### +spring.security.oauth2.client.registration.zenodo.client-name=zenodo +spring.security.oauth2.client.registration.zenodo.client-id=XOLzn76ndm5ZCRMa8157l44fqMLw3Tz743nLQz3i +spring.security.oauth2.client.registration.zenodo.client-secret=zSwZKoh6aA0zuFUL4nCxpo53lKpuqK6X8cYIEpaIivcl8mNf95XWqDGrByw3 +spring.security.oauth2.client.registration.zenodo.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.zenodo.redirect-uri={baseUrl}/zenodo/callback +spring.security.oauth2.client.registration.zenodo.scope=deposit:write +spring.security.oauth2.client.provider.zenodo.authorization-uri=https://zenodo.org/oauth/authorize +spring.security.oauth2.client.provider.zenodo.token-uri=https://zenodo.org/oauth/token +spring.security.oauth2.client.provider.zenodo.user-info-uri=https://zenodo.org/api/me +spring.security.oauth2.client.provider.zenodo.user-name-attribute=id + +logging.level.org.springframework.security=DEBUG +logging.level.org.springframework.security.oauth2=DEBUG +logging.level.org.springframework.security.web.FilterChainProxy=DEBUG +logging.level.org.springframework.security.web=DEBUG +logging.level.org.springframework.security.oauth2.client=DEBUG +logging.level.org.springframework.web.client.RestTemplate=DEBUG ############################################################################### ################### ActiveMQ Artemis ########################################## # ActiveMQ Artemis is used as a global message broker handling diff --git a/user-interface/src/main/resources/templates/login.html b/user-interface/src/main/resources/templates/login.html new file mode 100644 index 0000000000..04e75bc585 --- /dev/null +++ b/user-interface/src/main/resources/templates/login.html @@ -0,0 +1,10 @@ + + + + + Title + + + + +