diff --git a/server/src/main/java/org/eclipse/openvsx/RegistryApplication.java b/server/src/main/java/org/eclipse/openvsx/RegistryApplication.java index 5a3745d63..89c24ad53 100644 --- a/server/src/main/java/org/eclipse/openvsx/RegistryApplication.java +++ b/server/src/main/java/org/eclipse/openvsx/RegistryApplication.java @@ -12,11 +12,13 @@ import io.micrometer.core.aop.TimedAspect; import io.micrometer.core.instrument.MeterRegistry; import org.eclipse.openvsx.mirror.ReadOnlyRequestFilter; +import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.web.ShallowEtagHeaderFilter; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; @@ -34,6 +36,7 @@ @EnableRetry @EnableAsync @EnableCaching(proxyTargetClass = true) +@EnableConfigurationProperties(OAuth2AttributesConfig.class) public class RegistryApplication { public static void main(String[] args) { diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index c5a47c4e3..e85a4ff25 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -29,12 +29,10 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.WebAttributes; import org.springframework.security.web.csrf.CsrfToken; -import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import java.net.URI; import java.util.LinkedHashMap; import java.util.List; import java.util.concurrent.TimeUnit; @@ -67,29 +65,18 @@ public UserAPI( } @GetMapping( - path = "/can-login", - produces = MediaType.APPLICATION_JSON_VALUE - ) - public ResponseEntity canLogin() { - return ResponseEntity.ok() - .cacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES).cachePublic()) - .body(users.canLogin()); - } - - /** - * Redirect to GitHub Oauth2 login as default login provider. - */ - @GetMapping( - path = "/login" + path = "/login-providers" ) - public ResponseEntity login(ModelMap model) { - if(users.canLogin()) { - return ResponseEntity.status(HttpStatus.FOUND) - .location(URI.create(UrlUtil.createApiUrl(UrlUtil.getBaseUrl(), "oauth2", "authorization", "github"))) - .build(); + public ResponseEntity login() { + var json = new LoginProvidersJson(); + var providers = users.getLoginProviders(); + if(!providers.isEmpty()) { + json.setLoginProviders(providers); } else { - return ResponseEntity.notFound().build(); + json.setSuccess("No login providers available."); } + + return ResponseEntity.ok(json); } /** @@ -313,7 +300,7 @@ public ResponseEntity getNamespaceMembers(@PathVari membershipList.setNamespaceMemberships(memberships.stream().map(NamespaceMembership::toJson).toList()); return new ResponseEntity<>(membershipList, HttpStatus.OK); } else { - return new ResponseEntity<>(NamespaceMembershipListJson.error("You don't have the permission to see this."), HttpStatus.FORBIDDEN); + return new ResponseEntity<>(NamespaceMembershipListJson.error("You don't have the permission to see this."), HttpStatus.FORBIDDEN); } } @@ -366,4 +353,4 @@ public ResponseEntity signPublisherAgreement() { } } -} \ No newline at end of file +} diff --git a/server/src/main/java/org/eclipse/openvsx/UserService.java b/server/src/main/java/org/eclipse/openvsx/UserService.java index 1c9ce83f9..87fc5493f 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserService.java +++ b/server/src/main/java/org/eclipse/openvsx/UserService.java @@ -28,21 +28,20 @@ import org.eclipse.openvsx.json.ResultJson; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.security.IdPrincipal; +import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.storage.StorageUtilService; import org.eclipse.openvsx.util.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.CacheEvict; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.Files; -import java.util.List; -import java.util.Objects; -import java.util.UUID; +import java.util.*; +import java.util.stream.Collectors; import static org.eclipse.openvsx.cache.CacheService.CACHE_NAMESPACE_DETAILS_JSON; import static org.eclipse.openvsx.util.UrlUtil.createApiUrl; @@ -56,6 +55,7 @@ public class UserService { private final CacheService cache; private final ExtensionValidator validator; private final ClientRegistrationRepository clientRegistrationRepository; + private final OAuth2AttributesConfig attributesConfig; public UserService( EntityManager entityManager, @@ -63,7 +63,8 @@ public UserService( StorageUtilService storageUtil, CacheService cache, ExtensionValidator validator, - @Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository + @Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository, + OAuth2AttributesConfig attributesConfig ) { this.entityManager = entityManager; this.repositories = repositories; @@ -71,6 +72,7 @@ public UserService( this.cache = cache; this.validator = validator; this.clientRegistrationRepository = clientRegistrationRepository; + this.attributesConfig = attributesConfig; } public UserData findLoggedInUser() { @@ -88,56 +90,6 @@ public UserData findLoggedInUser() { return null; } - @Transactional - public UserData registerNewUser(OAuth2User oauth2User) { - var user = new UserData(); - user.setProvider("github"); - user.setAuthId(oauth2User.getName()); - user.setLoginName(oauth2User.getAttribute("login")); - user.setFullName(oauth2User.getAttribute("name")); - user.setEmail(oauth2User.getAttribute("email")); - user.setProviderUrl(oauth2User.getAttribute("html_url")); - user.setAvatarUrl(oauth2User.getAttribute("avatar_url")); - entityManager.persist(user); - return user; - } - - @Transactional - public UserData updateExistingUser(UserData user, OAuth2User oauth2User) { - if ("github".equals(user.getProvider())) { - var updated = false; - String loginName = oauth2User.getAttribute("login"); - if (loginName != null && !loginName.equals(user.getLoginName())) { - user.setLoginName(loginName); - updated = true; - } - String fullName = oauth2User.getAttribute("name"); - if (fullName != null && !fullName.equals(user.getFullName())) { - user.setFullName(fullName); - updated = true; - } - String email = oauth2User.getAttribute("email"); - if (email != null && !email.equals(user.getEmail())) { - user.setEmail(email); - updated = true; - } - String providerUrl = oauth2User.getAttribute("html_url"); - if (providerUrl != null && !providerUrl.equals(user.getProviderUrl())) { - user.setProviderUrl(providerUrl); - updated = true; - } - String avatarUrl = oauth2User.getAttribute("avatar_url"); - if (avatarUrl != null && !avatarUrl.equals(user.getAvatarUrl())) { - user.setAvatarUrl(avatarUrl); - updated = true; - } - if (updated) { - cache.evictExtensionJsons(user); - } - } - return user; - } - @Transactional public PersonalAccessToken useAccessToken(String tokenValue) { var token = repositories.findAccessToken(tokenValue); @@ -332,6 +284,53 @@ public ResultJson deleteAccessToken(UserData user, long id) { } public boolean canLogin() { - return clientRegistrationRepository != null && clientRegistrationRepository.findByRegistrationId("github") != null; + return !getLoginProviders().isEmpty(); + } + + @Transactional + public UserData upsertUser(UserData newUser) { + var userData = repositories.findUserByLoginName(newUser.getProvider(), newUser.getLoginName()); + if (userData == null) { + entityManager.persist(newUser); + userData = newUser; + } else { + var updated = false; + if (!StringUtils.equals(userData.getLoginName(), newUser.getLoginName())) { + userData.setLoginName(newUser.getLoginName()); + updated = true; + } + if (!StringUtils.equals(userData.getFullName(), newUser.getFullName())) { + userData.setFullName(newUser.getFullName()); + updated = true; + } + if (!StringUtils.equals(userData.getEmail(), newUser.getEmail())) { + userData.setEmail(newUser.getEmail()); + updated = true; + } + if (!StringUtils.equals(userData.getProviderUrl(), newUser.getProviderUrl())) { + userData.setProviderUrl(newUser.getProviderUrl()); + updated = true; + } + if (!StringUtils.equals(userData.getAvatarUrl(), newUser.getAvatarUrl())) { + userData.setAvatarUrl(newUser.getAvatarUrl()); + updated = true; + } + if (updated) { + cache.evictExtensionJsons(userData); + } + } + + return userData; + } + + public Map getLoginProviders() { + if(clientRegistrationRepository == null) { + return Collections.emptyMap(); + } + + return attributesConfig.getProviders().stream() + .filter(provider -> clientRegistrationRepository.findByRegistrationId(provider) != null) + .map(provider -> Map.entry(provider, UrlUtil.createApiUrl(UrlUtil.getBaseUrl(), "oauth2", "authorization", provider))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } -} \ No newline at end of file +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/LoginProvidersJson.java b/server/src/main/java/org/eclipse/openvsx/json/LoginProvidersJson.java new file mode 100644 index 000000000..c9e4cb883 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/json/LoginProvidersJson.java @@ -0,0 +1,27 @@ +/** ****************************************************************************** + * Copyright (c) 2025 Precies. Software OU and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + * ****************************************************************************** */ +package org.eclipse.openvsx.json; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.Map; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class LoginProvidersJson extends ResultJson { + private Map loginProviders; + + public Map getLoginProviders() { + return loginProviders; + } + + public void setLoginProviders(Map loginProviders) { + this.loginProviders = loginProviders; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/security/CodedAuthException.java b/server/src/main/java/org/eclipse/openvsx/security/CodedAuthException.java index 23df99eee..a6a9bddf9 100644 --- a/server/src/main/java/org/eclipse/openvsx/security/CodedAuthException.java +++ b/server/src/main/java/org/eclipse/openvsx/security/CodedAuthException.java @@ -21,22 +21,27 @@ public class CodedAuthException extends AuthenticationException { public static final String UNSUPPORTED_REGISTRATION = "unsupported-registration"; public static final String INVALID_GITHUB_USER = "invalid-github-user"; + public static final String INVALID_USER = "invalid-user"; public static final String NEED_MAIN_LOGIN = "need-main-login"; public static final String ECLIPSE_MISSING_GITHUB_ID = "eclipse-missing-github-id"; public static final String ECLIPSE_MISMATCH_GITHUB_ID = "eclipse-mismatch-github-id"; @Serial private static final long serialVersionUID = 1L; - + private final String code; - public CodedAuthException(String message, String code) { + public CodedAuthException(String message, String code) { super(message); this.code = code; } - + + public CodedAuthException(String message, String code, Throwable cause) { + super(message, cause); + this.code = code; + } + public String getCode() { - return code; - } - -} \ No newline at end of file + return code; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/security/OAuth2AttributesConfig.java b/server/src/main/java/org/eclipse/openvsx/security/OAuth2AttributesConfig.java new file mode 100644 index 000000000..f6d97c902 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/security/OAuth2AttributesConfig.java @@ -0,0 +1,48 @@ +/******************************************************************************** + * Copyright (c) 2023 Ericsson and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.*; + +/** + * Configuration example: + *

+ *ovsx:
+ *  oauth2:
+ *    attribute-names:
+ *      [provider-name]:
+ *        avatar-url: string
+ *        email: string
+ *        full-name: string
+ *        login-name: string
+ *        provider-url: string
+ * 
+ */ +@ConfigurationProperties(prefix = "ovsx.oauth2") +public record OAuth2AttributesConfig(Map attributeNames) { + private static final Map DEFAULT_MAPPINGS = Map.of( + "github", new OAuth2AttributesMapping("avatar_url", "email", "name", "login", "html_url") + ); + + public OAuth2AttributesMapping getAttributeMapping(String provider) { + return Optional.ofNullable(attributeNames).map(a -> a.get(provider)).orElse(DEFAULT_MAPPINGS.get(provider)); + } + + public List getProviders() { + var providers = new ArrayList<>(DEFAULT_MAPPINGS.keySet()); + if(attributeNames != null) { + providers.addAll(attributeNames.keySet()); + } + + return providers; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/security/OAuth2AttributesMapping.java b/server/src/main/java/org/eclipse/openvsx/security/OAuth2AttributesMapping.java new file mode 100644 index 000000000..b3f4fd7e2 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/security/OAuth2AttributesMapping.java @@ -0,0 +1,43 @@ +/** ****************************************************************************** + * Copyright (c) 2025 Precies. Software OU and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + * ****************************************************************************** */ +package org.eclipse.openvsx.security; + +import org.eclipse.openvsx.entities.UserData; +import org.springframework.security.oauth2.core.user.OAuth2User; + +public record OAuth2AttributesMapping( + String avatarUrl, + String email, + String fullName, + String loginName, + String providerUrl +) { + + /** + * @param provider The configured OAuth2 provider from which the user object came from. + * @param oauth2User The OAuth2 user object to get attributes from. + * @return A {@link UserData} instance with attributes set according to the current configuration. + */ + public UserData toUserData(String provider, OAuth2User oauth2User) { + var userData = new UserData(); + userData.setAuthId(oauth2User.getName()); + userData.setProvider(provider); + userData.setAvatarUrl(getAttribute(oauth2User, avatarUrl)); + userData.setEmail(getAttribute(oauth2User, email)); + userData.setFullName(getAttribute(oauth2User, fullName)); + userData.setLoginName(getAttribute(oauth2User, loginName)); + userData.setProviderUrl(getAttribute(oauth2User, providerUrl)); + return userData; + } + + private T getAttribute(OAuth2User oauth2User, String attribute) { + return attribute == null ? null : oauth2User.getAttribute(attribute); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/security/OAuth2UserServices.java b/server/src/main/java/org/eclipse/openvsx/security/OAuth2UserServices.java index 8a81ba8d2..afd3005ec 100644 --- a/server/src/main/java/org/eclipse/openvsx/security/OAuth2UserServices.java +++ b/server/src/main/java/org/eclipse/openvsx/security/OAuth2UserServices.java @@ -21,22 +21,25 @@ import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.authentication.event.AuthenticationSuccessEvent; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken; 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.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import java.util.Collection; -import java.util.Collections; +import static java.util.Collections.emptyList; +import static java.util.Objects.requireNonNullElse; +import static org.eclipse.openvsx.entities.UserData.ROLE_ADMIN; +import static org.eclipse.openvsx.entities.UserData.ROLE_PRIVILEGED; import static org.eclipse.openvsx.security.CodedAuthException.*; +import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; @Service public class OAuth2UserServices { @@ -46,43 +49,30 @@ public class OAuth2UserServices { private final RepositoryService repositories; private final EntityManager entityManager; private final EclipseService eclipse; - private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); - private final OAuth2UserService oauth2; - private final OAuth2UserService oidc; + private final OAuth2AttributesConfig attributesConfig; + private final DefaultOAuth2UserService springOAuth2UserService; + private final OidcUserService springOidcUserService; public OAuth2UserServices( UserService users, TokenService tokens, RepositoryService repositories, EntityManager entityManager, - EclipseService eclipse + EclipseService eclipse, + OAuth2AttributesConfig attributesConfig ) { this.users = users; this.tokens = tokens; this.repositories = repositories; this.entityManager = entityManager; this.eclipse = eclipse; - this.oauth2 = new OAuth2UserService() { - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - return OAuth2UserServices.this.loadUser(userRequest); - } - }; - this.oidc = new OAuth2UserService() { - @Override - public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { - return OAuth2UserServices.this.loadUser(userRequest); - } - }; - } - - public OAuth2UserService getOauth2() { - return oauth2; + this.attributesConfig = attributesConfig; + springOAuth2UserService = new DefaultOAuth2UserService(); + springOidcUserService = new OidcUserService(); } - public OAuth2UserService getOidc() { - return oidc; - } + public OAuth2UserService getOauth2() { return this::loadUser; } + public OAuth2UserService getOidc() { return this::loadUser; } @EventListener public void authenticationSucceeded(AuthenticationSuccessEvent event) { @@ -99,32 +89,34 @@ public void authenticationSucceeded(AuthenticationSuccessEvent event) { } public IdPrincipal loadUser(OAuth2UserRequest userRequest) { - var registrationId = userRequest.getClientRegistration().getRegistrationId(); - switch (registrationId) { - case "github": - return loadGitHubUser(userRequest); - case "eclipse": - return loadEclipseUser(userRequest); - default: - throw new CodedAuthException("Unsupported registration: " + registrationId, UNSUPPORTED_REGISTRATION); - } + return switch (userRequest.getClientRegistration().getRegistrationId()) { + case "eclipse" -> loadEclipseUser(userRequest); + default -> loadGenericUser(userRequest); + }; } public boolean canLogin() { return users.canLogin(); } - private IdPrincipal loadGitHubUser(OAuth2UserRequest userRequest) { - var authUser = delegate.loadUser(userRequest); - String loginName = authUser.getAttribute("login"); - if (StringUtils.isEmpty(loginName)) - throw new CodedAuthException("Invalid login: missing 'login' field.", INVALID_GITHUB_USER); - var userData = repositories.findUserByLoginName("github", loginName); - if (userData == null) - userData = users.registerNewUser(authUser); - else - users.updateExistingUser(userData, authUser); - return new IdPrincipal(userData.getId(), authUser.getName(), getAuthorities(userData)); + private IdPrincipal loadGenericUser(OAuth2UserRequest userRequest) { + var registrationId = userRequest.getClientRegistration().getRegistrationId(); + var mapping = attributesConfig.getAttributeMapping(registrationId); + if(mapping == null) { + throw new CodedAuthException("Unsupported registration: " + registrationId ,UNSUPPORTED_REGISTRATION); + } + + var oauth2User = userRequest instanceof OidcUserRequest oidcRequest + ? springOidcUserService.loadUser(oidcRequest) + : springOAuth2UserService.loadUser(userRequest); + + var userAttributes = mapping.toUserData(registrationId, oauth2User); + if (StringUtils.isEmpty(userAttributes.getLoginName())) { + throw new CodedAuthException("Invalid login: missing 'login' field.", INVALID_USER); + } + + var userData = users.upsertUser(userAttributes); + return new IdPrincipal(userData.getId(), userData.getAuthId(), getAuthorities(userData)); } private IdPrincipal loadEclipseUser(OAuth2UserRequest userRequest) { @@ -159,15 +151,10 @@ private IdPrincipal loadEclipseUser(OAuth2UserRequest userRequest) { } private Collection getAuthorities(UserData userData) { - var role = userData.getRole(); - switch (role != null ? role : "") { - case UserData.ROLE_ADMIN: - return AuthorityUtils.createAuthorityList("ROLE_ADMIN"); - case UserData.ROLE_PRIVILEGED: - return AuthorityUtils.createAuthorityList("ROLE_PRIVILEGED"); - default: - return Collections.emptyList(); - } + return switch (requireNonNullElse(userData.getRole(), "")) { + case ROLE_ADMIN -> createAuthorityList("ROLE_ADMIN"); + case ROLE_PRIVILEGED -> createAuthorityList("ROLE_PRIVILEGED"); + default -> emptyList(); + }; } - -} \ No newline at end of file +} diff --git a/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java b/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java index 44e561861..949db5d49 100644 --- a/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java @@ -10,13 +10,11 @@ package org.eclipse.openvsx.security; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -32,18 +30,11 @@ public class SecurityConfig { @Value("${ovsx.webui.frontendRoutes:/extension/**,/namespace/**,/user-settings/**,/admin-dashboard/**}") String[] frontendRoutes; - private final ClientRegistrationRepository clientRegistrationRepository; - - @Autowired - public SecurityConfig(@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository) { - this.clientRegistrationRepository = clientRegistrationRepository; - } - @Bean public SecurityFilterChain filterChain(HttpSecurity http, OAuth2UserServices userServices) throws Exception { var filterChain = http.authorizeHttpRequests( registry -> registry - .requestMatchers(antMatchers("/*", "/login/**", "/oauth2/**", "/can-login", "/user", "/user/auth-error", "/logout", "/actuator/health/**", "/actuator/metrics", "/actuator/metrics/**", "/actuator/prometheus", "/v3/api-docs/**", "/swagger-resources/**", "/swagger-ui/**", "/webjars/**")) + .requestMatchers(antMatchers("/*", "/login/**", "/oauth2/**", "/login-providers", "/user", "/user/auth-error", "/logout", "/actuator/health/**", "/actuator/metrics", "/actuator/metrics/**", "/actuator/prometheus", "/v3/api-docs/**", "/swagger-resources/**", "/swagger-ui/**", "/webjars/**")) .permitAll() .requestMatchers(antMatchers("/api/*/*/review", "/api/*/*/review/delete", "/api/user/publish", "/api/user/namespace/create")) .authenticated() diff --git a/server/src/main/java/org/eclipse/openvsx/web/WebConfig.java b/server/src/main/java/org/eclipse/openvsx/web/WebConfig.java index dabce6231..6b510d01c 100644 --- a/server/src/main/java/org/eclipse/openvsx/web/WebConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/web/WebConfig.java @@ -53,7 +53,7 @@ public void addCorsMappings(CorsRegistry registry) { .allowedOrigins(webuiUrl) .allowCredentials(true); } - registry.addMapping("/can-login") + registry.addMapping("/login-providers") .allowedOrigins(webuiUrl); registry.addMapping("/documents/**") .allowedOrigins("*"); diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 4510294aa..46a76beb7 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -30,6 +30,7 @@ import org.eclipse.openvsx.search.ExtensionSearch; import org.eclipse.openvsx.search.ISearchService; import org.eclipse.openvsx.search.SearchUtilService; +import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.security.OAuth2UserServices; import org.eclipse.openvsx.security.SecurityConfig; import org.eclipse.openvsx.storage.*; @@ -2394,9 +2395,10 @@ OAuth2UserServices oauth2UserServices( TokenService tokens, RepositoryService repositories, EntityManager entityManager, - EclipseService eclipse + EclipseService eclipse, + OAuth2AttributesConfig attributesConfig ) { - return new OAuth2UserServices(users, tokens, repositories, entityManager, eclipse); + return new OAuth2UserServices(users, tokens, repositories, entityManager, eclipse, attributesConfig); } @Bean @@ -2521,4 +2523,4 @@ PublishExtensionVersionHandler publishExtensionVersionHandler( ); } } -} \ No newline at end of file +} diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index 7043ea427..294d735e1 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -23,6 +23,7 @@ import org.eclipse.openvsx.entities.UserData; import org.eclipse.openvsx.json.*; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.security.OAuth2UserServices; import org.eclipse.openvsx.security.SecurityConfig; import org.eclipse.openvsx.storage.StorageUtilService; @@ -555,9 +556,10 @@ OAuth2UserServices oauth2UserServices( TokenService tokens, RepositoryService repositories, EntityManager entityManager, - EclipseService eclipse + EclipseService eclipse, + OAuth2AttributesConfig attributesConfig ) { - return new OAuth2UserServices(users, tokens, repositories, entityManager, eclipse); + return new OAuth2UserServices(users, tokens, repositories, entityManager, eclipse, attributesConfig); } @Bean @@ -574,5 +576,4 @@ LatestExtensionVersionCacheKeyGenerator latestExtensionVersionCacheKeyGenerator( return new LatestExtensionVersionCacheKeyGenerator(); } } - } \ No newline at end of file diff --git a/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java b/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java index b1c501cf6..a6f499204 100644 --- a/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java @@ -16,6 +16,7 @@ import jakarta.persistence.EntityManager; import org.eclipse.openvsx.ExtensionValidator; import org.eclipse.openvsx.MockTransactionTemplate; +import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.UserService; import org.eclipse.openvsx.cache.CacheService; import org.eclipse.openvsx.cache.FilesCacheKeyGenerator; @@ -872,9 +873,10 @@ OAuth2UserServices oauth2UserServices( TokenService tokens, RepositoryService repositories, EntityManager entityManager, - EclipseService eclipse + EclipseService eclipse, + OAuth2AttributesConfig attributesConfig ) { - return new OAuth2UserServices(users, tokens, repositories, entityManager, eclipse); + return new OAuth2UserServices(users, tokens, repositories, entityManager, eclipse, attributesConfig); } @Bean @@ -916,9 +918,10 @@ UserService userService( StorageUtilService storageUtil, CacheService cache, ExtensionValidator validator, - ClientRegistrationRepository clientRegistrationRepository + ClientRegistrationRepository clientRegistrationRepository, + OAuth2AttributesConfig attributesConfig ) { - return new UserService(entityManager, repositories, storageUtil, cache, validator, clientRegistrationRepository); + return new UserService(entityManager, repositories, storageUtil, cache, validator, clientRegistrationRepository, attributesConfig); } @Bean @@ -968,5 +971,4 @@ FilesCacheKeyGenerator filesCacheKeyGenerator() { return new FilesCacheKeyGenerator(); } } - } diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index 21de120a6..bd1a54921 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -25,6 +25,7 @@ import org.eclipse.openvsx.publish.PublishExtensionVersionHandler; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; +import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.security.OAuth2UserServices; import org.eclipse.openvsx.security.SecurityConfig; import org.eclipse.openvsx.storage.*; @@ -1200,9 +1201,10 @@ OAuth2UserServices oauth2UserServices( TokenService tokens, RepositoryService repositories, EntityManager entityManager, - EclipseService eclipse + EclipseService eclipse, + OAuth2AttributesConfig attributesConfig ) { - return new OAuth2UserServices(users, tokens, repositories, entityManager, eclipse); + return new OAuth2UserServices(users, tokens, repositories, entityManager, eclipse, attributesConfig); } @Bean diff --git a/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java b/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java index 9beb6ad4e..e6d75e410 100644 --- a/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/cache/CacheServiceTest.java @@ -30,7 +30,6 @@ import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.test.context.ActiveProfiles; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -39,7 +38,6 @@ import java.nio.file.Files; import java.time.LocalDateTime; import java.util.Collections; -import java.util.HashMap; import java.util.List; import static org.eclipse.openvsx.cache.CacheService.CACHE_EXTENSION_JSON; @@ -102,31 +100,22 @@ void testUpdateExistingUser() throws IOException { registry.getExtension(namespace.getName(), extension.getName(), extVersion.getTargetPlatform(), extVersion.getVersion()); - var authority = "github"; - var authorities = List.of((GrantedAuthority) () -> authority); - - var loginName = "amvanbaren"; - var fullName = "Aart van Baren"; - var htmlUrl = "https://amvanbaren.github.io"; - var avatarUrl = "https://amvanbaren.github.io/avatar.png"; - var attributes = new HashMap(); - attributes.put("login", loginName); - attributes.put("name", fullName); - attributes.put("email", "amvanbaren@hotmail.com"); - attributes.put("html_url", htmlUrl); - attributes.put("avatar_url", avatarUrl); - - var user = extVersion.getPublishedWith().getUser(); - var oauthUser = new DefaultOAuth2User(authorities, attributes, "name"); - users.updateExistingUser(user, oauthUser); + var updatedUser = new UserData(); + updatedUser.setProvider("github"); + updatedUser.setLoginName("user"); + updatedUser.setFullName("User2"); + updatedUser.setProviderUrl("https://user2.github.io"); + updatedUser.setAvatarUrl("https://github.com/user2/avatar"); + + users.upsertUser(updatedUser); assertNull(cache.getCache(CACHE_EXTENSION_JSON).get(cacheKey, ExtensionJson.class)); var json = registry.getExtension(namespace.getName(), extension.getName(), extVersion.getTargetPlatform(), extVersion.getVersion()); - assertEquals(loginName, json.getPublishedBy().getLoginName()); - assertEquals(fullName, json.getPublishedBy().getFullName()); - assertEquals(htmlUrl, json.getPublishedBy().getHomepage()); - assertEquals(authority, json.getPublishedBy().getProvider()); - assertEquals(avatarUrl, json.getPublishedBy().getAvatarUrl()); + assertEquals("user", json.getPublishedBy().getLoginName()); + assertEquals("User2", json.getPublishedBy().getFullName()); + assertEquals("https://user2.github.io", json.getPublishedBy().getHomepage()); + assertEquals("github", json.getPublishedBy().getProvider()); + assertEquals("https://github.com/user2/avatar", json.getPublishedBy().getAvatarUrl()); var cachedJson = cache.getCache(CACHE_EXTENSION_JSON).get(cacheKey, ExtensionJson.class); assertEquals(json, cachedJson); diff --git a/server/src/test/java/org/eclipse/openvsx/web/SitemapControllerTest.java b/server/src/test/java/org/eclipse/openvsx/web/SitemapControllerTest.java index 964bf3167..8afa3571c 100644 --- a/server/src/test/java/org/eclipse/openvsx/web/SitemapControllerTest.java +++ b/server/src/test/java/org/eclipse/openvsx/web/SitemapControllerTest.java @@ -15,6 +15,7 @@ import org.eclipse.openvsx.eclipse.EclipseService; import org.eclipse.openvsx.eclipse.TokenService; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.security.OAuth2AttributesConfig; import org.eclipse.openvsx.security.OAuth2UserServices; import org.eclipse.openvsx.security.SecurityConfig; import org.junit.jupiter.api.Test; @@ -75,9 +76,10 @@ OAuth2UserServices oauth2UserServices( TokenService tokens, RepositoryService repositories, EntityManager entityManager, - EclipseService eclipse + EclipseService eclipse, + OAuth2AttributesConfig attributesConfig ) { - return new OAuth2UserServices(users, tokens, repositories, entityManager, eclipse); + return new OAuth2UserServices(users, tokens, repositories, entityManager, eclipse, attributesConfig); } @Bean diff --git a/webui/package.json b/webui/package.json index 1a8bb9c46..5c62d6523 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "openvsx-webui", - "version": "0.15.1", + "version": "0.16.0", "description": "User interface for Eclipse Open VSX", "keywords": [ "react", diff --git a/webui/src/context.ts b/webui/src/context.ts index cbbc73777..deae63d70 100644 --- a/webui/src/context.ts +++ b/webui/src/context.ts @@ -20,7 +20,7 @@ export interface MainContext { handleError: (err: Error | Partial) => void; user?: UserData; updateUser: () => void; - canLogin: boolean; + loginProviders?: Record; } // We don't include `undefined` as context value to avoid checking the value in all components diff --git a/webui/src/default/login.tsx b/webui/src/default/login.tsx new file mode 100644 index 000000000..ae647ce76 --- /dev/null +++ b/webui/src/default/login.tsx @@ -0,0 +1,43 @@ +/** ****************************************************************************** + * Copyright (c) 2025 Precies. Software OU and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + * ****************************************************************************** */ +import React, { FunctionComponent, ReactNode, useState } from "react"; +import { Button, Dialog, DialogContent, DialogTitle, Stack } from "@mui/material"; + +export const LoginComponent: FunctionComponent = (props) => { + const [dialogOpen, setDialogOpen] = useState(false); + + const showLoginDialog = () => setDialogOpen(true); + + const providers = Object.keys(props.loginProviders); + if (providers.length === 1) { + return props.renderButton(props.loginProviders[providers[0]]); + } else { + return <> + {props.renderButton(undefined, showLoginDialog)} + setDialogOpen(false)} + > + Log In + + + {providers.map((provider) => ())} + + + + ; + } +}; + +export interface LoginComponentProps { + loginProviders: Record + renderButton: (href?: string, onClick?: () => void) => ReactNode +} diff --git a/webui/src/default/menu-content.tsx b/webui/src/default/menu-content.tsx index 6ca35ac94..6ea7b1bb7 100644 --- a/webui/src/default/menu-content.tsx +++ b/webui/src/default/menu-content.tsx @@ -28,6 +28,7 @@ import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import LogoutIcon from '@mui/icons-material/Logout'; import { AdminDashboardRoutes } from '../pages/admin-dashboard/admin-dashboard'; import { LogoutForm } from '../pages/user/logout'; +import { LoginComponent } from './login'; //-------------------- Mobile View --------------------// @@ -117,24 +118,29 @@ export const MobileUserAvatar: FunctionComponent = () => { export const MobileMenuContent: FunctionComponent = () => { const location = useLocation(); - const { service, user, canLogin } = useContext(MainContext); + const { user, loginProviders } = useContext(MainContext); return <> - {canLogin && ( + {loginProviders && ( user ? ( ) : ( - - - - Log In - - + { + return ( + + + Log In + + ); + }} + /> ) )} - {canLogin && !location.pathname.startsWith(UserSettingsRoutes.ROOT) && ( + {loginProviders && !location.pathname.startsWith(UserSettingsRoutes.ROOT) && ( @@ -199,7 +205,7 @@ export const MenuLink = styled(Link)(headerItem); export const MenuRouteLink = styled(RouteLink)(headerItem); export const DefaultMenuContent: FunctionComponent = () => { - const { service, user, canLogin } = useContext(MainContext); + const { user, loginProviders } = useContext(MainContext); return <> Documentation @@ -210,7 +216,7 @@ export const DefaultMenuContent: FunctionComponent = () => { About - {canLogin && ( + {loginProviders && ( <>