From fa39410cd2c8cf4d076ec5bbeb058b6083ac642f Mon Sep 17 00:00:00 2001 From: DelaunayAlex Date: Tue, 1 Oct 2024 08:40:51 +0200 Subject: [PATCH 01/26] [WIP] feat(ui, server): Generic SSO OAuth2 OpenID Connect --- .../src/main/resources/application.yml | 30 +++++++++ chutney/server/pom.xml | 4 ++ .../security/ChutneyWebSecurityConfig.java | 21 ++++++ .../security/api/SsoOpenIdConnectConfig.java | 12 ++++ .../api/SsoOpenIdConnectController.java | 39 +++++++++++ .../domain/CustomOAuth2UserService.java | 34 ++++++++++ chutney/ui/package-lock.json | 13 ++++ chutney/ui/package.json | 1 + chutney/ui/src/app/app.module.ts | 20 +++++- .../components/login/login.component.html | 5 ++ .../core/components/login/login.component.ts | 11 ++++ .../services/sso-open-id-connect.service.ts | 66 +++++++++++++++++++ 12 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfig.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/domain/CustomOAuth2UserService.java create mode 100644 chutney/ui/src/app/core/services/sso-open-id-connect.service.ts diff --git a/chutney/packaging/local-dev/src/main/resources/application.yml b/chutney/packaging/local-dev/src/main/resources/application.yml index 9b7920aa6..78d068e6d 100644 --- a/chutney/packaging/local-dev/src/main/resources/application.yml +++ b/chutney/packaging/local-dev/src/main/resources/application.yml @@ -55,6 +55,27 @@ spring: - ldap - mem-auth - db-sqlite + security: + oauth2: + client: + registration: + my-client: # Identifiant pour le client dans Spring Security + client-id: my-client # Le client_id que vous avez défini dans votre serveur OIDC + client-secret: my-client-secret # Le client_secret du serveur OIDC + scope: openid, profile, email # Scopes requis (OpenID Connect nécessite "openid") + authorization-grant-type: authorization_code # Type de grant (flux d'autorisation) + redirect-uri: "{baseUrl}/auth-callback" # URL de redirection après authentification + provider: my-provider # Le nom du provider associé (référence à la section provider) + provider: + my-provider: # Configuration du fournisseur OIDC + issuer-uri: http://localhost:3000 # URI de l'issuer (serveur OIDC) + user-info-uri: http://localhost:3000/userinfo # URI pour obtenir les informations utilisateur + jwk-set-uri: http://localhost:3000/.well-known/openid-configuration/jwks # URI pour obtenir la clé publique pour valider les tokens JWT + token-uri: http://localhost:3000/token # URI pour échanger le code d'autorisation contre un token + authorization-uri: http://localhost:3000/auth # URI pour l'authentification et obtenir le code d'autorisation + introspection-uri: http://localhost:3000/introspect # URI d'introspection du token (facultatif) + + chutney: configuration-folder: .chutney/conf @@ -110,3 +131,12 @@ jasypt: encryptor: private-key-format: pem private-key-location: classpath:/security/private.pem + +auth: + sso: + issuer: 'http://localhost:3000' + clientId: 'my-client' + responseType: 'code' + scope: 'openid profile email' + redirectBaseUrl: 'https://localhost:4200' + ssoProviderName: 'SSO OIDC' 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..253a6ee41 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java @@ -8,10 +8,12 @@ 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; import com.chutneytesting.security.domain.Authorizations; +import com.chutneytesting.security.domain.CustomOAuth2UserService; import com.chutneytesting.security.infra.handlers.Http401FailureHandler; import com.chutneytesting.security.infra.handlers.HttpEmptyLogoutSuccessHandler; import com.chutneytesting.security.infra.handlers.HttpLoginSuccessHandler; @@ -28,6 +30,7 @@ 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.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; @@ -53,6 +56,11 @@ public AuthenticationService authenticationService(Authorizations authorizations return new AuthenticationService(authorizations); } + @Bean + public CustomOAuth2UserService oauth2UserService() { + return new CustomOAuth2UserService(); + } + @Bean public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { configureBaseHttpSecurity(http); @@ -67,10 +75,23 @@ 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, 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(); }) + .oauth2Login(oauth2Login -> oauth2Login + .loginPage(LOGIN_URL) + .defaultSuccessUrl("/") + .failureUrl("/") + .userInfoEndpoint(userInfoEndpointConfig -> { + userInfoEndpointConfig.userService(oauth2UserService()); + })) + .sessionManagement(sessionManagement -> { + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED); + }) + .csrf(AbstractHttpConfigurer::disable) .httpBasic(Customizer.withDefaults()); return http.build(); diff --git a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfig.java b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfig.java new file mode 100644 index 000000000..a725d321b --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfig.java @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.api; + +public record SsoOpenIdConnectConfig(String issuer, String clientId, String responseType, String scope, String redirectBaseUrl, String ssoProviderName) { + +} 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..ed010b45b --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.api; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +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 SsoOpenIdConnectConfig ssoOpenIdConnectConfig; + + SsoOpenIdConnectController(@Value("${auth.sso.issuer}") String issuer, + @Value("${auth.sso.clientId}") String clientId, + @Value("${auth.sso.responseType}") String responseType, + @Value("${auth.sso.scope}") String scope, + @Value("${auth.sso.redirectBaseUrl}") String redirectUri, + @Value("${auth.sso.ssoProviderName}") String ssoProviderName) { + this.ssoOpenIdConnectConfig = new SsoOpenIdConnectConfig(issuer, clientId, responseType, scope, redirectUri, ssoProviderName); + } + + @GetMapping(path = "/config", produces = MediaType.APPLICATION_JSON_VALUE) + public SsoOpenIdConnectConfig getLastCampaignExecution() { + return ssoOpenIdConnectConfig; + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/domain/CustomOAuth2UserService.java b/chutney/server/src/main/java/com/chutneytesting/security/domain/CustomOAuth2UserService.java new file mode 100644 index 000000000..34cebbc48 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/domain/CustomOAuth2UserService.java @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.domain; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +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 java.util.Collections; +import java.util.Map; + +public class CustomOAuth2UserService implements OAuth2UserService { + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) { + DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oauth2User = delegate.loadUser(userRequest); + + Map attributes = oauth2User.getAttributes(); + String userName = (String) attributes.get("preferred_username"); + return new DefaultOAuth2User( + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")), + attributes, + "preferred_username" // TODO + ); + } +} 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..2f1c685db 100644 --- a/chutney/ui/src/app/app.module.ts +++ b/chutney/ui/src/app/app.module.ts @@ -27,6 +27,9 @@ 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, OAuthService } from 'angular-oauth2-oidc'; +import { SsoOpenIdConnectService } from '@core/services/sso-open-id-connect.service'; +import { tap } from 'rxjs'; @NgModule({ declarations: [ @@ -60,6 +63,7 @@ import { themeInitializer } from '@core/initializer/theme.initializer'; NgbModule, // Internal common SharedModule, + OAuthModule.forRoot() ], providers: [BsModalService, { @@ -71,7 +75,21 @@ import { themeInitializer } from '@core/initializer/theme.initializer'; ], bootstrap: [AppComponent] }) -export class ChutneyAppModule { } +export class ChutneyAppModule { + + constructor(private oauthService: OAuthService, private ssoOpenIdConnectService: SsoOpenIdConnectService) { + this.configureOAuth(); + } + + private configureOAuth() { + this.ssoOpenIdConnectService.fetchSsoConfig().pipe( + tap(ssoConfig => { + this.oauthService.configure(ssoConfig) + this.oauthService.loadDiscoveryDocumentAndTryLogin(); + }) + ).subscribe() + } + } 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..75962cce9 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,11 @@

Login

+ @if (getSsoProviderName()) { +
+ +
+ } @if (connectionError) {
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..c62944c83 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 { SsoOpenIdConnectService } from '@core/services/sso-open-id-connect.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: SsoOpenIdConnectService ) { this.paramsSubscription = this.route.params.subscribe(params => { this.action = params['action']; @@ -78,4 +80,13 @@ export class LoginComponent implements OnDestroy, OnInit { } ); } + + connectSso() { + console.log('TOTOTOTOT') + this.ssoService.login() + } + + getSsoProviderName() { + return this.ssoService.getSsoProviderName() + } } diff --git a/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts b/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts new file mode 100644 index 000000000..b0bd19ee8 --- /dev/null +++ b/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts @@ -0,0 +1,66 @@ +import { HttpClient } from '@angular/common/http'; +import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; +import { environment } from '@env/environment'; +import { Observable, map, tap } from 'rxjs'; +import { Injectable } from '@angular/core'; + +interface SsoAuthConfig { + issuer: string, + clientId: string, + responseType: string, + scope: string, + redirectBaseUrl: string, + ssoProviderName: string +} + +@Injectable({ + providedIn: 'root' +}) +export class SsoOpenIdConnectService { + + private resourceUrl = '/api/v1/sso/config'; + + private ssoConfig: SsoAuthConfig + + + constructor(private oauthService: OAuthService, private http: HttpClient) {} + + fetchSsoConfig(): Observable { + return 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 + '/' + } + }) + ) + } + + login() { + this.oauthService.initCodeFlow(); + } + + logout() { + this.oauthService.logOut(); + } + + getSsoProviderName() { + if (this.ssoConfig) { + return this.ssoConfig.ssoProviderName + } + return null + } + + get identityClaims() { + return this.oauthService.getIdentityClaims(); + } + + get isLoggedIn() { + return this.oauthService.hasValidAccessToken(); + } + +} From 6988c7c5f70ddde54d3573f8d2497c43c40e3167 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Thu, 3 Oct 2024 17:37:05 +0200 Subject: [PATCH 02/26] [WIP] feat(server, ui): Conf SSO OAuth2 --- .../main/resources/application-sso-auth.yml | 18 +++++ .../src/main/resources/application.yml | 13 +--- chutney/server/pom.xml | 2 +- .../security/ChutneyWebSecurityConfig.java | 22 ++---- .../security/api/SsoOpenIdConnectConfig.java | 12 --- .../api/SsoOpenIdConnectController.java | 11 +-- .../domain/CustomOAuth2UserService.java | 34 --------- .../sso/OAuth2SsoSecurityConfiguration.java | 73 +++++++++++++++++++ .../infra/sso/OAuth2UserDetailsService.java | 40 ++++++++++ .../infra/sso/SsoOpenIdConnectConfig.java | 42 +++++++++++ .../src/test/resources/sso/package.json | 17 +++++ .../src/test/resources/sso/sso-oidc.mjs | 42 +++++++++++ .../components/login/login.component.html | 2 +- .../core/components/login/login.component.ts | 1 - chutney/ui/src/app/core/guards/auth.guard.ts | 21 +++++- .../ui/src/app/core/services/login.service.ts | 19 +++-- .../services/sso-open-id-connect.service.ts | 13 +++- 17 files changed, 289 insertions(+), 93 deletions(-) create mode 100644 chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml delete mode 100644 chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfig.java delete mode 100644 chutney/server/src/main/java/com/chutneytesting/security/domain/CustomOAuth2UserService.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2UserDetailsService.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfig.java create mode 100644 chutney/server/src/test/resources/sso/package.json create mode 100644 chutney/server/src/test/resources/sso/sso-oidc.mjs 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..1deb65a22 --- /dev/null +++ b/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml @@ -0,0 +1,18 @@ + +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:3000 # URL du serveur OIDC + +auth: + sso: + issuer: 'http://localhost:3000' + clientId: 'my-client' + clientSecret: 'my-client-secret' + responseType: 'code' + scope: 'openid profile email' + redirectBaseUrl: 'https://localhost:4200' + ssoProviderName: 'SSO OIDC' + oidc: true diff --git a/chutney/packaging/local-dev/src/main/resources/application.yml b/chutney/packaging/local-dev/src/main/resources/application.yml index 78d068e6d..1a7297b04 100644 --- a/chutney/packaging/local-dev/src/main/resources/application.yml +++ b/chutney/packaging/local-dev/src/main/resources/application.yml @@ -55,8 +55,12 @@ spring: - ldap - mem-auth - db-sqlite + - sso-auth security: oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:3000 # URL du serveur OIDC client: registration: my-client: # Identifiant pour le client dans Spring Security @@ -131,12 +135,3 @@ jasypt: encryptor: private-key-format: pem private-key-location: classpath:/security/private.pem - -auth: - sso: - issuer: 'http://localhost:3000' - clientId: 'my-client' - responseType: 'code' - scope: 'openid profile email' - redirectBaseUrl: 'https://localhost:4200' - ssoProviderName: 'SSO OIDC' diff --git a/chutney/server/pom.xml b/chutney/server/pom.xml index a989b9971..c3e54544a 100644 --- a/chutney/server/pom.xml +++ b/chutney/server/pom.xml @@ -88,7 +88,7 @@ org.springframework.boot - spring-boot-starter-oauth2-client + spring-boot-starter-oauth2-resource-server org.springframework.boot 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 253a6ee41..b116460e9 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java @@ -13,14 +13,16 @@ import com.chutneytesting.security.api.UserDto; import com.chutneytesting.security.domain.AuthenticationService; import com.chutneytesting.security.domain.Authorizations; -import com.chutneytesting.security.domain.CustomOAuth2UserService; 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.OAuth2UserDetailsService; +import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfig; import com.chutneytesting.server.core.domain.security.Authorization; import com.chutneytesting.server.core.domain.security.User; import java.util.ArrayList; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; @@ -30,7 +32,6 @@ 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.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; @@ -57,8 +58,9 @@ public AuthenticationService authenticationService(Authorizations authorizations } @Bean - public CustomOAuth2UserService oauth2UserService() { - return new CustomOAuth2UserService(); + @ConditionalOnMissingBean + public SsoOpenIdConnectConfig emptySsoOpenIdConnectConfig() { + return new SsoOpenIdConnectConfig(); } @Bean @@ -81,17 +83,7 @@ public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws E .requestMatchers(new MvcRequestMatcher(introspector, actuatorBaseUrl + "/**")).hasAuthority(Authorization.ADMIN_ACCESS.name()) .anyRequest().permitAll(); }) - .oauth2Login(oauth2Login -> oauth2Login - .loginPage(LOGIN_URL) - .defaultSuccessUrl("/") - .failureUrl("/") - .userInfoEndpoint(userInfoEndpointConfig -> { - userInfoEndpointConfig.userService(oauth2UserService()); - })) - .sessionManagement(sessionManagement -> { - sessionManagement.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED); - }) - .csrf(AbstractHttpConfigurer::disable) + .oauth2ResourceServer(oauth2ResourceServerCustomizer -> oauth2ResourceServerCustomizer.jwt(Customizer.withDefaults())) .httpBasic(Customizer.withDefaults()); return http.build(); diff --git a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfig.java b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfig.java deleted file mode 100644 index a725d321b..000000000 --- a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfig.java +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 Enedis - * - * SPDX-License-Identifier: Apache-2.0 - * - */ - -package com.chutneytesting.security.api; - -public record SsoOpenIdConnectConfig(String issuer, String clientId, String responseType, String scope, String redirectBaseUrl, String ssoProviderName) { - -} 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 index ed010b45b..13d29e854 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java @@ -7,7 +7,7 @@ package com.chutneytesting.security.api; -import org.springframework.beans.factory.annotation.Value; +import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfig; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; @@ -23,13 +23,8 @@ public class SsoOpenIdConnectController { private final SsoOpenIdConnectConfig ssoOpenIdConnectConfig; - SsoOpenIdConnectController(@Value("${auth.sso.issuer}") String issuer, - @Value("${auth.sso.clientId}") String clientId, - @Value("${auth.sso.responseType}") String responseType, - @Value("${auth.sso.scope}") String scope, - @Value("${auth.sso.redirectBaseUrl}") String redirectUri, - @Value("${auth.sso.ssoProviderName}") String ssoProviderName) { - this.ssoOpenIdConnectConfig = new SsoOpenIdConnectConfig(issuer, clientId, responseType, scope, redirectUri, ssoProviderName); + SsoOpenIdConnectController(SsoOpenIdConnectConfig ssoOpenIdConnectConfig) { + this.ssoOpenIdConnectConfig = ssoOpenIdConnectConfig; } @GetMapping(path = "/config", produces = MediaType.APPLICATION_JSON_VALUE) diff --git a/chutney/server/src/main/java/com/chutneytesting/security/domain/CustomOAuth2UserService.java b/chutney/server/src/main/java/com/chutneytesting/security/domain/CustomOAuth2UserService.java deleted file mode 100644 index 34cebbc48..000000000 --- a/chutney/server/src/main/java/com/chutneytesting/security/domain/CustomOAuth2UserService.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 Enedis - * - * SPDX-License-Identifier: Apache-2.0 - * - */ - -package com.chutneytesting.security.domain; - -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2User; -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 java.util.Collections; -import java.util.Map; - -public class CustomOAuth2UserService implements OAuth2UserService { - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) { - DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); - OAuth2User oauth2User = delegate.loadUser(userRequest); - - Map attributes = oauth2User.getAttributes(); - String userName = (String) attributes.get("preferred_username"); - return new DefaultOAuth2User( - Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")), - attributes, - "preferred_username" // TODO - ); - } -} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java new file mode 100644 index 000000000..6ea3c5beb --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.infra.sso; + +import com.chutneytesting.security.domain.AuthenticationService; +import com.chutneytesting.security.infra.memory.InMemoryUserDetailsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@Profile("sso-auth") +public class OAuth2SsoSecurityConfiguration { + + @Bean + @Primary + SsoOpenIdConnectConfig ssoOpenIdConnectConfig( + @Value("${auth.sso.issuer}") String issuer, + @Value("${auth.sso.clientId}") String clientId, + @Value("${auth.sso.clientSecret}") String clientSecret, + @Value("${auth.sso.responseType}") String responseType, + @Value("${auth.sso.scope}") String scope, + @Value("${auth.sso.redirectBaseUrl}") String redirectUri, + @Value("${auth.sso.oidc}") Boolean oidc, + @Value("${auth.sso.ssoProviderName}") String ssoProviderName + ) { + return new SsoOpenIdConnectConfig(issuer, + clientId, + clientSecret, + responseType, + scope, + redirectUri, + ssoProviderName, + oidc); + } + + @Bean + public OAuth2UserDetailsService oAuth2UserDetailsService(AuthenticationService authenticationService) { + return new OAuth2UserDetailsService(authenticationService); + } + + @Configuration + @Profile("sso-auth") + public static class OAuth2SsoConfiguration { + + @Autowired + protected void configure( + final AuthenticationManagerBuilder auth, + final InMemoryUserDetailsService authService + ) throws Exception { + auth.userDetailsService(authService); + } + + @Bean + public SecurityFilterChain securityFilterChainOAuth2Sso(final HttpSecurity http, OAuth2UserDetailsService oAuth2UserDetailsService) throws Exception { + return http.oauth2ResourceServer(oauth2ResourceServerCustomizer -> oauth2ResourceServerCustomizer.jwt(Customizer.withDefaults())) + .userDetailsService(oAuth2UserDetailsService) + .build(); + } + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2UserDetailsService.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2UserDetailsService.java new file mode 100644 index 000000000..a8d691690 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2UserDetailsService.java @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.infra.sso; + +import static java.util.stream.Collectors.toUnmodifiableMap; + +import com.chutneytesting.security.api.UserDto; +import com.chutneytesting.security.domain.AuthenticationService; +import com.chutneytesting.security.infra.UserDetailsServiceHelper; +import com.chutneytesting.security.infra.memory.InMemoryUsersProperties; +import java.util.Collections; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +public class OAuth2UserDetailsService implements UserDetailsService { + + private static final Logger LOGGER = LoggerFactory.getLogger(OAuth2UserDetailsService.class); + private final AuthenticationService authenticationService; + + public OAuth2UserDetailsService(AuthenticationService authenticationService) { + this.authenticationService = authenticationService; + } + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + UserDto user = new UserDto(); + user.setId(username); + user.setName(username); + user.setRoles(Collections.emptySet()); + UserDetailsServiceHelper.grantAuthoritiesFromUserRole(user, authenticationService); + return null; + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfig.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfig.java new file mode 100644 index 000000000..94e5a12c8 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfig.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.infra.sso; + +public class SsoOpenIdConnectConfig { + + 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 Boolean oidc; + + public SsoOpenIdConnectConfig() { + this.issuer = null; + this.clientId = null; + this.clientSecret = null; + this.responseType = null; + this.scope = null; + this.redirectBaseUrl = null; + this.ssoProviderName = null; + this.oidc = null; + } + + public SsoOpenIdConnectConfig(String issuer, String clientId, String clientSecret, String responseType, String scope, String redirectBaseUrl, String ssoProviderName, Boolean oidc) { + this.issuer = issuer; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.responseType = responseType; + this.scope = scope; + this.redirectBaseUrl = redirectBaseUrl; + this.ssoProviderName = ssoProviderName; + this.oidc = oidc; + } +} diff --git a/chutney/server/src/test/resources/sso/package.json b/chutney/server/src/test/resources/sso/package.json new file mode 100644 index 000000000..e4607d620 --- /dev/null +++ b/chutney/server/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": "", + "license": "ISC", + "description": "", + "dependencies": { + "express": "^4.21.0", + "oidc-provider": "^8.5.1" + } +} diff --git a/chutney/server/src/test/resources/sso/sso-oidc.mjs b/chutney/server/src/test/resources/sso/sso-oidc.mjs new file mode 100644 index 000000000..a183097d9 --- /dev/null +++ b/chutney/server/src/test/resources/sso/sso-oidc.mjs @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import express from 'express'; +import { Provider } from 'oidc-provider'; + +const clients = [{ + client_id: 'my-client', + client_secret: 'my-client-secret', + grant_types: ['authorization_code'], + redirect_uris: ['https://localhost:4200/'], +}]; +const oidc = new Provider('http://localhost:3000', { + clients, + formats: { + AccessToken: 'jwt', + }, + features: { + introspection: { enabled: true }, + revocation: { enabled: true }, + }, + clientBasedCORS(ctx, origin, client) { + const allowedOrigins = ['https://localhost:4200']; + 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()); +app.listen(3000, () => { + console.log('OIDC provider listening on port 3000'); +}); 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 75962cce9..64ef12288 100644 --- a/chutney/ui/src/app/core/components/login/login.component.html +++ b/chutney/ui/src/app/core/components/login/login.component.html @@ -48,7 +48,7 @@

Login

@if (getSsoProviderName()) {
- +
} 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 c62944c83..4163bb3ca 100644 --- a/chutney/ui/src/app/core/components/login/login.component.ts +++ b/chutney/ui/src/app/core/components/login/login.component.ts @@ -82,7 +82,6 @@ export class LoginComponent implements OnDestroy, OnInit { } connectSso() { - console.log('TOTOTOTOT') this.ssoService.login() } diff --git a/chutney/ui/src/app/core/guards/auth.guard.ts b/chutney/ui/src/app/core/guards/auth.guard.ts index 3b532dafc..178e6c5dc 100644 --- a/chutney/ui/src/app/core/guards/auth.guard.ts +++ b/chutney/ui/src/app/core/guards/auth.guard.ts @@ -11,17 +11,34 @@ import { TranslateService } from '@ngx-translate/core'; import { LoginService } from '@core/services'; import { AlertService } from '@shared'; -import { Authorization } from '@model'; +import {Authorization, User} from '@model'; +import {OAuthService} from "angular-oauth2-oidc"; +import {SsoOpenIdConnectService} from "@core/services/sso-open-id-connect.service"; +import {HttpHeaders} from "@angular/common/http"; +import {firstValueFrom} from "rxjs"; -export const authGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { +export const authGuard: CanActivateFn = async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { const translateService = inject(TranslateService); const loginService = inject(LoginService); const alertService = inject(AlertService); + const ssoOpenIdConnectService = inject(SsoOpenIdConnectService); const requestURL = state.url !== undefined ? state.url : ''; const unauthorizedMessage = translateService.instant('login.unauthorized') + if (!loginService.isAuthenticated()) { + if (ssoOpenIdConnectService.token) { + console.log(ssoOpenIdConnectService.token) + const user: User = await firstValueFrom(loginService.currentUser(true, { + 'Authorization': 'Bearer ' + ssoOpenIdConnectService.token + })) + if (user) { + console.log('-------------------') + console.log(user) + return true + } + } loginService.initLogin(requestURL); return false; } diff --git a/chutney/ui/src/app/core/services/login.service.ts b/chutney/ui/src/app/core/services/login.service.ts index 991b5c1a4..9a895cadb 100644 --- a/chutney/ui/src/app/core/services/login.service.ts +++ b/chutney/ui/src/app/core/services/login.service.ts @@ -109,15 +109,20 @@ export class LoginService { return url.includes(this.loginUrl); } - private setUser(user: User) { - this.user$.next(user); + currentUser(skipInterceptor: boolean = false, headers: HttpHeaders | { + [header: string]: string | string[]; + } = {}): Observable { + const headersInterceptor = skipInterceptor ? { 'no-intercept-error': ''} : {} + console.log(headers) + const options = { + headers: { ...headersInterceptor, ...headers} + }; + console.log(options) + return this.http.get(environment.backend + this.url, options); } - private currentUser(skipInterceptor: boolean = false): Observable { - const options = { - headers: { 'no-intercept-error': ''} - }; - return this.http.get(environment.backend + this.url, skipInterceptor ? options : {}); + private setUser(user: User) { + this.user$.next(user); } private defaultForwardUrl(user: User): string { diff --git a/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts b/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts index b0bd19ee8..88a34cd89 100644 --- a/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts +++ b/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts @@ -7,10 +7,12 @@ import { Injectable } from '@angular/core'; interface SsoAuthConfig { issuer: string, clientId: string, + clientSecret: string, responseType: string, scope: string, redirectBaseUrl: string, - ssoProviderName: string + ssoProviderName: string, + oidc: boolean } @Injectable({ @@ -34,7 +36,9 @@ export class SsoOpenIdConnectService { clientId: ssoConfig.clientId, responseType: ssoConfig.responseType, scope: ssoConfig.scope, - redirectUri: ssoConfig.redirectBaseUrl + '/' + redirectUri: ssoConfig.redirectBaseUrl + '/', + dummyClientSecret: ssoConfig.clientSecret, + oidc: ssoConfig.oidc } }) ) @@ -59,8 +63,11 @@ export class SsoOpenIdConnectService { return this.oauthService.getIdentityClaims(); } + get token(): string { + return this.oauthService.getIdToken(); + } + get isLoggedIn() { return this.oauthService.hasValidAccessToken(); } - } From 8c5d14f6896564eae5542f829b1c28b698b6a159 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Thu, 10 Oct 2024 15:19:08 +0200 Subject: [PATCH 03/26] feat(server, ui): SSO OAuth2 with mock oidc-provider, authenticate SSO Opaque token and generate session on server side --- .../main/resources/application-sso-auth.yml | 29 ++++- .../src/main/resources/application.yml | 24 ---- chutney/server/pom.xml | 6 +- .../security/ChutneyWebSecurityConfig.java | 24 ++-- .../infra/sso/CustomOAuth2UserService.java | 46 ++++++++ .../sso/OAuth2SsoSecurityConfiguration.java | 107 ++++++++++++++---- .../infra/sso/OAuth2UserDetailsService.java | 40 ------- .../infra/sso/TokenAuthenticationFilter.java | 51 +++++++++ .../sso/TokenAuthenticationProvider.java | 49 ++++++++ .../infra/sso/TokenAuthenticationToken.java | 31 +++++ .../src/test/resources/sso/sso-oidc.mjs | 23 ++-- chutney/ui/src/app/core/guards/auth.guard.ts | 20 ++-- .../ui/src/app/core/services/login.service.ts | 41 ++++--- .../services/sso-open-id-connect.service.ts | 4 +- 14 files changed, 360 insertions(+), 135 deletions(-) create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/infra/sso/CustomOAuth2UserService.java delete mode 100644 chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2UserDetailsService.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationFilter.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationProvider.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationToken.java 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 index 1deb65a22..6bcd42d34 100644 --- a/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml +++ b/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml @@ -2,9 +2,34 @@ spring: security: oauth2: + client: + registration: + my-provider: + provider: my-provider + client-id: my-client + client-secret: my-client-secret + authorization-grant-type: authorization_code + redirect-uri: "https://localhost:4200/login/oauth2/code/{registrationId}" + scope: openid, profile, email + client-name: My Provider + provider: + my-provider: + issuer-uri: http://localhost:3000 + authorization-uri: http://localhost:3000/auth + token-uri: http://localhost:3000/token + user-info-uri: http://localhost:3000/me + user-name-attribute: sub + jwk-set-uri: http://localhost:3000/jwks resourceserver: - jwt: - issuer-uri: http://localhost:3000 # URL du serveur OIDC + opaque-token: + introspection-uri: http://localhost:3000/token/introspection + client-id: 'my-client' + client-secret: 'my-client-secret' + authorizationserver: + issuer: http://localhost:3000 + endpoint: + oidc: + user-info-uri: http://localhost:3000/userinfo auth: sso: diff --git a/chutney/packaging/local-dev/src/main/resources/application.yml b/chutney/packaging/local-dev/src/main/resources/application.yml index 1a7297b04..d575e311b 100644 --- a/chutney/packaging/local-dev/src/main/resources/application.yml +++ b/chutney/packaging/local-dev/src/main/resources/application.yml @@ -56,30 +56,6 @@ spring: - mem-auth - db-sqlite - sso-auth - security: - oauth2: - resourceserver: - jwt: - issuer-uri: http://localhost:3000 # URL du serveur OIDC - client: - registration: - my-client: # Identifiant pour le client dans Spring Security - client-id: my-client # Le client_id que vous avez défini dans votre serveur OIDC - client-secret: my-client-secret # Le client_secret du serveur OIDC - scope: openid, profile, email # Scopes requis (OpenID Connect nécessite "openid") - authorization-grant-type: authorization_code # Type de grant (flux d'autorisation) - redirect-uri: "{baseUrl}/auth-callback" # URL de redirection après authentification - provider: my-provider # Le nom du provider associé (référence à la section provider) - provider: - my-provider: # Configuration du fournisseur OIDC - issuer-uri: http://localhost:3000 # URI de l'issuer (serveur OIDC) - user-info-uri: http://localhost:3000/userinfo # URI pour obtenir les informations utilisateur - jwk-set-uri: http://localhost:3000/.well-known/openid-configuration/jwks # URI pour obtenir la clé publique pour valider les tokens JWT - token-uri: http://localhost:3000/token # URI pour échanger le code d'autorisation contre un token - authorization-uri: http://localhost:3000/auth # URI pour l'authentification et obtenir le code d'autorisation - introspection-uri: http://localhost:3000/introspect # URI d'introspection du token (facultatif) - - chutney: configuration-folder: .chutney/conf diff --git a/chutney/server/pom.xml b/chutney/server/pom.xml index c3e54544a..4f2c0ab2a 100644 --- a/chutney/server/pom.xml +++ b/chutney/server/pom.xml @@ -88,7 +88,11 @@
org.springframework.boot - spring-boot-starter-oauth2-resource-server + spring-boot-starter-oauth2-authorization-server + + + org.springframework.boot + spring-boot-starter-oauth2-client org.springframework.boot 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 b116460e9..88c34a847 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java @@ -7,6 +7,7 @@ package com.chutneytesting.security; + import com.chutneytesting.admin.api.InfoController; import com.chutneytesting.security.api.SsoOpenIdConnectController; import com.chutneytesting.security.api.UserController; @@ -16,7 +17,6 @@ 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.OAuth2UserDetailsService; import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfig; import com.chutneytesting.server.core.domain.security.Authorization; import com.chutneytesting.server.core.domain.security.User; @@ -25,6 +25,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -40,6 +42,7 @@ @Configuration @EnableWebSecurity @EnableMethodSecurity +@Profile("!sso-auth") public class ChutneyWebSecurityConfig { public static final String LOGIN_URL = UserController.BASE_URL + "/login"; @@ -47,7 +50,7 @@ public class ChutneyWebSecurityConfig { public static final String API_BASE_URL_PATTERN = "/api/**"; @Value("${management.endpoints.web.base-path:/actuator}") - String actuatorBaseUrl; + public static String ACTUATOR_BASE_URL; @Value("${server.ssl.enabled:true}") Boolean sslEnabled; @@ -63,9 +66,12 @@ public SsoOpenIdConnectConfig emptySsoOpenIdConnectConfig() { return new SsoOpenIdConnectConfig(); } + @Bean + @Order() + @ConditionalOnMissingBean(value = SecurityFilterChain.class) public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { - configureBaseHttpSecurity(http); + configureBaseHttpSecurity(http, sslEnabled); UserDto anonymous = anonymous(); http .anonymous(anonymousConfigurer -> anonymousConfigurer @@ -80,20 +86,18 @@ public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws E .requestMatchers(new MvcRequestMatcher(introspector, SsoOpenIdConnectController.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()) + .requestMatchers(new MvcRequestMatcher(introspector, ACTUATOR_BASE_URL + "/**")).hasAuthority(Authorization.ADMIN_ACCESS.name()) .anyRequest().permitAll(); }) - .oauth2ResourceServer(oauth2ResourceServerCustomizer -> oauth2ResourceServerCustomizer.jwt(Customizer.withDefaults())) .httpBasic(Customizer.withDefaults()); - return http.build(); } - protected void configureBaseHttpSecurity(final HttpSecurity http) throws Exception { + public void configureBaseHttpSecurity(final HttpSecurity http, Boolean sslEnabled) throws Exception { 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()) @@ -103,7 +107,7 @@ protected void configureBaseHttpSecurity(final HttpSecurity http) throws Excepti .logoutSuccessHandler(new HttpEmptyLogoutSuccessHandler())); } - protected UserDto anonymous() { + public UserDto anonymous() { UserDto anonymous = new UserDto(); anonymous.setId(User.ANONYMOUS.id); anonymous.setName(User.ANONYMOUS.id); @@ -111,7 +115,7 @@ protected UserDto anonymous() { return anonymous; } - private Customizer.ChannelRequestMatcherRegistry> requireChannel() { + private Customizer.ChannelRequestMatcherRegistry> requireChannel(Boolean sslEnabled) { if (sslEnabled) { return channelRequestMatcherRegistry -> channelRequestMatcherRegistry.anyRequest().requiresSecure(); } else { diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/CustomOAuth2UserService.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/CustomOAuth2UserService.java new file mode 100644 index 000000000..47bd469fc --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/CustomOAuth2UserService.java @@ -0,0 +1,46 @@ +/* + * 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.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; + +public class CustomOAuth2UserService implements OAuth2UserService { + + private final AuthenticationService authenticationService; + + public CustomOAuth2UserService(AuthenticationService authenticationService) { + this.authenticationService = authenticationService; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2UserService delegate = new DefaultOAuth2UserService(); + 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/OAuth2SsoSecurityConfiguration.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java index 6ea3c5beb..e383f9f22 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java @@ -7,23 +7,68 @@ package com.chutneytesting.security.infra.sso; +import static com.chutneytesting.security.ChutneyWebSecurityConfig.API_BASE_URL_PATTERN; +import static com.chutneytesting.security.ChutneyWebSecurityConfig.LOGIN_URL; +import static com.chutneytesting.security.ChutneyWebSecurityConfig.LOGOUT_URL; + +import com.chutneytesting.admin.api.InfoController; +import com.chutneytesting.security.ChutneyWebSecurityConfig; +import com.chutneytesting.security.api.SsoOpenIdConnectController; +import com.chutneytesting.security.api.UserDto; import com.chutneytesting.security.domain.AuthenticationService; -import com.chutneytesting.security.infra.memory.InMemoryUserDetailsService; -import org.springframework.beans.factory.annotation.Autowired; +import com.chutneytesting.security.domain.Authorizations; +import com.chutneytesting.server.core.domain.security.Authorization; +import java.util.ArrayList; +import java.util.Collections; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +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.Primary; import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +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.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.www.BasicAuthenticationFilter; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; @Configuration @Profile("sso-auth") +@EnableWebSecurity +@EnableMethodSecurity +@EnableConfigurationProperties(OAuth2AuthorizationServerProperties.class) public class OAuth2SsoSecurityConfiguration { + @Value("${management.endpoints.web.base-path:/actuator}") + public static String ACTUATOR_BASE_URL; + + @Value("${server.ssl.enabled:true}") + Boolean sslEnabled; + + @Bean + public AuthenticationService authenticationService(Authorizations authorizations) { + return new AuthenticationService(authorizations); + } + + @Bean + @ConditionalOnMissingBean + public SsoOpenIdConnectConfig emptySsoOpenIdConnectConfig() { + return new SsoOpenIdConnectConfig(); + } + @Bean @Primary SsoOpenIdConnectConfig ssoOpenIdConnectConfig( @@ -47,27 +92,47 @@ SsoOpenIdConnectConfig ssoOpenIdConnectConfig( } @Bean - public OAuth2UserDetailsService oAuth2UserDetailsService(AuthenticationService authenticationService) { - return new OAuth2UserDetailsService(authenticationService); + public OAuth2UserService customOAuth2UserService(AuthenticationService authenticationService) { + return new CustomOAuth2UserService(authenticationService); } - @Configuration - @Profile("sso-auth") - public static class OAuth2SsoConfiguration { + @Bean + public TokenAuthenticationProvider tokenAuthenticationProvider(AuthenticationService authenticationService, ClientRegistrationRepository clientRegistrationRepository) { + return new TokenAuthenticationProvider(customOAuth2UserService(authenticationService), clientRegistrationRepository.findByRegistrationId("my-provider")); + } - @Autowired - protected void configure( - final AuthenticationManagerBuilder auth, - final InMemoryUserDetailsService authService - ) throws Exception { - auth.userDetailsService(authService); - } + @Bean + public AuthenticationManager authenticationManager(TokenAuthenticationProvider tokenAuthenticationProvider) { + return new ProviderManager(Collections.singletonList(tokenAuthenticationProvider)); + } - @Bean - public SecurityFilterChain securityFilterChainOAuth2Sso(final HttpSecurity http, OAuth2UserDetailsService oAuth2UserDetailsService) throws Exception { - return http.oauth2ResourceServer(oauth2ResourceServerCustomizer -> oauth2ResourceServerCustomizer.jwt(Customizer.withDefaults())) - .userDetailsService(oAuth2UserDetailsService) - .build(); - } + @Bean + @Order(1) + public SecurityFilterChain securityFilterChainOAuth2Sso(final HttpSecurity http, TokenAuthenticationProvider tokenAuthenticationProvider, AuthenticationManager authenticationManager) throws Exception { + ChutneyWebSecurityConfig chutneyWebSecurityConfig = new ChutneyWebSecurityConfig(); + TokenAuthenticationFilter tokenFilter = new TokenAuthenticationFilter(authenticationManager); + chutneyWebSecurityConfig.configureBaseHttpSecurity(http, sslEnabled); + UserDto anonymous = chutneyWebSecurityConfig.anonymous(); + http + .authenticationProvider(tokenAuthenticationProvider) + .addFilterBefore(tokenFilter, BasicAuthenticationFilter.class) + .anonymous(anonymousConfigurer -> anonymousConfigurer + .principal(anonymous) + .authorities(new ArrayList<>(anonymous.getAuthorities()))) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .authorizeHttpRequests(httpRequest -> { + HandlerMappingIntrospector introspector = new HandlerMappingIntrospector(); + httpRequest + .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, SsoOpenIdConnectController.BASE_URL + "/**")).permitAll() + .requestMatchers(new MvcRequestMatcher(introspector, API_BASE_URL_PATTERN)).authenticated() + .requestMatchers(new MvcRequestMatcher(introspector, ACTUATOR_BASE_URL + "/**")).hasAuthority(Authorization.ADMIN_ACCESS.name()) + .anyRequest().permitAll(); + }) + .httpBasic(Customizer.withDefaults()); + return http.build(); } } diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2UserDetailsService.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2UserDetailsService.java deleted file mode 100644 index a8d691690..000000000 --- a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2UserDetailsService.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 Enedis - * - * SPDX-License-Identifier: Apache-2.0 - * - */ - -package com.chutneytesting.security.infra.sso; - -import static java.util.stream.Collectors.toUnmodifiableMap; - -import com.chutneytesting.security.api.UserDto; -import com.chutneytesting.security.domain.AuthenticationService; -import com.chutneytesting.security.infra.UserDetailsServiceHelper; -import com.chutneytesting.security.infra.memory.InMemoryUsersProperties; -import java.util.Collections; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; - -public class OAuth2UserDetailsService implements UserDetailsService { - - private static final Logger LOGGER = LoggerFactory.getLogger(OAuth2UserDetailsService.class); - private final AuthenticationService authenticationService; - - public OAuth2UserDetailsService(AuthenticationService authenticationService) { - this.authenticationService = authenticationService; - } - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - UserDto user = new UserDto(); - user.setId(username); - user.setName(username); - user.setRoles(Collections.emptySet()); - UserDetailsServiceHelper.grantAuthoritiesFromUserRole(user, authenticationService); - return null; - } -} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationFilter.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationFilter.java new file mode 100644 index 000000000..deff01fb5 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationFilter.java @@ -0,0 +1,51 @@ +/* + * 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 TokenAuthenticationFilter extends OncePerRequestFilter { + + private final AuthenticationManager authenticationManager; + + public TokenAuthenticationFilter(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(7); + TokenAuthenticationToken authRequest = new TokenAuthenticationToken(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/TokenAuthenticationProvider.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationProvider.java new file mode 100644 index 000000000..45d860b82 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationProvider.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 TokenAuthenticationProvider implements AuthenticationProvider { + + private final OAuth2UserService oAuth2UserService; + private final ClientRegistration clientRegistration; + + public TokenAuthenticationProvider(OAuth2UserService oAuth2UserService, ClientRegistration clientRegistration) { + this.oAuth2UserService = oAuth2UserService; + this.clientRegistration = clientRegistration; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + TokenAuthenticationToken tokenAuth = (TokenAuthenticationToken) 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 TokenAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationToken.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationToken.java new file mode 100644 index 000000000..0a289d53d --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationToken.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 TokenAuthenticationToken extends AbstractAuthenticationToken { + + private final String token; + + public TokenAuthenticationToken(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/test/resources/sso/sso-oidc.mjs b/chutney/server/src/test/resources/sso/sso-oidc.mjs index a183097d9..5ca5aaed6 100644 --- a/chutney/server/src/test/resources/sso/sso-oidc.mjs +++ b/chutney/server/src/test/resources/sso/sso-oidc.mjs @@ -8,20 +8,25 @@ import express from 'express'; import { Provider } from 'oidc-provider'; -const clients = [{ - client_id: 'my-client', - client_secret: 'my-client-secret', - grant_types: ['authorization_code'], - redirect_uris: ['https://localhost:4200/'], -}]; const oidc = new Provider('http://localhost:3000', { - clients, + clients: [{ + client_id: 'my-client', + client_secret: 'my-client-secret', + grant_types: ['authorization_code'], + redirect_uris: ['https://localhost:4200/'], + post_logout_redirect_uris: ['https://localhost:4200/'], + }], formats: { - AccessToken: 'jwt', + AccessToken: 'opaque', + RefreshToken: 'opaque', + IdToken: 'opaque' }, features: { - introspection: { enabled: true }, + introspection: { + enabled: true + }, revocation: { enabled: true }, + userinfo: { enabled: true }, }, clientBasedCORS(ctx, origin, client) { const allowedOrigins = ['https://localhost:4200']; diff --git a/chutney/ui/src/app/core/guards/auth.guard.ts b/chutney/ui/src/app/core/guards/auth.guard.ts index 178e6c5dc..927ee7a29 100644 --- a/chutney/ui/src/app/core/guards/auth.guard.ts +++ b/chutney/ui/src/app/core/guards/auth.guard.ts @@ -22,25 +22,19 @@ export const authGuard: CanActivateFn = async (route: ActivatedRouteSnapshot, st const translateService = inject(TranslateService); const loginService = inject(LoginService); const alertService = inject(AlertService); - const ssoOpenIdConnectService = inject(SsoOpenIdConnectService); const requestURL = state.url !== undefined ? state.url : ''; const unauthorizedMessage = translateService.instant('login.unauthorized') if (!loginService.isAuthenticated()) { - if (ssoOpenIdConnectService.token) { - console.log(ssoOpenIdConnectService.token) - const user: User = await firstValueFrom(loginService.currentUser(true, { - 'Authorization': 'Bearer ' + ssoOpenIdConnectService.token - })) - if (user) { - console.log('-------------------') - console.log(user) - return true - } + if (loginService.oauth2Token) { + await firstValueFrom(loginService.initLoginObservable(requestURL, { + 'Authorization': 'Bearer ' + loginService.oauth2Token + })); + } else { + loginService.initLogin(requestURL); + return false; } - loginService.initLogin(requestURL); - return false; } const authorizations: Array = route.data['authorizations'] || []; diff --git a/chutney/ui/src/app/core/services/login.service.ts b/chutney/ui/src/app/core/services/login.service.ts index 9a895cadb..e4028dd9b 100644 --- a/chutney/ui/src/app/core/services/login.service.ts +++ b/chutney/ui/src/app/core/services/login.service.ts @@ -9,11 +9,12 @@ 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 { catchError, delay, tap } from 'rxjs/operators'; import { environment } from '@env/environment'; import { Authorization, User } from '@model'; import { contains, intersection, isNullOrBlankString } from '@shared/tools'; +import { SsoOpenIdConnectService } from "@core/services/sso-open-id-connect.service"; @Injectable({ providedIn: 'root' @@ -27,20 +28,33 @@ export class LoginService { constructor( private http: HttpClient, - private router: Router + private router: Router, + private ssoOpenIdConnectService: SsoOpenIdConnectService ) { } - 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); - } - ); + initLogin(url?: string, headers: HttpHeaders | { + [header: string]: string | string[]; + } = {}) { + this.initLoginObservable(url, headers).subscribe() + } + + 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); + return error + }) + ); + } + + get oauth2Token(): string { + return this.ssoOpenIdConnectService.token } login(username: string, password: string): Observable { @@ -79,6 +93,7 @@ export class LoginService { logout() { this.http.post(environment.backend + this.url + '/logout', null).pipe( tap(() => this.setUser(this.NO_USER)), + tap(() => this.ssoOpenIdConnectService.logout()), delay(500) ).subscribe( () => { diff --git a/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts b/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts index 88a34cd89..222e3adff 100644 --- a/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts +++ b/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; import { environment } from '@env/environment'; -import { Observable, map, tap } from 'rxjs'; +import { Observable, map } from 'rxjs'; import { Injectable } from '@angular/core'; interface SsoAuthConfig { @@ -64,7 +64,7 @@ export class SsoOpenIdConnectService { } get token(): string { - return this.oauthService.getIdToken(); + return this.oauthService.getAccessToken(); } get isLoggedIn() { From 0e8ac003c88b6bd6bf8075eb2bd92e1e98f75aea Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Thu, 10 Oct 2024 16:52:40 +0200 Subject: [PATCH 04/26] feat(server, ui): SSO OAuth2 with mock oidc-provider, authenticate SSO Opaque token and generate session on server side --- ...Token.java => OAuth2AuthenticationToken.java} | 4 ++-- .../sso/OAuth2SsoSecurityConfiguration.java | 16 ++++++++-------- ...serService.java => OAuth2SsoUserService.java} | 7 +++---- ...java => OAuth2TokenAuthenticationFilter.java} | 6 +++--- ...va => OAuth2TokenAuthenticationProvider.java} | 8 ++++---- chutney/ui/src/app/core/guards/auth.guard.ts | 7 ++----- .../ui/src/app/core/services/login.service.ts | 2 -- 7 files changed, 22 insertions(+), 28 deletions(-) rename chutney/server/src/main/java/com/chutneytesting/security/infra/sso/{TokenAuthenticationToken.java => OAuth2AuthenticationToken.java} (79%) rename chutney/server/src/main/java/com/chutneytesting/security/infra/sso/{CustomOAuth2UserService.java => OAuth2SsoUserService.java} (81%) rename chutney/server/src/main/java/com/chutneytesting/security/infra/sso/{TokenAuthenticationFilter.java => OAuth2TokenAuthenticationFilter.java} (88%) rename chutney/server/src/main/java/com/chutneytesting/security/infra/sso/{TokenAuthenticationProvider.java => OAuth2TokenAuthenticationProvider.java} (82%) diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationToken.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2AuthenticationToken.java similarity index 79% rename from chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationToken.java rename to chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2AuthenticationToken.java index 0a289d53d..6bd16f4c7 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationToken.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2AuthenticationToken.java @@ -9,11 +9,11 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; -public class TokenAuthenticationToken extends AbstractAuthenticationToken { +public class OAuth2AuthenticationToken extends AbstractAuthenticationToken { private final String token; - public TokenAuthenticationToken(String token) { + public OAuth2AuthenticationToken(String token) { super(null); this.token = token; setAuthenticated(false); diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java index e383f9f22..bb68746df 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java @@ -93,28 +93,28 @@ SsoOpenIdConnectConfig ssoOpenIdConnectConfig( @Bean public OAuth2UserService customOAuth2UserService(AuthenticationService authenticationService) { - return new CustomOAuth2UserService(authenticationService); + return new OAuth2SsoUserService(authenticationService); } @Bean - public TokenAuthenticationProvider tokenAuthenticationProvider(AuthenticationService authenticationService, ClientRegistrationRepository clientRegistrationRepository) { - return new TokenAuthenticationProvider(customOAuth2UserService(authenticationService), clientRegistrationRepository.findByRegistrationId("my-provider")); + public OAuth2TokenAuthenticationProvider tokenAuthenticationProvider(AuthenticationService authenticationService, ClientRegistrationRepository clientRegistrationRepository) { + return new OAuth2TokenAuthenticationProvider(customOAuth2UserService(authenticationService), clientRegistrationRepository.findByRegistrationId("my-provider")); } @Bean - public AuthenticationManager authenticationManager(TokenAuthenticationProvider tokenAuthenticationProvider) { - return new ProviderManager(Collections.singletonList(tokenAuthenticationProvider)); + public AuthenticationManager authenticationManager(OAuth2TokenAuthenticationProvider OAuth2TokenAuthenticationProvider) { + return new ProviderManager(Collections.singletonList(OAuth2TokenAuthenticationProvider)); } @Bean @Order(1) - public SecurityFilterChain securityFilterChainOAuth2Sso(final HttpSecurity http, TokenAuthenticationProvider tokenAuthenticationProvider, AuthenticationManager authenticationManager) throws Exception { + public SecurityFilterChain securityFilterChainOAuth2Sso(final HttpSecurity http, OAuth2TokenAuthenticationProvider OAuth2TokenAuthenticationProvider, AuthenticationManager authenticationManager) throws Exception { ChutneyWebSecurityConfig chutneyWebSecurityConfig = new ChutneyWebSecurityConfig(); - TokenAuthenticationFilter tokenFilter = new TokenAuthenticationFilter(authenticationManager); + OAuth2TokenAuthenticationFilter tokenFilter = new OAuth2TokenAuthenticationFilter(authenticationManager); chutneyWebSecurityConfig.configureBaseHttpSecurity(http, sslEnabled); UserDto anonymous = chutneyWebSecurityConfig.anonymous(); http - .authenticationProvider(tokenAuthenticationProvider) + .authenticationProvider(OAuth2TokenAuthenticationProvider) .addFilterBefore(tokenFilter, BasicAuthenticationFilter.class) .anonymous(anonymousConfigurer -> anonymousConfigurer .principal(anonymous) diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/CustomOAuth2UserService.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoUserService.java similarity index 81% rename from chutney/server/src/main/java/com/chutneytesting/security/infra/sso/CustomOAuth2UserService.java rename to chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoUserService.java index 47bd469fc..149599908 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/CustomOAuth2UserService.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoUserService.java @@ -15,22 +15,21 @@ import java.util.Map; 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; -public class CustomOAuth2UserService implements OAuth2UserService { +public class OAuth2SsoUserService implements org.springframework.security.oauth2.client.userinfo.OAuth2UserService { private final AuthenticationService authenticationService; - public CustomOAuth2UserService(AuthenticationService authenticationService) { + public OAuth2SsoUserService(AuthenticationService authenticationService) { this.authenticationService = authenticationService; } @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - OAuth2UserService delegate = new DefaultOAuth2UserService(); + org.springframework.security.oauth2.client.userinfo.OAuth2UserService delegate = new DefaultOAuth2UserService(); OAuth2User oAuth2User = delegate.loadUser(userRequest); Map oAuth2UserAttributes = oAuth2User.getAttributes(); String username = (String) oAuth2UserAttributes.get("sub"); diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationFilter.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationFilter.java similarity index 88% rename from chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationFilter.java rename to chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationFilter.java index deff01fb5..211e490dd 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationFilter.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationFilter.java @@ -19,11 +19,11 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; -public class TokenAuthenticationFilter extends OncePerRequestFilter { +public class OAuth2TokenAuthenticationFilter extends OncePerRequestFilter { private final AuthenticationManager authenticationManager; - public TokenAuthenticationFilter(AuthenticationManager authenticationManager){ + public OAuth2TokenAuthenticationFilter(AuthenticationManager authenticationManager){ this.authenticationManager = authenticationManager; } @@ -35,7 +35,7 @@ protected void doFilterInternal(HttpServletRequest request, String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { String token = authorizationHeader.substring(7); - TokenAuthenticationToken authRequest = new TokenAuthenticationToken(token); + OAuth2AuthenticationToken authRequest = new OAuth2AuthenticationToken(token); try { Authentication authentication = authenticationManager.authenticate(authRequest); SecurityContextHolder.getContext().setAuthentication(authentication); diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationProvider.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationProvider.java similarity index 82% rename from chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationProvider.java rename to chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationProvider.java index 45d860b82..2038bdc2b 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/TokenAuthenticationProvider.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2TokenAuthenticationProvider.java @@ -18,19 +18,19 @@ import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.user.OAuth2User; -public class TokenAuthenticationProvider implements AuthenticationProvider { +public class OAuth2TokenAuthenticationProvider implements AuthenticationProvider { private final OAuth2UserService oAuth2UserService; private final ClientRegistration clientRegistration; - public TokenAuthenticationProvider(OAuth2UserService oAuth2UserService, ClientRegistration clientRegistration) { + public OAuth2TokenAuthenticationProvider(OAuth2UserService oAuth2UserService, ClientRegistration clientRegistration) { this.oAuth2UserService = oAuth2UserService; this.clientRegistration = clientRegistration; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { - TokenAuthenticationToken tokenAuth = (TokenAuthenticationToken) authentication; + 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); @@ -44,6 +44,6 @@ public Authentication authenticate(Authentication authentication) throws Authent @Override public boolean supports(Class authentication) { - return TokenAuthenticationToken.class.isAssignableFrom(authentication); + return OAuth2AuthenticationToken.class.isAssignableFrom(authentication); } } diff --git a/chutney/ui/src/app/core/guards/auth.guard.ts b/chutney/ui/src/app/core/guards/auth.guard.ts index 927ee7a29..a4bc17b18 100644 --- a/chutney/ui/src/app/core/guards/auth.guard.ts +++ b/chutney/ui/src/app/core/guards/auth.guard.ts @@ -11,11 +11,8 @@ import { TranslateService } from '@ngx-translate/core'; import { LoginService } from '@core/services'; import { AlertService } from '@shared'; -import {Authorization, User} from '@model'; -import {OAuthService} from "angular-oauth2-oidc"; -import {SsoOpenIdConnectService} from "@core/services/sso-open-id-connect.service"; -import {HttpHeaders} from "@angular/common/http"; -import {firstValueFrom} from "rxjs"; +import { Authorization } from '@model'; +import { firstValueFrom } from "rxjs"; export const authGuard: CanActivateFn = async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { diff --git a/chutney/ui/src/app/core/services/login.service.ts b/chutney/ui/src/app/core/services/login.service.ts index e4028dd9b..e204a5fca 100644 --- a/chutney/ui/src/app/core/services/login.service.ts +++ b/chutney/ui/src/app/core/services/login.service.ts @@ -128,11 +128,9 @@ export class LoginService { [header: string]: string | string[]; } = {}): Observable { const headersInterceptor = skipInterceptor ? { 'no-intercept-error': ''} : {} - console.log(headers) const options = { headers: { ...headersInterceptor, ...headers} }; - console.log(options) return this.http.get(environment.backend + this.url, options); } From b492609b2b7609ca19a31fec9b77b4ddb0fcc9eb Mon Sep 17 00:00:00 2001 From: DelaunayAlex Date: Mon, 14 Oct 2024 10:37:12 +0200 Subject: [PATCH 05/26] feat(ui, server): Doc local oidc provider --- .../server/src/test/resources/sso/README.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 chutney/server/src/test/resources/sso/README.md diff --git a/chutney/server/src/test/resources/sso/README.md b/chutney/server/src/test/resources/sso/README.md new file mode 100644 index 000000000..3887e85ef --- /dev/null +++ b/chutney/server/src/test/resources/sso/README.md @@ -0,0 +1,30 @@ +# 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 From 9ffca82c3cb8978428ee6edd050b9423badccc14 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Sun, 20 Oct 2024 23:57:46 +0200 Subject: [PATCH 06/26] feat(server, ui): Fix PR --- .../main/resources/application-sso-auth.yml | 2 +- .../src/main/resources/application.yml | 1 - chutney/server/pom.xml | 4 - .../AbstractChutneyWebSecurityConfig.java | 102 ++++++++++++++++++ .../security/ChutneyWebSecurityConfig.java | 90 +--------------- .../api/SsoOpenIdConnectConfigDto.java | 30 ++++++ .../api/SsoOpenIdConnectController.java | 17 +-- .../security/api/SsoOpenIdConnectMapper.java | 26 +++++ .../SsoOpenIdConnectConfig.java | 14 +-- .../domain/SsoOpenIdConnectConfigService.java | 25 +++++ .../domain/SsoOpenIdConnectMapper.java | 28 +++++ .../sso/OAuth2SsoSecurityConfiguration.java | 81 ++------------ .../infra/sso/OAuth2SsoUserService.java | 3 +- .../sso/OAuth2TokenAuthenticationFilter.java | 5 +- .../sso/SsoOpenIdConnectConfigProperties.java | 38 +++++++ .../src/test/resources/sso/package.json | 1 - chutney/ui/src/app/app.module.ts | 4 +- .../core/components/login/login.component.ts | 5 +- .../ui/src/app/core/services/login.service.ts | 14 +-- ...n-id-connect.service.ts => sso.service.ts} | 10 +- .../dataset-list.component.spec.ts | 3 + .../search-list/scenarios.component.spec.ts | 3 + 22 files changed, 294 insertions(+), 212 deletions(-) create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/AbstractChutneyWebSecurityConfig.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfigDto.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectMapper.java rename chutney/server/src/main/java/com/chutneytesting/security/{infra/sso => domain}/SsoOpenIdConnectConfig.java (72%) create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfigService.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectMapper.java create mode 100644 chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfigProperties.java rename chutney/ui/src/app/core/services/{sso-open-id-connect.service.ts => sso.service.ts} (88%) 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 index 6bcd42d34..25482b605 100644 --- a/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml +++ b/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml @@ -9,7 +9,7 @@ spring: client-id: my-client client-secret: my-client-secret authorization-grant-type: authorization_code - redirect-uri: "https://localhost:4200/login/oauth2/code/{registrationId}" + redirect-uri: "https://${server.http.interface}:${server.port}/login/oauth2/code/{registrationId}" scope: openid, profile, email client-name: My Provider provider: diff --git a/chutney/packaging/local-dev/src/main/resources/application.yml b/chutney/packaging/local-dev/src/main/resources/application.yml index d575e311b..9b7920aa6 100644 --- a/chutney/packaging/local-dev/src/main/resources/application.yml +++ b/chutney/packaging/local-dev/src/main/resources/application.yml @@ -55,7 +55,6 @@ spring: - ldap - mem-auth - db-sqlite - - sso-auth chutney: configuration-folder: .chutney/conf diff --git a/chutney/server/pom.xml b/chutney/server/pom.xml index 4f2c0ab2a..a989b9971 100644 --- a/chutney/server/pom.xml +++ b/chutney/server/pom.xml @@ -86,10 +86,6 @@ org.springframework.boot spring-boot-starter-security - - org.springframework.boot - spring-boot-starter-oauth2-authorization-server - org.springframework.boot spring-boot-starter-oauth2-client diff --git a/chutney/server/src/main/java/com/chutneytesting/security/AbstractChutneyWebSecurityConfig.java b/chutney/server/src/main/java/com/chutneytesting/security/AbstractChutneyWebSecurityConfig.java new file mode 100644 index 000000000..03017cdbc --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/AbstractChutneyWebSecurityConfig.java @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +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.SsoOpenIdConnectConfigService; +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.SsoOpenIdConnectConfigProperties; +import com.chutneytesting.server.core.domain.security.Authorization; +import com.chutneytesting.server.core.domain.security.User; +import java.util.ArrayList; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.lang.Nullable; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.ChannelSecurityConfigurer; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; + +public abstract class AbstractChutneyWebSecurityConfig { + + protected static final String LOGIN_URL = UserController.BASE_URL + "/login"; + protected static final String LOGOUT_URL = UserController.BASE_URL + "/logout"; + protected static final String API_BASE_URL_PATTERN = "/api/**"; + + @Bean + public SsoOpenIdConnectConfigService ssoOpenIdConnectConfigService(@Nullable SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties) { + return new SsoOpenIdConnectConfigService(ssoOpenIdConnectConfigProperties); + } + + @Value("${management.endpoints.web.base-path:/actuator}") + protected String actuatorBaseUrl; + + @Value("${server.ssl.enabled:true}") + private Boolean sslEnabled; + + protected HttpSecurity configureHttp(final HttpSecurity http) throws Exception { + configureBaseHttpSecurity(http); + UserDto anonymous = anonymous(); + http + .anonymous(anonymousConfigurer -> anonymousConfigurer + .principal(anonymous) + .authorities(new ArrayList<>(anonymous.getAuthorities()))) + .authorizeHttpRequests(httpRequest -> { + HandlerMappingIntrospector introspector = new HandlerMappingIntrospector(); + httpRequest + .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; + } + + protected void configureBaseHttpSecurity(final HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .exceptionHandling(httpSecurityExceptionHandlingConfigurer -> httpSecurityExceptionHandlingConfigurer.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .requiresChannel(this.requireChannel(sslEnabled)) + .formLogin(httpSecurityFormLoginConfigurer -> httpSecurityFormLoginConfigurer + .loginProcessingUrl(LOGIN_URL) + .successHandler(new HttpLoginSuccessHandler()) + .failureHandler(new Http401FailureHandler())) + .logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer + .logoutUrl(LOGOUT_URL) + .logoutSuccessHandler(new HttpEmptyLogoutSuccessHandler())); + } + + protected UserDto anonymous() { + UserDto anonymous = new UserDto(); + anonymous.setId(User.ANONYMOUS.id); + anonymous.setName(User.ANONYMOUS.id); + anonymous.grantAuthority("ANONYMOUS"); + return anonymous; + } + + private Customizer.ChannelRequestMatcherRegistry> requireChannel(Boolean sslEnabled) { + if (sslEnabled) { + return channelRequestMatcherRegistry -> channelRequestMatcherRegistry.anyRequest().requiresSecure(); + } else { + return channelRequestMatcherRegistry -> channelRequestMatcherRegistry.anyRequest().requiresInsecure(); + } + } +} 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 88c34a847..89e50c76b 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java @@ -7,119 +7,33 @@ 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; import com.chutneytesting.security.domain.Authorizations; -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.SsoOpenIdConnectConfig; -import com.chutneytesting.server.core.domain.security.Authorization; -import com.chutneytesting.server.core.domain.security.User; -import java.util.ArrayList; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.core.annotation.Order; -import org.springframework.http.HttpStatus; -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.web.SecurityFilterChain; -import org.springframework.security.web.authentication.HttpStatusEntryPoint; -import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; -import org.springframework.web.servlet.handler.HandlerMappingIntrospector; @Configuration @EnableWebSecurity @EnableMethodSecurity @Profile("!sso-auth") -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/**"; - - @Value("${management.endpoints.web.base-path:/actuator}") - public static String ACTUATOR_BASE_URL; - - @Value("${server.ssl.enabled:true}") - Boolean sslEnabled; +public class ChutneyWebSecurityConfig extends AbstractChutneyWebSecurityConfig { @Bean public AuthenticationService authenticationService(Authorizations authorizations) { return new AuthenticationService(authorizations); } - @Bean - @ConditionalOnMissingBean - public SsoOpenIdConnectConfig emptySsoOpenIdConnectConfig() { - return new SsoOpenIdConnectConfig(); - } - - @Bean @Order() @ConditionalOnMissingBean(value = SecurityFilterChain.class) public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { - configureBaseHttpSecurity(http, sslEnabled); - UserDto anonymous = anonymous(); - http - .anonymous(anonymousConfigurer -> anonymousConfigurer - .principal(anonymous) - .authorities(new ArrayList<>(anonymous.getAuthorities()))) - .authorizeHttpRequests(httpRequest -> { - HandlerMappingIntrospector introspector = new HandlerMappingIntrospector(); - httpRequest - .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, SsoOpenIdConnectController.BASE_URL + "/**")).permitAll() - .requestMatchers(new MvcRequestMatcher(introspector, API_BASE_URL_PATTERN)).authenticated() - .requestMatchers(new MvcRequestMatcher(introspector, ACTUATOR_BASE_URL + "/**")).hasAuthority(Authorization.ADMIN_ACCESS.name()) - .anyRequest().permitAll(); - }) - .httpBasic(Customizer.withDefaults()); - return http.build(); - } - - public void configureBaseHttpSecurity(final HttpSecurity http, Boolean sslEnabled) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .exceptionHandling(httpSecurityExceptionHandlingConfigurer -> httpSecurityExceptionHandlingConfigurer.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) - .requiresChannel(this.requireChannel(sslEnabled)) - .formLogin(httpSecurityFormLoginConfigurer -> httpSecurityFormLoginConfigurer - .loginProcessingUrl(LOGIN_URL) - .successHandler(new HttpLoginSuccessHandler()) - .failureHandler(new Http401FailureHandler())) - .logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer - .logoutUrl(LOGOUT_URL) - .logoutSuccessHandler(new HttpEmptyLogoutSuccessHandler())); - } - - public UserDto anonymous() { - UserDto anonymous = new UserDto(); - anonymous.setId(User.ANONYMOUS.id); - anonymous.setName(User.ANONYMOUS.id); - anonymous.grantAuthority("ANONYMOUS"); - return anonymous; - } - - private Customizer.ChannelRequestMatcherRegistry> requireChannel(Boolean sslEnabled) { - if (sslEnabled) { - return channelRequestMatcherRegistry -> channelRequestMatcherRegistry.anyRequest().requiresSecure(); - } else { - return channelRequestMatcherRegistry -> channelRequestMatcherRegistry.anyRequest().requiresInsecure(); - } + return configureHttp(http).build(); } } 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..4aaef8bca --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfigDto.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.api; + +public class SsoOpenIdConnectConfigDto { + 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 Boolean oidc; + + public SsoOpenIdConnectConfigDto(String issuer, String clientId, String clientSecret, String responseType, String scope, String redirectBaseUrl, String ssoProviderName, Boolean oidc) { + this.issuer = issuer; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.responseType = responseType; + this.scope = scope; + this.redirectBaseUrl = redirectBaseUrl; + this.ssoProviderName = ssoProviderName; + this.oidc = oidc; + } +} 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 index 13d29e854..966b4634f 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java @@ -7,7 +7,9 @@ package com.chutneytesting.security.api; -import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfig; +import static com.chutneytesting.security.api.SsoOpenIdConnectMapper.toDto; + +import com.chutneytesting.security.domain.SsoOpenIdConnectConfigService; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; @@ -21,14 +23,17 @@ public class SsoOpenIdConnectController { public static final String BASE_URL = "/api/v1/sso"; - private final SsoOpenIdConnectConfig ssoOpenIdConnectConfig; + private final SsoOpenIdConnectConfigService ssoOpenIdConnectConfigService; - SsoOpenIdConnectController(SsoOpenIdConnectConfig ssoOpenIdConnectConfig) { - this.ssoOpenIdConnectConfig = ssoOpenIdConnectConfig; + SsoOpenIdConnectController(SsoOpenIdConnectConfigService ssoOpenIdConnectConfigService) { + this.ssoOpenIdConnectConfigService = ssoOpenIdConnectConfigService; } @GetMapping(path = "/config", produces = MediaType.APPLICATION_JSON_VALUE) - public SsoOpenIdConnectConfig getLastCampaignExecution() { - return ssoOpenIdConnectConfig; + public SsoOpenIdConnectConfigDto getSsoOpenIdConnectConfig() { + if (ssoOpenIdConnectConfigService == null) { + return null; + } + return toDto(ssoOpenIdConnectConfigService.getSsoOpenIdConnectConfig()); } } 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..c47e32110 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectMapper.java @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.api; + +public class SsoOpenIdConnectMapper { + public static SsoOpenIdConnectConfigDto toDto(com.chutneytesting.security.domain.SsoOpenIdConnectConfig ssoOpenIdConnectConfig) { + if (ssoOpenIdConnectConfig == null) { + return null; + } + return new SsoOpenIdConnectConfigDto( + ssoOpenIdConnectConfig.issuer, + ssoOpenIdConnectConfig.clientId, + ssoOpenIdConnectConfig.clientSecret, + ssoOpenIdConnectConfig.responseType, + ssoOpenIdConnectConfig.scope, + ssoOpenIdConnectConfig.redirectBaseUrl, + ssoOpenIdConnectConfig.ssoProviderName, + ssoOpenIdConnectConfig.oidc + ); + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfig.java b/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfig.java similarity index 72% rename from chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfig.java rename to chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfig.java index 94e5a12c8..643e30eaf 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfig.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfig.java @@ -5,10 +5,9 @@ * */ -package com.chutneytesting.security.infra.sso; +package com.chutneytesting.security.domain; public class SsoOpenIdConnectConfig { - public final String issuer; public final String clientId; public final String clientSecret; @@ -18,17 +17,6 @@ public class SsoOpenIdConnectConfig { public final String ssoProviderName; public final Boolean oidc; - public SsoOpenIdConnectConfig() { - this.issuer = null; - this.clientId = null; - this.clientSecret = null; - this.responseType = null; - this.scope = null; - this.redirectBaseUrl = null; - this.ssoProviderName = null; - this.oidc = null; - } - public SsoOpenIdConnectConfig(String issuer, String clientId, String clientSecret, String responseType, String scope, String redirectBaseUrl, String ssoProviderName, Boolean oidc) { this.issuer = issuer; this.clientId = clientId; diff --git a/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfigService.java b/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfigService.java new file mode 100644 index 000000000..04985c2fc --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfigService.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.domain; + +import static com.chutneytesting.security.domain.SsoOpenIdConnectMapper.toDomain; + +import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfigProperties; + +public class SsoOpenIdConnectConfigService { + + private final SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties; + + public SsoOpenIdConnectConfigService(SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties) { + this.ssoOpenIdConnectConfigProperties = ssoOpenIdConnectConfigProperties; + } + + public SsoOpenIdConnectConfig getSsoOpenIdConnectConfig() { + return toDomain(ssoOpenIdConnectConfigProperties); + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectMapper.java b/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectMapper.java new file mode 100644 index 000000000..12317290a --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectMapper.java @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.domain; + +import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfigProperties; + +public class SsoOpenIdConnectMapper { + public static SsoOpenIdConnectConfig toDomain(SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties) { + if (ssoOpenIdConnectConfigProperties == null) { + return null; + } + return new SsoOpenIdConnectConfig( + ssoOpenIdConnectConfigProperties.issuer, + ssoOpenIdConnectConfigProperties.clientId, + ssoOpenIdConnectConfigProperties.clientSecret, + ssoOpenIdConnectConfigProperties.responseType, + ssoOpenIdConnectConfigProperties.scope, + ssoOpenIdConnectConfigProperties.redirectBaseUrl, + ssoOpenIdConnectConfigProperties.ssoProviderName, + ssoOpenIdConnectConfigProperties.oidc + ); + } +} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java index bb68746df..a90729bf7 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java @@ -7,31 +7,18 @@ package com.chutneytesting.security.infra.sso; -import static com.chutneytesting.security.ChutneyWebSecurityConfig.API_BASE_URL_PATTERN; -import static com.chutneytesting.security.ChutneyWebSecurityConfig.LOGIN_URL; -import static com.chutneytesting.security.ChutneyWebSecurityConfig.LOGOUT_URL; -import com.chutneytesting.admin.api.InfoController; -import com.chutneytesting.security.ChutneyWebSecurityConfig; -import com.chutneytesting.security.api.SsoOpenIdConnectController; -import com.chutneytesting.security.api.UserDto; +import com.chutneytesting.security.AbstractChutneyWebSecurityConfig; import com.chutneytesting.security.domain.AuthenticationService; import com.chutneytesting.security.domain.Authorizations; -import com.chutneytesting.server.core.domain.security.Authorization; -import java.util.ArrayList; import java.util.Collections; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 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.Primary; import org.springframework.context.annotation.Profile; -import org.springframework.core.annotation.Order; 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; @@ -42,55 +29,19 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; -import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; -import org.springframework.web.servlet.handler.HandlerMappingIntrospector; @Configuration @Profile("sso-auth") @EnableWebSecurity @EnableMethodSecurity -@EnableConfigurationProperties(OAuth2AuthorizationServerProperties.class) -public class OAuth2SsoSecurityConfiguration { - - @Value("${management.endpoints.web.base-path:/actuator}") - public static String ACTUATOR_BASE_URL; - - @Value("${server.ssl.enabled:true}") - Boolean sslEnabled; +@EnableConfigurationProperties({OAuth2AuthorizationServerProperties.class, SsoOpenIdConnectConfigProperties.class}) +public class OAuth2SsoSecurityConfiguration extends AbstractChutneyWebSecurityConfig { @Bean public AuthenticationService authenticationService(Authorizations authorizations) { return new AuthenticationService(authorizations); } - @Bean - @ConditionalOnMissingBean - public SsoOpenIdConnectConfig emptySsoOpenIdConnectConfig() { - return new SsoOpenIdConnectConfig(); - } - - @Bean - @Primary - SsoOpenIdConnectConfig ssoOpenIdConnectConfig( - @Value("${auth.sso.issuer}") String issuer, - @Value("${auth.sso.clientId}") String clientId, - @Value("${auth.sso.clientSecret}") String clientSecret, - @Value("${auth.sso.responseType}") String responseType, - @Value("${auth.sso.scope}") String scope, - @Value("${auth.sso.redirectBaseUrl}") String redirectUri, - @Value("${auth.sso.oidc}") Boolean oidc, - @Value("${auth.sso.ssoProviderName}") String ssoProviderName - ) { - return new SsoOpenIdConnectConfig(issuer, - clientId, - clientSecret, - responseType, - scope, - redirectUri, - ssoProviderName, - oidc); - } - @Bean public OAuth2UserService customOAuth2UserService(AuthenticationService authenticationService) { return new OAuth2SsoUserService(authenticationService); @@ -107,32 +58,12 @@ public AuthenticationManager authenticationManager(OAuth2TokenAuthenticationProv } @Bean - @Order(1) public SecurityFilterChain securityFilterChainOAuth2Sso(final HttpSecurity http, OAuth2TokenAuthenticationProvider OAuth2TokenAuthenticationProvider, AuthenticationManager authenticationManager) throws Exception { - ChutneyWebSecurityConfig chutneyWebSecurityConfig = new ChutneyWebSecurityConfig(); OAuth2TokenAuthenticationFilter tokenFilter = new OAuth2TokenAuthenticationFilter(authenticationManager); - chutneyWebSecurityConfig.configureBaseHttpSecurity(http, sslEnabled); - UserDto anonymous = chutneyWebSecurityConfig.anonymous(); - http - .authenticationProvider(OAuth2TokenAuthenticationProvider) + http.authenticationProvider(OAuth2TokenAuthenticationProvider) .addFilterBefore(tokenFilter, BasicAuthenticationFilter.class) - .anonymous(anonymousConfigurer -> anonymousConfigurer - .principal(anonymous) - .authorities(new ArrayList<>(anonymous.getAuthorities()))) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) - .authorizeHttpRequests(httpRequest -> { - HandlerMappingIntrospector introspector = new HandlerMappingIntrospector(); - httpRequest - .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, SsoOpenIdConnectController.BASE_URL + "/**")).permitAll() - .requestMatchers(new MvcRequestMatcher(introspector, API_BASE_URL_PATTERN)).authenticated() - .requestMatchers(new MvcRequestMatcher(introspector, ACTUATOR_BASE_URL + "/**")).hasAuthority(Authorization.ADMIN_ACCESS.name()) - .anyRequest().permitAll(); - }) - .httpBasic(Customizer.withDefaults()); + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); + configureHttp(http); return http.build(); } } 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 index 149599908..6cec6abff 100644 --- 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 @@ -15,11 +15,12 @@ import java.util.Map; 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; -public class OAuth2SsoUserService implements org.springframework.security.oauth2.client.userinfo.OAuth2UserService { +public class OAuth2SsoUserService implements OAuth2UserService { private final AuthenticationService authenticationService; 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 index 211e490dd..78abd9c8b 100644 --- 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 @@ -21,6 +21,7 @@ public class OAuth2TokenAuthenticationFilter extends OncePerRequestFilter { + private static final String BEARER = "Bearer "; private final AuthenticationManager authenticationManager; public OAuth2TokenAuthenticationFilter(AuthenticationManager authenticationManager){ @@ -33,8 +34,8 @@ protected void doFilterInternal(HttpServletRequest request, FilterChain filterChain) throws ServletException, IOException { String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { - String token = authorizationHeader.substring(7); + if (authorizationHeader != null && authorizationHeader.startsWith(BEARER)) { + String token = authorizationHeader.substring(BEARER.length()); OAuth2AuthenticationToken authRequest = new OAuth2AuthenticationToken(token); try { Authentication authentication = authenticationManager.authenticate(authRequest); 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..8649dae45 --- /dev/null +++ b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/SsoOpenIdConnectConfigProperties.java @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package com.chutneytesting.security.infra.sso; + +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 Boolean oidc; + + public SsoOpenIdConnectConfigProperties(String issuer, String clientId, String clientSecret, String responseType, String scope, String redirectBaseUrl, String ssoProviderName, Boolean oidc) { + this.issuer = issuer; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.responseType = responseType; + this.scope = scope; + this.redirectBaseUrl = redirectBaseUrl; + this.ssoProviderName = ssoProviderName; + this.oidc = oidc; + } + + @Override + public void afterPropertiesSet() {} +} diff --git a/chutney/server/src/test/resources/sso/package.json b/chutney/server/src/test/resources/sso/package.json index e4607d620..f4d487967 100644 --- a/chutney/server/src/test/resources/sso/package.json +++ b/chutney/server/src/test/resources/sso/package.json @@ -8,7 +8,6 @@ }, "keywords": [], "author": "", - "license": "ISC", "description": "", "dependencies": { "express": "^4.21.0", diff --git a/chutney/ui/src/app/app.module.ts b/chutney/ui/src/app/app.module.ts index 2f1c685db..a7c46a313 100644 --- a/chutney/ui/src/app/app.module.ts +++ b/chutney/ui/src/app/app.module.ts @@ -28,7 +28,7 @@ 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, OAuthService } from 'angular-oauth2-oidc'; -import { SsoOpenIdConnectService } from '@core/services/sso-open-id-connect.service'; +import { SsoService } from '@core/services/sso.service'; import { tap } from 'rxjs'; @NgModule({ @@ -77,7 +77,7 @@ import { tap } from 'rxjs'; }) export class ChutneyAppModule { - constructor(private oauthService: OAuthService, private ssoOpenIdConnectService: SsoOpenIdConnectService) { + constructor(private oauthService: OAuthService, private ssoOpenIdConnectService: SsoService) { this.configureOAuth(); } 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 4163bb3ca..d2d15f5fa 100644 --- a/chutney/ui/src/app/core/components/login/login.component.ts +++ b/chutney/ui/src/app/core/components/login/login.component.ts @@ -11,7 +11,7 @@ import { Subscription } from 'rxjs'; import { AlertService } from '@shared'; import { InfoService, LoginService } from '@core/services'; -import { SsoOpenIdConnectService } from '@core/services/sso-open-id-connect.service'; +import { SsoService } from '@core/services/sso.service'; @Component({ selector: 'chutney-login', @@ -36,7 +36,7 @@ export class LoginComponent implements OnDestroy, OnInit { private infoService: InfoService, private route: ActivatedRoute, private alertService: AlertService, - private ssoService: SsoOpenIdConnectService + private ssoService: SsoService ) { this.paramsSubscription = this.route.params.subscribe(params => { this.action = params['action']; @@ -56,6 +56,7 @@ export class LoginComponent implements OnDestroy, OnInit { if (this.loginService.isAuthenticated()) { this.loginService.navigateAfterLogin(); } + this.ssoService.fetchSsoConfig() } ngOnDestroy() { diff --git a/chutney/ui/src/app/core/services/login.service.ts b/chutney/ui/src/app/core/services/login.service.ts index e204a5fca..2b20c820e 100644 --- a/chutney/ui/src/app/core/services/login.service.ts +++ b/chutney/ui/src/app/core/services/login.service.ts @@ -8,13 +8,13 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Router } from '@angular/router'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs'; import { catchError, delay, tap } from 'rxjs/operators'; import { environment } from '@env/environment'; import { Authorization, User } from '@model'; import { contains, intersection, isNullOrBlankString } from '@shared/tools'; -import { SsoOpenIdConnectService } from "@core/services/sso-open-id-connect.service"; +import { SsoService } from "@core/services/sso.service"; @Injectable({ providedIn: 'root' @@ -29,7 +29,7 @@ export class LoginService { constructor( private http: HttpClient, private router: Router, - private ssoOpenIdConnectService: SsoOpenIdConnectService + private ssoService: SsoService ) { } initLogin(url?: string, headers: HttpHeaders | { @@ -38,9 +38,9 @@ export class LoginService { this.initLoginObservable(url, headers).subscribe() } - initLoginObservable(url?: string, headers: HttpHeaders | { + initLoginObservable(url?: string, headers?: HttpHeaders | { [header: string]: string | string[]; - } = {}) { + }) { return this.currentUser(true, headers).pipe( tap(user => this.setUser(user)), tap(_ => this.navigateAfterLogin(url)), @@ -54,7 +54,7 @@ export class LoginService { } get oauth2Token(): string { - return this.ssoOpenIdConnectService.token + return this.ssoService.token } login(username: string, password: string): Observable { @@ -93,7 +93,7 @@ export class LoginService { logout() { this.http.post(environment.backend + this.url + '/logout', null).pipe( tap(() => this.setUser(this.NO_USER)), - tap(() => this.ssoOpenIdConnectService.logout()), + tap(() => this.ssoService.logout()), delay(500) ).subscribe( () => { diff --git a/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts b/chutney/ui/src/app/core/services/sso.service.ts similarity index 88% rename from chutney/ui/src/app/core/services/sso-open-id-connect.service.ts rename to chutney/ui/src/app/core/services/sso.service.ts index 222e3adff..f713a8467 100644 --- a/chutney/ui/src/app/core/services/sso-open-id-connect.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -18,7 +18,7 @@ interface SsoAuthConfig { @Injectable({ providedIn: 'root' }) -export class SsoOpenIdConnectService { +export class SsoService { private resourceUrl = '/api/v1/sso/config'; @@ -59,15 +59,7 @@ export class SsoOpenIdConnectService { return null } - get identityClaims() { - return this.oauthService.getIdentityClaims(); - } - get token(): string { return this.oauthService.getAccessToken(); } - - get isLoggedIn() { - return this.oauthService.hasValidAccessToken(); - } } 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..0426bf261 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 @@ -22,10 +22,12 @@ 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"; describe('DatasetListComponent', () => { const dataSetService = jasmine.createSpyObj('DataSetService', ['findAll']); + const oAuthService = jasmine.createSpyObj('OAuthService', ['loadDiscoveryDocumentAndTryLogin', 'configure', 'initCodeFlow', 'logOut', 'getAccessToken']); dataSetService.findAll.and.returnValue(of([])); beforeEach(waitForAsync(() => { TestBed.resetTestingModule(); @@ -48,6 +50,7 @@ describe('DatasetListComponent', () => { ], providers: [ { provide: DataSetService, useValue: dataSetService }, + { 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..ece98d9d7 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 @@ -26,6 +26,7 @@ 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"; function getScenarios(html: HTMLElement) { return html.querySelectorAll('.scenario-title'); @@ -42,6 +43,7 @@ describe('ScenariosComponent', () => { beforeEach(waitForAsync(() => { TestBed.resetTestingModule(); const scenarioService = jasmine.createSpyObj('ScenarioService', ['findScenarios', 'search']); + const oAuthService = jasmine.createSpyObj('OAuthService', ['loadDiscoveryDocumentAndTryLogin', 'configure', 'initCodeFlow', 'logOut', 'getAccessToken']); 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 +72,7 @@ describe('ScenariosComponent', () => { providers: [ NgbPopoverConfig, {provide: ScenarioService, useValue: scenarioService}, + {provide: OAuthService, useValue: oAuthService}, {provide: JiraPluginService, useValue: jiraPluginService}, {provide: JiraPluginConfigurationService, useValue: jiraPluginConfigurationService}, {provide: ActivatedRoute, useValue: activatedRouteStub}, From e9f42b404d006ae1d643c82fa3a619ac3cfcbf3f Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Tue, 22 Oct 2024 08:18:26 +0200 Subject: [PATCH 07/26] feat(server, ui): Add licence --- .../local-dev/src/main/resources/application-sso-auth.yml | 2 ++ chutney/server/src/test/resources/sso/README.md | 7 +++++++ chutney/ui/src/app/core/services/sso.service.ts | 7 +++++++ 3 files changed, 16 insertions(+) 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 index 25482b605..817ac387c 100644 --- a/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml +++ b/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml @@ -1,3 +1,5 @@ +#SPDX-FileCopyrightText: 2017-2024 Enedis +#SPDX-License-Identifier: Apache-2.0 spring: security: diff --git a/chutney/server/src/test/resources/sso/README.md b/chutney/server/src/test/resources/sso/README.md index 3887e85ef..7ba3f1541 100644 --- a/chutney/server/src/test/resources/sso/README.md +++ b/chutney/server/src/test/resources/sso/README.md @@ -1,3 +1,10 @@ + + # Local OpenID Connect Server ## Configuration diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index f713a8467..c0d1df9b4 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -1,3 +1,10 @@ +/* + * 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'; From 1fa05b04fde91eb15384cf9e2782b21d77138188 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Tue, 22 Oct 2024 08:38:39 +0200 Subject: [PATCH 08/26] feat(ui): Use app initializer --- chutney/ui/src/app/app.module.ts | 28 +++++++------------ .../core/components/login/login.component.ts | 1 - .../app/core/initializer/sso.initializer.ts | 12 ++++++++ .../ui/src/app/core/services/sso.service.ts | 12 +++++--- 4 files changed, 30 insertions(+), 23 deletions(-) create mode 100644 chutney/ui/src/app/core/initializer/sso.initializer.ts diff --git a/chutney/ui/src/app/app.module.ts b/chutney/ui/src/app/app.module.ts index a7c46a313..25d969b5a 100644 --- a/chutney/ui/src/app/app.module.ts +++ b/chutney/ui/src/app/app.module.ts @@ -27,9 +27,9 @@ 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, OAuthService } from 'angular-oauth2-oidc'; -import { SsoService } from '@core/services/sso.service'; -import { tap } from 'rxjs'; +import { OAuthModule } from 'angular-oauth2-oidc'; +import { ssoInitializer } from "@core/initializer/sso.initializer"; +import { SsoService } from "@core/services/sso.service"; @NgModule({ declarations: [ @@ -71,25 +71,17 @@ import { tap } from 'rxjs'; useFactory: themeInitializer, deps: [ThemeService], multi: true + }, + { + provide: APP_INITIALIZER, + useFactory: ssoInitializer, + deps: [SsoService], + multi: true } ], bootstrap: [AppComponent] }) -export class ChutneyAppModule { - - constructor(private oauthService: OAuthService, private ssoOpenIdConnectService: SsoService) { - this.configureOAuth(); - } - - private configureOAuth() { - this.ssoOpenIdConnectService.fetchSsoConfig().pipe( - tap(ssoConfig => { - this.oauthService.configure(ssoConfig) - this.oauthService.loadDiscoveryDocumentAndTryLogin(); - }) - ).subscribe() - } - } +export class ChutneyAppModule {} 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 d2d15f5fa..7c9d1a032 100644 --- a/chutney/ui/src/app/core/components/login/login.component.ts +++ b/chutney/ui/src/app/core/components/login/login.component.ts @@ -56,7 +56,6 @@ export class LoginComponent implements OnDestroy, OnInit { if (this.loginService.isAuthenticated()) { this.loginService.navigateAfterLogin(); } - this.ssoService.fetchSsoConfig() } ngOnDestroy() { diff --git a/chutney/ui/src/app/core/initializer/sso.initializer.ts b/chutney/ui/src/app/core/initializer/sso.initializer.ts new file mode 100644 index 000000000..68bc1ee71 --- /dev/null +++ b/chutney/ui/src/app/core/initializer/sso.initializer.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2017-2024 Enedis + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import { SsoService } from "@core/services/sso.service"; + +export function ssoInitializer(ssoOpenIdConnectService: SsoService): () => void { + return () => ssoOpenIdConnectService.fetchSsoConfig() +} diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index c0d1df9b4..33fbc5aad 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -8,7 +8,7 @@ import { HttpClient } from '@angular/common/http'; import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; import { environment } from '@env/environment'; -import { Observable, map } from 'rxjs'; +import {Observable, map, tap} from 'rxjs'; import { Injectable } from '@angular/core'; interface SsoAuthConfig { @@ -34,8 +34,8 @@ export class SsoService { constructor(private oauthService: OAuthService, private http: HttpClient) {} - fetchSsoConfig(): Observable { - return this.http.get(environment.backend + this.resourceUrl).pipe( + fetchSsoConfig(): void { + this.http.get(environment.backend + this.resourceUrl).pipe( map(ssoConfig => { this.ssoConfig = ssoConfig return { @@ -47,8 +47,12 @@ export class SsoService { dummyClientSecret: ssoConfig.clientSecret, oidc: ssoConfig.oidc } + }), + tap(ssoConfig => { + this.oauthService.configure(ssoConfig) + this.oauthService.loadDiscoveryDocumentAndTryLogin(); }) - ) + ).subscribe() } login() { From 0853d58544348266d55f74c8d1428061ae68cd63 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Tue, 29 Oct 2024 17:17:54 +0100 Subject: [PATCH 09/26] feat(server, ui): Fix PR --- .../main/resources/application-sso-auth.yml | 29 +- .../local-dev/src/test/resources/sso/.env | 9 + .../src/test/resources/sso/README.md | 0 .../src/test/resources/sso/package.json | 1 + .../src/test/resources/sso/sso-oidc.mjs | 26 +- .../AbstractChutneyWebSecurityConfig.java | 102 ------- .../security/ChutneyWebSecurityConfig.java | 118 ++++++- .../api/SsoOpenIdConnectController.java | 18 +- .../security/api/SsoOpenIdConnectMapper.java | 4 +- .../domain/SsoOpenIdConnectConfig.java | 30 -- .../domain/SsoOpenIdConnectConfigService.java | 25 -- .../domain/SsoOpenIdConnectMapper.java | 28 -- .../sso/OAuth2SsoSecurityConfiguration.java | 69 ----- chutney/ui/src/app/app.module.ts | 13 +- chutney/ui/src/app/core/guards/auth.guard.ts | 30 +- .../app/core/initializer/sso.initializer.ts | 12 - .../ui/src/app/core/services/login.service.ts | 289 ++++++++++-------- .../ui/src/app/core/services/sso.service.ts | 14 +- .../app/shared/error-interceptor.service.ts | 10 +- 19 files changed, 340 insertions(+), 487 deletions(-) create mode 100644 chutney/packaging/local-dev/src/test/resources/sso/.env rename chutney/{server => packaging/local-dev}/src/test/resources/sso/README.md (100%) rename chutney/{server => packaging/local-dev}/src/test/resources/sso/package.json (92%) rename chutney/{server => packaging/local-dev}/src/test/resources/sso/sso-oidc.mjs (54%) delete mode 100644 chutney/server/src/main/java/com/chutneytesting/security/AbstractChutneyWebSecurityConfig.java delete mode 100644 chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfig.java delete mode 100644 chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfigService.java delete mode 100644 chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectMapper.java delete mode 100644 chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java delete mode 100644 chutney/ui/src/app/core/initializer/sso.initializer.ts 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 index 817ac387c..4ace89b76 100644 --- a/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml +++ b/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml @@ -8,38 +8,37 @@ spring: registration: my-provider: provider: my-provider - client-id: my-client - client-secret: my-client-secret + client-id: "${auth.sso.clientId}" + client-secret: "${auth.sso.clientSecret}" authorization-grant-type: authorization_code redirect-uri: "https://${server.http.interface}:${server.port}/login/oauth2/code/{registrationId}" scope: openid, profile, email client-name: My Provider provider: my-provider: - issuer-uri: http://localhost:3000 - authorization-uri: http://localhost:3000/auth - token-uri: http://localhost:3000/token - user-info-uri: http://localhost:3000/me + 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: http://localhost:3000/jwks + jwk-set-uri: ${auth.sso.issuer}/jwks resourceserver: opaque-token: - introspection-uri: http://localhost:3000/token/introspection - client-id: 'my-client' - client-secret: 'my-client-secret' + introspection-uri: "${auth.sso.issuer}/token/introspection" + client-id: "${auth.sso.clientId}" + client-secret: "${auth.sso.clientSecret}" authorizationserver: - issuer: http://localhost:3000 + issuer: "${auth.sso.issuer}" endpoint: oidc: - user-info-uri: http://localhost:3000/userinfo + user-info-uri: "${auth.sso.issuer}/userinfo" auth: sso: - issuer: 'http://localhost:3000' + issuer: "http://localhost:3000" clientId: 'my-client' clientSecret: 'my-client-secret' responseType: 'code' scope: 'openid profile email' - redirectBaseUrl: 'https://localhost:4200' - ssoProviderName: 'SSO OIDC' + redirectBaseUrl: "https://localhost: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..5c59d759f --- /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' +TOKEN_FORMAT='opaque' +PORT=3000 +GRANT_TYPE=authorization_code diff --git a/chutney/server/src/test/resources/sso/README.md b/chutney/packaging/local-dev/src/test/resources/sso/README.md similarity index 100% rename from chutney/server/src/test/resources/sso/README.md rename to chutney/packaging/local-dev/src/test/resources/sso/README.md diff --git a/chutney/server/src/test/resources/sso/package.json b/chutney/packaging/local-dev/src/test/resources/sso/package.json similarity index 92% rename from chutney/server/src/test/resources/sso/package.json rename to chutney/packaging/local-dev/src/test/resources/sso/package.json index f4d487967..b3883b7a9 100644 --- a/chutney/server/src/test/resources/sso/package.json +++ b/chutney/packaging/local-dev/src/test/resources/sso/package.json @@ -10,6 +10,7 @@ "author": "", "description": "", "dependencies": { + "dotenv": "^16.4.5", "express": "^4.21.0", "oidc-provider": "^8.5.1" } diff --git a/chutney/server/src/test/resources/sso/sso-oidc.mjs b/chutney/packaging/local-dev/src/test/resources/sso/sso-oidc.mjs similarity index 54% rename from chutney/server/src/test/resources/sso/sso-oidc.mjs rename to chutney/packaging/local-dev/src/test/resources/sso/sso-oidc.mjs index 5ca5aaed6..0cbcefd39 100644 --- a/chutney/server/src/test/resources/sso/sso-oidc.mjs +++ b/chutney/packaging/local-dev/src/test/resources/sso/sso-oidc.mjs @@ -7,19 +7,22 @@ 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: 'my-client', - client_secret: 'my-client-secret', - grant_types: ['authorization_code'], - redirect_uris: ['https://localhost:4200/'], - post_logout_redirect_uris: ['https://localhost:4200/'], + client_id: process.env.CLIENT_ID, + client_secret: process.env.CLIENT_SECRET, + grant_types: [process.env.GRANT_TYPE], + redirect_uris: [process.env.REDIRECT_URI], + post_logout_redirect_uris: [process.env.REDIRECT_URI], }], formats: { - AccessToken: 'opaque', - RefreshToken: 'opaque', - IdToken: 'opaque' + AccessToken: process.env.TOKEN_FORMAT, + RefreshToken: process.env.TOKEN_FORMAT, + IdToken: process.env.TOKEN_FORMAT }, features: { introspection: { @@ -29,7 +32,7 @@ const oidc = new Provider('http://localhost:3000', { userinfo: { enabled: true }, }, clientBasedCORS(ctx, origin, client) { - const allowedOrigins = ['https://localhost:4200']; + const allowedOrigins = [process.env.REDIRECT_URI]; return allowedOrigins.includes(origin); }, async findAccount(ctx, id) { @@ -42,6 +45,7 @@ const oidc = new Provider('http://localhost:3000', { const app = express(); app.use(oidc.callback()); -app.listen(3000, () => { - console.log('OIDC provider listening on port 3000'); +const port = parseInt(process.env.PORT, 10) +app.listen(port, () => { + console.log(`OIDC provider listening on port ${port}`); }); diff --git a/chutney/server/src/main/java/com/chutneytesting/security/AbstractChutneyWebSecurityConfig.java b/chutney/server/src/main/java/com/chutneytesting/security/AbstractChutneyWebSecurityConfig.java deleted file mode 100644 index 03017cdbc..000000000 --- a/chutney/server/src/main/java/com/chutneytesting/security/AbstractChutneyWebSecurityConfig.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 Enedis - * - * SPDX-License-Identifier: Apache-2.0 - * - */ - -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.SsoOpenIdConnectConfigService; -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.SsoOpenIdConnectConfigProperties; -import com.chutneytesting.server.core.domain.security.Authorization; -import com.chutneytesting.server.core.domain.security.User; -import java.util.ArrayList; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.http.HttpStatus; -import org.springframework.lang.Nullable; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.configurers.ChannelSecurityConfigurer; -import org.springframework.security.web.authentication.HttpStatusEntryPoint; -import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; -import org.springframework.web.servlet.handler.HandlerMappingIntrospector; - -public abstract class AbstractChutneyWebSecurityConfig { - - protected static final String LOGIN_URL = UserController.BASE_URL + "/login"; - protected static final String LOGOUT_URL = UserController.BASE_URL + "/logout"; - protected static final String API_BASE_URL_PATTERN = "/api/**"; - - @Bean - public SsoOpenIdConnectConfigService ssoOpenIdConnectConfigService(@Nullable SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties) { - return new SsoOpenIdConnectConfigService(ssoOpenIdConnectConfigProperties); - } - - @Value("${management.endpoints.web.base-path:/actuator}") - protected String actuatorBaseUrl; - - @Value("${server.ssl.enabled:true}") - private Boolean sslEnabled; - - protected HttpSecurity configureHttp(final HttpSecurity http) throws Exception { - configureBaseHttpSecurity(http); - UserDto anonymous = anonymous(); - http - .anonymous(anonymousConfigurer -> anonymousConfigurer - .principal(anonymous) - .authorities(new ArrayList<>(anonymous.getAuthorities()))) - .authorizeHttpRequests(httpRequest -> { - HandlerMappingIntrospector introspector = new HandlerMappingIntrospector(); - httpRequest - .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; - } - - protected void configureBaseHttpSecurity(final HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .exceptionHandling(httpSecurityExceptionHandlingConfigurer -> httpSecurityExceptionHandlingConfigurer.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) - .requiresChannel(this.requireChannel(sslEnabled)) - .formLogin(httpSecurityFormLoginConfigurer -> httpSecurityFormLoginConfigurer - .loginProcessingUrl(LOGIN_URL) - .successHandler(new HttpLoginSuccessHandler()) - .failureHandler(new Http401FailureHandler())) - .logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer - .logoutUrl(LOGOUT_URL) - .logoutSuccessHandler(new HttpEmptyLogoutSuccessHandler())); - } - - protected UserDto anonymous() { - UserDto anonymous = new UserDto(); - anonymous.setId(User.ANONYMOUS.id); - anonymous.setName(User.ANONYMOUS.id); - anonymous.grantAuthority("ANONYMOUS"); - return anonymous; - } - - private Customizer.ChannelRequestMatcherRegistry> requireChannel(Boolean sslEnabled) { - if (sslEnabled) { - return channelRequestMatcherRegistry -> channelRequestMatcherRegistry.anyRequest().requiresSecure(); - } else { - return channelRequestMatcherRegistry -> channelRequestMatcherRegistry.anyRequest().requiresInsecure(); - } - } -} 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 89e50c76b..fe601e6e6 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java @@ -7,23 +7,64 @@ 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; import com.chutneytesting.security.domain.Authorizations; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +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.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.core.annotation.Order; +import org.springframework.http.HttpStatus; +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.servlet.handler.HandlerMappingIntrospector; @Configuration @EnableWebSecurity @EnableMethodSecurity -@Profile("!sso-auth") -public class ChutneyWebSecurityConfig extends AbstractChutneyWebSecurityConfig { +@EnableConfigurationProperties({OAuth2AuthorizationServerProperties.class, SsoOpenIdConnectConfigProperties.class}) +public class ChutneyWebSecurityConfig { + + protected static final String LOGIN_URL = UserController.BASE_URL + "/login"; + protected static final String LOGOUT_URL = UserController.BASE_URL + "/logout"; + protected static final String API_BASE_URL_PATTERN = "/api/**"; + + @Value("${management.endpoints.web.base-path:/actuator}") + protected String actuatorBaseUrl; + + @Value("${server.ssl.enabled:true}") + private Boolean sslEnabled; @Bean public AuthenticationService authenticationService(Authorizations authorizations) { @@ -31,9 +72,68 @@ public AuthenticationService authenticationService(Authorizations authorizations } @Bean - @Order() - @ConditionalOnMissingBean(value = SecurityFilterChain.class) - public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { - return configureHttp(http).build(); + public SecurityFilterChain securityFilterChain(final HttpSecurity http, AuthenticationService authenticationService, @Nullable ClientRegistrationRepository clientRegistrationRepository) throws Exception { + configureSso(http, authenticationService, clientRegistrationRepository); + configureBaseHttpSecurity(http); + UserDto anonymous = anonymous(); + http.anonymous(anonymousConfigurer -> anonymousConfigurer + .principal(anonymous) + .authorities(new ArrayList<>(anonymous.getAuthorities()))) + .authorizeHttpRequests(httpRequest -> { + HandlerMappingIntrospector introspector = new HandlerMappingIntrospector(); + httpRequest + .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(); + } + + protected void configureBaseHttpSecurity(final HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .exceptionHandling(httpSecurityExceptionHandlingConfigurer -> httpSecurityExceptionHandlingConfigurer.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .requiresChannel(this.requireChannel(sslEnabled)) + .formLogin(httpSecurityFormLoginConfigurer -> httpSecurityFormLoginConfigurer + .loginProcessingUrl(LOGIN_URL) + .successHandler(new HttpLoginSuccessHandler()) + .failureHandler(new Http401FailureHandler())) + .logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer + .logoutUrl(LOGOUT_URL) + .logoutSuccessHandler(new HttpEmptyLogoutSuccessHandler())); + } + + protected UserDto anonymous() { + UserDto anonymous = new UserDto(); + anonymous.setId(User.ANONYMOUS.id); + anonymous.setName(User.ANONYMOUS.id); + anonymous.grantAuthority("ANONYMOUS"); + return anonymous; + } + + 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) throws Exception { + if (clientRegistrationRepository != null) { + OAuth2UserService oAuth2UserService = new OAuth2SsoUserService(authenticationService); + OAuth2TokenAuthenticationProvider oAuth2TokenAuthenticationProvider = new OAuth2TokenAuthenticationProvider(oAuth2UserService, clientRegistrationRepository.findByRegistrationId("my-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)); + } } } 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 index 966b4634f..0e5a6ce4f 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java @@ -8,8 +8,11 @@ package com.chutneytesting.security.api; import static com.chutneytesting.security.api.SsoOpenIdConnectMapper.toDto; +import static java.util.Optional.ofNullable; -import com.chutneytesting.security.domain.SsoOpenIdConnectConfigService; +import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfigProperties; +import java.util.NoSuchElementException; +import org.springframework.context.annotation.Profile; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; @@ -19,21 +22,20 @@ @RestController @RequestMapping(SsoOpenIdConnectController.BASE_URL) @CrossOrigin(origins = "*") +@Profile("sso-auth") public class SsoOpenIdConnectController { public static final String BASE_URL = "/api/v1/sso"; - private final SsoOpenIdConnectConfigService ssoOpenIdConnectConfigService; + private final SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties; - SsoOpenIdConnectController(SsoOpenIdConnectConfigService ssoOpenIdConnectConfigService) { - this.ssoOpenIdConnectConfigService = ssoOpenIdConnectConfigService; + SsoOpenIdConnectController(SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties) { + this.ssoOpenIdConnectConfigProperties = ssoOpenIdConnectConfigProperties; } @GetMapping(path = "/config", produces = MediaType.APPLICATION_JSON_VALUE) public SsoOpenIdConnectConfigDto getSsoOpenIdConnectConfig() { - if (ssoOpenIdConnectConfigService == null) { - return null; - } - return toDto(ssoOpenIdConnectConfigService.getSsoOpenIdConnectConfig()); + return ofNullable(toDto(ssoOpenIdConnectConfigProperties)) + .orElseThrow(NoSuchElementException::new); } } 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 index c47e32110..59ef2d836 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectMapper.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectMapper.java @@ -7,8 +7,10 @@ package com.chutneytesting.security.api; +import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfigProperties; + public class SsoOpenIdConnectMapper { - public static SsoOpenIdConnectConfigDto toDto(com.chutneytesting.security.domain.SsoOpenIdConnectConfig ssoOpenIdConnectConfig) { + public static SsoOpenIdConnectConfigDto toDto(SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfig) { if (ssoOpenIdConnectConfig == null) { return null; } diff --git a/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfig.java b/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfig.java deleted file mode 100644 index 643e30eaf..000000000 --- a/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfig.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 Enedis - * - * SPDX-License-Identifier: Apache-2.0 - * - */ - -package com.chutneytesting.security.domain; - -public class SsoOpenIdConnectConfig { - 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 Boolean oidc; - - public SsoOpenIdConnectConfig(String issuer, String clientId, String clientSecret, String responseType, String scope, String redirectBaseUrl, String ssoProviderName, Boolean oidc) { - this.issuer = issuer; - this.clientId = clientId; - this.clientSecret = clientSecret; - this.responseType = responseType; - this.scope = scope; - this.redirectBaseUrl = redirectBaseUrl; - this.ssoProviderName = ssoProviderName; - this.oidc = oidc; - } -} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfigService.java b/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfigService.java deleted file mode 100644 index 04985c2fc..000000000 --- a/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectConfigService.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 Enedis - * - * SPDX-License-Identifier: Apache-2.0 - * - */ - -package com.chutneytesting.security.domain; - -import static com.chutneytesting.security.domain.SsoOpenIdConnectMapper.toDomain; - -import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfigProperties; - -public class SsoOpenIdConnectConfigService { - - private final SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties; - - public SsoOpenIdConnectConfigService(SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties) { - this.ssoOpenIdConnectConfigProperties = ssoOpenIdConnectConfigProperties; - } - - public SsoOpenIdConnectConfig getSsoOpenIdConnectConfig() { - return toDomain(ssoOpenIdConnectConfigProperties); - } -} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectMapper.java b/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectMapper.java deleted file mode 100644 index 12317290a..000000000 --- a/chutney/server/src/main/java/com/chutneytesting/security/domain/SsoOpenIdConnectMapper.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 Enedis - * - * SPDX-License-Identifier: Apache-2.0 - * - */ - -package com.chutneytesting.security.domain; - -import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfigProperties; - -public class SsoOpenIdConnectMapper { - public static SsoOpenIdConnectConfig toDomain(SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties) { - if (ssoOpenIdConnectConfigProperties == null) { - return null; - } - return new SsoOpenIdConnectConfig( - ssoOpenIdConnectConfigProperties.issuer, - ssoOpenIdConnectConfigProperties.clientId, - ssoOpenIdConnectConfigProperties.clientSecret, - ssoOpenIdConnectConfigProperties.responseType, - ssoOpenIdConnectConfigProperties.scope, - ssoOpenIdConnectConfigProperties.redirectBaseUrl, - ssoOpenIdConnectConfigProperties.ssoProviderName, - ssoOpenIdConnectConfigProperties.oidc - ); - } -} diff --git a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java deleted file mode 100644 index a90729bf7..000000000 --- a/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoSecurityConfiguration.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 Enedis - * - * SPDX-License-Identifier: Apache-2.0 - * - */ - -package com.chutneytesting.security.infra.sso; - - -import com.chutneytesting.security.AbstractChutneyWebSecurityConfig; -import com.chutneytesting.security.domain.AuthenticationService; -import com.chutneytesting.security.domain.Authorizations; -import java.util.Collections; -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.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.ProviderManager; -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.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.www.BasicAuthenticationFilter; - -@Configuration -@Profile("sso-auth") -@EnableWebSecurity -@EnableMethodSecurity -@EnableConfigurationProperties({OAuth2AuthorizationServerProperties.class, SsoOpenIdConnectConfigProperties.class}) -public class OAuth2SsoSecurityConfiguration extends AbstractChutneyWebSecurityConfig { - - @Bean - public AuthenticationService authenticationService(Authorizations authorizations) { - return new AuthenticationService(authorizations); - } - - @Bean - public OAuth2UserService customOAuth2UserService(AuthenticationService authenticationService) { - return new OAuth2SsoUserService(authenticationService); - } - - @Bean - public OAuth2TokenAuthenticationProvider tokenAuthenticationProvider(AuthenticationService authenticationService, ClientRegistrationRepository clientRegistrationRepository) { - return new OAuth2TokenAuthenticationProvider(customOAuth2UserService(authenticationService), clientRegistrationRepository.findByRegistrationId("my-provider")); - } - - @Bean - public AuthenticationManager authenticationManager(OAuth2TokenAuthenticationProvider OAuth2TokenAuthenticationProvider) { - return new ProviderManager(Collections.singletonList(OAuth2TokenAuthenticationProvider)); - } - - @Bean - public SecurityFilterChain securityFilterChainOAuth2Sso(final HttpSecurity http, OAuth2TokenAuthenticationProvider OAuth2TokenAuthenticationProvider, AuthenticationManager authenticationManager) throws Exception { - OAuth2TokenAuthenticationFilter tokenFilter = new OAuth2TokenAuthenticationFilter(authenticationManager); - http.authenticationProvider(OAuth2TokenAuthenticationProvider) - .addFilterBefore(tokenFilter, BasicAuthenticationFilter.class) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); - configureHttp(http); - return http.build(); - } -} diff --git a/chutney/ui/src/app/app.module.ts b/chutney/ui/src/app/app.module.ts index 25d969b5a..82e1c969a 100644 --- a/chutney/ui/src/app/app.module.ts +++ b/chutney/ui/src/app/app.module.ts @@ -28,7 +28,6 @@ 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 { ssoInitializer } from "@core/initializer/sso.initializer"; import { SsoService } from "@core/services/sso.service"; @NgModule({ @@ -71,17 +70,15 @@ import { SsoService } from "@core/services/sso.service"; useFactory: themeInitializer, deps: [ThemeService], multi: true - }, - { - provide: APP_INITIALIZER, - useFactory: ssoInitializer, - deps: [SsoService], - multi: true } ], bootstrap: [AppComponent] }) -export class ChutneyAppModule {} +export class ChutneyAppModule { + constructor(private ssoOpenIdConnectService: SsoService) { + this.ssoOpenIdConnectService.fetchSsoConfig() + } +} diff --git a/chutney/ui/src/app/core/guards/auth.guard.ts b/chutney/ui/src/app/core/guards/auth.guard.ts index a4bc17b18..ba22b18a5 100644 --- a/chutney/ui/src/app/core/guards/auth.guard.ts +++ b/chutney/ui/src/app/core/guards/auth.guard.ts @@ -7,39 +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'; -import { firstValueFrom } from "rxjs"; export const authGuard: CanActivateFn = async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { - const translateService = inject(TranslateService); const loginService = inject(LoginService); - const alertService = inject(AlertService); - const requestURL = state.url !== undefined ? state.url : ''; - const unauthorizedMessage = translateService.instant('login.unauthorized') - - if (!loginService.isAuthenticated()) { - if (loginService.oauth2Token) { - await firstValueFrom(loginService.initLoginObservable(requestURL, { - 'Authorization': 'Bearer ' + loginService.oauth2Token - })); - } else { - 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/initializer/sso.initializer.ts b/chutney/ui/src/app/core/initializer/sso.initializer.ts deleted file mode 100644 index 68bc1ee71..000000000 --- a/chutney/ui/src/app/core/initializer/sso.initializer.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2017-2024 Enedis - * - * SPDX-License-Identifier: Apache-2.0 - * - */ - -import { SsoService } from "@core/services/sso.service"; - -export function ssoInitializer(ssoOpenIdConnectService: SsoService): () => void { - return () => ssoOpenIdConnectService.fetchSsoConfig() -} diff --git a/chutney/ui/src/app/core/services/login.service.ts b/chutney/ui/src/app/core/services/login.service.ts index 2b20c820e..85394168c 100644 --- a/chutney/ui/src/app/core/services/login.service.ts +++ b/chutney/ui/src/app/core/services/login.service.ts @@ -7,152 +7,179 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { Router } from '@angular/router'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs'; import { catchError, delay, 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 { 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, - private ssoService: SsoService - ) { } - - initLogin(url?: string, headers: HttpHeaders | { - [header: string]: string | string[]; - } = {}) { - this.initLoginObservable(url, headers).subscribe() - } - - 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); - return error - }) - ); - } - - get oauth2Token(): string { - return this.ssoService.token - } - - 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() + 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 firstValueFrom(this.initLoginObservable(requestURL, { + 'Authorization': 'Bearer ' + this.oauth2Token + })); + } 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; + } + } + + 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); + return error + }) + ); + } + + get oauth2Token(): string { + return this.ssoService.token + } + + 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() .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)), - tap(() => this.ssoService.logout()), - 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); - } - - 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; - } + } + + 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; + } + + isLoginUrl(url: string): boolean { + return url.includes(this.loginUrl); + } + + 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/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index 33fbc5aad..81c940137 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -6,9 +6,9 @@ */ import { HttpClient } from '@angular/common/http'; -import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; +import { OAuthService } from 'angular-oauth2-oidc'; import { environment } from '@env/environment'; -import {Observable, map, tap} from 'rxjs'; +import { map, tap } from 'rxjs'; import { Injectable } from '@angular/core'; interface SsoAuthConfig { @@ -48,9 +48,13 @@ export class SsoService { oidc: ssoConfig.oidc } }), - tap(ssoConfig => { - this.oauthService.configure(ssoConfig) - this.oauthService.loadDiscoveryDocumentAndTryLogin(); + tap(async ssoConfig => { + try { + this.oauthService.configure(ssoConfig) + await this.oauthService.loadDiscoveryDocumentAndTryLogin(); + } catch (e) { + console.error("SSO provider not available") + } }) ).subscribe() } 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); From 583c67c5d7704628de34727dee9ca5d46f1dcf59 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Wed, 30 Oct 2024 15:24:49 +0100 Subject: [PATCH 10/26] feat(server): Add proxy --- .../security/ChutneyWebSecurityConfig.java | 28 ++++++++++++++++--- .../infra/sso/OAuth2SsoUserService.java | 11 ++++++-- .../sso/SsoOpenIdConnectConfigProperties.java | 6 +++- .../dataset-list.component.spec.ts | 4 +++ .../search-list/scenarios.component.spec.ts | 3 ++ 5 files changed, 45 insertions(+), 7 deletions(-) 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 fe601e6e6..bb037acb4 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java @@ -22,6 +22,8 @@ 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; @@ -29,7 +31,9 @@ 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; @@ -48,6 +52,8 @@ 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 @@ -72,8 +78,8 @@ public AuthenticationService authenticationService(Authorizations authorizations } @Bean - public SecurityFilterChain securityFilterChain(final HttpSecurity http, AuthenticationService authenticationService, @Nullable ClientRegistrationRepository clientRegistrationRepository) throws Exception { - configureSso(http, authenticationService, clientRegistrationRepository); + 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 @@ -124,9 +130,9 @@ private Customizer.ChannelRequestMatcher } } - private void configureSso(final HttpSecurity http, AuthenticationService authenticationService, ClientRegistrationRepository clientRegistrationRepository) throws Exception { + private void configureSso(final HttpSecurity http, AuthenticationService authenticationService, ClientRegistrationRepository clientRegistrationRepository, RestOperations restOperations) throws Exception { if (clientRegistrationRepository != null) { - OAuth2UserService oAuth2UserService = new OAuth2SsoUserService(authenticationService); + OAuth2UserService oAuth2UserService = new OAuth2SsoUserService(authenticationService, restOperations); OAuth2TokenAuthenticationProvider oAuth2TokenAuthenticationProvider = new OAuth2TokenAuthenticationProvider(oAuth2UserService, clientRegistrationRepository.findByRegistrationId("my-provider")); AuthenticationManager authenticationManager = new ProviderManager(Collections.singletonList(oAuth2TokenAuthenticationProvider)); OAuth2TokenAuthenticationFilter tokenFilter = new OAuth2TokenAuthenticationFilter(authenticationManager); @@ -136,4 +142,18 @@ private void configureSso(final HttpSecurity http, AuthenticationService authent .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/infra/sso/OAuth2SsoUserService.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoUserService.java index 6cec6abff..80d97075a 100644 --- 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 @@ -13,24 +13,31 @@ 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) { + public OAuth2SsoUserService(AuthenticationService authenticationService, @Nullable RestOperations restOperations) { this.authenticationService = authenticationService; + this.restOperations = restOperations; } @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - org.springframework.security.oauth2.client.userinfo.OAuth2UserService delegate = new DefaultOAuth2UserService(); + org.springframework.security.oauth2.client.userinfo.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"); 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 index 8649dae45..8683f600d 100644 --- 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 @@ -20,9 +20,11 @@ public class SsoOpenIdConnectConfigProperties implements InitializingBean { 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 SsoOpenIdConnectConfigProperties(String issuer, String clientId, String clientSecret, String responseType, String scope, String redirectBaseUrl, String ssoProviderName, Boolean oidc) { + public SsoOpenIdConnectConfigProperties(String issuer, String clientId, String clientSecret, String responseType, String scope, String redirectBaseUrl, String ssoProviderName, String proxyHost, Integer proxyPort, Boolean oidc) { this.issuer = issuer; this.clientId = clientId; this.clientSecret = clientSecret; @@ -30,6 +32,8 @@ public SsoOpenIdConnectConfigProperties(String issuer, String clientId, String c this.scope = scope; this.redirectBaseUrl = redirectBaseUrl; this.ssoProviderName = ssoProviderName; + this.proxyHost = proxyHost; + this.proxyPort = proxyPort; this.oidc = oidc; } 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 0426bf261..6270b2c7f 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 @@ -23,11 +23,14 @@ 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 { ToastrService } from 'ngx-toastr'; +import { AlertService } from '@shared'; describe('DatasetListComponent', () => { const dataSetService = jasmine.createSpyObj('DataSetService', ['findAll']); const oAuthService = jasmine.createSpyObj('OAuthService', ['loadDiscoveryDocumentAndTryLogin', 'configure', 'initCodeFlow', 'logOut', 'getAccessToken']); + const alertService = jasmine.createSpyObj('AlertService', ['error']); dataSetService.findAll.and.returnValue(of([])); beforeEach(waitForAsync(() => { TestBed.resetTestingModule(); @@ -50,6 +53,7 @@ describe('DatasetListComponent', () => { ], providers: [ { provide: DataSetService, useValue: dataSetService }, + { provide: AlertService, useValue: alertService }, { provide: OAuthService, useValue: oAuthService }, {provide: DROPDOWN_SETTINGS, useClass: DropdownSettings} ] 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 ece98d9d7..4754d6871 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 @@ -27,6 +27,7 @@ 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'); @@ -44,6 +45,7 @@ describe('ScenariosComponent', () => { TestBed.resetTestingModule(); const scenarioService = jasmine.createSpyObj('ScenarioService', ['findScenarios', 'search']); const oAuthService = jasmine.createSpyObj('OAuthService', ['loadDiscoveryDocumentAndTryLogin', 'configure', 'initCodeFlow', 'logOut', 'getAccessToken']); + 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', [], []), @@ -73,6 +75,7 @@ describe('ScenariosComponent', () => { 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}, From fb4efde01238cc9158cdedf6547fca07cc74904c Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Mon, 4 Nov 2024 15:35:20 +0100 Subject: [PATCH 11/26] feat(ui): Update headers OAuth2 --- chutney/ui/src/app/core/core.module.ts | 6 +++-- .../ui/src/app/core/services/sso.service.ts | 27 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/chutney/ui/src/app/core/core.module.ts b/chutney/ui/src/app/core/core.module.ts index 8d6a394d3..ed1b97a17 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/sso.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/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index 81c940137..a67e04cb1 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -5,10 +5,10 @@ * */ -import { HttpClient } from '@angular/common/http'; -import { OAuthService } from 'angular-oauth2-oidc'; +import { HttpClient, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; import { environment } from '@env/environment'; -import { map, tap } from 'rxjs'; +import { map, Observable, tap } from 'rxjs'; import { Injectable } from '@angular/core'; interface SsoAuthConfig { @@ -45,8 +45,9 @@ export class SsoService { scope: ssoConfig.scope, redirectUri: ssoConfig.redirectBaseUrl + '/', dummyClientSecret: ssoConfig.clientSecret, - oidc: ssoConfig.oidc - } + oidc: ssoConfig.oidc, + useHttpBasicAuth: true, + } as AuthConfig }), tap(async ssoConfig => { try { @@ -78,3 +79,19 @@ export class SsoService { return this.oauthService.getAccessToken(); } } + +@Injectable() +export class OAuth2ContentTypeInterceptor implements HttpInterceptor { + intercept(req: HttpRequest, next: HttpHandler): Observable> { + const isOAuth2Service = req.url.includes('/oauth2/multiauth/access_token'); + if (isOAuth2Service) { + const modifiedReq = req.clone({ + setHeaders: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + return next.handle(modifiedReq); + } + return next.handle(req); + } +} From d8b7122622291c3ad9a0b4406ed0416b3f5523b3 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Mon, 4 Nov 2024 15:35:20 +0100 Subject: [PATCH 12/26] feat(ui): Update headers OAuth2 --- chutney/ui/src/app/core/services/sso.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index a67e04cb1..d430d05db 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -76,7 +76,7 @@ export class SsoService { } get token(): string { - return this.oauthService.getAccessToken(); + return this.oauthService.getIdToken(); } } From 0e7caa2bf2bfbf4be9ba69bb0e0db233059b546c Mon Sep 17 00:00:00 2001 From: DelaunayAlex Date: Wed, 6 Nov 2024 10:08:56 +0100 Subject: [PATCH 13/26] feat(ui): SSO : Replace idToken with accessToken --- chutney/ui/src/app/core/services/sso.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index d430d05db..a67e04cb1 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -76,7 +76,7 @@ export class SsoService { } get token(): string { - return this.oauthService.getIdToken(); + return this.oauthService.getAccessToken(); } } From 975bf1977521617f10fb3b47e80f9ab0196807dd Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Wed, 6 Nov 2024 15:37:12 +0100 Subject: [PATCH 14/26] feat(ui, server): Ui parameter via server, add logo sso --- .../api/SsoOpenIdConnectConfigDto.java | 13 +++++++- .../security/api/SsoOpenIdConnectMapper.java | 6 +++- .../sso/SsoOpenIdConnectConfigProperties.java | 11 ++++++- .../components/login/login.component.html | 5 ++- .../components/login/login.component.scss | 4 +++ .../core/components/login/login.component.ts | 4 +++ .../ui/src/app/core/services/sso.service.ts | 33 ++++++++++++++++--- 7 files changed, 68 insertions(+), 8 deletions(-) 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 index 4aaef8bca..552716d1d 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfigDto.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfigDto.java @@ -7,6 +7,8 @@ package com.chutneytesting.security.api; +import java.util.Map; + public class SsoOpenIdConnectConfigDto { public final String issuer; public final String clientId; @@ -16,8 +18,13 @@ public class SsoOpenIdConnectConfigDto { public final String redirectBaseUrl; public final String ssoProviderName; public final Boolean oidc; + public final String uriRequireHeader; + public final Map headers; + public final String ssoProviderImageUrl; + public final Map additionalQueryParams; + - public SsoOpenIdConnectConfigDto(String issuer, String clientId, String clientSecret, String responseType, String scope, String redirectBaseUrl, String ssoProviderName, Boolean oidc) { + public 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) { this.issuer = issuer; this.clientId = clientId; this.clientSecret = clientSecret; @@ -26,5 +33,9 @@ public SsoOpenIdConnectConfigDto(String issuer, String clientId, String clientSe this.redirectBaseUrl = redirectBaseUrl; this.ssoProviderName = ssoProviderName; this.oidc = oidc; + this.uriRequireHeader = uriRequireHeader; + this.headers = headers; + this.ssoProviderImageUrl = ssoProviderImageUrl; + this.additionalQueryParams = additionalQueryParams; } } 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 index 59ef2d836..b237f19b8 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectMapper.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectMapper.java @@ -22,7 +22,11 @@ public static SsoOpenIdConnectConfigDto toDto(SsoOpenIdConnectConfigProperties s ssoOpenIdConnectConfig.scope, ssoOpenIdConnectConfig.redirectBaseUrl, ssoOpenIdConnectConfig.ssoProviderName, - ssoOpenIdConnectConfig.oidc + ssoOpenIdConnectConfig.oidc, + ssoOpenIdConnectConfig.uriRequireHeader, + ssoOpenIdConnectConfig.headers, + ssoOpenIdConnectConfig.ssoProviderImageUrl, + ssoOpenIdConnectConfig.additionalQueryParams ); } } 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 index 8683f600d..38100eda7 100644 --- 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 @@ -7,6 +7,7 @@ package com.chutneytesting.security.infra.sso; +import java.util.Map; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -23,8 +24,12 @@ public class SsoOpenIdConnectConfigProperties implements InitializingBean { 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) { + 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; @@ -35,6 +40,10 @@ public SsoOpenIdConnectConfigProperties(String issuer, String clientId, String c this.proxyHost = proxyHost; this.proxyPort = proxyPort; this.oidc = oidc; + this.uriRequireHeader = uriRequireHeader; + this.headers = headers; + this.additionalQueryParams = additionalQueryParams; + this.ssoProviderImageUrl = ssoProviderImageUrl; } @Override 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 64ef12288..cca961e85 100644 --- a/chutney/ui/src/app/core/components/login/login.component.html +++ b/chutney/ui/src/app/core/components/login/login.component.html @@ -48,7 +48,10 @@

Login

@if (getSsoProviderName()) {
- +
} 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..cba5bcb5f 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,7 @@ align-items: center; } } + +.ssoImage { + width: 40px +} 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 7c9d1a032..930ac30f7 100644 --- a/chutney/ui/src/app/core/components/login/login.component.ts +++ b/chutney/ui/src/app/core/components/login/login.component.ts @@ -88,4 +88,8 @@ export class LoginComponent implements OnDestroy, OnInit { getSsoProviderName() { return this.ssoService.getSsoProviderName() } + + getSsoProviderImageUrl() { + return this.ssoService.getSsoProviderImageUrl() + } } diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index a67e04cb1..039afac9c 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -19,6 +19,10 @@ interface SsoAuthConfig { scope: string, redirectBaseUrl: string, ssoProviderName: string, + ssoProviderImageUrl: string, + uriRequireHeader: string, + headers: { [name: string]: string | string[]; }, + additionalQueryParams: { [name: string]: string | string[]; } oidc: boolean } @@ -47,6 +51,11 @@ export class SsoService { dummyClientSecret: ssoConfig.clientSecret, oidc: ssoConfig.oidc, useHttpBasicAuth: true, + postLogoutRedirectUri: ssoConfig.redirectBaseUrl, + sessionChecksEnabled: true, + logoutUrl: ssoConfig.redirectBaseUrl, + customQueryParams: ssoConfig.additionalQueryParams, + useIdTokenHintForSilentRefresh: true } as AuthConfig }), tap(async ssoConfig => { @@ -75,20 +84,36 @@ export class SsoService { return null } + getSsoProviderImageUrl() { + if (this.ssoConfig) { + return this.ssoConfig.ssoProviderImageUrl + } + return null + } + get token(): string { return this.oauthService.getAccessToken(); } + + get uriRequireHeader() { + return this.ssoConfig?.uriRequireHeader + } + + get headers() { + return this.ssoConfig?.headers + } } @Injectable() export class OAuth2ContentTypeInterceptor implements HttpInterceptor { + + constructor(private ssoService: SsoService) {} + intercept(req: HttpRequest, next: HttpHandler): Observable> { - const isOAuth2Service = req.url.includes('/oauth2/multiauth/access_token'); + const isOAuth2Service = this.ssoService.uriRequireHeader && req.url.includes(this.ssoService.uriRequireHeader); if (isOAuth2Service) { const modifiedReq = req.clone({ - setHeaders: { - 'Content-Type': 'application/x-www-form-urlencoded' - } + setHeaders: this.ssoService.headers }); return next.handle(modifiedReq); } From d4029d4a2586e6b83491677b8a49459a6b8116d3 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Wed, 6 Nov 2024 16:57:11 +0100 Subject: [PATCH 15/26] [WIP] feat(ui, server): add param id_token_hint --- chutney/ui/src/app/core/services/login.service.ts | 2 +- chutney/ui/src/app/core/services/sso.service.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/chutney/ui/src/app/core/services/login.service.ts b/chutney/ui/src/app/core/services/login.service.ts index 85394168c..83189e6fd 100644 --- a/chutney/ui/src/app/core/services/login.service.ts +++ b/chutney/ui/src/app/core/services/login.service.ts @@ -81,7 +81,7 @@ export class LoginService { } get oauth2Token(): string { - return this.ssoService.token + return this.ssoService.accessToken } login(username: string, password: string): Observable { diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index 039afac9c..c74d5ac9f 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -91,10 +91,14 @@ export class SsoService { return null } - get token(): string { + get accessToken(): string { return this.oauthService.getAccessToken(); } + get idToken(): string { + return this.oauthService.getIdToken(); + } + get uriRequireHeader() { return this.ssoConfig?.uriRequireHeader } @@ -117,6 +121,13 @@ export class OAuth2ContentTypeInterceptor implements HttpInterceptor { }); return next.handle(modifiedReq); } + const isEndSessionUri = this.ssoService.uriRequireHeader && req.url.includes('oauth2/multiauth/connect/endSession'); + if (isEndSessionUri) { + const modifiedReq = req.clone({ + setParams: {'id_token_hint': this.ssoService.idToken} + }); + return next.handle(modifiedReq); + } return next.handle(req); } } From 562b35b3a3531f64b40af305b4ffa9b09c13fdee Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Wed, 6 Nov 2024 17:11:01 +0100 Subject: [PATCH 16/26] [WIP] feat(ui, server): add param id_token_hint --- chutney/ui/src/app/core/services/sso.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index c74d5ac9f..0cd7d96d2 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -121,8 +121,9 @@ export class OAuth2ContentTypeInterceptor implements HttpInterceptor { }); return next.handle(modifiedReq); } - const isEndSessionUri = this.ssoService.uriRequireHeader && req.url.includes('oauth2/multiauth/connect/endSession'); + const isEndSessionUri = req.url.includes('oauth2/multiauth/connect/endSession'); if (isEndSessionUri) { + console.log('TOTOTOTOOTOTTOTOT') const modifiedReq = req.clone({ setParams: {'id_token_hint': this.ssoService.idToken} }); From 5b6fcc329b2819c7c07e137cfe381923efbd069f Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Thu, 7 Nov 2024 16:59:46 +0100 Subject: [PATCH 17/26] feat(ui): fix logout sso --- .../ui/src/app/core/services/sso.service.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index 0cd7d96d2..4b06ff9a6 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -20,7 +20,6 @@ interface SsoAuthConfig { redirectBaseUrl: string, ssoProviderName: string, ssoProviderImageUrl: string, - uriRequireHeader: string, headers: { [name: string]: string | string[]; }, additionalQueryParams: { [name: string]: string | string[]; } oidc: boolean @@ -55,7 +54,7 @@ export class SsoService { sessionChecksEnabled: true, logoutUrl: ssoConfig.redirectBaseUrl, customQueryParams: ssoConfig.additionalQueryParams, - useIdTokenHintForSilentRefresh: true + useIdTokenHintForSilentRefresh: true, } as AuthConfig }), tap(async ssoConfig => { @@ -71,10 +70,15 @@ export class SsoService { login() { this.oauthService.initCodeFlow(); + this.oauthService } logout() { - this.oauthService.logOut(); + if (this.idToken) { + this.oauthService.logOut({ + 'id_token_hint': this.idToken + }); + } } getSsoProviderName() { @@ -99,8 +103,8 @@ export class SsoService { return this.oauthService.getIdToken(); } - get uriRequireHeader() { - return this.ssoConfig?.uriRequireHeader + get tokenEndpoint(): string { + return this.oauthService.tokenEndpoint; } get headers() { @@ -114,21 +118,13 @@ export class OAuth2ContentTypeInterceptor implements HttpInterceptor { constructor(private ssoService: SsoService) {} intercept(req: HttpRequest, next: HttpHandler): Observable> { - const isOAuth2Service = this.ssoService.uriRequireHeader && req.url.includes(this.ssoService.uriRequireHeader); - if (isOAuth2Service) { + 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); } - const isEndSessionUri = req.url.includes('oauth2/multiauth/connect/endSession'); - if (isEndSessionUri) { - console.log('TOTOTOTOOTOTTOTOT') - const modifiedReq = req.clone({ - setParams: {'id_token_hint': this.ssoService.idToken} - }); - return next.handle(modifiedReq); - } return next.handle(req); } } From 46455119d5f8d0c5d85f9672ba44c2e59f1d3c63 Mon Sep 17 00:00:00 2001 From: DelaunayAlex Date: Tue, 12 Nov 2024 01:20:30 +0100 Subject: [PATCH 18/26] feat(ui): SSO : Fix redirect uri --- chutney/ui/src/app/core/services/sso.service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index 4b06ff9a6..d69c5152c 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -50,11 +50,12 @@ export class SsoService { dummyClientSecret: ssoConfig.clientSecret, oidc: ssoConfig.oidc, useHttpBasicAuth: true, - postLogoutRedirectUri: ssoConfig.redirectBaseUrl, + postLogoutRedirectUri: ssoConfig.redirectBaseUrl + '/', sessionChecksEnabled: true, - logoutUrl: ssoConfig.redirectBaseUrl, + logoutUrl: ssoConfig.redirectBaseUrl + '/', customQueryParams: ssoConfig.additionalQueryParams, useIdTokenHintForSilentRefresh: true, + redirectUriAsPostLogoutRedirectUriFallback: true, } as AuthConfig }), tap(async ssoConfig => { From 8fb7b7032f3c9f35ce7a38ccf466d52e82b02c03 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Wed, 13 Nov 2024 00:33:49 +0100 Subject: [PATCH 19/26] feat(ui): fix retry sso --- .../ui/src/app/core/services/login.service.ts | 27 ++++++++++++++----- .../ui/src/app/core/services/sso.service.ts | 17 ++++++++++-- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/chutney/ui/src/app/core/services/login.service.ts b/chutney/ui/src/app/core/services/login.service.ts index 83189e6fd..df299b60f 100644 --- a/chutney/ui/src/app/core/services/login.service.ts +++ b/chutney/ui/src/app/core/services/login.service.ts @@ -8,8 +8,8 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { ActivatedRouteSnapshot, Router } from '@angular/router'; -import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs'; -import { catchError, delay, tap } from 'rxjs/operators'; +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'; @@ -41,12 +41,19 @@ export class LoginService { const unauthorizedMessage = this.translateService.instant('login.unauthorized') if (!this.isAuthenticated()) { if (this.oauth2Token) { - await firstValueFrom(this.initLoginObservable(requestURL, { - 'Authorization': 'Bearer ' + this.oauth2Token - })); + await this.initLoginWithToken(requestURL) } else { - await this.initLogin(requestURL); - return false; + 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'] || []; @@ -59,6 +66,12 @@ export class LoginService { } } + private async initLoginWithToken(requestURL: string) { + await firstValueFrom(this.initLoginObservable(requestURL, { + Authorization: 'Bearer ' + this.oauth2Token, + })); + } + async initLogin(url?: string, headers: HttpHeaders | { [header: string]: string | string[]; } = {}) { diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index d69c5152c..11ac85914 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -8,8 +8,9 @@ import { HttpClient, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; import { environment } from '@env/environment'; -import { map, Observable, tap } from 'rxjs'; +import { BehaviorSubject, map, Observable, tap } from 'rxjs'; import { Injectable } from '@angular/core'; +import { filter } from 'rxjs/operators'; interface SsoAuthConfig { issuer: string, @@ -34,8 +35,17 @@ export class SsoService { private ssoConfig: SsoAuthConfig + private tokenLoadedSubject = new BehaviorSubject(false); + public tokenLoaded$ = this.tokenLoadedSubject.asObservable(); - constructor(private oauthService: OAuthService, private http: HttpClient) {} + + 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( @@ -62,6 +72,9 @@ export class SsoService { try { this.oauthService.configure(ssoConfig) await this.oauthService.loadDiscoveryDocumentAndTryLogin(); + if (this.oauthService.hasValidAccessToken()) { + this.tokenLoadedSubject.next(true); + } } catch (e) { console.error("SSO provider not available") } From c7d26f27fcec8ceb24dee17b554920e00817a53d Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Wed, 13 Nov 2024 11:11:48 +0100 Subject: [PATCH 20/26] feat(ui): fix test --- .../components/dataset-list/dataset-list.component.spec.ts | 6 ++++-- .../components/search-list/scenarios.component.spec.ts | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) 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 6270b2c7f..6c41fd9ed 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,7 +17,7 @@ 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'; @@ -28,8 +28,10 @@ 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']); + 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(() => { 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 4754d6871..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'; @@ -43,8 +43,9 @@ 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']); + 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']); From 56ccf3619fa90bea4c74b6515e180023df92c55b Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Thu, 14 Nov 2024 10:41:33 +0100 Subject: [PATCH 21/26] feat(ui): fix PR --- chutney/ui/src/app/core/services/sso.service.ts | 1 - .../components/dataset-list/dataset-list.component.spec.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/chutney/ui/src/app/core/services/sso.service.ts b/chutney/ui/src/app/core/services/sso.service.ts index 11ac85914..c84698da6 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -84,7 +84,6 @@ export class SsoService { login() { this.oauthService.initCodeFlow(); - this.oauthService } logout() { 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 6c41fd9ed..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 @@ -23,7 +23,6 @@ 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 { ToastrService } from 'ngx-toastr'; import { AlertService } from '@shared'; describe('DatasetListComponent', () => { From 33c2a05d623deed76ba466d5c7f87ec004aba684 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Tue, 19 Nov 2024 16:48:22 +0100 Subject: [PATCH 22/26] feat(ui): fix PR --- .../api/SsoOpenIdConnectConfigDto.java | 33 ++---------- .../api/SsoOpenIdConnectController.java | 10 ++-- .../security/api/SsoOpenIdConnectMapper.java | 34 ++++++------ .../infra/sso/OAuth2SsoUserService.java | 2 +- .../components/login/login.component.html | 12 +++-- .../components/login/login.component.scss | 5 +- .../core/components/login/login.component.ts | 4 ++ chutney/ui/src/app/core/core.module.ts | 2 +- .../ui/src/app/core/services/login.service.ts | 4 -- ...oauth2-content-type-interceptor.service.ts | 28 ++++++++++ .../ui/src/app/core/services/sso.service.ts | 52 +++++-------------- 11 files changed, 84 insertions(+), 102 deletions(-) create mode 100644 chutney/ui/src/app/core/services/oauth2-content-type-interceptor.service.ts 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 index 552716d1d..34e32f7e7 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfigDto.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectConfigDto.java @@ -9,33 +9,8 @@ import java.util.Map; -public class SsoOpenIdConnectConfigDto { - 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 Boolean oidc; - public final String uriRequireHeader; - public final Map headers; - public final String ssoProviderImageUrl; - public final Map additionalQueryParams; - - - public 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) { - this.issuer = issuer; - this.clientId = clientId; - this.clientSecret = clientSecret; - this.responseType = responseType; - this.scope = scope; - this.redirectBaseUrl = redirectBaseUrl; - this.ssoProviderName = ssoProviderName; - this.oidc = oidc; - this.uriRequireHeader = uriRequireHeader; - this.headers = headers; - this.ssoProviderImageUrl = ssoProviderImageUrl; - this.additionalQueryParams = additionalQueryParams; - } +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 index 0e5a6ce4f..469ec7fce 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectController.java @@ -8,12 +8,10 @@ package com.chutneytesting.security.api; import static com.chutneytesting.security.api.SsoOpenIdConnectMapper.toDto; -import static java.util.Optional.ofNullable; import com.chutneytesting.security.infra.sso.SsoOpenIdConnectConfigProperties; -import java.util.NoSuchElementException; -import org.springframework.context.annotation.Profile; 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; @@ -22,20 +20,18 @@ @RestController @RequestMapping(SsoOpenIdConnectController.BASE_URL) @CrossOrigin(origins = "*") -@Profile("sso-auth") public class SsoOpenIdConnectController { public static final String BASE_URL = "/api/v1/sso"; private final SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties; - SsoOpenIdConnectController(SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties) { + SsoOpenIdConnectController(@Nullable SsoOpenIdConnectConfigProperties ssoOpenIdConnectConfigProperties) { this.ssoOpenIdConnectConfigProperties = ssoOpenIdConnectConfigProperties; } @GetMapping(path = "/config", produces = MediaType.APPLICATION_JSON_VALUE) public SsoOpenIdConnectConfigDto getSsoOpenIdConnectConfig() { - return ofNullable(toDto(ssoOpenIdConnectConfigProperties)) - .orElseThrow(NoSuchElementException::new); + 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 index b237f19b8..1169e9a07 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectMapper.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/api/SsoOpenIdConnectMapper.java @@ -7,26 +7,26 @@ 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) { - if (ssoOpenIdConnectConfig == null) { - return null; - } - return 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 - ); + 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/OAuth2SsoUserService.java b/chutney/server/src/main/java/com/chutneytesting/security/infra/sso/OAuth2SsoUserService.java index 80d97075a..bc50f2973 100644 --- 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 @@ -34,7 +34,7 @@ public OAuth2SsoUserService(AuthenticationService authenticationService, @Nullab @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); if (restOperations != null) { delegate.setRestOperations(restOperations); } 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 cca961e85..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,11 +46,15 @@

Login

- @if (getSsoProviderName()) { + @if (displaySsoButton()) {
-
} 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 cba5bcb5f..fbf0853e3 100644 --- a/chutney/ui/src/app/core/components/login/login.component.scss +++ b/chutney/ui/src/app/core/components/login/login.component.scss @@ -62,5 +62,8 @@ } .ssoImage { - width: 40px + 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 930ac30f7..ed6e25470 100644 --- a/chutney/ui/src/app/core/components/login/login.component.ts +++ b/chutney/ui/src/app/core/components/login/login.component.ts @@ -89,6 +89,10 @@ export class LoginComponent implements OnDestroy, OnInit { 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 ed1b97a17..b97c48315 100644 --- a/chutney/ui/src/app/core/core.module.ts +++ b/chutney/ui/src/app/core/core.module.ts @@ -15,7 +15,7 @@ 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/sso.service'; +import { OAuth2ContentTypeInterceptor } from '@core/services/oauth2-content-type-interceptor.service'; @NgModule({ declarations: [ diff --git a/chutney/ui/src/app/core/services/login.service.ts b/chutney/ui/src/app/core/services/login.service.ts index df299b60f..f6b361241 100644 --- a/chutney/ui/src/app/core/services/login.service.ts +++ b/chutney/ui/src/app/core/services/login.service.ts @@ -160,10 +160,6 @@ export class LoginService { return false; } - isLoginUrl(url: string): boolean { - return url.includes(this.loginUrl); - } - currentUser(skipInterceptor: boolean = false, headers: HttpHeaders | { [header: string]: string | string[]; } = {}): Observable { 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 index c84698da6..8bcbaef12 100644 --- a/chutney/ui/src/app/core/services/sso.service.ts +++ b/chutney/ui/src/app/core/services/sso.service.ts @@ -5,12 +5,12 @@ * */ -import { HttpClient, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; import { environment } from '@env/environment'; -import { BehaviorSubject, map, Observable, tap } from 'rxjs'; +import { BehaviorSubject, map, tap } from 'rxjs'; import { Injectable } from '@angular/core'; -import { filter } from 'rxjs/operators'; +import { filter, switchMap } from 'rxjs/operators'; interface SsoAuthConfig { issuer: string, @@ -37,6 +37,7 @@ export class SsoService { private tokenLoadedSubject = new BehaviorSubject(false); public tokenLoaded$ = this.tokenLoadedSubject.asObservable(); + private enableSso = false constructor(private oauthService: OAuthService, private http: HttpClient) { @@ -68,17 +69,11 @@ export class SsoService { redirectUriAsPostLogoutRedirectUriFallback: true, } as AuthConfig }), - tap(async ssoConfig => { - try { - this.oauthService.configure(ssoConfig) - await this.oauthService.loadDiscoveryDocumentAndTryLogin(); - if (this.oauthService.hasValidAccessToken()) { - this.tokenLoadedSubject.next(true); - } - } catch (e) { - console.error("SSO provider not available") - } - }) + 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() } @@ -95,17 +90,11 @@ export class SsoService { } getSsoProviderName() { - if (this.ssoConfig) { - return this.ssoConfig.ssoProviderName - } - return null + return this.ssoConfig?.ssoProviderName } getSsoProviderImageUrl() { - if (this.ssoConfig) { - return this.ssoConfig.ssoProviderImageUrl - } - return null + return this.ssoConfig?.ssoProviderImageUrl } get accessToken(): string { @@ -123,21 +112,8 @@ export class SsoService { get headers() { return this.ssoConfig?.headers } -} -@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); - } + get getEnableSso() { + return this.enableSso + } } From 95e6a5d41d5c49f4a3f0242e2e4c973982b4138c Mon Sep 17 00:00:00 2001 From: boddissattva <58807088+boddissattva@users.noreply.github.com> Date: Wed, 20 Nov 2024 12:03:58 +0100 Subject: [PATCH 23/26] chore(): Allow to test sso with a local-dev server running on 8443 --- .../local-dev/src/main/resources/application-sso-auth.yml | 2 +- chutney/packaging/local-dev/src/test/resources/sso/.env | 2 +- .../packaging/local-dev/src/test/resources/sso/sso-oidc.mjs | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) 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 index 4ace89b76..3c68852df 100644 --- a/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml +++ b/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml @@ -39,6 +39,6 @@ auth: clientSecret: 'my-client-secret' responseType: 'code' scope: 'openid profile email' - redirectBaseUrl: "https://localhost:4200" + redirectBaseUrl: "https://${server.http.interface}:${server.port}" 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 index 5c59d759f..2cfd6931a 100644 --- a/chutney/packaging/local-dev/src/test/resources/sso/.env +++ b/chutney/packaging/local-dev/src/test/resources/sso/.env @@ -3,7 +3,7 @@ CLIENT_ID=my-client CLIENT_SECRET=my-client-secret -REDIRECT_URI='https://localhost:4200' +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/sso-oidc.mjs b/chutney/packaging/local-dev/src/test/resources/sso/sso-oidc.mjs index 0cbcefd39..e78119a35 100644 --- a/chutney/packaging/local-dev/src/test/resources/sso/sso-oidc.mjs +++ b/chutney/packaging/local-dev/src/test/resources/sso/sso-oidc.mjs @@ -16,8 +16,8 @@ const oidc = new Provider('http://localhost:3000', { client_id: process.env.CLIENT_ID, client_secret: process.env.CLIENT_SECRET, grant_types: [process.env.GRANT_TYPE], - redirect_uris: [process.env.REDIRECT_URI], - post_logout_redirect_uris: [process.env.REDIRECT_URI], + redirect_uris: process.env.REDIRECT_URI.split(' '), + post_logout_redirect_uris: process.env.REDIRECT_URI.split(' '), }], formats: { AccessToken: process.env.TOKEN_FORMAT, @@ -32,7 +32,7 @@ const oidc = new Provider('http://localhost:3000', { userinfo: { enabled: true }, }, clientBasedCORS(ctx, origin, client) { - const allowedOrigins = [process.env.REDIRECT_URI]; + const allowedOrigins = process.env.REDIRECT_URI.split(' '); return allowedOrigins.includes(origin); }, async findAccount(ctx, id) { From 8f51a23aaad80ea5a8f2cdfcab4587c73570ed87 Mon Sep 17 00:00:00 2001 From: boddissattva <58807088+boddissattva@users.noreply.github.com> Date: Wed, 20 Nov 2024 15:24:11 +0100 Subject: [PATCH 24/26] chore(): Clean and fix flaky test --- .../tests/engine/RetryAcceptanceTest.kt | 4 +-- .../action/assertion/compare/Parser.java | 34 ------------------- 2 files changed, 2 insertions(+), 36 deletions(-) delete mode 100644 chutney/action-impl/src/main/java/com/chutneytesting/action/assertion/compare/Parser.java 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..7aab41e0a 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 @@ -44,7 +44,7 @@ val `Retry should stop after success assertion` = Scenario(title = "Retry should { "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: string-assert \n inputs: {\n document: \${'$'}{T(java.lang.String).format('%02d', new Integer(#secondsPlus5) + 1)} \n expected: \${'$'}{T(java.lang.String).format('%02d', new Integer(#currentSeconds) + 2)} \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; - } -} From 36ddfb2d0659c459d3c3febe2a410bd9950402a3 Mon Sep 17 00:00:00 2001 From: boddissattva <58807088+boddissattva@users.noreply.github.com> Date: Wed, 20 Nov 2024 15:50:45 +0100 Subject: [PATCH 25/26] chore(): Fix flaky test --- .../acceptance/tests/engine/RetryAcceptanceTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 7aab41e0a..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: \${'$'}{T(java.lang.String).format('%02d', new Integer(#secondsPlus5) + 1)} \n expected: \${'$'}{T(java.lang.String).format('%02d', new Integer(#currentSeconds) + 2)} \n} \n}" + "task":"{\n type: assert \n inputs: {\n asserts: [{\n assert-true: ${"currentDate.isAfter(#stopDate)".hjsonSpEL} \n}] \n} \n}" } } ] From 5a38e60a32335cbea74ee908fee453db0c4a71f8 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Mon, 25 Nov 2024 16:44:21 +0100 Subject: [PATCH 26/26] feat(ui, server): Alert message when sso auth fails, fix PR --- .../src/main/resources/application-sso-auth.yml | 10 +++++----- .../security/ChutneyWebSecurityConfig.java | 8 ++++---- chutney/ui/src/app/core/services/login.service.ts | 1 + 3 files changed, 10 insertions(+), 9 deletions(-) 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 index 3c68852df..93f699feb 100644 --- a/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml +++ b/chutney/packaging/local-dev/src/main/resources/application-sso-auth.yml @@ -6,16 +6,16 @@ spring: oauth2: client: registration: - my-provider: - provider: my-provider + 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}:${server.port}/login/oauth2/code/{registrationId}" + redirect-uri: "https://${server.http.interface}:4200/login/oauth2/code/{registrationId}" scope: openid, profile, email client-name: My Provider provider: - my-provider: + sso-provider: authorization-uri: ${auth.sso.issuer}/auth token-uri: ${auth.sso.issuer}/token user-info-uri: ${auth.sso.issuer}/me @@ -39,6 +39,6 @@ auth: clientSecret: 'my-client-secret' responseType: 'code' scope: 'openid profile email' - redirectBaseUrl: "https://${server.http.interface}:${server.port}" + redirectBaseUrl: "https://${server.http.interface}:4200" ssoProviderName: 'SSO OpenID Connect' oidc: true 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 bb037acb4..e5f05e3c8 100644 --- a/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java +++ b/chutney/server/src/main/java/com/chutneytesting/security/ChutneyWebSecurityConfig.java @@ -62,9 +62,9 @@ @EnableConfigurationProperties({OAuth2AuthorizationServerProperties.class, SsoOpenIdConnectConfigProperties.class}) public class ChutneyWebSecurityConfig { - protected static final String LOGIN_URL = UserController.BASE_URL + "/login"; - protected static final String LOGOUT_URL = UserController.BASE_URL + "/logout"; - protected 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}") protected String actuatorBaseUrl; @@ -133,7 +133,7 @@ private Customizer.ChannelRequestMatcher 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("my-provider")); + OAuth2TokenAuthenticationProvider oAuth2TokenAuthenticationProvider = new OAuth2TokenAuthenticationProvider(oAuth2UserService, clientRegistrationRepository.findByRegistrationId("sso-provider")); AuthenticationManager authenticationManager = new ProviderManager(Collections.singletonList(oAuth2TokenAuthenticationProvider)); OAuth2TokenAuthenticationFilter tokenFilter = new OAuth2TokenAuthenticationFilter(authenticationManager); http diff --git a/chutney/ui/src/app/core/services/login.service.ts b/chutney/ui/src/app/core/services/login.service.ts index f6b361241..b737568a9 100644 --- a/chutney/ui/src/app/core/services/login.service.ts +++ b/chutney/ui/src/app/core/services/login.service.ts @@ -88,6 +88,7 @@ export class LoginService { 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 }) );