diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8cd01ddd..f9ebf8c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,3 +38,4 @@ repos: rev: v1.35.8 hooks: - id: typos + exclude: 'src/main/resources/cp_messages_.*' diff --git a/pom.xml b/pom.xml index af354ea5..7b615440 100644 --- a/pom.xml +++ b/pom.xml @@ -34,7 +34,7 @@ 7.0.8-OA-5 1.3.2 12.0 - 42.7.8 + 42.7.11 7.6.1 3.7.1 4.7.0 diff --git a/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java b/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java index 1ae87ef2..22286494 100644 --- a/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java +++ b/src/main/java/eu/openanalytics/containerproxy/ContainerProxyApplication.java @@ -63,6 +63,8 @@ import org.springframework.session.security.SpringSessionBackedSessionRegistry; import org.springframework.session.web.http.DefaultCookieSerializer; import org.springframework.web.filter.FormContentFilter; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.i18n.CookieLocaleResolver; import javax.annotation.PostConstruct; import javax.inject.Inject; @@ -99,6 +101,8 @@ public class ContainerProxyApplication { private static final String SAME_SITE_COOKIE_DEFAULT_VALUE = "Lax"; private static final String PROP_SERVER_SECURE_COOKIES = "server.secure-cookies"; private static final Boolean SECURE_COOKIES_DEFAULT_VALUE = false; + private static final String PROP_FORCE_HTTPS_IN_REDIRECTS = "proxy.force-https-in-redirects"; + private static final Boolean FORCE_HTTPS_IN_REDIRECTS_DEFAULT_VALUE = false; private static final Path TERMINATION_LOG_FILE = Path.of("/dev/termination-log"); private static Boolean logAsJson = false; public static Boolean secureCookiesEnabled; @@ -333,6 +337,15 @@ public UndertowServletWebServerFactory servletContainer() { info.setSessionManagerFactory(sessionManagerFactory); } info.setResourceManager(EMPTY_RESOURCE_MANAGER); + if (environment.getProperty(PROP_FORCE_HTTPS_IN_REDIRECTS, Boolean.class, FORCE_HTTPS_IN_REDIRECTS_DEFAULT_VALUE)) { + log.debug("Enforcing redirects to always use https."); + info.addInitialHandlerChainWrapper(handler -> + exchange -> { + exchange.setRequestScheme("https"); + handler.handleRequest(exchange); + } + ); + } }); try { factory.setAddress(InetAddress.getByName(environment.getProperty("proxy.bind-address", "0.0.0.0"))); @@ -420,4 +433,14 @@ public GroupedOpenApi groupOpenApi() { .build(); } + @Bean + public LocaleResolver localeResolver() { + // no default language is configured, therefore the Accept header will determine the language if there is no cookie + CookieLocaleResolver cookieLocaleResolver = new CookieLocaleResolver("shinyproxy_language"); + cookieLocaleResolver.setCookieSecure(secureCookiesEnabled); + cookieLocaleResolver.setCookieSameSite(sameSiteCookiePolicy); + cookieLocaleResolver.setCookieHttpOnly(true); + return cookieLocaleResolver; + } + } diff --git a/src/main/java/eu/openanalytics/containerproxy/api/BaseController.java b/src/main/java/eu/openanalytics/containerproxy/api/BaseController.java index 2b3644b7..fbfacdb3 100644 --- a/src/main/java/eu/openanalytics/containerproxy/api/BaseController.java +++ b/src/main/java/eu/openanalytics/containerproxy/api/BaseController.java @@ -21,6 +21,7 @@ package eu.openanalytics.containerproxy.api; import eu.openanalytics.containerproxy.service.IdentifierService; +import eu.openanalytics.containerproxy.service.LanguageService; import eu.openanalytics.containerproxy.service.UserService; import eu.openanalytics.containerproxy.spec.expression.SpecExpressionContext; import eu.openanalytics.containerproxy.spec.expression.SpecExpressionResolver; @@ -49,6 +50,9 @@ public class BaseController { @Inject private UserService userService; + @Inject + private LanguageService languageService; + private String title; private boolean titleContainsExpression; @@ -72,6 +76,7 @@ protected void prepareMap(ModelMap map) { map.put("request", httpServletRequest); map.put("response", httpServletResponse); + map.put("language", languageService.getAndUpdateLanguageOfUser(httpServletRequest, httpServletResponse)); } private String getTitle(Authentication user, String serverName) { diff --git a/src/main/java/eu/openanalytics/containerproxy/api/UserController.java b/src/main/java/eu/openanalytics/containerproxy/api/UserController.java new file mode 100644 index 00000000..461cc566 --- /dev/null +++ b/src/main/java/eu/openanalytics/containerproxy/api/UserController.java @@ -0,0 +1,57 @@ +/* + * ContainerProxy + * + * Copyright (C) 2016-2026 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.containerproxy.api; + +import eu.openanalytics.containerproxy.api.dto.ApiResponse; +import eu.openanalytics.containerproxy.api.dto.LanguageDto; +import eu.openanalytics.containerproxy.service.LanguageService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class UserController extends BaseController { + + private final LanguageService languageService; + + public UserController(LanguageService languageService) { + this.languageService = languageService; + } + + @RequestMapping(value = "/api/user/language", method = RequestMethod.PUT, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> updateUserLanguage(@RequestBody LanguageDto body, HttpServletRequest request, HttpServletResponse response) { + try { + languageService.updateLanguageOfUser(request, response, body.language()); + } catch (IllegalArgumentException e) { + return ApiResponse.fail(e.getMessage()); + } catch (IllegalStateException e) { + return ApiResponse.error(e); + } + + return ApiResponse.success(); + } + +} diff --git a/src/main/java/eu/openanalytics/containerproxy/api/dto/LanguageDto.java b/src/main/java/eu/openanalytics/containerproxy/api/dto/LanguageDto.java new file mode 100644 index 00000000..1a4e522b --- /dev/null +++ b/src/main/java/eu/openanalytics/containerproxy/api/dto/LanguageDto.java @@ -0,0 +1,25 @@ +/* + * ContainerProxy + * + * Copyright (C) 2016-2026 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.containerproxy.api.dto; + +public record LanguageDto(String language) { + +} diff --git a/src/main/java/eu/openanalytics/containerproxy/auth/impl/OpenIDAuthenticationBackend.java b/src/main/java/eu/openanalytics/containerproxy/auth/impl/OpenIDAuthenticationBackend.java index 558a46fe..45b93994 100644 --- a/src/main/java/eu/openanalytics/containerproxy/auth/impl/OpenIDAuthenticationBackend.java +++ b/src/main/java/eu/openanalytics/containerproxy/auth/impl/OpenIDAuthenticationBackend.java @@ -24,9 +24,12 @@ import eu.openanalytics.containerproxy.auth.impl.msgraph.MicrosoftGraphGroupFetcher; import eu.openanalytics.containerproxy.auth.impl.oidc.AccessTokenDecoder; import eu.openanalytics.containerproxy.auth.impl.oidc.OpenIdReAuthorizeFilter; +import eu.openanalytics.containerproxy.model.runtime.Proxy; import eu.openanalytics.containerproxy.spec.expression.SpecExpressionContext; import eu.openanalytics.containerproxy.spec.expression.SpecExpressionResolver; import eu.openanalytics.containerproxy.util.ContextPathHelper; +import io.undertow.util.HeaderMap; +import io.undertow.util.HttpString; import net.minidev.json.JSONArray; import net.minidev.json.parser.JSONParser; import net.minidev.json.parser.ParseException; @@ -41,6 +44,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; @@ -50,9 +54,11 @@ import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.OidcUserInfo; import org.springframework.security.oauth2.core.oidc.StandardClaimAccessor; @@ -83,11 +89,22 @@ public class OpenIDAuthenticationBackend implements IAuthenticationBackend { public static final String NAME = "openid"; private static final String ENV_TOKEN_NAME = "SHINYPROXY_OIDC_ACCESS_TOKEN"; + private static final String PROP_SEND_ACCESS_TOKEN_HEADER = "proxy.openid.add-access-token-header"; + private static final String PROP_SEND_REFRESH_TOKEN_HEADER = "proxy.openid.add-refresh-token-header"; + private static final String PROP_SEND_ID_TOKEN_HEADER = "proxy.openid.add-id-token-header"; + private static final HttpString HEADER_ACCESS_TOKEN_NAME = new HttpString("X-SP-OpenId-Access-Token"); + private static final HttpString HEADER_REFRESH_TOKEN_NAME = new HttpString("X-SP-OpenId-Refresh-Token"); + private static final HttpString HEADER_ID_TOKEN_NAME = new HttpString("X-SP-OpenId-Id-Token"); + private static OAuth2AuthorizedClientService oAuth2AuthorizedClientService; private static AccessTokenDecoder accessTokenDecoder; private static final Logger log = LogManager.getLogger(OpenIDAuthenticationBackend.class); - @Inject - private Environment environment; + + private static Boolean sendAccessTokenHeader = false; + private static Boolean sendRefreshTokenHeader = false; + private static Boolean sendIdTokenHeader = false; + private static Environment environment; + @Inject private ClientRegistrationRepository clientRegistrationRepo; @Inject @@ -180,7 +197,7 @@ public Set parseClaims(StandardClaimAccessor standardClaimAcce return mappedAuthorities; } - private static OAuth2AuthorizedClient refreshClient(String principalName) { + public static OAuth2AuthorizedClient refreshClient(String principalName) { return oAuth2AuthorizedClientService.loadAuthorizedClient(REG_ID, principalName); } @@ -194,6 +211,14 @@ public void setOAuth2AuthorizedClientService(OAuth2AuthorizedClientService oAuth OpenIDAuthenticationBackend.oAuth2AuthorizedClientService = oAuth2AuthorizedClientService; } + @Autowired + public void setEnvironment(Environment environment) { + OpenIDAuthenticationBackend.environment = environment; + OpenIDAuthenticationBackend.sendAccessTokenHeader = environment.getProperty(PROP_SEND_ACCESS_TOKEN_HEADER, Boolean.class, false); + OpenIDAuthenticationBackend.sendRefreshTokenHeader = environment.getProperty(PROP_SEND_REFRESH_TOKEN_HEADER, Boolean.class, false); + OpenIDAuthenticationBackend.sendIdTokenHeader = environment.getProperty(PROP_SEND_ID_TOKEN_HEADER, Boolean.class, false); + } + @Override public String getName() { return NAME; @@ -350,6 +375,38 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio }; } + public static HeaderMap addHeaders(Proxy proxy) { + HeaderMap result = new HeaderMap(); + if (sendAccessTokenHeader || sendRefreshTokenHeader) { + OAuth2AuthorizedClient client = refreshClient(proxy.getUserId()); + if (client != null) { + if (sendAccessTokenHeader) { + OAuth2AccessToken accessToken = client.getAccessToken(); + if (accessToken != null) { + result.put(HEADER_ACCESS_TOKEN_NAME, accessToken.getTokenValue()); + } + } + if (sendRefreshTokenHeader) { + OAuth2RefreshToken refreshToken = client.getRefreshToken(); + if (refreshToken != null) { + result.put(HEADER_REFRESH_TOKEN_NAME, refreshToken.getTokenValue()); + } + } + } + } + if (sendIdTokenHeader) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth.getPrincipal() instanceof CustomNameOidcUser) { + OidcIdToken idToken = ((CustomNameOidcUser) auth.getPrincipal()).getIdToken(); + if (idToken != null) { + result.put(HEADER_ID_TOKEN_NAME, idToken.getTokenValue()); + } + } + } + + return result; + } + public static class CustomNameOidcUser extends DefaultOidcUser { private static final long serialVersionUID = 7563253562760236634L; diff --git a/src/main/java/eu/openanalytics/containerproxy/backend/docker/DockerSwarmBackend.java b/src/main/java/eu/openanalytics/containerproxy/backend/docker/DockerSwarmBackend.java index cf14a2e2..b461834d 100644 --- a/src/main/java/eu/openanalytics/containerproxy/backend/docker/DockerSwarmBackend.java +++ b/src/main/java/eu/openanalytics/containerproxy/backend/docker/DockerSwarmBackend.java @@ -32,6 +32,7 @@ import eu.openanalytics.containerproxy.model.runtime.runtimevalues.BackendContainerNameKey; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ContainerImageKey; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.InstanceIdKey; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.NodeNameKey; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValue; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValueKey; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.UserIdKey; @@ -220,6 +221,7 @@ public Proxy startContainer(Authentication user, Container initialContainer, Con .stream().findAny().orElseThrow(() -> new IllegalStateException("Swarm service has no tasks")); if (serviceTask.status().containerStatus() != null && serviceTask.status().state().equals("running")) { rContainerBuilder.id(serviceTask.status().containerStatus().containerId()); + rContainerBuilder.addRuntimeValue(new RuntimeValue(NodeNameKey.inst, serviceTask.nodeId()), false); return Retrying.SUCCESS; } else if (!STARTING_STATES.contains(serviceTask.status().state())) { slog.warn(proxy, "Docker Swarm container failed: container not running, state reported by docker: " + toJson(serviceTask.status())); diff --git a/src/main/java/eu/openanalytics/containerproxy/backend/kubernetes/KubernetesBackend.java b/src/main/java/eu/openanalytics/containerproxy/backend/kubernetes/KubernetesBackend.java index c157e709..12298718 100644 --- a/src/main/java/eu/openanalytics/containerproxy/backend/kubernetes/KubernetesBackend.java +++ b/src/main/java/eu/openanalytics/containerproxy/backend/kubernetes/KubernetesBackend.java @@ -38,6 +38,7 @@ import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ContainerImageKey; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ContainerIndexKey; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.InstanceIdKey; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.NodeNameKey; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ProxiedAppKey; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ProxyIdKey; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValue; @@ -398,6 +399,7 @@ public Proxy startContainer(Authentication user, Container initialContainer, Con Pod pod = kubeClient.resource(startedPod).get(); parseKubernetesEvents(spec.getIndex(), pod, proxyStartupLogBuilder); + rContainerBuilder.addRuntimeValue(new RuntimeValue(NodeNameKey.inst, pod.getSpec().getNodeName()), false); Service service; Map portBindings = new HashMap<>(); diff --git a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/HttpHeaders.java b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/HttpHeaders.java index 5320b4ca..e27afce8 100644 --- a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/HttpHeaders.java +++ b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/HttpHeaders.java @@ -22,12 +22,14 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; +import eu.openanalytics.containerproxy.auth.impl.OpenIDAuthenticationBackend; +import eu.openanalytics.containerproxy.model.runtime.Proxy; +import eu.openanalytics.containerproxy.service.LanguageService; import io.undertow.util.HeaderMap; import io.undertow.util.HttpString; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.nio.charset.CharsetEncoder; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; @@ -53,7 +55,9 @@ public HttpHeaders(Map headers) { this.headers = filteredHeaders; } - public HeaderMap getUndertowHeaderMap() { + public HeaderMap getUndertowHeaderMap(Proxy proxy) { + undertowHeaderMap.putAll(OpenIDAuthenticationBackend.addHeaders(proxy)); + undertowHeaderMap.putAll(LanguageService.addHeaders()); return undertowHeaderMap; } diff --git a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/LanguageKey.java b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/LanguageKey.java new file mode 100644 index 00000000..9f5b3696 --- /dev/null +++ b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/LanguageKey.java @@ -0,0 +1,49 @@ +/* + * ContainerProxy + * + * Copyright (C) 2016-2026 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.containerproxy.model.runtime.runtimevalues; + +public class LanguageKey extends RuntimeValueKey { + + public static final LanguageKey inst = new LanguageKey(); + + private LanguageKey() { + super("openanalytics.eu/sp-language", + "SHINYPROXY_LANGUAGE", + false, + true, + true, + false, // no need to expose in API + true, + false, + String.class); + } + + @Override + public String deserializeFromString(String value) { + return value; + } + + @Override + public String serializeToString(String value) { + return value; + } + +} diff --git a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/NodeNameKey.java b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/NodeNameKey.java new file mode 100644 index 00000000..69f905de --- /dev/null +++ b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/NodeNameKey.java @@ -0,0 +1,49 @@ +/* + * ContainerProxy + * + * Copyright (C) 2016-2026 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.containerproxy.model.runtime.runtimevalues; + +public class NodeNameKey extends RuntimeValueKey { + + public final static NodeNameKey inst = new NodeNameKey(); + + private NodeNameKey() { + super("openanalytics.eu/sp-node-name", + "SHINYPROXY_NODE_NAME", + false, + false, + false, + false, // important: may not be exposed in API for security + false, + true, + String.class); + } + + @Override + public String deserializeFromString(String value) { + return value; + } + + @Override + public String serializeToString(String value) { + return value; + } + +} diff --git a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/RuntimeValueKeyRegistry.java b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/RuntimeValueKeyRegistry.java index 43e18fb2..f120c203 100644 --- a/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/RuntimeValueKeyRegistry.java +++ b/src/main/java/eu/openanalytics/containerproxy/model/runtime/runtimevalues/RuntimeValueKeyRegistry.java @@ -53,6 +53,8 @@ public class RuntimeValueKeyRegistry { addRuntimeValueKey(HttpHeadersKey.inst); addRuntimeValueKey(TargetIdKey.inst); addRuntimeValueKey(CacheHeadersModeKey.inst); + addRuntimeValueKey(LanguageKey.inst); + addRuntimeValueKey(NodeNameKey.inst); } public static void addRuntimeValueKey(RuntimeValueKey key) { diff --git a/src/main/java/eu/openanalytics/containerproxy/security/WebSecurityConfig.java b/src/main/java/eu/openanalytics/containerproxy/security/WebSecurityConfig.java index 71b389d9..5c624244 100644 --- a/src/main/java/eu/openanalytics/containerproxy/security/WebSecurityConfig.java +++ b/src/main/java/eu/openanalytics/containerproxy/security/WebSecurityConfig.java @@ -213,6 +213,7 @@ public void handle(HttpServletRequest request, HttpServletResponse response, Acc .permitAll() .requestMatchers( new MvcRequestMatcher(handlerMappingIntrospector, "/login"), + new MvcRequestMatcher(handlerMappingIntrospector, "/user/me"), new MvcRequestMatcher(handlerMappingIntrospector, "/signin/**"), new MvcRequestMatcher(handlerMappingIntrospector, "/auth-error"), new MvcRequestMatcher(handlerMappingIntrospector, "/error"), diff --git a/src/main/java/eu/openanalytics/containerproxy/service/LanguageService.java b/src/main/java/eu/openanalytics/containerproxy/service/LanguageService.java new file mode 100644 index 00000000..495faa84 --- /dev/null +++ b/src/main/java/eu/openanalytics/containerproxy/service/LanguageService.java @@ -0,0 +1,125 @@ +/* + * ContainerProxy + * + * Copyright (C) 2016-2026 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.containerproxy.service; + +import eu.openanalytics.containerproxy.util.EnvironmentUtils; +import io.undertow.util.HeaderMap; +import io.undertow.util.HttpString; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.support.RequestContextUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + + +@Service +public class LanguageService { + + private static final String PROP_PROXY_AVAILABLE_LANGUAGES = "proxy.available-languages"; + private static final HttpString HEADER_LANGUAGE_NAME = new HttpString("X-SP-Language"); + private static final Map DEFAULT_AVAILABLE_LANGUAGES = Map.of( + "en", new Language("en", "English"), + "nl", new Language("nl", "Dutch"), + "fr", new Language("fr", "French"), + "es", new Language("es", "Spanish") + ); + private static final String PROP_PROXY_FALLBACK_LANGUAGE = "proxy.fallback-language"; + private static final String DEFAULT_FALLBACK_LANGUAGE = "en"; + + private final Map enabledLanguages; + private final String fallbackLanguage; + private final Logger logger = LoggerFactory.getLogger(getClass()); + + public LanguageService(Environment environment) { + enabledLanguages = readEnabledLanguages(environment); + fallbackLanguage = environment.getProperty(PROP_PROXY_FALLBACK_LANGUAGE, DEFAULT_FALLBACK_LANGUAGE); + } + + public void updateLanguageOfUser(HttpServletRequest request, HttpServletResponse response, String language) { + if (StringUtils.isBlank(language)) { + throw new IllegalArgumentException("No language provided"); + } + + if (!enabledLanguages.containsKey(language)) { + throw new IllegalArgumentException("Requested language not enabled"); + } + + LocaleResolver localeResolver = RequestContextUtils.getLocaleResolver(request); + if (localeResolver == null) { + throw new IllegalStateException("Unable to change language"); + } + + localeResolver.setLocale(request, response, Locale.forLanguageTag(language)); + } + + public String getAndUpdateLanguageOfUser(HttpServletRequest request, HttpServletResponse response) { + String language = LocaleContextHolder.getLocale().getLanguage(); + if (enabledLanguages.containsKey(language)) { + return language; + } + // language of user is not supported -> use fallback language + updateLanguageOfUser(request, response, fallbackLanguage); + return fallbackLanguage; + } + + private Map readEnabledLanguages(Environment environment) { + List configuredLanguages = EnvironmentUtils.readList(environment, PROP_PROXY_AVAILABLE_LANGUAGES); + if (configuredLanguages == null) { + return DEFAULT_AVAILABLE_LANGUAGES; + } + Map result = new HashMap<>(); + for (String key : configuredLanguages) { + Language lang = DEFAULT_AVAILABLE_LANGUAGES.get(key); + if (lang != null) { + result.put(lang.key, lang); + } else { + logger.warn("Enabled language '{}' is not supported", key); + } + } + return result; + } + + public Map getEnabledLanguages() { + return enabledLanguages; + } + + public record Language(String key, String displayName) { + + } + + public static HeaderMap addHeaders() { + HeaderMap result = new HeaderMap(); + String language = LocaleContextHolder.getLocale().getLanguage(); + result.add(HEADER_LANGUAGE_NAME, language); + return result; + } + +} diff --git a/src/main/java/eu/openanalytics/containerproxy/service/ProxyService.java b/src/main/java/eu/openanalytics/containerproxy/service/ProxyService.java index 3d630704..d5e0956e 100644 --- a/src/main/java/eu/openanalytics/containerproxy/service/ProxyService.java +++ b/src/main/java/eu/openanalytics/containerproxy/service/ProxyService.java @@ -37,6 +37,7 @@ import eu.openanalytics.containerproxy.model.runtime.ProxyStartupLog; import eu.openanalytics.containerproxy.model.runtime.ProxyStatus; import eu.openanalytics.containerproxy.model.runtime.ProxyStopReason; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.LanguageKey; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValue; import eu.openanalytics.containerproxy.model.spec.ContainerSpec; import eu.openanalytics.containerproxy.model.spec.ProxySpec; @@ -49,6 +50,7 @@ import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Lazy; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.core.env.Environment; import org.springframework.data.util.Pair; import org.springframework.security.access.AccessDeniedException; @@ -318,6 +320,7 @@ public Command startProxy(Authentication user, ProxySpec spec, List error, ModelMap map) { prepareMap(map); if (error.isPresent()) { + Locale locale = LocaleContextHolder.getLocale(); if (error.get().equals("expired")) { - map.put("error", "You took too long to login, please try again"); + map.put("error", messageSource.getMessage("auth.simple.expired_error", null, locale)); } else { - map.put("error", "Invalid user name or password"); + map.put("error", messageSource.getMessage("auth.simple.credentials_error", null, locale)); } } @@ -77,14 +93,15 @@ public Object getLoginPage(@RequestParam Optional error, ModelMap map) { @RequestMapping(value = AUTH_SUCCESS_URL, method = RequestMethod.GET) public String authSuccess(ModelMap map, HttpServletRequest request) { prepareMap(map); - map.put("url", ServletUriComponentsBuilder.fromCurrentContextPath().path("/").build().toUriString()); // default url + // protocol is added to the url on the client-side + map.put("url", ServletUriComponentsBuilder.fromCurrentContextPath().path("/").build().toUriString().replace("https://", "//").replace("http://", "//")); // default url Object redirectUrl = request.getSession().getAttribute(AUTH_SUCCESS_URL_SESSION_ATTR); if (redirectUrl instanceof String sRedirectUrl) { request.getSession().removeAttribute(AUTH_SUCCESS_URL_SESSION_ATTR); // sanity check: does the redirect url start with the url of this current request if (sRedirectUrl.startsWith(ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString())) { - map.put("url", redirectUrl); + map.put("url", sRedirectUrl.replace("https://", "//").replace("http://", "//")); } } return "auth-success"; @@ -111,4 +128,23 @@ public String getLogoutSuccessPage(ModelMap map) { return "logout-success"; } + + @ResponseBody + @GetMapping(value = "/user/me", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> getUserMetadata() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + boolean isLoggedIn = authentication != null && !(authentication instanceof AnonymousAuthenticationToken) && authentication.isAuthenticated(); + if (!isLoggedIn) { + return ApiResponse.success( + Map.of("authenticated", false) + ); + } + return ApiResponse.success( + Map.of( + "authenticated", true, + "username", authentication.getName() + ) + ); + } + } diff --git a/src/main/java/eu/openanalytics/containerproxy/util/CustomMessageSource.java b/src/main/java/eu/openanalytics/containerproxy/util/CustomMessageSource.java new file mode 100644 index 00000000..f6e8d1ad --- /dev/null +++ b/src/main/java/eu/openanalytics/containerproxy/util/CustomMessageSource.java @@ -0,0 +1,115 @@ +/* + * ContainerProxy + * + * Copyright (C) 2016-2026 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.containerproxy.util; + +import eu.openanalytics.containerproxy.service.LanguageService; +import org.apache.commons.lang3.StringUtils; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.support.AbstractMessageSource; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import javax.annotation.Nonnull; +import java.nio.charset.StandardCharsets; +import java.text.MessageFormat; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.ResourceBundle; +import java.util.stream.Collectors; + +@Component("messageSource") +public class CustomMessageSource extends AbstractMessageSource { + + private static final String PROP_PROXY_TRANSLATION_OVERRIDES = "proxy.translation-overrides"; + private final Map> translationOverrides = new HashMap<>(); + private final Map> translationOverridesMessageFormat = new HashMap<>(); + + public CustomMessageSource(Environment environment, LanguageService languageService) { + ResourceBundleMessageSource parentMessageSource = new CapitalizedMessageSource(); + parentMessageSource.setBasenames("messages", "cp_messages"); + parentMessageSource.setAlwaysUseMessageFormat(true); + parentMessageSource.setDefaultEncoding(StandardCharsets.UTF_8.name()); + setParentMessageSource(parentMessageSource); + + // read overridden translations from config file + for (String language : languageService.getEnabledLanguages().keySet()) { + Map messages = getTranslationOverrides(environment, language); + translationOverrides.put(language, messages); + translationOverridesMessageFormat.put(language, convertToMessageFormat(messages, language)); + } + } + + @Override + protected @Nullable String resolveCodeWithoutArguments(@Nonnull String code, @Nonnull Locale locale) { + if (translationOverrides.containsKey(locale.getLanguage())) { + return translationOverrides.get(locale.getLanguage()).get(code); + } + return null; + } + + @Override + protected @Nullable MessageFormat resolveCode(@Nonnull String code, @Nonnull Locale locale) { + if (translationOverrides.containsKey(locale.getLanguage())) { + return translationOverridesMessageFormat.get(locale.getLanguage()).get(code); + } + return null; + } + + private Map getTranslationOverrides(Environment environment, String language) { + return Binder.get(environment) + .bind(PROP_PROXY_TRANSLATION_OVERRIDES + "." + language, Bindable.mapOf(String.class, String.class)) + .orElse(new HashMap<>()); + } + + private Map convertToMessageFormat(Map messages, String language) { + Locale locale = Locale.forLanguageTag(language); + return messages.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, + e -> new MessageFormat(StringUtils.capitalize(e.getValue()), locale)) + ); + } + + private static class CapitalizedMessageSource extends ResourceBundleMessageSource { + + /** + * Capitalizes the first letter of each string. Because this low-level method + * is overridden, the strings are still properly cached. + * + * @param bundle the ResourceBundle to perform the lookup in + * @param key the key to look up + * @return the associated value, or {@code null} if none + */ + @Override + protected @Nullable String getStringOrNull(@Nonnull ResourceBundle bundle, @Nonnull String key) { + String result = super.getStringOrNull(bundle, key); + if (result != null) { + return StringUtils.capitalize(result); + } + return null; + } + + } + +} diff --git a/src/main/java/eu/openanalytics/containerproxy/util/ProxyMappingManager.java b/src/main/java/eu/openanalytics/containerproxy/util/ProxyMappingManager.java index 888058b8..4a13553a 100644 --- a/src/main/java/eu/openanalytics/containerproxy/util/ProxyMappingManager.java +++ b/src/main/java/eu/openanalytics/containerproxy/util/ProxyMappingManager.java @@ -258,7 +258,7 @@ public void dispatchAsync(Proxy proxy, String mapping, HttpServletRequest reques // add headers HttpHeaders headers = proxy.getRuntimeObject(HttpHeadersKey.inst); - exchange.getRequestHeaders().putAll(headers.getUndertowHeaderMap()); + exchange.getRequestHeaders().putAll(headers.getUndertowHeaderMap(proxy)); exchange.addResponseWrapper((f, exchange1) -> { proxyCacheHeadersService.addAppCacheHeaders(proxy, exchange1); diff --git a/src/main/resources/cp_messages.properties b/src/main/resources/cp_messages.properties new file mode 100644 index 00000000..15ecfb93 --- /dev/null +++ b/src/main/resources/cp_messages.properties @@ -0,0 +1,19 @@ +app-access-denied.header=You do not have access to this application. +app-access-denied.p1=Your account does not have the required roles or groups required for accessing this application. +app-access-denied.p2=Please contact your administrator if you believe this is a mistake. +auth-error.header=An error occurred during the authentication procedure. +auth-error.link=Go back to the main page +auth-error.message-admin-if=If you are an administrator of {0} +auth-error.message-admin-text=this error page is typically shown because of an configuration error in the authentication setup. See the logs for more information. +auth-error.message-user-if=If you are a user of {0} +auth-error.message-user-text=please report this issue to your administrator and try to log out from your Identity Provider. +auth.simple.button=Sign in +auth.simple.header=Please sign in +auth.simple.password=password +auth.simple.sign-in-error=Could not sign in! +auth.simple.username=username +error.link=Go back to the main page +logout-success.header=You have been logged out successfully. +logout-success.link=Login again +auth.simple.expired_error=You took too long to login, please try again. +auth.simple.credentials_error=Invalid username or password. diff --git a/src/main/resources/cp_messages_es.properties b/src/main/resources/cp_messages_es.properties new file mode 100644 index 00000000..2af550cc --- /dev/null +++ b/src/main/resources/cp_messages_es.properties @@ -0,0 +1,19 @@ +app-access-denied.header=No tienes acceso a esta aplicación. +app-access-denied.p1=Tu cuenta no tiene los roles o grupos necesarios para acceder a esta aplicación. +app-access-denied.p2=Si cree que se trata de un error, póngase en contacto con su administrador. +auth-error.header=Se ha producido un error durante el procedimiento de autenticación. +auth-error.link=Volver a la página principal +auth-error.message-admin-if=Si eres administrador de {0} +auth-error.message-admin-text=Esta página de error suele aparecer debido a un error de configuración autenticación. Consulte los registros para obtener más información. +auth-error.message-user-if=Si eres usuario de {0} +auth-error.message-user-text=Por favor, informe de este problema a su administrador e intente cerrar sesión en su proveedor de identidad. +auth.simple.button=Iniciar sesión +auth.simple.credentials_error=Nombre de usuario o contraseña no válidos. +auth.simple.expired_error=Has tardado demasiado en iniciar sesión, por favor, vuelve a intentarlo. +auth.simple.header=Por favor, inicia sesión +auth.simple.password=contraseña +auth.simple.sign-in-error=¡No se pudo iniciar sesión! +auth.simple.username=nombre de usuario +error.link=Volver a la página principal +logout-success.header=Ha cerrado sesión correctamente. +logout-success.link=Inicie sesión de nuevo diff --git a/src/main/resources/cp_messages_fr.properties b/src/main/resources/cp_messages_fr.properties new file mode 100644 index 00000000..f6987c7b --- /dev/null +++ b/src/main/resources/cp_messages_fr.properties @@ -0,0 +1,19 @@ +app-access-denied.header=Vous n''avez pas accès à cette application. +app-access-denied.p1=Votre compte ne dispose pas des rôles ou groupes requis pour accéder à cette application. +app-access-denied.p2=Veuillez contacter votre administrateur si vous pensez qu''il s''agit d''une erreur. +auth-error.header=Une erreur s'est produite pendant la procédure d'authentification. +auth-error.link=Retour à la page principale +auth-error.message-admin-if=Si vous êtes administrateur de {0} +auth-error.message-admin-text=cette page d''erreur s''affiche généralement en raison d''une erreur de configuration dans les paramètres d''authentification. Consultez les journaux pour plus d''informations. +auth-error.message-user-if=Si vous êtes un utilisateur de {0} +auth-error.message-user-text=Veuillez signaler ce problème à votre administrateur et essayer de vous déconnecter de votre fournisseur d''identité. +auth.simple.button=Se connecter +auth.simple.credentials_error=Nom d''utilisateur ou mot de passe invalide. +auth.simple.expired_error=Vous avez mis trop de temps à vous connecter, veuillez réessayer. +auth.simple.header=Veuillez vous connecter +auth.simple.password=mot de passe +auth.simple.sign-in-error=Impossible de se connecter ! +auth.simple.username=nom d''utilisateur +error.link=Retour à la page principale +logout-success.header=Vous avez été déconnecté avec succès. +logout-success.link=Se reconnecter diff --git a/src/main/resources/cp_messages_nl.properties b/src/main/resources/cp_messages_nl.properties new file mode 100644 index 00000000..487a965d --- /dev/null +++ b/src/main/resources/cp_messages_nl.properties @@ -0,0 +1,19 @@ +app-access-denied.header=U heeft geen toegang tot deze applicatie. +app-access-denied.p1=Uw account heeft niet de vereiste rollen of groepen om deze applicatie te kunnen gebruiken. +app-access-denied.p2=Neem contact op met uw beheerder als u denkt dat dit een fout is. +auth-error.header=Er is een fout opgetreden tijdens de authenticatieprocedure. +auth-error.link=Ga terug naar de hoofdpagina +auth-error.message-admin-if=Als u beheerder bent van {0} +auth-error.message-admin-text=Deze foutpagina wordt meestal weergegeven vanwege een configuratiefout in de authenticatie-instellingen. Raadpleeg de logbestanden voor meer informatie. +auth-error.message-user-if=Als u een gebruiker bent van {0} +auth-error.message-user-text=Meld dit probleem aan uw beheerder en probeer uit te loggen bij uw identiteitsprovider. +auth.simple.button=Aanmelden +auth.simple.credentials_error=Ongeldige gebruikersnaam of wachtwoord. +auth.simple.expired_error=Het duurde te lang om aan te melden, probeer het opnieuw. +auth.simple.header=Meld u aan +auth.simple.password=paswoord +auth.simple.sign-in-error=Aanmelden mislukt! +auth.simple.username=gebruikersnaam +error.link=Ga terug naar de hoofdpagina +logout-success.header=U bent succesvol uitgelogd. +logout-success.link=Opnieuw aanmelden diff --git a/src/main/resources/templates/app-access-denied.html b/src/main/resources/templates/app-access-denied.html index 2d051afb..0a8d96b9 100644 --- a/src/main/resources/templates/app-access-denied.html +++ b/src/main/resources/templates/app-access-denied.html @@ -38,9 +38,9 @@
-

You do not have access to this application.

-

Your account does not have the required roles or groups required for accessing this application.

-

Please contact your administrator if you believe this is a mistake.

+

+

+