diff --git a/acceptance-tests/src/test/kotlin/com/chutneytesting/acceptance/tests/engine/RetryAcceptanceTest.kt b/acceptance-tests/src/test/kotlin/com/chutneytesting/acceptance/tests/engine/RetryAcceptanceTest.kt index b16af81c0..684e52f12 100644 --- a/acceptance-tests/src/test/kotlin/com/chutneytesting/acceptance/tests/engine/RetryAcceptanceTest.kt +++ b/acceptance-tests/src/test/kotlin/com/chutneytesting/acceptance/tests/engine/RetryAcceptanceTest.kt @@ -21,7 +21,7 @@ val `Retry should stop after success assertion` = Scenario(title = "Retry should "when":{ "sentence":"Set stop date", "implementation":{ - "task":"{\n type: context-put \n inputs: {\n entries: {\n dateTimeFormat: ss \n secondsPlus5: ${"dateFormatter(#dateTimeFormat).format(#now().plusSeconds(5))".hjsonSpEL} \n} \n} \n}" + "task":"{\n type: context-put \n inputs: {\n entries: {\n stopDate: ${"now().plusSeconds(5)".hjsonSpEL} \n} \n} \n}" } }, "thens":[ @@ -38,13 +38,13 @@ val `Retry should stop after success assertion` = Scenario(title = "Retry should { "sentence":"Set current date", "implementation":{ - "task":"{\n type: context-put \n inputs: {\n entries: {\n currentSeconds: ${"dateFormatter(#dateTimeFormat).format(#now())".hjsonSpEL} \n} \n} \n}" + "task":"{\n type: context-put \n inputs: {\n entries: {\n currentDate: ${"now()".hjsonSpEL} \n} \n} \n}" } }, { "sentence":"Check current date get to stop date", "implementation":{ - "task":"{\n type: string-assert \n inputs: {\n document: ${"secondsPlus5".hjsonSpEL} \n expected: \${'$'}{T(java.lang.String).format('%02d', new Integer(#currentSeconds) + 1)} \n} \n}" + "task":"{\n type: assert \n inputs: {\n asserts: [{\n assert-true: ${"currentDate.isAfter(#stopDate)".hjsonSpEL} \n}] \n} \n}" } } ] @@ -60,4 +60,4 @@ val `Retry should stop after success assertion` = Scenario(title = "Retry should Then("the report status is SUCCESS") { checkScenarioReportSuccess() } -} \ No newline at end of file +} diff --git a/chutney/action-impl/src/main/java/com/chutneytesting/action/assertion/compare/Parser.java b/chutney/action-impl/src/main/java/com/chutneytesting/action/assertion/compare/Parser.java deleted file mode 100644 index d92f7a4d0..000000000 --- a/chutney/action-impl/src/main/java/com/chutneytesting/action/assertion/compare/Parser.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 Enedis - * - * SPDX-License-Identifier: Apache-2.0 - * - */ - -package com.chutneytesting.action.assertion.compare; - -import com.chutneytesting.action.spi.injectable.Logger; - -public class Parser { - - static double actualDouble; - static double expectedDouble; - - public static boolean isParsableFrom(Logger logger, String actual, String expected) { - - try { - actualDouble = Double.parseDouble(actual); - } catch (NumberFormatException nfe) { - logger.error("[" + actual + "] is Not Numeric"); - } - - try { - expectedDouble = Double.parseDouble(expected); - } catch (NumberFormatException nfe) { - logger.error("[" + expected + "] is Not Numeric"); - return false; - } - - return true; - } -} diff --git a/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml b/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml new file mode 100644 index 000000000..93f699feb --- /dev/null +++ b/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml @@ -0,0 +1,44 @@ +#SPDX-FileCopyrightText: 2017-2024 Enedis +#SPDX-License-Identifier: Apache-2.0 + +spring: + security: + oauth2: + client: + registration: + sso-provider: + provider: sso-provider + client-id: "${auth.sso.clientId}" + client-secret: "${auth.sso.clientSecret}" + authorization-grant-type: authorization_code + redirect-uri: "https://${server.http.interface}:4200/login/oauth2/code/{registrationId}" + scope: openid, profile, email + client-name: My Provider + provider: + sso-provider: + authorization-uri: ${auth.sso.issuer}/auth + token-uri: ${auth.sso.issuer}/token + user-info-uri: ${auth.sso.issuer}/me + user-name-attribute: sub + jwk-set-uri: ${auth.sso.issuer}/jwks + resourceserver: + opaque-token: + introspection-uri: "${auth.sso.issuer}/token/introspection" + client-id: "${auth.sso.clientId}" + client-secret: "${auth.sso.clientSecret}" + authorizationserver: + issuer: "${auth.sso.issuer}" + endpoint: + oidc: + user-info-uri: "${auth.sso.issuer}/userinfo" + +auth: + sso: + issuer: "http://localhost:3000" + clientId: 'my-client' + clientSecret: 'my-client-secret' + responseType: 'code' + scope: 'openid profile email' + redirectBaseUrl: "https://${server.http.interface}:4200" + ssoProviderName: 'SSO OpenID Connect' + oidc: true diff --git a/chutney/packaging/local-dev/src/test/resources/sso/.env b/chutney/packaging/local-dev/src/test/resources/sso/.env new file mode 100644 index 000000000..2cfd6931a --- /dev/null +++ b/chutney/packaging/local-dev/src/test/resources/sso/.env @@ -0,0 +1,9 @@ +#SPDX-FileCopyrightText: 2017-2024 Enedis +#SPDX-License-Identifier: Apache-2.0 + +CLIENT_ID=my-client +CLIENT_SECRET=my-client-secret +REDIRECT_URI='https://localhost:4200 https://localhost:8443' +TOKEN_FORMAT='opaque' +PORT=3000 +GRANT_TYPE=authorization_code diff --git a/chutney/packaging/local-dev/src/test/resources/sso/README.md b/chutney/packaging/local-dev/src/test/resources/sso/README.md new file mode 100644 index 000000000..7ba3f1541 --- /dev/null +++ b/chutney/packaging/local-dev/src/test/resources/sso/README.md @@ -0,0 +1,37 @@ + + +# Local OpenID Connect Server + +## Configuration + +In order to use the local oidc provider you need to configure your project with the values : +- client id: 'my-client' +- client secret: 'my-client-secret' + + +## Start OIDC-provider + +Open a new terminal + +Start installing the dependencies with : $ npm install + +Start the local server with : $ npm start + +The server will start on port 3000 + +## How to use + +Start Chutney with the profile sso-auth to enable sso + +On the login page you should see the SSO button below the login button. + +This will redirect you on the OIDC-provider interface. You can write the credential you want as long as the username is known in the authorization.json file + +After completing the OIDC-provider workflow you should be authenticated and redirected to home screen of Chutney + +OIDC-provider will keep you connected until you restart the OIDC-provider server diff --git a/chutney/packaging/local-dev/src/test/resources/sso/package.json b/chutney/packaging/local-dev/src/test/resources/sso/package.json new file mode 100644 index 000000000..b3883b7a9 --- /dev/null +++ b/chutney/packaging/local-dev/src/test/resources/sso/package.json @@ -0,0 +1,17 @@ +{ + "name": "oidc-sso-server", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node sso-oidc.mjs" + }, + "keywords": [], + "author": "", + "description": "", + "dependencies": { + "dotenv": "^16.4.5", + "express": "^4.21.0", + "oidc-provider": "^8.5.1" + } +} diff --git a/chutney/packaging/local-dev/src/test/resources/sso/sso-oidc.mjs b/chutney/packaging/local-dev/src/test/resources/sso/sso-oidc.mjs new file mode 100644 index 000000000..e78119a35 --- /dev/null +++ b/chutney/packaging/local-dev/src/test/resources/sso/sso-oidc.mjs @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import express from 'express'; +import { Provider } from 'oidc-provider'; +import * as dotenv from 'dotenv'; + +dotenv.config() + +const oidc = new Provider('http://localhost:3000', { + clients: [{ + client_id: process.env.CLIENT_ID, + client_secret: process.env.CLIENT_SECRET, + grant_types: [process.env.GRANT_TYPE], + redirect_uris: process.env.REDIRECT_URI.split(' '), + post_logout_redirect_uris: process.env.REDIRECT_URI.split(' '), + }], + formats: { + AccessToken: process.env.TOKEN_FORMAT, + RefreshToken: process.env.TOKEN_FORMAT, + IdToken: process.env.TOKEN_FORMAT + }, + features: { + introspection: { + enabled: true + }, + revocation: { enabled: true }, + userinfo: { enabled: true }, + }, + clientBasedCORS(ctx, origin, client) { + const allowedOrigins = process.env.REDIRECT_URI.split(' '); + return allowedOrigins.includes(origin); + }, + async findAccount(ctx, id) { + return { + accountId: id, + async claims(use, scope) { return { sub: id }; }, + }; + } +}); + +const app = express(); +app.use(oidc.callback()); +const port = parseInt(process.env.PORT, 10) +app.listen(port, () => { + console.log(`OIDC provider listening on port ${port}`); +}); diff --git a/chutney/server/pom.xml b/chutney/server/pom.xml index 73ab9b498..a989b9971 100644 --- a/chutney/server/pom.xml +++ b/chutney/server/pom.xml @@ -86,6 +86,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-client + org.springframework.boot spring-boot-starter-jdbc diff --git a/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java b/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java index 42c394ff3..e5f05e3c8 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java @@ -8,6 +8,7 @@ package com.chutneytesting.security; import com.chutneytesting.admin.api.InfoController; +import com.chutneytesting.security.api.SsoOpenIdConnectController; import com.chutneytesting.security.api.UserController; import com.chutneytesting.security.api.UserDto; import com.chutneytesting.security.domain.AuthenticationService; @@ -15,38 +16,61 @@ import com.chutneytesting.security.infra.handlers.Http401FailureHandler; import com.chutneytesting.security.infra.handlers.HttpEmptyLogoutSuccessHandler; import com.chutneytesting.security.infra.handlers.HttpLoginSuccessHandler; +import com.chutneytesting.security.infra.sso.OAuth2SsoUserService; +import com.chutneytesting.security.infra.sso.OAuth2TokenAuthenticationFilter; +import com.chutneytesting.security.infra.sso.OAuth2TokenAuthenticationProvider; +import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfigProperties; import com.chutneytesting.server.core.domain.security.Authorization; import com.chutneytesting.server.core.domain.security.User; +import java.net.InetSocketAddress; +import java.net.Proxy; import java.util.ArrayList; +import java.util.Collections; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.security.oauth2.server.servlet.OAuth2AuthorizationServerProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.ChannelSecurityConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; import org.springframework.web.servlet.handler.HandlerMappingIntrospector; @Configuration @EnableWebSecurity @EnableMethodSecurity +@EnableConfigurationProperties({OAuth2AuthorizationServerProperties.class, SsoOpenIdConnectConfigProperties.class}) public class ChutneyWebSecurityConfig { - public static final String LOGIN_URL = UserController.BASE_URL + "/login"; - public static final String LOGOUT_URL = UserController.BASE_URL + "/logout"; - public static final String API_BASE_URL_PATTERN = "/api/**"; + private static final String LOGIN_URL = UserController.BASE_URL + "/login"; + private static final String LOGOUT_URL = UserController.BASE_URL + "/logout"; + private static final String API_BASE_URL_PATTERN = "/api/**"; @Value("${management.endpoints.web.base-path:/actuator}") - String actuatorBaseUrl; + protected String actuatorBaseUrl; @Value("${server.ssl.enabled:true}") - Boolean sslEnabled; + private Boolean sslEnabled; @Bean public AuthenticationService authenticationService(Authorizations authorizations) { @@ -54,11 +78,11 @@ public AuthenticationService authenticationService(Authorizations authorizations } @Bean - public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(final HttpSecurity http, AuthenticationService authenticationService, @Nullable ClientRegistrationRepository clientRegistrationRepository, @Nullable RestOperations restOperations) throws Exception { + configureSso(http, authenticationService, clientRegistrationRepository, restOperations); configureBaseHttpSecurity(http); UserDto anonymous = anonymous(); - http - .anonymous(anonymousConfigurer -> anonymousConfigurer + http.anonymous(anonymousConfigurer -> anonymousConfigurer .principal(anonymous) .authorities(new ArrayList<>(anonymous.getAuthorities()))) .authorizeHttpRequests(httpRequest -> { @@ -67,12 +91,12 @@ public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws E .requestMatchers(new MvcRequestMatcher(introspector, LOGIN_URL)).permitAll() .requestMatchers(new MvcRequestMatcher(introspector, LOGOUT_URL)).permitAll() .requestMatchers(new MvcRequestMatcher(introspector, InfoController.BASE_URL + "/**")).permitAll() + .requestMatchers(new MvcRequestMatcher(introspector, SsoOpenIdConnectController.BASE_URL + "/**")).permitAll() .requestMatchers(new MvcRequestMatcher(introspector, API_BASE_URL_PATTERN)).authenticated() .requestMatchers(new MvcRequestMatcher(introspector, actuatorBaseUrl + "/**")).hasAuthority(Authorization.ADMIN_ACCESS.name()) .anyRequest().permitAll(); }) .httpBasic(Customizer.withDefaults()); - return http.build(); } @@ -80,7 +104,7 @@ protected void configureBaseHttpSecurity(final HttpSecurity http) throws Excepti http .csrf(AbstractHttpConfigurer::disable) .exceptionHandling(httpSecurityExceptionHandlingConfigurer -> httpSecurityExceptionHandlingConfigurer.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) - .requiresChannel(this.requireChannel()) + .requiresChannel(this.requireChannel(sslEnabled)) .formLogin(httpSecurityFormLoginConfigurer -> httpSecurityFormLoginConfigurer .loginProcessingUrl(LOGIN_URL) .successHandler(new HttpLoginSuccessHandler()) @@ -98,11 +122,38 @@ protected UserDto anonymous() { return anonymous; } - private Customizer.ChannelRequestMatcherRegistry> requireChannel() { + private Customizer.ChannelRequestMatcherRegistry> requireChannel(Boolean sslEnabled) { if (sslEnabled) { return channelRequestMatcherRegistry -> channelRequestMatcherRegistry.anyRequest().requiresSecure(); } else { return channelRequestMatcherRegistry -> channelRequestMatcherRegistry.anyRequest().requiresInsecure(); } } + + private void configureSso(final HttpSecurity http, AuthenticationService authenticationService, ClientRegistrationRepository clientRegistrationRepository, RestOperations restOperations) throws Exception { + if (clientRegistrationRepository != null) { + OAuth2UserService oAuth2UserService = new OAuth2SsoUserService(authenticationService, restOperations); + OAuth2TokenAuthenticationProvider oAuth2TokenAuthenticationProvider = new OAuth2TokenAuthenticationProvider(oAuth2UserService, clientRegistrationRepository.findByRegistrationId("sso-provider")); + AuthenticationManager authenticationManager = new ProviderManager(Collections.singletonList(oAuth2TokenAuthenticationProvider)); + OAuth2TokenAuthenticationFilter tokenFilter = new OAuth2TokenAuthenticationFilter(authenticationManager); + http + .authenticationProvider(oAuth2TokenAuthenticationProvider) + .addFilterBefore(tokenFilter, BasicAuthenticationFilter.class) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); + } + } + + @Configuration + @Profile("sso-auth") + public static class SsoConfiguration { + @Bean + public RestOperations restOperations(SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + if (ssoOpenIdConnectConfigProperties.proxyHost != null && !ssoOpenIdConnectConfigProperties.proxyHost.isEmpty()) { + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(ssoOpenIdConnectConfigProperties.proxyHost, ssoOpenIdConnectConfigProperties.proxyPort)); + requestFactory.setProxy(proxy); + } + return new RestTemplate(requestFactory); + } + } } diff --git a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfigDto.java b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfigDto.java new file mode 100644 index 000000000..34e32f7e7 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfigDto.java @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.api; + +import java.util.Map; + +public record SsoOpenIdConnectConfigDto(String issuer, String clientId, String clientSecret, String responseType, + String scope, String redirectBaseUrl, String ssoProviderName, Boolean oidc, + String uriRequireHeader, Map headers, + String ssoProviderImageUrl, Map additionalQueryParams) { +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java new file mode 100644 index 000000000..469ec7fce --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.api; + +import static com.chutneytesting.security.api.SsoOpenIdConnectMapper.toDto; + +import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfigProperties; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(SsoOpenIdConnectController.BASE_URL) +@CrossOrigin(origins = "*") +public class SsoOpenIdConnectController { + + public static final String BASE_URL = "/api/v1/sso"; + + private final SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties; + + SsoOpenIdConnectController(@Nullable SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties) { + this.ssoOpenIdConnectConfigProperties = ssoOpenIdConnectConfigProperties; + } + + @GetMapping(path = "/config", produces = MediaType.APPLICATION_JSON_VALUE) + public SsoOpenIdConnectConfigDto getSsoOpenIdConnectConfig() { + return toDto(ssoOpenIdConnectConfigProperties); + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectMapper.java b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectMapper.java new file mode 100644 index 000000000..1169e9a07 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectMapper.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.api; + +import static java.util.Optional.ofNullable; + +import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfigProperties; + +public class SsoOpenIdConnectMapper { + public static SsoOpenIdConnectConfigDto toDto(SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfig) { + return ofNullable(ssoOpenIdConnectConfig) + .map(config -> new SsoOpenIdConnectConfigDto( + ssoOpenIdConnectConfig.issuer, + ssoOpenIdConnectConfig.clientId, + ssoOpenIdConnectConfig.clientSecret, + ssoOpenIdConnectConfig.responseType, + ssoOpenIdConnectConfig.scope, + ssoOpenIdConnectConfig.redirectBaseUrl, + ssoOpenIdConnectConfig.ssoProviderName, + ssoOpenIdConnectConfig.oidc, + ssoOpenIdConnectConfig.uriRequireHeader, + ssoOpenIdConnectConfig.headers, + ssoOpenIdConnectConfig.ssoProviderImageUrl, + ssoOpenIdConnectConfig.additionalQueryParams + )).orElse(null); + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2AuthenticationToken.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2AuthenticationToken.java new file mode 100644 index 000000000..6bd16f4c7 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2AuthenticationToken.java @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.infra.sso; + +import org.springframework.security.authentication.AbstractAuthenticationToken; + +public class OAuth2AuthenticationToken extends AbstractAuthenticationToken { + + private final String token; + + public OAuth2AuthenticationToken(String token) { + super(null); + this.token = token; + setAuthenticated(false); + } + + @Override + public Object getCredentials() { + return token; + } + + @Override + public Object getPrincipal() { + return null; + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoUserService.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoUserService.java new file mode 100644 index 000000000..bc50f2973 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoUserService.java @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.infra.sso; + +import com.chutneytesting.security.api.UserDto; +import com.chutneytesting.security.domain.AuthenticationService; +import com.chutneytesting.security.infra.UserDetailsServiceHelper; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.springframework.lang.Nullable; +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.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.web.client.RestOperations; + +public class OAuth2SsoUserService implements OAuth2UserService { + + private final AuthenticationService authenticationService; + private final RestOperations restOperations; + + public OAuth2SsoUserService(AuthenticationService authenticationService, @Nullable RestOperations restOperations) { + this.authenticationService = authenticationService; + this.restOperations = restOperations; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + if (restOperations != null) { + delegate.setRestOperations(restOperations); + } + OAuth2User oAuth2User = delegate.loadUser(userRequest); + Map oAuth2UserAttributes = oAuth2User.getAttributes(); + String username = (String) oAuth2UserAttributes.get("sub"); + UserDto user = new UserDto(); + user.setId(username); + user.setName(username); + user.setRoles(Collections.emptySet()); + user = UserDetailsServiceHelper.grantAuthoritiesFromUserRole(user, authenticationService); + Map attributes = new HashMap<>(oAuth2UserAttributes); + attributes.put("user", user); + return new DefaultOAuth2User(user.getAuthorities(), attributes, "sub"); + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationFilter.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationFilter.java new file mode 100644 index 000000000..78abd9c8b --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationFilter.java @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.infra.sso; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +public class OAuth2TokenAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER = "Bearer "; + private final AuthenticationManager authenticationManager; + + public OAuth2TokenAuthenticationFilter(AuthenticationManager authenticationManager){ + this.authenticationManager = authenticationManager; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authorizationHeader != null && authorizationHeader.startsWith(BEARER)) { + String token = authorizationHeader.substring(BEARER.length()); + OAuth2AuthenticationToken authRequest = new OAuth2AuthenticationToken(token); + try { + Authentication authentication = authenticationManager.authenticate(authRequest); + SecurityContextHolder.getContext().setAuthentication(authentication); + request.getSession(true); + } catch (AuthenticationException ex) { + SecurityContextHolder.clearContext(); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage()); + return; + } + } + filterChain.doFilter(request, response); + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationProvider.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationProvider.java new file mode 100644 index 000000000..2038bdc2b --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationProvider.java @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.infra.sso; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.OAuth2User; + +public class OAuth2TokenAuthenticationProvider implements AuthenticationProvider { + + private final OAuth2UserService oAuth2UserService; + private final ClientRegistration clientRegistration; + + public OAuth2TokenAuthenticationProvider(OAuth2UserService oAuth2UserService, ClientRegistration clientRegistration) { + this.oAuth2UserService = oAuth2UserService; + this.clientRegistration = clientRegistration; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2AuthenticationToken tokenAuth = (OAuth2AuthenticationToken) authentication; + String token = tokenAuth.getCredentials().toString(); + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, token, null, null); + OAuth2UserRequest userRequest = new OAuth2UserRequest(clientRegistration, accessToken); + OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest); + if (oAuth2User != null) { + return new UsernamePasswordAuthenticationToken(oAuth2User.getAttribute("user"), null, oAuth2User.getAuthorities()); + } else { + throw new BadCredentialsException("Invalid token"); + } + } + + @Override + public boolean supports(Class authentication) { + return OAuth2AuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfigProperties.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfigProperties.java new file mode 100644 index 000000000..38100eda7 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfigProperties.java @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.infra.sso; + +import java.util.Map; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("auth.sso") +public class SsoOpenIdConnectConfigProperties implements InitializingBean { + + public final String issuer; + public final String clientId; + public final String clientSecret; + public final String responseType; + public final String scope; + public final String redirectBaseUrl; + public final String ssoProviderName; + public final String proxyHost; + public final Integer proxyPort; + public final Boolean oidc; + public final String uriRequireHeader; + public final Map headers; + public final Map additionalQueryParams; + public final String ssoProviderImageUrl; + + public SsoOpenIdConnectConfigProperties(String issuer, String clientId, String clientSecret, String responseType, String scope, String redirectBaseUrl, String ssoProviderName, String proxyHost, Integer proxyPort, Boolean oidc, String uriRequireHeader, Map headers, Map additionalQueryParams, String ssoProviderImageUrl) { + this.issuer = issuer; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.responseType = responseType; + this.scope = scope; + this.redirectBaseUrl = redirectBaseUrl; + this.ssoProviderName = ssoProviderName; + this.proxyHost = proxyHost; + this.proxyPort = proxyPort; + this.oidc = oidc; + this.uriRequireHeader = uriRequireHeader; + this.headers = headers; + this.additionalQueryParams = additionalQueryParams; + this.ssoProviderImageUrl = ssoProviderImageUrl; + } + + @Override + public void afterPropertiesSet() {} +} diff --git a/chutney/ui/package-lock.json b/chutney/ui/package-lock.json index ea679e9c4..45c0865df 100644 --- a/chutney/ui/package-lock.json +++ b/chutney/ui/package-lock.json @@ -28,6 +28,7 @@ "@storybook/test": "^8.0.10", "@types/uuid": "^9.0.8", "ace-builds": "^1.33.1", + "angular-oauth2-oidc": "^17.0.2", "bootstrap": "^5.3.3", "bootstrap-icons": "^1.11.3", "bootswatch": "5.3.3", @@ -10902,6 +10903,18 @@ "ajv": "^8.8.2" } }, + "node_modules/angular-oauth2-oidc": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/angular-oauth2-oidc/-/angular-oauth2-oidc-17.0.2.tgz", + "integrity": "sha512-zYgeLmAnu1g8XAYZK+csAsCQBDhgp9ffBv/eArEnujGxNPTeK00bREHWObtehflpQdSn+k9rY2D15ChCSydyVw==", + "dependencies": { + "tslib": "^2.5.2" + }, + "peerDependencies": { + "@angular/common": ">=14.0.0", + "@angular/core": ">=14.0.0" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", diff --git a/chutney/ui/package.json b/chutney/ui/package.json index ef2dad482..1c57fe034 100644 --- a/chutney/ui/package.json +++ b/chutney/ui/package.json @@ -34,6 +34,7 @@ "@storybook/test": "^8.0.10", "@types/uuid": "^9.0.8", "ace-builds": "^1.33.1", + "angular-oauth2-oidc": "^17.0.2", "bootstrap": "^5.3.3", "bootstrap-icons": "^1.11.3", "bootswatch": "5.3.3", diff --git a/chutney/ui/src/app/app.module.ts b/chutney/ui/src/app/app.module.ts index c0572ae3d..82e1c969a 100644 --- a/chutney/ui/src/app/app.module.ts +++ b/chutney/ui/src/app/app.module.ts @@ -27,6 +27,8 @@ import { BsModalService, ModalModule } from 'ngx-bootstrap/modal'; import { ThemeService } from '@core/theme/theme.service'; import { DefaultMissingTranslationHandler, HttpLoaderFactory } from '@core/initializer/app.translate.factory'; import { themeInitializer } from '@core/initializer/theme.initializer'; +import { OAuthModule } from 'angular-oauth2-oidc'; +import { SsoService } from "@core/services/sso.service"; @NgModule({ declarations: [ @@ -60,6 +62,7 @@ import { themeInitializer } from '@core/initializer/theme.initializer'; NgbModule, // Internal common SharedModule, + OAuthModule.forRoot() ], providers: [BsModalService, { @@ -71,7 +74,11 @@ import { themeInitializer } from '@core/initializer/theme.initializer'; ], bootstrap: [AppComponent] }) -export class ChutneyAppModule { } +export class ChutneyAppModule { + constructor(private ssoOpenIdConnectService: SsoService) { + this.ssoOpenIdConnectService.fetchSsoConfig() + } +} diff --git a/chutney/ui/src/app/core/components/login/login.component.html b/chutney/ui/src/app/core/components/login/login.component.html index 781557402..fa61dc7af 100644 --- a/chutney/ui/src/app/core/components/login/login.component.html +++ b/chutney/ui/src/app/core/components/login/login.component.html @@ -46,6 +46,18 @@

Login

+ @if (displaySsoButton()) { +
+ +
+ } @if (connectionError) {
diff --git a/chutney/ui/src/app/core/components/login/login.component.scss b/chutney/ui/src/app/core/components/login/login.component.scss index bfdfd89f8..fbf0853e3 100644 --- a/chutney/ui/src/app/core/components/login/login.component.scss +++ b/chutney/ui/src/app/core/components/login/login.component.scss @@ -60,3 +60,10 @@ align-items: center; } } + +.ssoImage { + height: 4rem; + border-radius: 10px; + border: solid 1px #d3d7d7; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); +} diff --git a/chutney/ui/src/app/core/components/login/login.component.ts b/chutney/ui/src/app/core/components/login/login.component.ts index 65bee858f..ed6e25470 100644 --- a/chutney/ui/src/app/core/components/login/login.component.ts +++ b/chutney/ui/src/app/core/components/login/login.component.ts @@ -11,6 +11,7 @@ import { Subscription } from 'rxjs'; import { AlertService } from '@shared'; import { InfoService, LoginService } from '@core/services'; +import { SsoService } from '@core/services/sso.service'; @Component({ selector: 'chutney-login', @@ -35,6 +36,7 @@ export class LoginComponent implements OnDestroy, OnInit { private infoService: InfoService, private route: ActivatedRoute, private alertService: AlertService, + private ssoService: SsoService ) { this.paramsSubscription = this.route.params.subscribe(params => { this.action = params['action']; @@ -78,4 +80,20 @@ export class LoginComponent implements OnDestroy, OnInit { } ); } + + connectSso() { + this.ssoService.login() + } + + getSsoProviderName() { + return this.ssoService.getSsoProviderName() + } + + displaySsoButton() { + return this.ssoService.getEnableSso + } + + getSsoProviderImageUrl() { + return this.ssoService.getSsoProviderImageUrl() + } } diff --git a/chutney/ui/src/app/core/core.module.ts b/chutney/ui/src/app/core/core.module.ts index 8d6a394d3..b97c48315 100644 --- a/chutney/ui/src/app/core/core.module.ts +++ b/chutney/ui/src/app/core/core.module.ts @@ -9,12 +9,13 @@ import { NgModule } from '@angular/core'; import { SharedModule } from '@shared/shared.module'; import { RouterModule } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { HttpClientModule } from '@angular/common/http'; +import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; import { LoginComponent } from './components/login/login.component'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; import { ParentComponent } from './components/parent/parent.component'; import { DROPDOWN_SETTINGS, DropdownSettings } from '@core/model/dropdown-settings'; +import { OAuth2ContentTypeInterceptor } from '@core/services/oauth2-content-type-interceptor.service'; @NgModule({ declarations: [ @@ -30,7 +31,8 @@ import { DROPDOWN_SETTINGS, DropdownSettings } from '@core/model/dropdown-settin TranslateModule ], providers: [ - {provide: DROPDOWN_SETTINGS, useClass: DropdownSettings} + {provide: DROPDOWN_SETTINGS, useClass: DropdownSettings}, + {provide: HTTP_INTERCEPTORS, useClass: OAuth2ContentTypeInterceptor, multi: true } ] }) diff --git a/chutney/ui/src/app/core/guards/auth.guard.ts b/chutney/ui/src/app/core/guards/auth.guard.ts index 3b532dafc..ba22b18a5 100644 --- a/chutney/ui/src/app/core/guards/auth.guard.ts +++ b/chutney/ui/src/app/core/guards/auth.guard.ts @@ -7,31 +7,11 @@ import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router'; -import { TranslateService } from '@ngx-translate/core'; - import { LoginService } from '@core/services'; -import { AlertService } from '@shared'; -import { Authorization } from '@model'; -export const authGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { - const translateService = inject(TranslateService); +export const authGuard: CanActivateFn = async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { const loginService = inject(LoginService); - const alertService = inject(AlertService); - const requestURL = state.url !== undefined ? state.url : ''; - const unauthorizedMessage = translateService.instant('login.unauthorized') - if (!loginService.isAuthenticated()) { - loginService.initLogin(requestURL); - return false; - } - - const authorizations: Array = route.data['authorizations'] || []; - if (loginService.hasAuthorization(authorizations)) { - return true; - } else { - alertService.error(unauthorizedMessage, {timeOut: 0, extendedTimeOut: 0, closeButton: true}); - loginService.navigateAfterLogin(); - return false; - } + return loginService.isAuthorized(requestURL, route) } diff --git a/chutney/ui/src/app/core/services/login.service.ts b/chutney/ui/src/app/core/services/login.service.ts index 991b5c1a4..b737568a9 100644 --- a/chutney/ui/src/app/core/services/login.service.ts +++ b/chutney/ui/src/app/core/services/login.service.ts @@ -7,134 +7,189 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { Router } from '@angular/router'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { delay, tap } from 'rxjs/operators'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; +import { BehaviorSubject, firstValueFrom, Observable, timeout } from 'rxjs'; +import { catchError, delay, filter, first, tap } from 'rxjs/operators'; import { environment } from '@env/environment'; import { Authorization, User } from '@model'; import { contains, intersection, isNullOrBlankString } from '@shared/tools'; +import { SsoService } from '@core/services/sso.service'; +import { TranslateService } from '@ngx-translate/core'; +import { AlertService } from '@shared'; @Injectable({ - providedIn: 'root' + providedIn: 'root' }) export class LoginService { - private url = '/api/v1/user'; - private loginUrl = this.url + '/login'; - private NO_USER = new User(''); - private user$: BehaviorSubject = new BehaviorSubject(this.NO_USER); - - constructor( - private http: HttpClient, - private router: Router - ) { } - - initLogin(url?: string) { - this.currentUser(true).pipe( - tap(user => this.setUser(user)) - ).subscribe( - () => this.navigateAfterLogin(url), - () => { - const nextUrl = this.nullifyLoginUrl(url); - const queryParams: Object = isNullOrBlankString(nextUrl) ? {} : { queryParams: { url: nextUrl } }; - this.router.navigate(['login'], queryParams); + private url = '/api/v1/user'; + private loginUrl = this.url + '/login'; + private NO_USER = new User(''); + private user$: BehaviorSubject = new BehaviorSubject(this.NO_USER); + + constructor( + private http: HttpClient, + private router: Router, + private ssoService: SsoService, + private translateService: TranslateService, + private alertService: AlertService + ) { + } + + async isAuthorized(requestURL: string, route: ActivatedRouteSnapshot) { + const unauthorizedMessage = this.translateService.instant('login.unauthorized') + if (!this.isAuthenticated()) { + if (this.oauth2Token) { + await this.initLoginWithToken(requestURL) + } else { + await firstValueFrom(this.ssoService.tokenLoaded$.pipe( + timeout(1000), + filter(tokenLoaded => tokenLoaded === true), + first(), + catchError(async error => this.initLogin(requestURL)))); + if (this.oauth2Token) { + await this.initLoginWithToken(requestURL) + } else { + await this.initLogin(requestURL); + return false; + } + } + } + const authorizations: Array = route.data['authorizations'] || []; + if (this.hasAuthorization(authorizations)) { + return true; + } else { + this.alertService.error(unauthorizedMessage, {timeOut: 0, extendedTimeOut: 0, closeButton: true}); + this.navigateAfterLogin(); + return false; } - ); - } + } + + private async initLoginWithToken(requestURL: string) { + await firstValueFrom(this.initLoginObservable(requestURL, { + Authorization: 'Bearer ' + this.oauth2Token, + })); + } + + async initLogin(url?: string, headers: HttpHeaders | { + [header: string]: string | string[]; + } = {}) { + await firstValueFrom(this.initLoginObservable(url, headers)) + } + + initLoginObservable(url?: string, headers?: HttpHeaders | { + [header: string]: string | string[]; + }) { + return this.currentUser(true, headers).pipe( + tap(user => this.setUser(user)), + tap(_ => this.navigateAfterLogin(url)), + catchError(error => { + const nextUrl = this.nullifyLoginUrl(url); + const queryParams: Object = isNullOrBlankString(nextUrl) ? {} : {queryParams: {url: nextUrl}}; + this.router.navigate(['login'], queryParams); + this.alertService.error("Unauthorized, you've been disconnected", {timeOut: 0, extendedTimeOut: 0, closeButton: true}); + return error + }) + ); + } - login(username: string, password: string): Observable { - if (isNullOrBlankString(username) && isNullOrBlankString(password)) { - return this.currentUser().pipe( - tap(user => this.setUser(user)) - ); + get oauth2Token(): string { + return this.ssoService.accessToken } - const body = new URLSearchParams(); - body.set('username', username); - body.set('password', password); + login(username: string, password: string): Observable { + if (isNullOrBlankString(username) && isNullOrBlankString(password)) { + return this.currentUser().pipe( + tap(user => this.setUser(user)) + ); + } + + const body = new URLSearchParams(); + body.set('username', username); + body.set('password', password); - const options = { - headers: new HttpHeaders() + const options = { + headers: new HttpHeaders() .set('Content-Type', 'application/x-www-form-urlencoded') .set('no-intercept-error', '') - }; + }; - return this.http.post(environment.backend + this.loginUrl, body.toString(), options) - .pipe( - tap(user => this.setUser(user)) - ); - } + return this.http.post(environment.backend + this.loginUrl, body.toString(), options) + .pipe( + tap(user => this.setUser(user)) + ); + } - navigateAfterLogin(url?: string) { - const nextUrl = this.nullifyLoginUrl(url); - if (this.isAuthenticated()) { - const user: User = this.user$.getValue(); - this.router.navigateByUrl(nextUrl ? nextUrl : this.defaultForwardUrl(user)); - } else { - this.router.navigateByUrl('/login'); - } - } - - logout() { - this.http.post(environment.backend + this.url + '/logout', null).pipe( - tap(() => this.setUser(this.NO_USER)), - delay(500) - ).subscribe( - () => { + navigateAfterLogin(url?: string) { + const nextUrl = this.nullifyLoginUrl(url); + if (this.isAuthenticated()) { + const user: User = this.user$.getValue(); + this.router.navigateByUrl(nextUrl ? nextUrl : this.defaultForwardUrl(user)); + } else { this.router.navigateByUrl('/login'); } - ); - } - - getUser(): Observable { - return this.user$; - } - - isAuthenticated(): boolean { - const user: User = this.user$.getValue(); - return this.NO_USER !== user; - } - - hasAuthorization(authorization: Array | Authorization = [], u: User = null): boolean { - const user: User = u || this.user$.getValue(); - const auth = [].concat(authorization); - if (user != this.NO_USER) { - return auth.length == 0 || intersection(user.authorizations, auth).length > 0; - } - return false; - } - - isLoginUrl(url: string): boolean { - return url.includes(this.loginUrl); - } - - private setUser(user: User) { - this.user$.next(user); - } - - private currentUser(skipInterceptor: boolean = false): Observable { - const options = { - headers: { 'no-intercept-error': ''} - }; - return this.http.get(environment.backend + this.url, skipInterceptor ? options : {}); - } - - private defaultForwardUrl(user: User): string { - const authorizations = user.authorizations; - if (authorizations) { - if (contains(authorizations, Authorization.SCENARIO_READ)) return '/scenario'; - if (contains(authorizations, Authorization.CAMPAIGN_READ)) return '/campaign'; - if (contains(authorizations, Authorization.ENVIRONMENT_ACCESS)) return '/targets'; - if (contains(authorizations, Authorization.GLOBAL_VAR_READ)) return '/variable'; - if (contains(authorizations, Authorization.DATASET_READ)) return '/dataset'; - if (contains(authorizations, Authorization.ADMIN_ACCESS)) return '/'; - } - - return '/login'; - } - - private nullifyLoginUrl(url: string): string { - return url && url !== '/login' ? url : null; - } + } + + logout() { + this.http.post(environment.backend + this.url + '/logout', null).pipe( + tap(() => this.setUser(this.NO_USER)), + tap(() => this.ssoService.logout()), + delay(500) + ).subscribe( + () => { + this.router.navigateByUrl('/login'); + } + ); + } + + getUser(): Observable { + return this.user$; + } + + isAuthenticated(): boolean { + const user: User = this.user$.getValue(); + return this.NO_USER !== user; + } + + hasAuthorization(authorization: Array | Authorization = [], u: User = null): boolean { + const user: User = u || this.user$.getValue(); + const auth = [].concat(authorization); + if (user != this.NO_USER) { + return auth.length == 0 || intersection(user.authorizations, auth).length > 0; + } + return false; + } + + currentUser(skipInterceptor: boolean = false, headers: HttpHeaders | { + [header: string]: string | string[]; + } = {}): Observable { + const headersInterceptor = skipInterceptor ? {'no-intercept-error': ''} : {} + const options = { + headers: {...headersInterceptor, ...headers} + }; + return this.http.get(environment.backend + this.url, options); + } + + private setUser(user: User) { + this.user$.next(user); + } + + private defaultForwardUrl(user: User): string { + const authorizations = user.authorizations; + if (authorizations) { + if (contains(authorizations, Authorization.SCENARIO_READ)) return '/scenario'; + if (contains(authorizations, Authorization.CAMPAIGN_READ)) return '/campaign'; + if (contains(authorizations, Authorization.ENVIRONMENT_ACCESS)) return '/targets'; + if (contains(authorizations, Authorization.GLOBAL_VAR_READ)) return '/variable'; + if (contains(authorizations, Authorization.DATASET_READ)) return '/dataset'; + if (contains(authorizations, Authorization.ADMIN_ACCESS)) return '/'; + } + + return '/login'; + } + + private nullifyLoginUrl(url: string): string { + return url && url !== '/login' ? url : null; + } } diff --git a/chutney/ui/src/app/core/services/oauth2-content-type-interceptor.service.ts b/chutney/ui/src/app/core/services/oauth2-content-type-interceptor.service.ts new file mode 100644 index 000000000..e959aeabf --- /dev/null +++ b/chutney/ui/src/app/core/services/oauth2-content-type-interceptor.service.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { SsoService } from '@core/services/sso.service'; + +@Injectable() +export class OAuth2ContentTypeInterceptor implements HttpInterceptor { + + constructor(private ssoService: SsoService) {} + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + const isTokenEndpoint = this.ssoService.headers && req.url.startsWith(this.ssoService.tokenEndpoint); + if (isTokenEndpoint) { + const modifiedReq = req.clone({ + setHeaders: this.ssoService.headers + }); + return next.handle(modifiedReq); + } + return next.handle(req); + } +} diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts new file mode 100644 index 000000000..8bcbaef12 --- /dev/null +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import { HttpClient } from '@angular/common/http'; +import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; +import { environment } from '@env/environment'; +import { BehaviorSubject, map, tap } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { filter, switchMap } from 'rxjs/operators'; + +interface SsoAuthConfig { + issuer: string, + clientId: string, + clientSecret: string, + responseType: string, + scope: string, + redirectBaseUrl: string, + ssoProviderName: string, + ssoProviderImageUrl: string, + headers: { [name: string]: string | string[]; }, + additionalQueryParams: { [name: string]: string | string[]; } + oidc: boolean +} + +@Injectable({ + providedIn: 'root' +}) +export class SsoService { + + private resourceUrl = '/api/v1/sso/config'; + + private ssoConfig: SsoAuthConfig + + private tokenLoadedSubject = new BehaviorSubject(false); + public tokenLoaded$ = this.tokenLoadedSubject.asObservable(); + private enableSso = false + + + constructor(private oauthService: OAuthService, private http: HttpClient) { + this.oauthService.events + .pipe(filter(e => e.type === 'token_received')) + .subscribe(() => { + this.tokenLoadedSubject.next(true); + }); + } + + fetchSsoConfig(): void { + this.http.get(environment.backend + this.resourceUrl).pipe( + map(ssoConfig => { + this.ssoConfig = ssoConfig + return { + issuer: ssoConfig.issuer, + clientId: ssoConfig.clientId, + responseType: ssoConfig.responseType, + scope: ssoConfig.scope, + redirectUri: ssoConfig.redirectBaseUrl + '/', + dummyClientSecret: ssoConfig.clientSecret, + oidc: ssoConfig.oidc, + useHttpBasicAuth: true, + postLogoutRedirectUri: ssoConfig.redirectBaseUrl + '/', + sessionChecksEnabled: true, + logoutUrl: ssoConfig.redirectBaseUrl + '/', + customQueryParams: ssoConfig.additionalQueryParams, + useIdTokenHintForSilentRefresh: true, + redirectUriAsPostLogoutRedirectUriFallback: true, + } as AuthConfig + }), + tap(ssoConfig => this.oauthService.configure(ssoConfig)), + switchMap(() => this.oauthService.loadDiscoveryDocumentAndTryLogin()), + tap(res => this.enableSso = res), + filter(() => this.oauthService.hasValidAccessToken() ), + tap(() => this.tokenLoadedSubject.next(true) ) + ).subscribe() + } + + login() { + this.oauthService.initCodeFlow(); + } + + logout() { + if (this.idToken) { + this.oauthService.logOut({ + 'id_token_hint': this.idToken + }); + } + } + + getSsoProviderName() { + return this.ssoConfig?.ssoProviderName + } + + getSsoProviderImageUrl() { + return this.ssoConfig?.ssoProviderImageUrl + } + + get accessToken(): string { + return this.oauthService.getAccessToken(); + } + + get idToken(): string { + return this.oauthService.getIdToken(); + } + + get tokenEndpoint(): string { + return this.oauthService.tokenEndpoint; + } + + get headers() { + return this.ssoConfig?.headers + } + + get getEnableSso() { + return this.enableSso + } +} diff --git a/chutney/ui/src/app/modules/dataset/components/dataset-list/dataset-list.component.spec.ts b/chutney/ui/src/app/modules/dataset/components/dataset-list/dataset-list.component.spec.ts index 6c8962e84..8ce9bf9e7 100644 --- a/chutney/ui/src/app/modules/dataset/components/dataset-list/dataset-list.component.spec.ts +++ b/chutney/ui/src/app/modules/dataset/components/dataset-list/dataset-list.component.spec.ts @@ -17,15 +17,21 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { DatasetListComponent } from './dataset-list.component'; import { DataSetService } from '@core/services'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { NgMultiSelectDropDownModule } from 'ng-multiselect-dropdown'; import { DROPDOWN_SETTINGS, DropdownSettings } from '@core/model/dropdown-settings'; import { RouterModule } from '@angular/router'; +import { OAuthService } from "angular-oauth2-oidc"; +import { AlertService } from '@shared'; describe('DatasetListComponent', () => { + + const eventsSubject = new Subject(); const dataSetService = jasmine.createSpyObj('DataSetService', ['findAll']); + const oAuthService = jasmine.createSpyObj('OAuthService', ['loadDiscoveryDocumentAndTryLogin', 'configure', 'initCodeFlow', 'logOut', 'getAccessToken'], {events: eventsSubject.asObservable()}); + const alertService = jasmine.createSpyObj('AlertService', ['error']); dataSetService.findAll.and.returnValue(of([])); beforeEach(waitForAsync(() => { TestBed.resetTestingModule(); @@ -48,6 +54,8 @@ describe('DatasetListComponent', () => { ], providers: [ { provide: DataSetService, useValue: dataSetService }, + { provide: AlertService, useValue: alertService }, + { provide: OAuthService, useValue: oAuthService }, {provide: DROPDOWN_SETTINGS, useClass: DropdownSettings} ] }).compileComponents(); diff --git a/chutney/ui/src/app/modules/scenarios/components/search-list/scenarios.component.spec.ts b/chutney/ui/src/app/modules/scenarios/components/search-list/scenarios.component.spec.ts index 72d80b9df..4a7d048f5 100644 --- a/chutney/ui/src/app/modules/scenarios/components/search-list/scenarios.component.spec.ts +++ b/chutney/ui/src/app/modules/scenarios/components/search-list/scenarios.component.spec.ts @@ -15,7 +15,7 @@ import { MoleculesModule } from '../../../../molecules/molecules.module'; import { MomentModule } from 'ngx-moment'; import { NgbModule, NgbPopoverConfig } from '@ng-bootstrap/ng-bootstrap'; -import { EMPTY, of } from 'rxjs'; +import { EMPTY, of, Subject } from 'rxjs'; import { ScenarioIndex } from '@core/model'; import { ScenarioService } from '@core/services'; @@ -26,6 +26,8 @@ import { ActivatedRouteStub } from '../../../../testing/activated-route-stub'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NgMultiSelectDropDownModule } from 'ng-multiselect-dropdown'; import { DROPDOWN_SETTINGS, DropdownSettings } from '@core/model/dropdown-settings'; +import { OAuthService } from "angular-oauth2-oidc"; +import { AlertService } from '@shared'; function getScenarios(html: HTMLElement) { return html.querySelectorAll('.scenario-title'); @@ -41,7 +43,10 @@ describe('ScenariosComponent', () => { beforeEach(waitForAsync(() => { TestBed.resetTestingModule(); + const eventsSubject = new Subject(); const scenarioService = jasmine.createSpyObj('ScenarioService', ['findScenarios', 'search']); + const oAuthService = jasmine.createSpyObj('OAuthService', ['loadDiscoveryDocumentAndTryLogin', 'configure', 'initCodeFlow', 'logOut', 'getAccessToken'], {events: eventsSubject.asObservable()}); + const alertService = jasmine.createSpyObj('AlertService', ['error']); const jiraPluginService = jasmine.createSpyObj('JiraPluginService', ['findScenarios', 'findCampaigns']); const jiraPluginConfigurationService = jasmine.createSpyObj('JiraPluginConfigurationService', ['getUrl']); const mockScenarioIndex = [new ScenarioIndex('1', 'title1', 'description', 'source', new Date(), new Date(), 1, 'guest', [], []), @@ -70,6 +75,8 @@ describe('ScenariosComponent', () => { providers: [ NgbPopoverConfig, {provide: ScenarioService, useValue: scenarioService}, + {provide: OAuthService, useValue: oAuthService}, + {provide: AlertService, useValue: alertService}, {provide: JiraPluginService, useValue: jiraPluginService}, {provide: JiraPluginConfigurationService, useValue: jiraPluginConfigurationService}, {provide: ActivatedRoute, useValue: activatedRouteStub}, diff --git a/chutney/ui/src/app/shared/error-interceptor.service.ts b/chutney/ui/src/app/shared/error-interceptor.service.ts index f096830bd..1479ba985 100644 --- a/chutney/ui/src/app/shared/error-interceptor.service.ts +++ b/chutney/ui/src/app/shared/error-interceptor.service.ts @@ -9,8 +9,8 @@ import { Injectable } from '@angular/core'; import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { EMPTY, Observable, throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { EMPTY, from, Observable, throwError } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; import { LoginService } from '@core/services'; import { AlertService } from '@shared'; @@ -45,11 +45,13 @@ export class ErrorInterceptor implements HttpInterceptor { if (this.loginService.isAuthenticated()) { this.loginService.logout(); this.alertService.error(this.sessionExpiredMessage, { timeOut: 0, extendedTimeOut: 0, closeButton: true }); + return EMPTY } else { const requestURL = this.router.url !== undefined ? this.router.url : ''; - this.loginService.initLogin(requestURL); + return from(this.loginService.initLogin(requestURL)).pipe( + switchMap(() => EMPTY) + ); } - return EMPTY; } } return throwError(err);