diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index 1ed50c1f4..dc4573350 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -33,8 +33,8 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.servlet.ModelAndView; +import java.net.URI; import java.util.LinkedHashMap; import java.util.List; import java.util.concurrent.TimeUnit; @@ -66,14 +66,30 @@ public UserAPI( this.storageUtil = storageUtil; } + @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" ) - public ModelAndView login(ModelMap model) { - return new ModelAndView("redirect:/oauth2/authorization/github", model); + public ResponseEntity login(ModelMap model) { + if(users.canLogin()) { + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(UrlUtil.createApiUrl(UrlUtil.getBaseUrl(), "oauth2", "authorization", "github"))) + .build(); + } else { + return ResponseEntity.notFound().build(); + } } /** @@ -84,7 +100,7 @@ public ModelAndView login(ModelMap model) { produces = MediaType.APPLICATION_JSON_VALUE ) public ErrorJson getAuthError(HttpServletRequest request) { - var authException = request.getSession().getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); + var authException = users.canLogin() ? request.getSession().getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION) : null; if (!(authException instanceof AuthenticationException)) throw new ResponseStatusException(HttpStatus.NOT_FOUND); diff --git a/server/src/main/java/org/eclipse/openvsx/UserService.java b/server/src/main/java/org/eclipse/openvsx/UserService.java index ec7794702..1c9ce83f9 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserService.java +++ b/server/src/main/java/org/eclipse/openvsx/UserService.java @@ -30,8 +30,10 @@ import org.eclipse.openvsx.security.IdPrincipal; 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; @@ -53,22 +55,29 @@ public class UserService { private final StorageUtilService storageUtil; private final CacheService cache; private final ExtensionValidator validator; + private final ClientRegistrationRepository clientRegistrationRepository; public UserService( EntityManager entityManager, RepositoryService repositories, StorageUtilService storageUtil, CacheService cache, - ExtensionValidator validator + ExtensionValidator validator, + @Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository ) { this.entityManager = entityManager; this.repositories = repositories; this.storageUtil = storageUtil; this.cache = cache; this.validator = validator; + this.clientRegistrationRepository = clientRegistrationRepository; } public UserData findLoggedInUser() { + if(!canLogin()) { + return null; + } + var authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null) { if (authentication.getPrincipal() instanceof IdPrincipal) { @@ -321,4 +330,8 @@ public ResultJson deleteAccessToken(UserData user, long id) { token.setActive(false); return ResultJson.success("Deleted access token for user " + user.getLoginName() + "."); } + + public boolean canLogin() { + return clientRegistrationRepository != null && clientRegistrationRepository.findByRegistrationId("github") != null; + } } \ No newline at end of file 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 f749e381f..077ed4d50 100644 --- a/server/src/main/java/org/eclipse/openvsx/security/OAuth2UserServices.java +++ b/server/src/main/java/org/eclipse/openvsx/security/OAuth2UserServices.java @@ -110,6 +110,10 @@ public IdPrincipal loadUser(OAuth2UserRequest userRequest) { } } + public boolean canLogin() { + return users.canLogin(); + } + private IdPrincipal loadGitHubUser(OAuth2UserRequest userRequest) { var authUser = delegate.loadUser(userRequest); String loginName = authUser.getAttribute("login"); 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 7b9c2fa6c..00477ed61 100644 --- a/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/security/SecurityConfig.java @@ -10,11 +10,13 @@ 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; @@ -30,12 +32,18 @@ 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 redirectUrl = StringUtils.isEmpty(webuiUrl) ? "/" : webuiUrl; - return http.authorizeHttpRequests( + var filterChain = http.authorizeHttpRequests( registry -> registry - .requestMatchers(antMatchers("/*", "/login/**", "/oauth2/**", "/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/**", "/can-login", "/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() @@ -52,15 +60,20 @@ public SecurityFilterChain filterChain(HttpSecurity http, OAuth2UserServices use .csrf(configurer -> { configurer.ignoringRequestMatchers(antMatchers("/api/-/publish", "/api/-/namespace/create", "/api/-/query", "/vscode/**")); }) - .exceptionHandling(configurer -> configurer.authenticationEntryPoint(new Http403ForbiddenEntryPoint())) - .oauth2Login(configurer -> { - configurer.defaultSuccessUrl(redirectUrl); - configurer.successHandler(new CustomAuthenticationSuccessHandler(redirectUrl)); - configurer.failureUrl(redirectUrl + "?auth-error"); - configurer.userInfoEndpoint(customizer -> customizer.oidcUserService(userServices.getOidc()).userService(userServices.getOauth2())); - }) - .logout(configurer -> configurer.logoutSuccessUrl(redirectUrl)) - .build(); + .exceptionHandling(configurer -> configurer.authenticationEntryPoint(new Http403ForbiddenEntryPoint())); + + if(userServices.canLogin()) { + var redirectUrl = StringUtils.isEmpty(webuiUrl) ? "/" : webuiUrl; + filterChain.oauth2Login(configurer -> { + configurer.defaultSuccessUrl(redirectUrl); + configurer.successHandler(new CustomAuthenticationSuccessHandler(redirectUrl)); + configurer.failureUrl(redirectUrl + "?auth-error"); + configurer.userInfoEndpoint(customizer -> customizer.oidcUserService(userServices.getOidc()).userService(userServices.getOauth2())); + }) + .logout(configurer -> configurer.logoutSuccessUrl(redirectUrl)); + } + + return filterChain.build(); } private RequestMatcher[] antMatchers(String... patterns) diff --git a/server/src/main/java/org/eclipse/openvsx/security/TokenService.java b/server/src/main/java/org/eclipse/openvsx/security/TokenService.java index 53546d952..2594a7b1c 100644 --- a/server/src/main/java/org/eclipse/openvsx/security/TokenService.java +++ b/server/src/main/java/org/eclipse/openvsx/security/TokenService.java @@ -28,6 +28,7 @@ import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; +import org.springframework.beans.factory.annotation.Autowired; import java.time.Instant; import java.util.Arrays; @@ -44,16 +45,20 @@ public class TokenService { public TokenService( TransactionTemplate transactions, EntityManager entityManager, - ClientRegistrationRepository clientRegistrationRepository + @Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository ) { this.transactions = transactions; this.entityManager = entityManager; this.clientRegistrationRepository = clientRegistrationRepository; } + private boolean isEnabled() { + return clientRegistrationRepository != null; + } + public AuthToken updateTokens(long userId, String registrationId, OAuth2AccessToken accessToken, OAuth2RefreshToken refreshToken) { - var userData = entityManager.find(UserData.class, userId); + var userData = isEnabled() ? entityManager.find(UserData.class, userId) : null; if (userData == null) { return null; } @@ -119,6 +124,10 @@ private AuthToken updateEclipseToken(UserData userData, AuthToken token) { } public AuthToken getActiveToken(UserData userData, String registrationId) { + if(!isEnabled()) { + return null; + } + switch (registrationId) { case "github": { return userData.getGithubToken(); @@ -148,7 +157,7 @@ private boolean isExpired(Instant instant) { return instant != null && Instant.now().isAfter(instant); } - protected Pair refreshEclipseToken(AuthToken token) { + private Pair refreshEclipseToken(AuthToken token) { if(token.refreshToken() == null || isExpired(token.refreshExpiresAt())) { return null; } 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 80aeda39b..dabce6231 100644 --- a/server/src/main/java/org/eclipse/openvsx/web/WebConfig.java +++ b/server/src/main/java/org/eclipse/openvsx/web/WebConfig.java @@ -53,6 +53,8 @@ public void addCorsMappings(CorsRegistry registry) { .allowedOrigins(webuiUrl) .allowCredentials(true); } + registry.addMapping("/can-login") + .allowedOrigins(webuiUrl); registry.addMapping("/documents/**") .allowedOrigins("*"); registry.addMapping("/api/**") 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 d8a43273a..487bd5734 100644 --- a/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java @@ -918,9 +918,10 @@ UserService userService( RepositoryService repositories, StorageUtilService storageUtil, CacheService cache, - ExtensionValidator validator + ExtensionValidator validator, + ClientRegistrationRepository clientRegistrationRepository ) { - return new UserService(entityManager, repositories, storageUtil, cache, validator); + return new UserService(entityManager, repositories, storageUtil, cache, validator, clientRegistrationRepository); } @Bean diff --git a/webui/src/context.ts b/webui/src/context.ts index 10c8c5053..cbbc73777 100644 --- a/webui/src/context.ts +++ b/webui/src/context.ts @@ -20,6 +20,7 @@ export interface MainContext { handleError: (err: Error | Partial) => void; user?: UserData; updateUser: () => void; + canLogin: boolean; } // We don't include `undefined` as context value to avoid checking the value in all components diff --git a/webui/src/default/menu-content.tsx b/webui/src/default/menu-content.tsx index e054a9b8c..6ca35ac94 100644 --- a/webui/src/default/menu-content.tsx +++ b/webui/src/default/menu-content.tsx @@ -116,15 +116,15 @@ export const MobileUserAvatar: FunctionComponent = () => { }; export const MobileMenuContent: FunctionComponent = () => { - const location = useLocation(); - const { service, user } = useContext(MainContext); + const { service, user, canLogin } = useContext(MainContext); return <> - { - user - ? - : + {canLogin && ( + user ? ( + + ) : ( + @@ -132,10 +132,10 @@ export const MobileMenuContent: FunctionComponent = () => { - } - { - !location.pathname.startsWith(UserSettingsRoutes.ROOT) - ? + ) + )} + {canLogin && !location.pathname.startsWith(UserSettingsRoutes.ROOT) && ( + @@ -143,8 +143,7 @@ export const MobileMenuContent: FunctionComponent = () => { - : null - } + )} @@ -200,7 +199,7 @@ export const MenuLink = styled(Link)(headerItem); export const MenuRouteLink = styled(RouteLink)(headerItem); export const DefaultMenuContent: FunctionComponent = () => { - const { service, user } = useContext(MainContext); + const { service, user, canLogin } = useContext(MainContext); return <> Documentation @@ -211,19 +210,23 @@ export const DefaultMenuContent: FunctionComponent = () => { About - - { - user ? - - : - - - - } + {canLogin && ( + <> + + { + user ? + + : + + + + } + + )} ; }; diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index ff872535a..fdb67b524 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -420,6 +420,11 @@ export class ExtensionRegistryService { const endpoint = createAbsoluteURL([this.serverUrl, 'api', 'version']); return sendRequest({ abortController, endpoint }); } + + async canLogin(abortController: AbortController): Promise> { + const endpoint = createAbsoluteURL([this.serverUrl, 'can-login']); + return sendRequest({ abortController, endpoint }); + } } export interface AdminService { diff --git a/webui/src/main.tsx b/webui/src/main.tsx index 34419329d..b6e3190bf 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -26,11 +26,14 @@ import { OtherPages } from './other-pages'; export const Main: FunctionComponent = props => { const [user, setUser] = useState(); const [userLoading, setUserLoading] = useState(true); + const [canLogin, setCanLogin] = useState(props.canLogin ?? true); const [error, setError] = useState<{message: string, code?: number | string}>(); const [isErrorDialogOpen, setIsErrorDialogOpen] = useState(false); const abortController = useRef(new AbortController()); useEffect(() => { + getCanLogin(); + // If there was an authentication error, get the message from the server and show it const searchParams = new URLSearchParams(window.location.search); if (searchParams.has('auth-error')) { @@ -60,6 +63,15 @@ export const Main: FunctionComponent = props => { setUserLoading(false); }; + const getCanLogin = async () => { + if (props.canLogin != null) { + return; + } + + const newCanLogin = await props.service.canLogin(abortController.current); + setCanLogin(newCanLogin); + }; + const onError = (err: Error | Partial | ReportedError) => { if (err instanceof DOMException && err.message.trim() === 'The operation was aborted.') { // ignore error caused by AbortController.abort() @@ -101,6 +113,7 @@ export const Main: FunctionComponent = props => { pageSettings: props.pageSettings, user, updateUser, + canLogin, handleError: onError }; return <> @@ -114,4 +127,5 @@ export const Main: FunctionComponent = props => { export interface MainProps { service: ExtensionRegistryService; pageSettings: PageSettings; + canLogin?: boolean; } \ No newline at end of file