diff --git a/gradle/libraries.gradle b/gradle/libraries.gradle index 37d5570466..abd276388a 100644 --- a/gradle/libraries.gradle +++ b/gradle/libraries.gradle @@ -123,7 +123,7 @@ ext { springboot_starter_mail: "org.springframework.boot:spring-boot-starter-mail", springboot_starter_validation: "org.springframework.boot:spring-boot-starter-validation", springboot_starter_webflux: "org.springframework.boot:spring-boot-starter-webflux", - + springboot_starter_oauth2_client: "org.springframework.boot:spring-boot-starter-oauth2-client", springframework_restdocs: "org.springframework.restdocs:spring-restdocs-mockmvc", springframework_security_test: "org.springframework.security:spring-security-test", springframework_web: "org.springframework:spring-web", diff --git a/sechub-webui-solution/helm/sechub-webui/values.yaml b/sechub-webui-solution/helm/sechub-webui/values.yaml index c3e52e3692..7771934866 100644 --- a/sechub-webui-solution/helm/sechub-webui/values.yaml +++ b/sechub-webui-solution/helm/sechub-webui/values.yaml @@ -39,9 +39,9 @@ webui: appenderName: "LOGSTASH_JSON" spring: # Spring profiles (comma-separated list): - # - Server mode: 'webui_localserver' (self-signed cert) or 'webui_server' (provide SSL certificate) - # - SecHub connection: 'webui_mocked' or leave empty for connect to configured SecHub server - profiles: "webui_localserver" + # - Server mode: 'ssl-cert-provided' (self-signed SSL cert included) or 'ssl-cert-required' (SSL certificate required by external config) + # - SecHub connection: 'basic-auth-mocked' or leave empty for connect to configured SecHub server + profiles: "ssl-cert-provided" # Configure Spring Boot's embedded Tomcat embeddedTomcat: logging: diff --git a/sechub-webui/.gitignore b/sechub-webui/.gitignore new file mode 100644 index 0000000000..0fc15402e5 --- /dev/null +++ b/sechub-webui/.gitignore @@ -0,0 +1,2 @@ +application-local.*.yaml +application-local.*.yml \ No newline at end of file diff --git a/sechub-webui/README.md b/sechub-webui/README.md new file mode 100644 index 0000000000..22c659cadc --- /dev/null +++ b/sechub-webui/README.md @@ -0,0 +1,61 @@ + + +# SecHub WebUI + +## Overview + +SecHub WebUI is a web-based user interface for managing and interacting with the SecHub application. + +## Profiles + +To start the application locally use the `webui_local` profile. + +This will include the following profiles: + +- `ssl-cert-provided`: a default ssl certificate will be used by the WebUI server +- `basic-auth-mocked`: mock the SecHub Server & enable login with preconfigured credentials at `/login/classic`) +- `local`: includes any local configurations matching `application-local.${USER}.yml` + +If you want to provide local configurations, create a file named `application-local.${USER}.yml` in the `src/main/resources` directory. +Make sure that the ${USER} part matches your system username. + +This will enable configurations suitable for local development and testing. + +## Running the application in OAuth2 Mode + +To run the application in OAuth2 mode, include the `oauth2-enabled` profile. + +Note: The `webui_prod` profile includes the `oauth2-enabled` profile. + +Make sure that you either provide a valid `application-oauth2-enabled.yml` file in the `src/main/resources` directory or set the required environment variables. + +Example `application-oauth2-enabled.yml`: + +```yaml +sechub: + security: + oauth2: + client-id: example-client-id + client-secret: example-client-secret + provider: example-provider + redirect-uri: {baseUrl}/login/oauth2/code/{provider} + issuer-uri: https://sso.example-provider.com + authorization-uri: https://sso.example-provider.com/as/authorization.oauth2 + token-uri: https://sso.example-provider.com/as/token.oauth2 + user-info-uri: https://sso.example-provider.com/idp/userinfo.openid + jwk-set-uri: https://sso.example-provider.com/pf/JWKS +``` + +Alternatively, you can provide the following environment variables: + +```bash +SECHUB_SECURITY_OAUTH2_CLIENT_ID=example-client-id +SECHUB_SECURITY_OAUTH2_CLIENT_SECRET=example-client-secret +SECHUB_SECURITY_OAUTH2_PROVIDER=example-provider +SECHUB_SECURITY_OAUTH2_REDIRECT_URI={baseUrl}/login/oauth2/code/{provider} +SECHUB_SECURITY_OAUTH2_ISSUER_URI=https://sso.example-provider.com +SECHUB_SECURITY_OAUTH2_AUTHORIZATION_URI=https://sso.example-provider.com/as/authorization.oauth2 +SECHUB_SECURITY_OAUTH2_TOKEN_URI=https://sso.example-provider.com/as/token.oauth2 +SECHUB_SECURITY_OAUTH2_USER_INFO_URI=https://sso.example-provider.com/idp/userinfo.openid +SECHUB_SECURITY_OAUTH2_JWK_SET_URI=https://sso.example-provider.com/pf/JWKS +``` diff --git a/sechub-webui/build.gradle b/sechub-webui/build.gradle index f5707a45db..f0dd829017 100644 --- a/sechub-webui/build.gradle +++ b/sechub-webui/build.gradle @@ -13,11 +13,16 @@ plugins { dependencies { implementation project(':sechub-commons-core') implementation project(':sechub-api-java') + implementation library.springboot_starter_web implementation library.springboot_starter_security implementation library.springboot_starter_thymeleaf implementation library.logstashLogbackEncoder - implementation library.springboot_starter_webflux implementation library.thymeleaf_extras_springsecurity5 + implementation library.springboot_starter_oauth2_client + implementation library.findbugs + + testImplementation library.springboot_starter_test + testImplementation library.springframework_security_test developmentOnly library.springboot_devtoolssf } diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/ApplicationProfiles.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/ApplicationProfiles.java new file mode 100644 index 0000000000..3026e44662 --- /dev/null +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/ApplicationProfiles.java @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui; + +public final class ApplicationProfiles { + + public static final String BASIC_AUTH_MOCKED = "basic-auth-mocked"; + public static final String INTEGRATION_TEST_DATA = "integrationtest-data"; + public static final String LOCAL = "local"; + public static final String OAUTH2_ENABLED = "oauth2-enabled"; + public static final String SSL_CERT_PROVIDED = "ssl-cert-provided"; + public static final String SSL_CERT_REQUIRED = "ssl-cert-required"; + public static final String TEST = "test"; + + private ApplicationProfiles() { + // Prevent instantiation + } +} \ No newline at end of file diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/RequestConstants.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/RequestConstants.java index 696d225824..189879c726 100644 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/RequestConstants.java +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/RequestConstants.java @@ -1,14 +1,17 @@ // SPDX-License-Identifier: MIT package com.mercedesbenz.sechub.webui; -public class RequestConstants { +public final class RequestConstants { public static final String ROOT = "/"; public static final String PROJECTS = "/projects"; public static final String PROJECT_SCANS = "/projects/{projectId}/scans"; public static final String STATUS = "/status"; - public static final String LOGIN = "/login"; + public static final String LOGIN_CLASSIC = "/login/classic"; + public static final String LOGIN_OAUTH2 = "/login/oauth2"; + public static final String HOME = "/home"; + public static final String LOGOUT = "/logout"; public static final String REQUEST_NEW_APITOKEN = "/request-new-apitoken"; } diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/credentials/NewApiTokenController.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/credentials/NewApiTokenController.java similarity index 56% rename from sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/credentials/NewApiTokenController.java rename to sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/credentials/NewApiTokenController.java index 11c7506e6b..094accdf3b 100644 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/credentials/NewApiTokenController.java +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/credentials/NewApiTokenController.java @@ -1,26 +1,26 @@ // SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page.credentials; +package com.mercedesbenz.sechub.webui.credentials; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import com.mercedesbenz.sechub.webui.RequestConstants; -import com.mercedesbenz.sechub.webui.page.user.UserInfoService; import com.mercedesbenz.sechub.webui.sechubaccess.SecHubAccessService; +import com.mercedesbenz.sechub.webui.user.UserInfoService; @Controller -public class NewApiTokenController { +class NewApiTokenController { - @Autowired - NewApiTokenService newApiTokenService; + private final NewApiTokenService newApiTokenService; + private final SecHubAccessService accessService; + private final UserInfoService userInfoService; - @Autowired - SecHubAccessService accessService; - - @Autowired - UserInfoService userInfoService; + NewApiTokenController(NewApiTokenService newApiTokenService, SecHubAccessService accessService, UserInfoService userInfoService) { + this.newApiTokenService = newApiTokenService; + this.accessService = accessService; + this.userInfoService = userInfoService; + } @GetMapping(RequestConstants.REQUEST_NEW_APITOKEN) String requestNewApiToken(Model model) { diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/credentials/NewApiTokenService.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/credentials/NewApiTokenService.java similarity index 64% rename from sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/credentials/NewApiTokenService.java rename to sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/credentials/NewApiTokenService.java index 6d5b4be480..cf9bb15bdf 100644 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/credentials/NewApiTokenService.java +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/credentials/NewApiTokenService.java @@ -1,21 +1,21 @@ // SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page.credentials; +package com.mercedesbenz.sechub.webui.credentials; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.mercedesbenz.sechub.webui.sechubaccess.SecHubAccessService; @Service -public class NewApiTokenService { +class NewApiTokenService { - @Autowired - SecHubAccessService accessService; + private final SecHubAccessService accessService; + + NewApiTokenService(SecHubAccessService accessService) { + this.accessService = accessService; + } /** - * Request new API token as described in - * - * documentation * @@ -25,9 +25,10 @@ public class NewApiTokenService { * com' -i -X POST -H 'Content-Type: application/json;charset=UTF-8' * */ - public boolean requestNewApiToken(String emailAddress) { + boolean requestNewApiToken(String emailAddress) { /* @formatter:off */ - Boolean succesfulSendNewApiToken = accessService.createExecutorForResult(Boolean.class). + + return accessService.createExecutorForResult(Boolean.class). whenDoing("request a new api token"). callAndReturn(client -> { client.requestNewApiToken(emailAddress); @@ -36,8 +37,6 @@ public boolean requestNewApiToken(String emailAddress) { onErrorReturn(exception -> Boolean.FALSE). execute(); - return succesfulSendNewApiToken; - /* @formatter:on */ } } diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/HomeController.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/HomeController.java new file mode 100644 index 0000000000..e041d4149e --- /dev/null +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/HomeController.java @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.page; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import com.mercedesbenz.sechub.webui.RequestConstants; + +@Controller +public class HomeController { + + @GetMapping({ RequestConstants.ROOT, RequestConstants.HOME }) + public String home(@AuthenticationPrincipal OidcUser principal, Model model) { + if (principal != null) { + model.addAttribute("principal", principal.getAttribute("name")); + } + return "home"; + } +} diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/LoginController.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/LoginClassicController.java similarity index 70% rename from sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/LoginController.java rename to sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/LoginClassicController.java index 6a9a2c0fa2..1a22f65737 100644 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/LoginController.java +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/LoginClassicController.java @@ -7,10 +7,10 @@ import com.mercedesbenz.sechub.webui.RequestConstants; @Controller -public class LoginController { +public class LoginClassicController { - @GetMapping(RequestConstants.LOGIN) + @GetMapping(RequestConstants.LOGIN_CLASSIC) String login() { - return "login"; + return "login-classic"; } } \ No newline at end of file diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/LoginOAuth2Controller.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/LoginOAuth2Controller.java new file mode 100644 index 0000000000..659574f2b9 --- /dev/null +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/LoginOAuth2Controller.java @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.page; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import com.mercedesbenz.sechub.webui.ApplicationProfiles; +import com.mercedesbenz.sechub.webui.RequestConstants; + +@Controller +@Profile(ApplicationProfiles.OAUTH2_ENABLED) +class LoginOAuth2Controller { + + @GetMapping(RequestConstants.LOGIN_OAUTH2) + String login(Model model) { + // TODO: make this configurable later for multiple client registrations + String registrationId = "mercedes-benz"; + model.addAttribute("registrationId", registrationId); + return "login-oauth2"; + } +} \ No newline at end of file diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/project/ProjectInfoService.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/project/ProjectInfoService.java deleted file mode 100644 index 8e329eca2c..0000000000 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/project/ProjectInfoService.java +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page.project; - -import java.util.ArrayList; -import java.util.List; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import com.mercedesbenz.sechub.webui.page.user.UserInfoService; - -@Service -public class ProjectInfoService { - - @Autowired - UserInfoService userInfoService; - - public List fetchProjectIdsForUser(String userId) { - /* @formatter:off */ - - List fakeList = new ArrayList<>(); - - fakeList.add("project-Testval1"); - fakeList.add("project-Testval2"); - fakeList.add("project-Testval3"); - - return fakeList; - /* @formatter:on */ - } - - public List fetchProjectIdsForCurrentUser() { - return fetchProjectIdsForUser(userInfoService.getUserId()); - } -} diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/project/ProjectsController.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/project/ProjectsController.java deleted file mode 100644 index 26c81a2330..0000000000 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/project/ProjectsController.java +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page.project; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; - -import com.mercedesbenz.sechub.webui.RequestConstants; -import com.mercedesbenz.sechub.webui.sechubaccess.SecHubAccessService; - -@Controller -public class ProjectsController { - - @Autowired - SecHubAccessService accessService; - - @Autowired - ProjectInfoService projectInfoService; - - @GetMapping(value = { RequestConstants.ROOT, RequestConstants.PROJECTS }) - String index(Model model) { - model.addAttribute("sechubServerUrl", accessService.getSecHubServerUri()); - model.addAttribute("projectIds", projectInfoService.fetchProjectIdsForCurrentUser()); - return "projects"; - } -} diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/scan/ProjectScanInfoService.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/scan/ProjectScanInfoService.java deleted file mode 100644 index 60418b8d2f..0000000000 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/scan/ProjectScanInfoService.java +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page.scan; - -import org.springframework.stereotype.Service; - -@Service -public class ProjectScanInfoService { - -} diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/scan/ProjectScansController.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/scan/ProjectScansController.java deleted file mode 100644 index 2b8d9be044..0000000000 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/scan/ProjectScansController.java +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page.scan; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; - -import com.mercedesbenz.sechub.webui.RequestConstants; -import com.mercedesbenz.sechub.webui.sechubaccess.SecHubAccessService; -import com.mercedesbenz.sechub.webui.security.UserInputSanitizer; - -@Controller -public class ProjectScansController { - - @Autowired - ProjectScanInfoService projectScanInfoService; - - @Autowired - SecHubAccessService accessService; - - @Autowired - UserInputSanitizer sanitizer; - - @GetMapping(RequestConstants.PROJECT_SCANS) - String scans(Model model, @PathVariable("projectId") String projectId) { - - model.addAttribute("scanProjectId", sanitizer.sanitizeProjectId(projectId)); - - return "project-scans"; - } -} \ No newline at end of file diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/status/SecHubStatusService.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/status/SecHubStatusService.java deleted file mode 100644 index 7db1b2ef2c..0000000000 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/status/SecHubStatusService.java +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page.status; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import com.mercedesbenz.sechub.api.SecHubStatus; -import com.mercedesbenz.sechub.webui.sechubaccess.SecHubAccessService; - -@Service -public class SecHubStatusService { - - @Autowired - SecHubAccessService accessService; - - public SecHubStatus getSecHubStatus() { - /* @formatter:off */ - - return accessService.createExecutorForResult(SecHubStatus.class). - whenDoing("fetching SecHub status"). - callAndReturn(client->{ - client.triggerRefreshOfSecHubSchedulerStatus(); - return client.fetchSecHubStatus(); - }). - onErrorReturnAlways(null). - execute(); - /* @formatter:on */ - } - -} diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/status/StatusController.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/status/StatusController.java deleted file mode 100644 index d84a0b66e1..0000000000 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/status/StatusController.java +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page.status; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; - -import com.mercedesbenz.sechub.webui.RequestConstants; -import com.mercedesbenz.sechub.webui.sechubaccess.SecHubAccessService; - -@Controller -public class StatusController { - - @Autowired - SecHubStatusService secHubStatusService; - - @Autowired - SecHubAccessService accessService; - - @GetMapping(RequestConstants.STATUS) - String status(Model model) { - - model.addAttribute("sechubStatus", secHubStatusService.getSecHubStatus()); - - model.addAttribute("sechubServerAlive", accessService.isSecHubServerAlive()); - model.addAttribute("sechubServerUrl", accessService.getSecHubServerUri()); - model.addAttribute("sechubServerVersion", accessService.getServerVersion()); - - return "status"; - } -} \ No newline at end of file diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/Base64EncodedClientIdAndSecretOAuth2AccessTokenClient.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/Base64EncodedClientIdAndSecretOAuth2AccessTokenClient.java new file mode 100644 index 0000000000..b6bde8c6d1 --- /dev/null +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/Base64EncodedClientIdAndSecretOAuth2AccessTokenClient.java @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.security; + +import java.util.Base64; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; + +/** + *

+ * Custom implementation of {@link OAuth2AccessTokenResponseClient} for + * retrieving a JWT token response from a configured Identity Provider (IDP) + * after a successful OAuth2 authorization code grant request. + *

+ *

+ * This class handles the exchange of the authorization code for an access + * token, refresh token, and ID token by making a POST request to the token + * endpoint of the IDP. The client credentials (client ID and client secret) are + * encoded in Base64 and included in the Authorization header of the request. + *

+ *

+ * The response from the IDP is expected to be a {@link JwtResponse}, which + * includes the access token, refresh token, ID token, and the expiration time + * of the access token. + *

+ * + * @see OAuth2AccessTokenResponseClient + * @see OAuth2AuthorizationCodeGrantRequest + * @see OAuth2AccessTokenResponse + * @see SecurityConfiguration + * @see JwtResponse + * + * @author hamidonos + */ +class Base64EncodedClientIdAndSecretOAuth2AccessTokenClient implements OAuth2AccessTokenResponseClient { + + private static final Logger LOG = LoggerFactory.getLogger(Base64EncodedClientIdAndSecretOAuth2AccessTokenClient.class); + private static final String GRANT_TYPE_VALUE = "authorization_code"; + private static final String BASIC_AUTHORIZATION_HEADER_VALUE_FORMAT = "Basic %s"; + private static final String CLIENT_ID_CLIENT_SECRET_FORMAT = "%s:%s"; + private static final String ID_TOKEN = "id_token"; + + private final RestTemplate restTemplate; + + Base64EncodedClientIdAndSecretOAuth2AccessTokenClient(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest) { + ClientRegistration clientRegistration = authorizationGrantRequest.getClientRegistration(); + String tokenUri = clientRegistration.getProviderDetails().getTokenUri(); + String clientId = clientRegistration.getClientId(); + String clientSecret = clientRegistration.getClientSecret(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set(HttpHeaders.AUTHORIZATION, getBasicAuthHeaderValue(clientId, clientSecret)); + + HttpEntity> entity = getMultiValueMapHttpEntity(authorizationGrantRequest, headers); + + JwtResponse jwtResponse; + try { + jwtResponse = restTemplate.postForObject(tokenUri, entity, JwtResponse.class); + + if (jwtResponse == null) { + throw new RestClientException("JWT response is null"); + } + } catch (RestClientException e) { + String errMsg = "Failed to get JWT token response"; + LOG.atError().log(errMsg, e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, errMsg, e); + } + + Map additionalParameters = Map.of(ID_TOKEN, jwtResponse.getIdToken()); + + // @formatter:off + return OAuth2AccessTokenResponse + .withToken(jwtResponse.getAccessToken()) + .tokenType(OAuth2AccessToken.TokenType.BEARER) + .expiresIn(jwtResponse.getExpiresIn()) + .refreshToken(jwtResponse.getRefreshToken()) + .additionalParameters(additionalParameters) + .build(); + // @formatter:on + } + + private static HttpEntity> getMultiValueMapHttpEntity(OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest, + HttpHeaders headers) { + MultiValueMap formParameters = new LinkedMultiValueMap<>(); + OAuth2AuthorizationExchange authorizationExchange = authorizationGrantRequest.getAuthorizationExchange(); + String code = authorizationExchange.getAuthorizationResponse().getCode(); + String redirectUri = authorizationExchange.getAuthorizationRequest().getRedirectUri(); + + formParameters.add(OAuth2ParameterNames.GRANT_TYPE, GRANT_TYPE_VALUE); + formParameters.add(OAuth2ParameterNames.CODE, code); + formParameters.add(OAuth2ParameterNames.REDIRECT_URI, redirectUri); + + return new HttpEntity<>(formParameters, headers); + } + + private static String getBasicAuthHeaderValue(String clientId, String clientSecret) { + String clientIdClientSecret = CLIENT_ID_CLIENT_SECRET_FORMAT.formatted(clientId, clientSecret); + String clientIdClientSecretB64Encoded = Base64.getEncoder().encodeToString(clientIdClientSecret.getBytes()); + return BASIC_AUTHORIZATION_HEADER_VALUE_FORMAT.formatted(clientIdClientSecretB64Encoded); + } +} \ No newline at end of file diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/JwtResponse.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/JwtResponse.java new file mode 100644 index 0000000000..bdbc7e96c6 --- /dev/null +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/JwtResponse.java @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.security; + +import static java.util.Objects.requireNonNull; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents the response containing JWT-related tokens returned by an OAuth2 + * or OpenID Connect (OIDC) authentication flow. This class encapsulates the + * access token, token type, ID token, expiration time, and refresh token. It is + * primarily used for handling token-based authentication in secure API + * communication. + * + *

+ * The {@code JwtResponse} object is constructed from JSON using Jackson, + * mapping the expected token fields from the authentication response. + *

+ * + *

+ * Fields: + *

    + *
  • {@code accessToken}: The access token used to authenticate subsequent + * requests to the API.
  • + *
  • {@code tokenType}: The type of the token (typically "Bearer").
  • + *
  • {@code idToken}: The ID token, which contains identity claims about the + * authenticated user.
  • + *
  • {@code expiresIn}: The duration in seconds until the access token + * expires.
  • + *
  • {@code refreshToken}: The token used to obtain a new access token without + * re-authenticating.
  • + *
+ *

+ * + *

+ * For more information on JSON Web Tokens (JWT), please refer to the + * official JWT documentation. + *

+ * + * @author hamidonos + */ +class JwtResponse { + + private static final String JSON_PROPERTY_ACCESS_TOKEN = "access_token"; + private static final String JSON_PROPERTY_TOKEN_TYPE = "token_type"; + private static final String JSON_PROPERTY_ID_TOKEN = "id_token"; + private static final String JSON_PROPERTY_EXPIRES_IN = "expires_in"; + private static final String JSON_PROPERTY_REFRESH_TOKEN = "refresh_token"; + + private final String accessToken; + private final String tokenType; + private final String idToken; + private final Long expiresIn; + private final String refreshToken; + + @JsonCreator + JwtResponse(@JsonProperty(JSON_PROPERTY_ACCESS_TOKEN) String accessToken, @JsonProperty(JSON_PROPERTY_TOKEN_TYPE) String tokenType, + @JsonProperty(JSON_PROPERTY_ID_TOKEN) String idToken, @JsonProperty(JSON_PROPERTY_EXPIRES_IN) Long expiresIn, + @JsonProperty(JSON_PROPERTY_REFRESH_TOKEN) String refreshToken) { + this.accessToken = requireNonNull(accessToken, JSON_PROPERTY_ACCESS_TOKEN + " must not be null"); + this.tokenType = requireNonNull(tokenType, JSON_PROPERTY_TOKEN_TYPE + " must not be null"); + this.idToken = requireNonNull(idToken, JSON_PROPERTY_ID_TOKEN + " must not be null"); + this.expiresIn = requireNonNull(expiresIn, JSON_PROPERTY_EXPIRES_IN + " must not be null"); + this.refreshToken = refreshToken; + } + + String getAccessToken() { + return accessToken; + } + + String getTokenType() { + return tokenType; + } + + String getIdToken() { + return idToken; + } + + Long getExpiresIn() { + return expiresIn; + } + + String getRefreshToken() { + return refreshToken; + } +} diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/LoginAuthenticationSuccessHandler.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/LoginAuthenticationSuccessHandler.java new file mode 100644 index 0000000000..78e639be53 --- /dev/null +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/LoginAuthenticationSuccessHandler.java @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.security; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import com.mercedesbenz.sechub.webui.RequestConstants; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * {@code OAuth2SuccessHandler} implements {@link AuthenticationSuccessHandler} + * to provide custom behavior upon successful authentication. This handler + * redirects the user to the /home page specified in {@link RequestConstants}. + * + * @see SecurityConfiguration + * @see RequestConstants + * + * @author hamidonos + */ +class LoginAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + + private static final Logger LOG = LoggerFactory.getLogger(LoginAuthenticationSuccessHandler.class); + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + LOG.atDebug().log("Redirecting to %s".formatted(RequestConstants.HOME)); + response.sendRedirect(RequestConstants.HOME); + } +} \ No newline at end of file diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/OAuth2Properties.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/OAuth2Properties.java new file mode 100644 index 0000000000..589a6deaeb --- /dev/null +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/OAuth2Properties.java @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.security; + +import static java.util.Objects.requireNonNull; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +@ConfigurationProperties(prefix = OAuth2Properties.PREFIX) +final class OAuth2Properties { + + static final String PREFIX = "sechub.security.oauth2"; + private static final String ERR_MSG_FORMAT = "The property '%s.%s' must not be null"; + + private final String clientId; + private final String clientSecret; + private final String provider; + private final String redirectUri; + private final String issuerUri; + private final String authorizationUri; + private final String tokenUri; + private final String userInfoUri; + private final String jwkSetUri; + + /* @formatter:off */ + @ConstructorBinding + OAuth2Properties(String clientId, + String clientSecret, + String provider, + String redirectUri, + String issuerUri, + String authorizationUri, + String tokenUri, + String userInfoUri, + String jwkSetUri) { + this.clientId = requireNonNull(clientId, ERR_MSG_FORMAT.formatted(PREFIX, "client-id")); + this.clientSecret = requireNonNull(clientSecret, ERR_MSG_FORMAT.formatted(PREFIX, "client-secret"));; + this.provider = requireNonNull(provider, ERR_MSG_FORMAT.formatted(PREFIX, "provider")); + this.redirectUri = requireNonNull(redirectUri, ERR_MSG_FORMAT.formatted(PREFIX, "redirect-uri")); + this.issuerUri = requireNonNull(issuerUri, ERR_MSG_FORMAT.formatted(PREFIX, "issuer-uri")); + this.authorizationUri = requireNonNull(authorizationUri, ERR_MSG_FORMAT.formatted(PREFIX, "authorization-uri")); + this.tokenUri = requireNonNull(tokenUri, ERR_MSG_FORMAT.formatted(PREFIX, "token-uri")); + this.userInfoUri = requireNonNull(userInfoUri, ERR_MSG_FORMAT.formatted(PREFIX, "user-info-uri")); + this.jwkSetUri = requireNonNull(jwkSetUri, ERR_MSG_FORMAT.formatted(PREFIX, "jwk-set-uri")); + } + /* @formatter:on */ + + String getClientId() { + return clientId; + } + + String getClientSecret() { + return clientSecret; + } + + String getProvider() { + return provider; + } + + String getRedirectUri() { + return redirectUri; + } + + String getIssuerUri() { + return issuerUri; + } + + String getAuthorizationUri() { + return authorizationUri; + } + + String getTokenUri() { + return tokenUri; + } + + String getUserInfoUri() { + return userInfoUri; + } + + String getJwkSetUri() { + return jwkSetUri; + } +} \ No newline at end of file diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/OAuth2PropertiesConfig.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/OAuth2PropertiesConfig.java new file mode 100644 index 0000000000..db08b4f4c9 --- /dev/null +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/OAuth2PropertiesConfig.java @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.security; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +import com.mercedesbenz.sechub.webui.ApplicationProfiles; + +/** + * The + * {@link org.springframework.boot.context.properties.ConfigurationProperties} + * annotation does not support the {@link Profile} annotation. To ensure that + * the properties are only loaded when the + * {@link ApplicationProfiles#OAUTH2_ENABLED} profile is active, this separate + * configuration class is created with the {@link Profile} annotation. + * + * @author hamidonos + */ +@Configuration +@Profile(ApplicationProfiles.OAUTH2_ENABLED) +@EnableConfigurationProperties(OAuth2Properties.class) +class OAuth2PropertiesConfig { +} diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/SecurityConfiguration.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/SecurityConfiguration.java index fc7d814296..a5b0ed4c22 100644 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/SecurityConfiguration.java +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/security/SecurityConfiguration.java @@ -1,49 +1,117 @@ // SPDX-License-Identifier: MIT package com.mercedesbenz.sechub.webui.security; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; -import org.springframework.security.config.web.server.ServerHttpSecurity; -import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; -import org.springframework.security.web.server.SecurityWebFilterChain; -import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; +import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; +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.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.web.client.RestTemplate; -import com.mercedesbenz.sechub.webui.page.user.UserDetailInformationService; +import com.mercedesbenz.sechub.webui.ApplicationProfiles; +import com.mercedesbenz.sechub.webui.RequestConstants; @Configuration -@EnableWebFluxSecurity -public class SecurityConfiguration { - @Autowired - UserDetailInformationService userDetailInformationService; +@EnableWebSecurity +@EnableMethodSecurity +class SecurityConfiguration { + private static final String[] PUBLIC_PATHS = { RequestConstants.LOGIN_CLASSIC, RequestConstants.LOGIN_OAUTH2, "/css/**", "/js/**", "/images/**" }; + private static final String SCOPE = "openid"; + private static final String USER_NAME_ATTRIBUTE_NAME = "sub"; + + private final Environment environment; + private final OAuth2Properties oAuth2Properties; + + SecurityConfiguration(@Autowired Environment environment, @Autowired(required = false) OAuth2Properties oAuth2Properties) { + this.environment = environment; + if (isOAuth2Enabled() && oAuth2Properties == null) { + throw new NoSuchBeanDefinitionException( + "No qualifying bean of type 'OAuth2Properties' available: expected at least 1 bean which qualifies as autowire candidate."); + } + this.oAuth2Properties = oAuth2Properties; + } + + @Bean + @Profile(ApplicationProfiles.OAUTH2_ENABLED) + ClientRegistrationRepository clientRegistrationRepository() { + /* @formatter:off */ + ClientRegistration clientRegistration = ClientRegistration + .withRegistrationId(oAuth2Properties.getProvider()) + .clientId(oAuth2Properties.getClientId()) + .clientSecret(oAuth2Properties.getClientSecret()) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(oAuth2Properties.getRedirectUri()) + .issuerUri(oAuth2Properties.getIssuerUri()).scope(SCOPE) + .authorizationUri(oAuth2Properties.getAuthorizationUri()) + .tokenUri(oAuth2Properties.getTokenUri()) + .userInfoUri(oAuth2Properties.getUserInfoUri()) + .jwkSetUri(oAuth2Properties.getJwkSetUri()) + .userNameAttributeName(USER_NAME_ATTRIBUTE_NAME) + .build(); + /* @formatter:on */ + + return new InMemoryClientRegistrationRepository(clientRegistration); + } @Bean - public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity httpSecurity) { + SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { + AuthenticationSuccessHandler authenticationSuccessHandler = new LoginAuthenticationSuccessHandler(); /* @formatter:off */ - httpSecurity. - authorizeExchange(exchanges -> exchanges. - pathMatchers("/css/**", "/js/**", "/images/**").permitAll(). - pathMatchers("/login").permitAll(). - anyExchange().authenticated() - ). - formLogin(formLogin -> formLogin. - loginPage("/login") - ). - logout(logout -> logout. - logoutUrl("/logout"). - requiresLogout(ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/logout")) - ). - csrf((csrf) -> csrf.disable() // CSRF protection disabled. The CookieServerCsrfTokenRepository does not work, since Spring Boot 3 - ); - /* @formatter:on */ + + httpSecurity + /* Disable CSRF */ + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(exchanges -> exchanges + /* Allow access to public paths */ + .requestMatchers(PUBLIC_PATHS).permitAll() + /* Protect all other paths */ + .anyRequest().authenticated() + ) + /* Enable stateful sessions */ + .sessionManagement(httpSecuritySessionManagementConfigurer -> httpSecuritySessionManagementConfigurer + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); + + if (isOAuth2Enabled()) { + RestTemplate restTemplate = new RestTemplate(); + Base64EncodedClientIdAndSecretOAuth2AccessTokenClient base64EncodedClientIdAndSecretOAuth2AccessTokenClient = new Base64EncodedClientIdAndSecretOAuth2AccessTokenClient(restTemplate); + /* Enable OAuth2 */ + httpSecurity.oauth2Login(oauth2 -> oauth2 + .loginPage(RequestConstants.LOGIN_OAUTH2) + .tokenEndpoint(token -> token.accessTokenResponseClient(base64EncodedClientIdAndSecretOAuth2AccessTokenClient)) + .successHandler(authenticationSuccessHandler)); + } + + /* + Enable Form Login + Note: This must be the last configuration in order to set the default 'loginPage' to oAuth2 + because spring uses the 'loginPage' from the first authentication method configured + */ + httpSecurity + .formLogin(form -> form + .loginPage(RequestConstants.LOGIN_CLASSIC) + .permitAll() + .successHandler(authenticationSuccessHandler)); + + /* @formatter:on */ + return httpSecurity.build(); } - @Bean - public MapReactiveUserDetailsService userDetailsService() { - return new MapReactiveUserDetailsService(userDetailInformationService.getUser(), userDetailInformationService.getUser()); + private boolean isOAuth2Enabled() { + return environment.matchesProfiles(ApplicationProfiles.OAUTH2_ENABLED); } + } diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/user/UserDetailInformationService.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/user/UserDetailInformationService.java similarity index 93% rename from sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/user/UserDetailInformationService.java rename to sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/user/UserDetailInformationService.java index 3400bbc5f5..42afd54caf 100644 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/user/UserDetailInformationService.java +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/user/UserDetailInformationService.java @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page.user; +package com.mercedesbenz.sechub.webui.user; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; diff --git a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/user/UserInfoService.java b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/user/UserInfoService.java similarity index 95% rename from sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/user/UserInfoService.java rename to sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/user/UserInfoService.java index 766ed0f020..12318d36e0 100644 --- a/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/page/user/UserInfoService.java +++ b/sechub-webui/src/main/java/com/mercedesbenz/sechub/webui/user/UserInfoService.java @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page.user; +package com.mercedesbenz.sechub.webui.user; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; diff --git a/sechub-webui/src/main/resources/application-webui_mocked.yml b/sechub-webui/src/main/resources/application-basic-auth-mocked.yml similarity index 74% rename from sechub-webui/src/main/resources/application-webui_mocked.yml rename to sechub-webui/src/main/resources/application-basic-auth-mocked.yml index 95e631b816..983a955dc7 100644 --- a/sechub-webui/src/main/resources/application-webui_mocked.yml +++ b/sechub-webui/src/main/resources/application-basic-auth-mocked.yml @@ -6,3 +6,9 @@ webui: ## Mocked sechub user (necessary for credentialinjection, we have no defaults...) userid: "mocked-user" apitoken: "mocked-apitoken" + +spring: + security: + user: + name: sechub + password: password \ No newline at end of file diff --git a/sechub-webui/src/main/resources/application-webui_integrationtest-data.yml b/sechub-webui/src/main/resources/application-integrationtest-data.yml similarity index 100% rename from sechub-webui/src/main/resources/application-webui_integrationtest-data.yml rename to sechub-webui/src/main/resources/application-integrationtest-data.yml diff --git a/sechub-webui/src/main/resources/application-local.yml b/sechub-webui/src/main/resources/application-local.yml new file mode 100644 index 0000000000..4a0591d0a0 --- /dev/null +++ b/sechub-webui/src/main/resources/application-local.yml @@ -0,0 +1,9 @@ +# Utility file to load user specific configuration for local development +# Define a profile for your local system user by creating a file named application-local.${USER}.yml (if needed) +# ${USER} is the value of your system username (e.g. application-local.JOHNDOE.yml) +# Note that all application-local.${USER}.yml files are ignored by git + +spring: + config: + import: + - optional:classpath:application-local.${USER}.yml \ No newline at end of file diff --git a/sechub-webui/src/main/resources/application-webui_localserver.yml b/sechub-webui/src/main/resources/application-ssl-cert-provided.yml similarity index 79% rename from sechub-webui/src/main/resources/application-webui_localserver.yml rename to sechub-webui/src/main/resources/application-ssl-cert-provided.yml index 1c2f4cc59c..958242c602 100644 --- a/sechub-webui/src/main/resources/application-webui_localserver.yml +++ b/sechub-webui/src/main/resources/application-ssl-cert-provided.yml @@ -1,4 +1,6 @@ # SPDX-License-Identifier: MIT +# This configuration is used for development and local testing. It uses a self-signed certificate provided by the build. + server: ssl: key-store-type: 'PKCS12' diff --git a/sechub-webui/src/main/resources/application-webui_server.yml b/sechub-webui/src/main/resources/application-ssl-cert-required.yml similarity index 69% rename from sechub-webui/src/main/resources/application-webui_server.yml rename to sechub-webui/src/main/resources/application-ssl-cert-required.yml index 70caeaaba8..a0ce3c19ef 100644 --- a/sechub-webui/src/main/resources/application-webui_server.yml +++ b/sechub-webui/src/main/resources/application-ssl-cert-required.yml @@ -1,4 +1,6 @@ # SPDX-License-Identifier: MIT +# This configuration is used for prod and int. It requires a valid certificate to be provided through the environment variables. + server: ssl: keyStoreType: diff --git a/sechub-webui/src/main/resources/application-webui_test.yml b/sechub-webui/src/main/resources/application-webui_test.yml deleted file mode 100644 index 05d1a2afbe..0000000000 --- a/sechub-webui/src/main/resources/application-webui_test.yml +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-License-Identifier: MIT -server: - ssl: - enabled: false \ No newline at end of file diff --git a/sechub-webui/src/main/resources/application.yml b/sechub-webui/src/main/resources/application.yml index 8a0261ee24..c1dc9e877c 100644 --- a/sechub-webui/src/main/resources/application.yml +++ b/sechub-webui/src/main/resources/application.yml @@ -9,12 +9,24 @@ server: protocol: TLS enabled-protocols: TLSv1.2,TLSv1.3 + spring: messages: basename: "i18n/messages" profiles: group: - webui_prod: "webui_server" - webui_dev: "webui_localserver,webui_mocked" - webui_test: "webui_test" - webui_integrationtest: "webui_localserver,webui_integrationtest-data" + webui_prod: "ssl-cert-required,oauth2-enabled" + webui_int: "ssl-cert-required,oauth2-enabled,basic-auth-mocked" + webui_dev: "ssl-cert-provided,basic-auth-mocked" + webui_local: "ssl-cert-provided,basic-auth-mocked,local" + webui_test: "test" + webui_integrationtest: "ssl-cert-provided,integrationtest-data" + web: + resources: + static-locations: classpath:/static + +logging: + level: + org: + springframework: + security: DEBUG diff --git a/sechub-webui/src/main/resources/templates/home.html b/sechub-webui/src/main/resources/templates/home.html new file mode 100644 index 0000000000..d57369166d --- /dev/null +++ b/sechub-webui/src/main/resources/templates/home.html @@ -0,0 +1,47 @@ + + + + + + SecHub WebUI + + + + +
+

Welcome to the SecHub Web-UI UNDEFINED!

+ SecHub Logo +

This site is under construction. Please check back later.

+
+ + \ No newline at end of file diff --git a/sechub-webui/src/main/resources/templates/login-classic.html b/sechub-webui/src/main/resources/templates/login-classic.html new file mode 100644 index 0000000000..3283c29852 --- /dev/null +++ b/sechub-webui/src/main/resources/templates/login-classic.html @@ -0,0 +1,43 @@ + + + + SecHub WebUI Login + + +
+
+
+

Classic Login

+
+
+
+ +
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+
+ +
+

Invalid username or password.

+
+
+ + diff --git a/sechub-webui/src/main/resources/templates/login-oauth2.html b/sechub-webui/src/main/resources/templates/login-oauth2.html new file mode 100644 index 0000000000..23270b9177 --- /dev/null +++ b/sechub-webui/src/main/resources/templates/login-oauth2.html @@ -0,0 +1,54 @@ + + + + + + SecHub WebUI + + + + +
+

Login to the SecHub WebUI with OAuth2

+

+
+ + \ No newline at end of file diff --git a/sechub-webui/src/main/resources/templates/login.html b/sechub-webui/src/main/resources/templates/login.html deleted file mode 100644 index 118cd0ccdc..0000000000 --- a/sechub-webui/src/main/resources/templates/login.html +++ /dev/null @@ -1,27 +0,0 @@ - - - -Please Log In - - - -
-
-

Please Log In

-
Invalid user name or password.
-
You have been logged out.
-
-
- -
-
- -
- -
-
- - \ No newline at end of file diff --git a/sechub-webui/src/main/resources/templates/project-scans.html b/sechub-webui/src/main/resources/templates/project-scans.html deleted file mode 100644 index 3940ed6500..0000000000 --- a/sechub-webui/src/main/resources/templates/project-scans.html +++ /dev/null @@ -1,40 +0,0 @@ - - - -Projects - - -
- -
-

Scans: Project 1x

- - - - - - - - - - - - - - - - - - - - - - - - - -
DateJob UUIDDownload
23.04.2022b9738d0e-a913-11ec-bd6c-c3a099311e89Download
22.04.2022f79ba56c-a913-11ec-bb97-93283fb5d88bDownload
04.04.202212c32504-a914-11ec-91ea-6b1199e06e4eDownload
-
-
- - \ No newline at end of file diff --git a/sechub-webui/src/main/resources/templates/projects.html b/sechub-webui/src/main/resources/templates/projects.html deleted file mode 100644 index 7ae783af46..0000000000 --- a/sechub-webui/src/main/resources/templates/projects.html +++ /dev/null @@ -1,21 +0,0 @@ - - - -Projects - - -
- - - -
-

Projects

- -
- -
- - \ No newline at end of file diff --git a/sechub-webui/src/main/resources/templates/status.html b/sechub-webui/src/main/resources/templates/status.html deleted file mode 100644 index 1379904aea..0000000000 --- a/sechub-webui/src/main/resources/templates/status.html +++ /dev/null @@ -1,81 +0,0 @@ - - - - - Status - - - -
- -
-

Status

- -
-

SecHub Server

- - - - - - - - - - - - - - - - - - - - -
URLServer URL
Statusunavailable
SchedulerdisabledERROR: No SecHub status available!
Version0.0.0
-
- -
-
-

Jobs

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Running99
Waiting99
Initializating99
Cancel requested9
Canceled9
Ended9
Jobs all999
-
-
-
-
- - - \ No newline at end of file diff --git a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/SecHubWebUiApplicationSpringBootTest.java b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/SecHubWebUiApplicationSpringBootTest.java deleted file mode 100644 index 9f0151fe36..0000000000 --- a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/SecHubWebUiApplicationSpringBootTest.java +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.ApplicationContext; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.reactive.server.WebTestClient; - -import com.mercedesbenz.sechub.webui.sechubaccess.SecHubAccessService; - -/* - * This test launches a real HTTP Server (Netty) during the test. - * - * The RANDOM_PORT setting starts the test application on an available port on the system. - * - * The operating system is responsible for allocating the port and it is guaranteed to be available (source: https://stackoverflow.com/a/48923117). - * As a result, there should never be any conflict with the ports, even if tests are running in parallel. - * - * The random port is injected into the WebTestClient by default. - * In addition, one can get the port using the @LocalServerPort annotation. - */ -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -@AutoConfigureWebTestClient -@ActiveProfiles("webui_test") -class SecHubWebUiApplicationSpringBootTest { - - @Autowired - private WebTestClient webTestClient; - - @MockBean - private SecHubAccessService mockAccessService; - - @Test - void contextLoads(ApplicationContext context) { - assertNotNull(context); - } - - @Test - void index() throws Exception { - webTestClient.get().uri("/").exchange().expectStatus().isFound(); - } -} diff --git a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/YamlPropertyLoaderFactory.java b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/YamlPropertyLoaderFactory.java new file mode 100644 index 0000000000..d9f0899f02 --- /dev/null +++ b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/YamlPropertyLoaderFactory.java @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui; + +import java.io.IOException; +import java.util.List; + +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.DefaultPropertySourceFactory; +import org.springframework.core.io.support.EncodedResource; +import org.springframework.lang.Nullable; + +/** + * Spring Boot's default property source factory does not support YAML files + * directly in `@TestPropertySource`. By using this custom factory, we can load + * properties from YAML files, which is often more convenient and readable for + * complex configurations. + * + * @author hamidonos + */ +public class YamlPropertyLoaderFactory extends DefaultPropertySourceFactory { + + @Override + public PropertySource createPropertySource(@Nullable String name, EncodedResource resource) throws IOException { + Resource actualResource = resource.getResource(); + if (actualResource.exists()) { + YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + List> propertySources = loader.load(name != null ? name : actualResource.getFilename(), actualResource); + return propertySources.isEmpty() ? null : propertySources.get(0); + } + return super.createPropertySource(name, resource); + } +} \ No newline at end of file diff --git a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/HomeControllerTest.java b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/HomeControllerTest.java new file mode 100644 index 0000000000..016a8099ec --- /dev/null +++ b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/HomeControllerTest.java @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.page; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import com.mercedesbenz.sechub.webui.YamlPropertyLoaderFactory; +import com.mercedesbenz.sechub.webui.security.SecurityTestConfiguration; + +@WebMvcTest(HomeController.class) +@Import(SecurityTestConfiguration.class) +@TestPropertySource(locations = "classpath:application-test.yml", factory = YamlPropertyLoaderFactory.class) +class HomeControllerTest { + + private final MockMvc mockMvc; + + @Autowired + public HomeControllerTest(MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + @Test + void home_page_is_not_accessible_anonymously() throws Exception { + /* @formatter:off */ + mockMvc + .perform(get("/home")) + .andExpect(status().is3xxRedirection()); + /* @formatter:on */ + } + + @Test + @WithMockUser + void home_page_is_accessible_with_authenticated_user() throws Exception { + /* @formatter:off */ + mockMvc + .perform(get("/home")) + .andExpect(status().isOk()); + /* @formatter:on */ + } +} diff --git a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/LoginClassicControllerTest.java b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/LoginClassicControllerTest.java new file mode 100644 index 0000000000..68e692bbfa --- /dev/null +++ b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/LoginClassicControllerTest.java @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.page; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import com.mercedesbenz.sechub.webui.YamlPropertyLoaderFactory; +import com.mercedesbenz.sechub.webui.security.SecurityTestConfiguration; + +@WebMvcTest(LoginClassicController.class) +@Import(SecurityTestConfiguration.class) +@TestPropertySource(locations = "classpath:application-test.yml", factory = YamlPropertyLoaderFactory.class) +class LoginClassicControllerTest { + + private final MockMvc mockMvc; + + @Autowired + LoginClassicControllerTest(MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + @Test + void login_classic_page_is_accessible_anonymously() throws Exception { + /* @formatter:off */ + mockMvc + .perform(get("/login/classic")) + .andExpect(status().isOk()); + /* @formatter:on */ + } +} diff --git a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/LoginControllerTest.java b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/LoginControllerTest.java deleted file mode 100644 index f55d78025f..0000000000 --- a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/LoginControllerTest.java +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.web.reactive.server.WebTestClient; - -import com.mercedesbenz.sechub.webui.sechubaccess.SecHubAccessService; - -/* - * No HTTP Server will be started for this test - * for more details see: https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#mock-objects-web-reactive - */ -@WebFluxTest(controllers = LoginController.class) -public class LoginControllerTest { - - @Autowired - private WebTestClient webTestClient; - - @MockBean - private SecHubAccessService mockAccessService; - - @Test - void login() throws Exception { - webTestClient.get().uri("/login").exchange().expectStatus().isOk(); - } -} diff --git a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/LoginOAuth2ControllerTest.java b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/LoginOAuth2ControllerTest.java new file mode 100644 index 0000000000..0cc8ea140c --- /dev/null +++ b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/LoginOAuth2ControllerTest.java @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.page; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import com.mercedesbenz.sechub.webui.YamlPropertyLoaderFactory; +import com.mercedesbenz.sechub.webui.security.SecurityTestConfiguration; + +@WebMvcTest(LoginOAuth2Controller.class) +@Import(SecurityTestConfiguration.class) +@ActiveProfiles("oauth2-enabled") +@TestPropertySource(locations = "classpath:application-test.yml", factory = YamlPropertyLoaderFactory.class) +class LoginOAuth2ControllerTest { + + private final MockMvc mockMvc; + + @Autowired + LoginOAuth2ControllerTest(MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + @Test + void login_o_auth_2_page_is_accessible_anonymously() throws Exception { + /* @formatter:off */ + mockMvc + .perform(get("/login/oauth2")) + .andExpect(status().isOk()); + /* @formatter:on */ + } +} diff --git a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/project/ProjectsControllerTest.java b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/project/ProjectsControllerTest.java deleted file mode 100644 index 74b5dc6fe4..0000000000 --- a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/page/project/ProjectsControllerTest.java +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: MIT -package com.mercedesbenz.sechub.webui.page.project; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.web.reactive.server.WebTestClient; - -import com.mercedesbenz.sechub.webui.sechubaccess.SecHubAccessService; - -/* - * No HTTP Server will be started for this test - * for more details see: https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#mock-objects-web-reactive - */ -@WebFluxTest(controllers = ProjectsController.class) -public class ProjectsControllerTest { - @Autowired - private WebTestClient webTestClient; - - @MockBean - private SecHubAccessService mockAccessService; - - @MockBean - private ProjectInfoService projectInfoService; - - @Test - void index() throws Exception { - webTestClient.get().uri("/").exchange().expectStatus().isUnauthorized(); - } - - @Test - void projects() throws Exception { - webTestClient.get().uri("/projects").exchange().expectStatus().isUnauthorized(); - } -} diff --git a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/Base64EncodedClientIdAndSecretOAuth2AccessTokenClientTest.java b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/Base64EncodedClientIdAndSecretOAuth2AccessTokenClientTest.java new file mode 100644 index 0000000000..2602f88448 --- /dev/null +++ b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/Base64EncodedClientIdAndSecretOAuth2AccessTokenClientTest.java @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.security; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.Base64; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentest4j.TestAbortedException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; + +import com.jayway.jsonpath.JsonPath; + +class Base64EncodedClientIdAndSecretOAuth2AccessTokenClientTest { + + // @formatter:off + private static final RestTemplate restTemplate = new RestTemplate(); + private static final MockRestServiceServer mockServer = MockRestServiceServer.createServer(restTemplate); + private static final Base64EncodedClientIdAndSecretOAuth2AccessTokenClient client = new Base64EncodedClientIdAndSecretOAuth2AccessTokenClient(restTemplate); + private static final ClientRegistration clientRegistration = ClientRegistration.withRegistrationId(Constants.REGISTRATION_ID) + .clientId(Constants.CLIENT_ID) + .clientSecret(Constants.CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(Constants.REDIRECT_URI) + .tokenUri(Constants.TOKEN_URI) + .authorizationUri(Constants.AUTHORIZATION_URI) + .build(); + private static final OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(Constants.AUTHORIZATION_URI) + .clientId(Constants.CLIENT_ID) + .redirectUri(Constants.REDIRECT_URI) + .scopes(Set.of(Constants.OPENID)) + .state(Constants.STATE) + .build(); + private static final OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success(Constants.CODE) + .redirectUri(Constants.REDIRECT_URI) + .state(Constants.STATE) + .build(); + // @formatter:on + private static final OAuth2AuthorizationExchange authorizationExchange = new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse); + private static final OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest = new OAuth2AuthorizationCodeGrantRequest(clientRegistration, + authorizationExchange); + private static final String jwtResponseJson; + + static { + try { + jwtResponseJson = Files.readString(Paths.get("src/test/resources/jwt-response.json")); + } catch (IOException e) { + throw new TestAbortedException("Failed to prepare test", e); + } + } + + @BeforeEach + void beforeEach() { + mockServer.reset(); + } + + @Test + void get_token_response_executes_correctly_formatted_http_request() { + // prepare + String tokenUri = clientRegistration.getProviderDetails().getTokenUri(); + String authorizationHeaderValue = getBasicAuthHeaderValue(clientRegistration.getClientId(), clientRegistration.getClientSecret()); + // @formatter:off + mockServer.expect(requestTo(tokenUri)) + .andExpect(method(HttpMethod.POST)) + .andExpect(header(HttpHeaders.CONTENT_TYPE, Constants.APPLICATION_FORM_URLENCODED_VALUE)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authorizationHeaderValue)) + .andExpect(content().formData(getMultiValueMap(authorizationCodeGrantRequest))) + .andRespond(withSuccess(jwtResponseJson, MediaType.APPLICATION_JSON)); + // @formatter:on + + // execute + client.getTokenResponse(authorizationCodeGrantRequest); + + // verify + mockServer.verify(); + } + + @Test + void get_token_response_returns_o_auth_access_token_as_expected() { + // prepare + String tokenUri = clientRegistration.getProviderDetails().getTokenUri(); + // @formatter:off + mockServer.expect(requestTo(tokenUri)) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess(jwtResponseJson, MediaType.APPLICATION_JSON)); + // @formatter:on + + // execute + OAuth2AccessTokenResponse oAuth2AccessTokenResponse = client.getTokenResponse(authorizationCodeGrantRequest); + + // verify + mockServer.verify(); + assertThat(oAuth2AccessTokenResponse.getAccessToken().getTokenValue()).isEqualTo(JsonPath.read(jwtResponseJson, "$.access_token")); + assertThat(oAuth2AccessTokenResponse.getAccessToken().getTokenType().getValue()).isEqualTo(JsonPath.read(jwtResponseJson, "$.token_type")); + assertThat(oAuth2AccessTokenResponse.getAdditionalParameters().get(Constants.ID_TOKEN)).isEqualTo(JsonPath.read(jwtResponseJson, "$.id_token")); + assertThat(oAuth2AccessTokenResponse.getRefreshToken()).isNotNull(); + assertThat(oAuth2AccessTokenResponse.getRefreshToken().getTokenValue()).isEqualTo(JsonPath.read(jwtResponseJson, "$.refresh_token")); + int expiresIn = JsonPath.read(jwtResponseJson, "$.expires_in"); + long expiresInLong = Long.parseLong(String.valueOf(expiresIn)); + assertThat(oAuth2AccessTokenResponse.getAccessToken().getExpiresAt()) + .isAfterOrEqualTo(Instant.now().minus(expiresInLong, java.time.temporal.ChronoUnit.SECONDS)); + } + + @Test + void get_token_response_handles_rest_client_exception_well() { + // prepare + String tokenUri = clientRegistration.getProviderDetails().getTokenUri(); + // @formatter:off + mockServer.expect(requestTo(tokenUri)) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess("", MediaType.APPLICATION_JSON)); + + // execute & assert + + assertThatThrownBy(() -> client.getTokenResponse(authorizationCodeGrantRequest)) + .isExactlyInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Failed to get JWT token response"); + // @formatter:on + } + + private static MultiValueMap getMultiValueMap(OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest) { + MultiValueMap formParameters = new LinkedMultiValueMap<>(); + OAuth2AuthorizationExchange authorizationExchange = authorizationGrantRequest.getAuthorizationExchange(); + String code = authorizationExchange.getAuthorizationResponse().getCode(); + String redirectUri = authorizationExchange.getAuthorizationRequest().getRedirectUri(); + + formParameters.add(OAuth2ParameterNames.GRANT_TYPE, Constants.GRANT_TYPE_VALUE); + formParameters.add(OAuth2ParameterNames.CODE, code); + formParameters.add(OAuth2ParameterNames.REDIRECT_URI, redirectUri); + + return formParameters; + } + + private static String getBasicAuthHeaderValue(String clientId, String clientSecret) { + String clientIdClientSecret = Constants.CLIENT_ID_CLIENT_SECRET_FORMAT.formatted(clientId, clientSecret); + String clientIdClientSecretB64Encoded = Base64.getEncoder().encodeToString(clientIdClientSecret.getBytes()); + return String.format(Constants.BASIC_AUTHORIZATION_HEADER_VALUE_FORMAT, clientIdClientSecretB64Encoded); + } + + private static final class Constants { + private static final String REGISTRATION_ID = "registration-id"; + private static final String CLIENT_ID = "client-id"; + private static final String CLIENT_SECRET = "client-secret"; + private static final String GRANT_TYPE_VALUE = "authorization_code"; + private static final String BASIC_AUTHORIZATION_HEADER_VALUE_FORMAT = "Basic %s"; + private static final String CLIENT_ID_CLIENT_SECRET_FORMAT = "%s:%s"; + private static final String APPLICATION_FORM_URLENCODED_VALUE = "%s;charset=%s".formatted(MediaType.APPLICATION_FORM_URLENCODED_VALUE, + StandardCharsets.UTF_8); + private static final String REDIRECT_URI = "http://localhost:8080/login/oauth2/code/registration-id"; + private static final String TOKEN_URI = "https://localhost:8080/oauth2/token"; + private static final String AUTHORIZATION_URI = "https://localhost:8080/oauth2/authorize"; + private static final String STATE = "state"; + private static final String CODE = "code"; + private static final String OPENID = "openid"; + private static final String ID_TOKEN = "id_token"; + + } +} \ No newline at end of file diff --git a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/JwtResponseTest.java b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/JwtResponseTest.java new file mode 100644 index 0000000000..828baf7cc0 --- /dev/null +++ b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/JwtResponseTest.java @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.opentest4j.TestAbortedException; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.ValueInstantiationException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.jayway.jsonpath.JsonPath; + +class JwtResponseTest { + + private static final String jwtResponseJson; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + static { + try { + jwtResponseJson = Files.readString(Paths.get("src/test/resources/jwt-response.json")); + } catch (IOException e) { + throw new TestAbortedException("Failed to prepare test", e); + } + } + + @Test + void construct_jwt_response_from_valid_json_is_successful() throws JsonProcessingException { + // execute + JwtResponse jwtResponse = objectMapper.readValue(jwtResponseJson, JwtResponse.class); + + // assert + assertThat(jwtResponse).isNotNull(); + assertThat(jwtResponse.getAccessToken()).isEqualTo(JsonPath.read(jwtResponseJson, "$.access_token")); + assertThat(jwtResponse.getRefreshToken()).isEqualTo(JsonPath.read(jwtResponseJson, "$.refresh_token")); + assertThat(jwtResponse.getIdToken()).isEqualTo(JsonPath.read(jwtResponseJson, "$.id_token")); + int expiresIn = JsonPath.read(jwtResponseJson, "$.expires_in"); + long expiresInLong = Long.parseLong(String.valueOf(expiresIn)); + assertThat(jwtResponse.getExpiresIn()).isEqualTo(expiresInLong); + assertThat(jwtResponse.getTokenType()).isEqualTo(JsonPath.read(jwtResponseJson, "$.token_type")); + } + + @Test + void construct_jwt_response_from_valid_json_with_no_refresh_token_is_successful() throws JsonProcessingException { + // prepare + String jwtResponseJsonWithoutRefreshToken = removeJsonKeyAndValue("refresh_token"); + + // execute & assert + + // @formatter:off + assertDoesNotThrow(() -> { + JwtResponse jwtResponse = objectMapper.readValue(jwtResponseJsonWithoutRefreshToken, JwtResponse.class); + assertThat(jwtResponse.getRefreshToken()).isNull(); + }); + // @formatter:on + } + + @ParameterizedTest + @ArgumentsSource(InvalidJwtResponseJsonProvider.class) + void construct_jwt_response_from_invalid_json_fails(String invalidJwtResponseJson, String expectedErrMsg) { + // execute & assert + + // @formatter:off + assertThatThrownBy(() -> objectMapper.readValue(invalidJwtResponseJson, JwtResponse.class)) + .isInstanceOf(ValueInstantiationException.class) + .hasMessageContaining(expectedErrMsg); + // @formatter:on + } + + private static String removeJsonKeyAndValue(String key) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode rootNode = objectMapper.readTree(jwtResponseJson); + + if (rootNode instanceof ObjectNode) { + ((ObjectNode) rootNode).remove(key); + } else { + throw new IllegalArgumentException("Invalid JSON"); + } + + return objectMapper.writeValueAsString(rootNode); + } + + private static class InvalidJwtResponseJsonProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(ExtensionContext extensionContext) throws Exception { + return Stream.of(Arguments.of(removeJsonKeyAndValue("access_token"), "access_token must not be null"), + Arguments.of(removeJsonKeyAndValue("token_type"), "token_type must not be null"), + Arguments.of(removeJsonKeyAndValue("id_token"), "id_token must not be null"), + Arguments.of(removeJsonKeyAndValue("expires_in"), "expires_in must not be null")); + } + } +} diff --git a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/OAuth2PropertiesTest.java b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/OAuth2PropertiesTest.java new file mode 100644 index 0000000000..1a748186ee --- /dev/null +++ b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/OAuth2PropertiesTest.java @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import com.mercedesbenz.sechub.webui.YamlPropertyLoaderFactory; + +@SpringBootTest +@ActiveProfiles("oauth2-enabled") +@TestPropertySource(locations = "classpath:application-test.yml", factory = YamlPropertyLoaderFactory.class) +class OAuth2PropertiesTest { + + private static final String ERR_MSG_FORMAT = "The property 'sechub.security.oauth2.%s' must not be null"; + + private final OAuth2Properties properties; + + @Autowired + OAuth2PropertiesTest(OAuth2Properties properties) { + this.properties = properties; + } + + @Test + void construct_o_auth_2_properties_with_valid_properties_file_succeeds() { + assertThat(properties.getClientId()).isEqualTo("client-id"); + assertThat(properties.getClientSecret()).isEqualTo("client-secret"); + assertThat(properties.getProvider()).isEqualTo("provider"); + assertThat(properties.getRedirectUri()).isEqualTo("redirect-uri"); + assertThat(properties.getIssuerUri()).isEqualTo("issuer-uri"); + assertThat(properties.getAuthorizationUri()).isEqualTo("authorization-uri"); + assertThat(properties.getTokenUri()).isEqualTo("token-uri"); + assertThat(properties.getUserInfoUri()).isEqualTo("user-info-uri"); + assertThat(properties.getJwkSetUri()).isEqualTo("jwk-set-uri"); + } + + /* @formatter:off */ + @ParameterizedTest + @ArgumentsSource(InvalidOAuth2PropertiesProvider.class) + void construct_o_auth_2_properties_with_null_property_fails(String clientId, + String clientSecret, + String provider, + String redirectUri, + String issuerUri, + String authorizationUri, + String tokenUri, + String userInfoUri, + String jwkSetUri, + String errMsg) { + assertThatThrownBy(() -> new OAuth2Properties(clientId, clientSecret, provider, redirectUri, issuerUri, authorizationUri, tokenUri, userInfoUri, jwkSetUri)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining(errMsg); + } + /* @formatter:on */ + + @Configuration + @Import(OAuth2PropertiesConfig.class) + static class TestConfig { + } + + private static class InvalidOAuth2PropertiesProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(ExtensionContext extensionContext) throws Exception { + /* @formatter:off */ + return Stream.of( + Arguments.of(null, "client-secret", "provider", "redirect-uri", "issuer-uri", "authorization-uri", "token-uri", "user-info-uri", "jwk-set-uri", ERR_MSG_FORMAT.formatted("client-id")), + Arguments.of("client-id", null, "provider", "redirect-uri", "issuer-uri", "authorization-uri", "token-uri", "user-info-uri", "jwk-set-uri", ERR_MSG_FORMAT.formatted("client-secret")), + Arguments.of("client-id", "client-secret", null, "redirect-uri", "issuer-uri", "authorization-uri", "token-uri", "user-info-uri", "jwk-set-uri", ERR_MSG_FORMAT.formatted("provider")), + Arguments.of("client-id", "client-secret", "provider", null, "issuer-uri", "authorization-uri", "token-uri", "user-info-uri", "jwk-set-uri", ERR_MSG_FORMAT.formatted("redirect-uri")), + Arguments.of("client-id", "client-secret", "provider", "redirect-uri", null, "authorization-uri", "token-uri", "user-info-uri", "jwk-set-uri", ERR_MSG_FORMAT.formatted("issuer-uri")), + Arguments.of("client-id", "client-secret", "provider", "redirect-uri", "issuer-uri", null, "token-uri", "user-info-uri", "jwk-set-uri", ERR_MSG_FORMAT.formatted("authorization-uri")), + Arguments.of("client-id", "client-secret", "provider", "redirect-uri", "issuer-uri", "authorization-uri", null, "user-info-uri", "jwk-set-uri", ERR_MSG_FORMAT.formatted("token-uri")), + Arguments.of("client-id", "client-secret", "provider", "redirect-uri", "issuer-uri", "authorization-uri", "token-uri", null, "jwk-set-uri", ERR_MSG_FORMAT.formatted("user-info-uri")), + Arguments.of("client-id", "client-secret", "provider", "redirect-uri", "issuer-uri", "authorization-uri", "token-uri", "user-info-uri", null, ERR_MSG_FORMAT.formatted("jwk-set-uri"))); + /* @formatter:on */ + } + } +} diff --git a/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/SecurityTestConfiguration.java b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/SecurityTestConfiguration.java new file mode 100644 index 0000000000..5306eaecd7 --- /dev/null +++ b/sechub-webui/src/test/java/com/mercedesbenz/sechub/webui/security/SecurityTestConfiguration.java @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +package com.mercedesbenz.sechub.webui.security; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; + +@TestConfiguration +@Import({ SecurityConfiguration.class, OAuth2PropertiesConfig.class }) +public class SecurityTestConfiguration { +} diff --git a/sechub-webui/src/test/resources/application-test.yml b/sechub-webui/src/test/resources/application-test.yml new file mode 100644 index 0000000000..211423ae47 --- /dev/null +++ b/sechub-webui/src/test/resources/application-test.yml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MIT + +sechub: + security: + oauth2: + client-id: client-id + client-secret: client-secret + provider: provider + redirect-uri: redirect-uri + issuer-uri: issuer-uri + authorization-uri: authorization-uri + token-uri: token-uri + user-info-uri: user-info-uri + jwk-set-uri: jwk-set-uri + +server: + ssl: + enabled: false \ No newline at end of file diff --git a/sechub-webui/src/test/resources/jwt-response.json b/sechub-webui/src/test/resources/jwt-response.json new file mode 100644 index 0000000000..2bec6853e9 --- /dev/null +++ b/sechub-webui/src/test/resources/jwt-response.json @@ -0,0 +1,7 @@ +{ + "access_token":"access_token", + "token_type":"Bearer", + "expires_in":3600, + "refresh_token": "refresh_token", + "id_token":"id_token" +} \ No newline at end of file