From 9918f5e0bff829b1cbea740e42b4532175038c5c Mon Sep 17 00:00:00 2001 From: antonioT90 <34568575+antonioT90@users.noreply.github.com> Date: Wed, 8 Jan 2025 12:38:38 +0100 Subject: [PATCH] feat: P4ADEV-1754 configuring inner security (#18) --- .devops/deploy-pipelines.yml | 45 +++++- build.gradle.kts | 10 +- gradle.lockfile | 10 +- helm/values-dev.yaml | 2 +- helm/values-prod.yaml | 2 +- helm/values-uat.yaml | 2 +- helm/values.yaml | 1 + ...A-PDND-Service API.postman_collection.json | 153 +++++++++++++++--- .../payhub/pdnd/config/SwaggerConfig.java | 46 +++--- .../payhub/pdnd/config/WebSecurityConfig.java | 91 +++++++++++ .../security/JwtAuthenticationFilter.java | 61 ------- .../pdnd/security/WebSecurityConfig.java | 47 ------ .../pagopa/payhub/pdnd/utils/CertUtils.java | 13 ++ .../payhub/pdnd/utils/SecurityUtils.java | 18 +++ src/main/resources/application.yml | 17 +- .../pdnd/config/WebSecurityConfigTest.java | 63 ++++++++ .../security/JwtAuthenticationFilterTest.java | 123 -------------- .../payhub/pdnd/utils/SecurityUtilsTest.java | 47 ++++++ 18 files changed, 458 insertions(+), 293 deletions(-) rename {src/test/postman => postman}/P4PA-PDND-Service API.postman_collection.json (63%) create mode 100644 src/main/java/it/gov/pagopa/payhub/pdnd/config/WebSecurityConfig.java delete mode 100644 src/main/java/it/gov/pagopa/payhub/pdnd/security/JwtAuthenticationFilter.java delete mode 100644 src/main/java/it/gov/pagopa/payhub/pdnd/security/WebSecurityConfig.java create mode 100644 src/main/java/it/gov/pagopa/payhub/pdnd/utils/SecurityUtils.java create mode 100644 src/test/java/it/gov/pagopa/payhub/pdnd/config/WebSecurityConfigTest.java delete mode 100644 src/test/java/it/gov/pagopa/payhub/pdnd/security/JwtAuthenticationFilterTest.java create mode 100644 src/test/java/it/gov/pagopa/payhub/pdnd/utils/SecurityUtilsTest.java diff --git a/.devops/deploy-pipelines.yml b/.devops/deploy-pipelines.yml index babeac7..d71f8f8 100644 --- a/.devops/deploy-pipelines.yml +++ b/.devops/deploy-pipelines.yml @@ -179,4 +179,47 @@ stages: curl -X POST \ -H "Content-type: application/json" \ --data '{"text": "*Attention: There is an error in pipeline $(System.DefinitionName) in step _deploy_!*\nCheck the logs for more details $(System.CollectionUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId) to view the build results."}' \ - $(SLACK_WEBHOOK_URL) \ No newline at end of file + $(SLACK_WEBHOOK_URL) + - stage: stage_postman_test + displayName: 'Test_e2e_on_${{ variables.environment }}' + condition: or(eq(variables.environment, 'DEV'), eq(variables.environment, 'UAT')) + jobs: + - job: 'Run_Postman_collection_on_${{ variables.environment }}' + displayName: 'Run Postman collection on ${{ variables.environment }}' + pool: + name: $(selfHostedAgentPool) + steps: + - task: NodeTool@0 + inputs: + versionSpec: '16.x' + - task: Npm@1 + displayName: Install newman + inputs: + command: custom + customCommand: install -g newman + - task: DownloadSecureFile@1 + displayName: 'download postman environment' + name: postman_env + inputs: + secureFile: $(postmanEnvFile) + - task: CmdLine@2 + displayName: Run newman + continueOnError: true # Useful to avoid the skipping of result publishing task + inputs: + script: newman run postman/P4PA-PDND-Service API.postman_collection.json -e $(postman_env.secureFilePath) --reporters cli,junit --reporter-junit-export result/test-result.xml + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: '**/test-*.xml' + searchFolder: '$(System.DefaultWorkingDirectory)/result/' + testRunTitle: 'Publish Newman Test Results' + - task: 'Bash@3' + displayName: 'Send message on Slack' + condition: in(variables['Agent.JobStatus'], 'SucceededWithIssues', 'Failed') + inputs: + targetType: 'inline' + script: > + curl -X POST \ + -H "Content-type: application/json" \ + --data '{"text": "*Attention: There is an error in pipeline $(System.DefinitionName) in step _postman test_!*\nCheck the logs for more details $(System.CollectionUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId) to view the build results."}' \ + $(SLACK_WEBHOOK_URL) diff --git a/build.gradle.kts b/build.gradle.kts index 08eba6a..5d01ce8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,17 +32,16 @@ val springDocOpenApiVersion = "2.7.0" val openApiToolsVersion = "0.2.6" val javaJwtVersion = "4.4.0" val jwksRsaVersion = "0.22.1" -val nimbusJoseJwtVersion = "9.48" -val jjwtVersion = "0.12.6" val wiremockVersion = "3.10.0" val wiremockSpringBootVersion = "2.1.3" val micrometerVersion = "1.4.1" +val bouncycastleVersion = "1.79" dependencies { implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springDocOpenApiVersion") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") implementation("org.openapitools:jackson-databind-nullable:$openApiToolsVersion") @@ -51,12 +50,13 @@ dependencies { // validation token jwt implementation("com.auth0:java-jwt:$javaJwtVersion") implementation("com.auth0:jwks-rsa:$jwksRsaVersion") - implementation("com.nimbusds:nimbus-jose-jwt:$nimbusJoseJwtVersion") - implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion") compileOnly("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") + //security + implementation("org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion") + // Testing testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") diff --git a/gradle.lockfile b/gradle.lockfile index 4de0c9d..854ae26 100644 --- a/gradle.lockfile +++ b/gradle.lockfile @@ -14,8 +14,8 @@ com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.2=compileClasspath com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2=compileClasspath com.fasterxml.jackson.module:jackson-module-parameter-names:2.18.2=compileClasspath com.fasterxml.jackson:jackson-bom:2.18.2=compileClasspath -com.nimbusds:nimbus-jose-jwt:9.48=compileClasspath -io.jsonwebtoken:jjwt-api:0.12.6=compileClasspath +com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath +com.nimbusds:nimbus-jose-jwt:9.37.3=compileClasspath io.micrometer:context-propagation:1.1.2=compileClasspath io.micrometer:micrometer-commons:1.14.2=compileClasspath io.micrometer:micrometer-core:1.14.2=compileClasspath @@ -47,6 +47,7 @@ org.apache.logging.log4j:log4j-to-slf4j:2.24.3=compileClasspath org.apache.tomcat.embed:tomcat-embed-core:10.1.34=compileClasspath org.apache.tomcat.embed:tomcat-embed-el:10.1.34=compileClasspath org.apache.tomcat.embed:tomcat-embed-websocket:10.1.34=compileClasspath +org.bouncycastle:bcprov-jdk18on:1.79=compileClasspath org.jspecify:jspecify:1.0.0=compileClasspath org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath org.projectlombok:lombok:1.18.36=compileClasspath @@ -61,7 +62,7 @@ org.springframework.boot:spring-boot-autoconfigure:3.4.1=compileClasspath org.springframework.boot:spring-boot-starter-actuator:3.4.1=compileClasspath org.springframework.boot:spring-boot-starter-json:3.4.1=compileClasspath org.springframework.boot:spring-boot-starter-logging:3.4.1=compileClasspath -org.springframework.boot:spring-boot-starter-security:3.4.1=compileClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.4.1=compileClasspath org.springframework.boot:spring-boot-starter-tomcat:3.4.1=compileClasspath org.springframework.boot:spring-boot-starter-web:3.4.1=compileClasspath org.springframework.boot:spring-boot-starter:3.4.1=compileClasspath @@ -69,6 +70,9 @@ org.springframework.boot:spring-boot:3.4.1=compileClasspath org.springframework.security:spring-security-config:6.4.2=compileClasspath org.springframework.security:spring-security-core:6.4.2=compileClasspath org.springframework.security:spring-security-crypto:6.4.2=compileClasspath +org.springframework.security:spring-security-oauth2-core:6.4.2=compileClasspath +org.springframework.security:spring-security-oauth2-jose:6.4.2=compileClasspath +org.springframework.security:spring-security-oauth2-resource-server:6.4.2=compileClasspath org.springframework.security:spring-security-web:6.4.2=compileClasspath org.springframework:spring-aop:6.2.1=compileClasspath org.springframework:spring-beans:6.2.1=compileClasspath diff --git a/helm/values-dev.yaml b/helm/values-dev.yaml index 16afb34..24be28b 100644 --- a/helm/values-dev.yaml +++ b/helm/values-dev.yaml @@ -10,7 +10,7 @@ microservice-chart: resources: requests: memory: "256Mi" - cpu: "40m" + cpu: "100m" limits: memory: "4Gi" cpu: "300m" diff --git a/helm/values-prod.yaml b/helm/values-prod.yaml index 8dd9e6e..0c3c381 100644 --- a/helm/values-prod.yaml +++ b/helm/values-prod.yaml @@ -10,7 +10,7 @@ microservice-chart: resources: requests: memory: "256Mi" - cpu: "40m" + cpu: "100m" limits: memory: "4Gi" cpu: "300m" diff --git a/helm/values-uat.yaml b/helm/values-uat.yaml index 4adce36..71e029a 100644 --- a/helm/values-uat.yaml +++ b/helm/values-uat.yaml @@ -10,7 +10,7 @@ microservice-chart: resources: requests: memory: "256Mi" - cpu: "40m" + cpu: "100m" limits: memory: "4Gi" cpu: "300m" diff --git a/helm/values.yaml b/helm/values.yaml index 396d277..583aae6 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -65,6 +65,7 @@ microservice-chart: envSecret: APPLICATIONINSIGHTS_CONNECTION_STRING: appinsights-connection-string + JWT_TOKEN_PUBLIC_KEY: jwt-public-key PDND_SERVICE_PRIVATEKEY: piattaforma-unitaria-interop-priv PDND_SERVICE_PUBLICKEY: piattaforma-unitaria-interop-pub diff --git a/src/test/postman/P4PA-PDND-Service API.postman_collection.json b/postman/P4PA-PDND-Service API.postman_collection.json similarity index 63% rename from src/test/postman/P4PA-PDND-Service API.postman_collection.json rename to postman/P4PA-PDND-Service API.postman_collection.json index fb2d351..24c29ff 100644 --- a/src/test/postman/P4PA-PDND-Service API.postman_collection.json +++ b/postman/P4PA-PDND-Service API.postman_collection.json @@ -1,25 +1,40 @@ { "info": { - "_postman_id": "07c1f103-1e5f-44fe-a5e3-00126c6ece77", + "_postman_id": "2474956e-ea82-4ca1-a3f4-19579b4b7f67", "name": "P4PA-PDND-Service API", "description": "API and Models.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "29646859", - "_collection_link": "https://crimson-zodiac-456704.postman.co/workspace/Personal-Workspace~7804a326-503c-4623-9152-3f4c38f2d060/collection/29646859-07c1f103-1e5f-44fe-a5e3-00126c6ece77?action=share&source=collection_link&creator=29646859" + "_exporter_id": "15747968", + "_collection_link": "https://warped-astronaut-141685.postman.co/workspace/P4PA~9a8b7dd5-97b6-4dd0-b3f5-95f25fd0b455/collection/15747968-2474956e-ea82-4ca1-a3f4-19579b4b7f67?action=share&source=collection_link&creator=15747968" }, "item": [ { - "name": "01.token", + "name": "00_login", "item": [ { - "name": "01_getAuthToken", + "name": "00_authtoken jwt", "event": [ { "listen": "test", "script": { "exec": [ - "var jsonData = pm.response.json();\r", - "pm.collectionVariables.set(\"token\", jsonData.accessToken);" + "pm=instrumentPmMethod(pm);\r", + "\r", + "pm.test(\"p4paAuth - 01_authtoken - Responses with 200\", function () {\r", + " pm.response.to.have.status(200);\r", + "});\r", + "\r", + "pm.test(\"p4paAuth - 01_authtoken jwt - Verify response body\", function () {\r", + " let jsonResponse = pm.response.json();\r", + "\r", + " pm.expect(jsonResponse).have.property(\"accessToken\")\r", + " pm.expect(jsonResponse).have.property(\"tokenType\")\r", + " pm.expect(jsonResponse).have.property(\"expiresIn\")\r", + "});\r", + "\r", + "let jsonResponse = pm.response.json();\r", + "pm.collectionVariables.set(\"accessToken\", jsonResponse.accessToken);\r", + "" ], "type": "text/javascript", "packages": {} @@ -27,17 +42,15 @@ } ], "request": { - "auth": { - "type": "noauth" - }, "method": "POST", "header": [], "url": { - "raw": "{{baseUrlAuth}}/auth/token?client_id=piattaforma-unitaria&grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=e1d9c534-86a9-4039-80da-8aa7a33ac9e7&subject_issuer=soak-test&subject_token_type=FAKE-AUTH&scope=openid&client_secret", + "raw": "{{p4paAuthBaseUrl}}/payhub/auth/token?client_id=piattaforma-unitaria&grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token={{tokenExchange_subjectToken}}&subject_issuer={{tokenExchange_issuer}}&scope=openid&subject_token_type=urn:ietf:params:oauth:token-type:jwt", "host": [ - "{{baseUrlAuth}}" + "{{p4paAuthBaseUrl}}" ], "path": [ + "payhub", "auth", "token" ], @@ -52,23 +65,19 @@ }, { "key": "subject_token", - "value": "e1d9c534-86a9-4039-80da-8aa7a33ac9e7" + "value": "{{tokenExchange_subjectToken}}" }, { "key": "subject_issuer", - "value": "soak-test" - }, - { - "key": "subject_token_type", - "value": "FAKE-AUTH" + "value": "{{tokenExchange_issuer}}" }, { "key": "scope", "value": "openid" }, { - "key": "client_secret", - "value": null + "key": "subject_token_type", + "value": "urn:ietf:params:oauth:token-type:jwt" } ] } @@ -78,10 +87,10 @@ ] }, { - "name": "02.citizen", + "name": "01_citizen", "item": [ { - "name": "02_getCitizenData", + "name": "01_getCitizenData", "event": [ { "listen": "test", @@ -329,6 +338,38 @@ } ] } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } ] } ], @@ -359,7 +400,69 @@ "type": "text/javascript", "packages": {}, "exec": [ - "" + "// START COMMON UTILITIES", + "", + "// global variable to use in order to skip tests", + "skipTests=false;", + "", + "// method to be invoked as first thing inside tests in order to instrument the \"pm\" variable:", + "// eg: pm = pm=instrumentPmMethod(pm);", + "instrumentPmMethod = (pm) => {", + " const pmProxy = {", + " get: function(pm, key) {", + " if (key == 'test') {", + " return (skipTests ? pm.test.skip : pm.test);", + " }", + " return pm[key];", + " }", + " };", + "", + " return new Proxy(pm, pmProxy);", + "}", + "", + "// function to be used in order to retry the current request, configuring a maximum number of attempts and a fixed delay between each invoke", + "retryRequest = (pm, setTimeout, waitingMillis = 1000, maxAttempts = 30) => {", + " if(!pm || !setTimeout){", + " throw new Error(\"Invalid invoke to retryRequest function! Some required parameters are undefined: pm=\" + pm + \", setTimeout=\" + setTimeout)", + " }", + "", + " const retryVariableName = \"retry_\" + pm.info.requestId", + " const attempt = (pm.variables.get(retryVariableName) ?? 0) + 1;", + " if(attempt < maxAttempts) {", + " console.info(pm.info.requestName + \" not ready, retrying [attempt \" + attempt + \"/\" + maxAttempts + \"] after \" + waitingMillis + \" ms\");", + " pm.variables.set(retryVariableName, attempt)", + " pm.execution.setNextRequest(pm.info.requestId);", + " return setTimeout(()=>{}, waitingMillis);", + " } else {", + " pm.test(pm.info.requestName + \" not ready\", () => pm.expect.fail(attempt + \" attempts\"));", + " }", + "}", + "", + "// function to be used in order to retry the current request until it returns a known response HTTP status code", + "retryWhenStatusCode = (pm, setTimeout, statusCode, waitingMillis, maxAttempts) => {", + " if(pm.response.code == statusCode){", + " console.log(\"Obtained \" + statusCode + \"! Performing retry...\")", + " skipTests=true;", + " return retryRequest(pm, setTimeout, waitingMillis, maxAttempts)", + " }", + "}", + "", + "// XML utilities", + "xml2js = require('xml2js');", + "", + "parseXmlResponse = (response) => {", + " let body;", + " xml2js.parseString(response.text(), {", + " ignoreAttrs: true, ", + " explicitArray: false,", + " }, function (err, result) {", + " if(err){", + " console.error(err)", + " }", + " body = result;", + " });", + " return body;", + "};" ] } } @@ -383,6 +486,10 @@ "key": "baseUrlAuth", "value": "https://api.dev.p4pa.pagopa.it/payhub-auth", "type": "string" + }, + { + "key": "accessToken", + "value": "" } ] } \ No newline at end of file diff --git a/src/main/java/it/gov/pagopa/payhub/pdnd/config/SwaggerConfig.java b/src/main/java/it/gov/pagopa/payhub/pdnd/config/SwaggerConfig.java index d8ad2ae..6bae823 100644 --- a/src/main/java/it/gov/pagopa/payhub/pdnd/config/SwaggerConfig.java +++ b/src/main/java/it/gov/pagopa/payhub/pdnd/config/SwaggerConfig.java @@ -1,35 +1,29 @@ package it.gov.pagopa.payhub.pdnd.config; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; import org.springframework.context.annotation.Configuration; /** * The Class SwaggerConfig. */ @Configuration +@OpenAPIDefinition( + info = @Info( + title = "${spring.application.name}", + version = "${spring.application.version}", + description = "Api and Models" + ), + security = @SecurityRequirement(name = "BearerAuth") +) +@SecurityScheme( + name = "BearerAuth", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + scheme = "bearer" +) public class SwaggerConfig { - - /** The title. */ - @Value("${swagger.title:${spring.application.name}}") - private String title; - - /** The description. */ - @Value("${swagger.description:Api and Models}") - private String description; - - /** The version. */ - @Value("${swagger.version:${spring.application.version}}") - private String version; - - @Bean - public OpenAPI customOpenAPI() { - return new OpenAPI().components(new Components()).info(new Info() - .title(title) - .description(description) - .version(version)); - } -} \ No newline at end of file +} diff --git a/src/main/java/it/gov/pagopa/payhub/pdnd/config/WebSecurityConfig.java b/src/main/java/it/gov/pagopa/payhub/pdnd/config/WebSecurityConfig.java new file mode 100644 index 0000000..e9c3f1b --- /dev/null +++ b/src/main/java/it/gov/pagopa/payhub/pdnd/config/WebSecurityConfig.java @@ -0,0 +1,91 @@ +package it.gov.pagopa.payhub.pdnd.config; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; +import it.gov.pagopa.payhub.pdnd.utils.CertUtils; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +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.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.web.SecurityFilterChain; + +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; + +import static com.nimbusds.jose.JOSEObjectType.JWT; + +@Configuration +@EnableWebSecurity +public class WebSecurityConfig { + + @Bean + public JwtDecoder jwtDecoder( + @Value("${jwt.access-token.public-key}") String publicKey + ) + throws NoSuchAlgorithmException, InvalidKeySpecException, IOException { + + RSAPublicKey rsaPublicKey = CertUtils.pemPub2PublicKey(publicKey); + return NimbusJwtDecoder + .withPublicKey(rsaPublicKey) + .signatureAlgorithm(SignatureAlgorithm.RS512) + .jwtProcessorCustomizer(processor -> processor + .setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(JWT, new JOSEObjectType("at+jwt")))) + .build(); + } + + public Converter jwtAuthenticationConverter(){ + return source -> { + String userExternalId = source.getSubject(); + MDC.put("externalUserId", userExternalId); + return new JwtAuthenticationToken(source, null, userExternalId); + }; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(requests -> requests + // Swagger endpoints + .requestMatchers( + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**" + ).permitAll() + + // Actuator endpoints + .requestMatchers( + "/actuator", + "/actuator/**" + ).permitAll() + + // WebMVC + .requestMatchers( + "/favicon.ico", "/error" + ).permitAll() + + .anyRequest().authenticated() + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .oauth2ResourceServer(c -> c + .jwt(jwt -> jwt + .decoder(jwtDecoder) + .jwtAuthenticationConverter(jwtAuthenticationConverter())) + ); + + return http.build(); + } +} diff --git a/src/main/java/it/gov/pagopa/payhub/pdnd/security/JwtAuthenticationFilter.java b/src/main/java/it/gov/pagopa/payhub/pdnd/security/JwtAuthenticationFilter.java deleted file mode 100644 index 3cbe1b9..0000000 --- a/src/main/java/it/gov/pagopa/payhub/pdnd/security/JwtAuthenticationFilter.java +++ /dev/null @@ -1,61 +0,0 @@ -package it.gov.pagopa.payhub.pdnd.security; - -import it.gov.pagopa.payhub.pdnd.dto.auth.UserInfoDTO; -import it.gov.pagopa.payhub.pdnd.exception.custom.InvalidAccessTokenException; -import it.gov.pagopa.payhub.pdnd.service.auth.AuthorizationService; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Collection; -import lombok.NonNull; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -@Component -@Slf4j -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private final AuthorizationService authorizationService; - - public JwtAuthenticationFilter(AuthorizationService authorizationService) { - this.authorizationService = authorizationService; - } - - @Override - protected void doFilterInternal(@NonNull HttpServletRequest request,@NonNull HttpServletResponse response, - @NonNull FilterChain filterChain) throws ServletException, IOException { - try { - String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); - if (StringUtils.hasText(authorization)) { - String token = authorization.replace("Bearer ", ""); - UserInfoDTO userInfo = authorizationService.validateToken(token); - Collection authorities = null; - if (userInfo.getOrganizationAccess() != null) { - authorities = userInfo.getOrganizations().stream() - .filter(o -> userInfo.getOrganizationAccess().equals(o.getOrganizationIpaCode())) - .flatMap(r -> r.getRoles().stream()) - .map(SimpleGrantedAuthority::new) - .toList(); - } - UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userInfo, null, authorities); - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authToken); - } - } catch (InvalidAccessTokenException e){ - log.info("An invalid accessToken has been provided: " + e.getMessage()); - } catch (Exception e){ - log.error("Something gone wrong while validate accessToken", e); - } - filterChain.doFilter(request, response); - } -} diff --git a/src/main/java/it/gov/pagopa/payhub/pdnd/security/WebSecurityConfig.java b/src/main/java/it/gov/pagopa/payhub/pdnd/security/WebSecurityConfig.java deleted file mode 100644 index 06f7ea0..0000000 --- a/src/main/java/it/gov/pagopa/payhub/pdnd/security/WebSecurityConfig.java +++ /dev/null @@ -1,47 +0,0 @@ -package it.gov.pagopa.payhub.pdnd.security; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -@Configuration -@EnableWebSecurity -public class WebSecurityConfig { - - private final JwtAuthenticationFilter jwtAuthenticationFilter; - - public WebSecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { - this.jwtAuthenticationFilter = jwtAuthenticationFilter; - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(requests -> requests - // Swagger endpoints - .requestMatchers( - "/swagger-ui.html", - "/swagger-ui/**", - "/v3/api-docs/**" - ).permitAll() - - // Actuator endpoints - .requestMatchers( - "/actuator", - "/actuator/**" - ).permitAll() - .anyRequest().authenticated() - ) - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - return http.build(); - } - -} diff --git a/src/main/java/it/gov/pagopa/payhub/pdnd/utils/CertUtils.java b/src/main/java/it/gov/pagopa/payhub/pdnd/utils/CertUtils.java index 5d9158a..0402369 100644 --- a/src/main/java/it/gov/pagopa/payhub/pdnd/utils/CertUtils.java +++ b/src/main/java/it/gov/pagopa/payhub/pdnd/utils/CertUtils.java @@ -6,13 +6,26 @@ import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; import java.util.Base64; public class CertUtils { private CertUtils(){} + public static RSAPublicKey pemPub2PublicKey(String publicKey) throws NoSuchAlgorithmException, InvalidKeySpecException, IOException { + String pubStringFormat = extractInlinePemBody(publicKey); + try( + InputStream is = new ByteArrayInputStream(Base64.getDecoder().decode(pubStringFormat)) + ) { + X509EncodedKeySpec encodedKeySpec = new X509EncodedKeySpec(is.readAllBytes()); + KeyFactory kf = KeyFactory.getInstance("RSA"); + return (RSAPublicKey) kf.generatePublic(encodedKeySpec); + } + } + public static RSAPrivateKey pemKey2PrivateKey(String privateKey) throws InvalidKeySpecException, NoSuchAlgorithmException, IOException { String keyStringFormat = extractInlinePemBody(privateKey); try( diff --git a/src/main/java/it/gov/pagopa/payhub/pdnd/utils/SecurityUtils.java b/src/main/java/it/gov/pagopa/payhub/pdnd/utils/SecurityUtils.java new file mode 100644 index 0000000..e7fc6cf --- /dev/null +++ b/src/main/java/it/gov/pagopa/payhub/pdnd/utils/SecurityUtils.java @@ -0,0 +1,18 @@ +package it.gov.pagopa.payhub.pdnd.utils; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.Optional; + +public class SecurityUtils { + private SecurityUtils() { + } + + public static String getAccessToken() { + return Optional.ofNullable(SecurityContextHolder.getContext()) + .flatMap(c -> Optional.ofNullable(c.getAuthentication())) + .map(a -> ((Jwt) a.getCredentials()).getTokenValue()) + .orElse(null); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index eff2380..7c627b5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -41,4 +41,19 @@ app: connect.timeout.millis: "\${CONNECT_TIMEOUT_MILLIS:120000}" read.timeout.millis: "\${READ_TIMEOUT_MILLIS:120000}" auth: - base-url: "\${AUTH_SERVER_BASE_URL:https://auth-server/api}" \ No newline at end of file + base-url: "\${AUTH_SERVER_BASE_URL:https://auth-server/api}" + +springdoc: + writer-with-default-pretty-printer: true + +jwt: + access-token: + public-key: "\${JWT_TOKEN_PUBLIC_KEY:-----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2ovm/rd3g69dq9PisinQ + 6mWy8ZttT8D+GKXCsHZycsGnN7b74TPyYy+4+h+9cgJeizp8RDRrufHjiBrqi/2r + eOk/rD7ZHbpfQvHK8MYfgIVdtTxYMX/GGdOrX6/5TV2b8e2aCG6GmxF0UuEvxY9o + TmcZUxnIeDtl/ixz4DQ754eS363qWfEA92opW+jcYzr07sbQtR86e+Z/s/CUeX6W + 1PHNvBqdlAgp2ecr/1DOLq1D9hEANBPSwbt+FM6FNe4vLphi7GTwiB0yaAuy+jE8 + odND6HPvvvmgbK1/2qTHn/HJjWUm11LUC73BszR32BKbdEEhxPQnnwswVekWzPi1 + IwIDAQAB + -----END PUBLIC KEY-----}" diff --git a/src/test/java/it/gov/pagopa/payhub/pdnd/config/WebSecurityConfigTest.java b/src/test/java/it/gov/pagopa/payhub/pdnd/config/WebSecurityConfigTest.java new file mode 100644 index 0000000..87ce93b --- /dev/null +++ b/src/test/java/it/gov/pagopa/payhub/pdnd/config/WebSecurityConfigTest.java @@ -0,0 +1,63 @@ +package it.gov.pagopa.payhub.pdnd.config; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; + +@ExtendWith(MockitoExtension.class) +class WebSecurityConfigTest { + + // public key and JWT token sample obtained through p4pa-auth test it.gov.pagopa.payhub.auth.service.AccessTokenBuilderServiceTest + private static final String PUBLIC_KEY = """ + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2ovm/rd3g69dq9PisinQ + 6mWy8ZttT8D+GKXCsHZycsGnN7b74TPyYy+4+h+9cgJeizp8RDRrufHjiBrqi/2r + eOk/rD7ZHbpfQvHK8MYfgIVdtTxYMX/GGdOrX6/5TV2b8e2aCG6GmxF0UuEvxY9o + TmcZUxnIeDtl/ixz4DQ754eS363qWfEA92opW+jcYzr07sbQtR86e+Z/s/CUeX6W + 1PHNvBqdlAgp2ecr/1DOLq1D9hEANBPSwbt+FM6FNe4vLphi7GTwiB0yaAuy+jE8 + odND6HPvvvmgbK1/2qTHn/HJjWUm11LUC73BszR32BKbdEEhxPQnnwswVekWzPi1 + IwIDAQAB + -----END PUBLIC KEY----- + """; + private static final String JWT_TOKEN_USERID = "MAPPEDUSEREXTERNALID"; + private static final String JWT_TOKEN = "eyJraWQiOiIyNWNhZDlkYi0wMDIyLTNiODctYTcwYS1mMmRhMjcyMTdjODgiLCJ0eXAiOiJhdCtKV1QiLCJhbGciOiJSUzUxMiJ9.eyJ0eXAiOiJiZWFyZXIiLCJpc3MiOiJBUFBMSUNBVElPTl9BVURJRU5DRSIsImp0aSI6IjA2ZWZmMzhjLTZhZDEtNGU5Ni1iYmYyLTUxYWVlMTFiNzZmYyIsInN1YiI6Ik1BUFBFRFVTRVJFWFRFUk5BTElEIiwiaWF0IjoxNzM2MDgwNTMzLCJleHAiOjI3MzYwODA1MzIsIm9yZ2FuaXphdGlvbklwYUNvZGUiOiJPUkdJUEFDT0RFIn0.qfcPvKVW6GOPC-Hb4QfqEpfT1zwrZ30QRbW2RPvrAlaBdYi51ZTmy6iWIcoy7YubkkctRp7xHDgcQuMRyRzGr2S-FayTA7kHXwa0y9UOnb7FXuZn9j0G6-4qVqlH6qo2KKTuDl_HykDAEmbI0AMJXilN8cM_ZkIQXCv6mDWsQCcxglsxcw89G0U9m5cZ5n9RxaAikMp8xRssiSqoFdhA67j-Iqs9P0vC-L0YvrIuqJ8CuJxoZQX_rPh-aLAzjPswctT_yaUk2tX5XpYG_1Yo0k9Mxy7CyyUa1JbRLRWbXkfOCPDbBOMn6KkXU_2w3pj4u6sIZsWuTNGT4d8zBye8JA"; + + private final WebSecurityConfig securityConfig = new WebSecurityConfig(); + private final JwtDecoder jwtDecoder = securityConfig.jwtDecoder(PUBLIC_KEY); + private final Converter jwtAuthenticationConverter = securityConfig.jwtAuthenticationConverter(); + + WebSecurityConfigTest() throws NoSuchAlgorithmException, InvalidKeySpecException, IOException { + } + + @AfterEach + void verifyNoMoreInteractions() { + SecurityContextHolder.clearContext(); + MDC.clear(); + } + + @Test + void givenNotValidTokenWhenJwtDecoderDecodeThenBadJwtException() { + Assertions.assertThrows(BadJwtException.class, () -> jwtDecoder.decode("INVALID")); + } + + @Test + void givenValidTokenWhenJwtDecoderDecodeAndJwtAuthenticationConverterConvertThenMdcConfigured() { + Jwt jwt = jwtDecoder.decode(JWT_TOKEN); + jwtAuthenticationConverter.convert(jwt); + + Assertions.assertEquals(JWT_TOKEN_USERID, MDC.get("externalUserId")); + } +} diff --git a/src/test/java/it/gov/pagopa/payhub/pdnd/security/JwtAuthenticationFilterTest.java b/src/test/java/it/gov/pagopa/payhub/pdnd/security/JwtAuthenticationFilterTest.java deleted file mode 100644 index 1cda4e2..0000000 --- a/src/test/java/it/gov/pagopa/payhub/pdnd/security/JwtAuthenticationFilterTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package it.gov.pagopa.payhub.pdnd.security; - -import it.gov.pagopa.payhub.pdnd.dto.auth.UserInfoDTO; -import it.gov.pagopa.payhub.pdnd.dto.auth.UserOrganizationRolesDTO; -import it.gov.pagopa.payhub.pdnd.exception.custom.InvalidAccessTokenException; -import it.gov.pagopa.payhub.pdnd.service.auth.AuthorizationService; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import java.io.IOException; -import java.util.Collection; -import java.util.List; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; - -@ExtendWith(MockitoExtension.class) -class JwtAuthenticationFilterTest { - - @Mock - private FilterChain filterChainMock; - - @Mock - private AuthorizationService authorizationService; - - @InjectMocks - private JwtAuthenticationFilter jwtAuthenticationFilter; - - @Test - void givenValidTokenWhenDoFilterInternalThenOk() throws ServletException, IOException { - // Given - String accessToken = "ACCESSTOKEN"; - MockHttpServletRequest request = new MockHttpServletRequest(HttpMethod.GET.name(), "/path"); - request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); - - MockHttpServletResponse response = new MockHttpServletResponse(); - - UserInfoDTO userInfo = UserInfoDTO.builder() - .mappedExternalUserId("MAPPEDEXTERNALUSERID") - .fiscalCode("FISCALCODE") - .familyName("FAMILYNAME") - .name("NAME") - .issuer("ISSUER") - .organizationAccess("ORG") - .organizations(List.of(UserOrganizationRolesDTO.builder() - .organizationIpaCode("ORG") - .roles(List.of("ROLE")) - .build())) - .build(); - - Collection authorities = null; - if (userInfo.getOrganizationAccess() != null) { - authorities = userInfo.getOrganizations().stream() - .filter(o -> userInfo.getOrganizationAccess().equals(o.getOrganizationIpaCode())) - .flatMap(r -> r.getRoles().stream()) - .map(SimpleGrantedAuthority::new) - .toList(); - } - - Mockito.when(authorizationService.validateToken(accessToken)).thenReturn(userInfo); - - UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userInfo, null, authorities); - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - - // When - jwtAuthenticationFilter.doFilterInternal(request, response, filterChainMock); - - // Then - Mockito.verify(filterChainMock).doFilter(request, response); - Assertions.assertEquals( - authToken, - SecurityContextHolder.getContext().getAuthentication() - ); - } - - @Test - void givenInvalidTokenWhenDoFilterInternalThenInvalidAccessTokenException() throws ServletException, IOException { - // Given - String accessToken = "INVALIDACCESSTOKEN"; - MockHttpServletRequest request = new MockHttpServletRequest(HttpMethod.GET.name(), "/path"); - request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); - - MockHttpServletResponse response = new MockHttpServletResponse(); - - Mockito.doThrow(new InvalidAccessTokenException("An invalid accessToken has been provided")).when(authorizationService).validateToken(accessToken); - - // When - jwtAuthenticationFilter.doFilterInternal(request, response, filterChainMock); - - // Then - Mockito.verify(filterChainMock).doFilter(request, response); - } - - @Test - void givenInvalidTokenWhenDoFilterInternalThenRuntimeException() throws ServletException, IOException { - // Given - String accessToken = "EXCEPTION"; - MockHttpServletRequest request = new MockHttpServletRequest(HttpMethod.GET.name(), "/path"); - request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); - - MockHttpServletResponse response = new MockHttpServletResponse(); - - Mockito.doThrow(new RuntimeException("Something gone wrong while validate accessToken")).when(authorizationService).validateToken(accessToken); - - // When - jwtAuthenticationFilter.doFilterInternal(request, response, filterChainMock); - - // Then - Mockito.verify(filterChainMock).doFilter(request, response); - } -} \ No newline at end of file diff --git a/src/test/java/it/gov/pagopa/payhub/pdnd/utils/SecurityUtilsTest.java b/src/test/java/it/gov/pagopa/payhub/pdnd/utils/SecurityUtilsTest.java new file mode 100644 index 0000000..3acf5b0 --- /dev/null +++ b/src/test/java/it/gov/pagopa/payhub/pdnd/utils/SecurityUtilsTest.java @@ -0,0 +1,47 @@ +package it.gov.pagopa.payhub.pdnd.utils; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +class SecurityUtilsTest { + + @AfterEach + void clear(){ + SecurityContextHolder.clearContext(); + } + +//region getAccessToken + @Test + void givenNoSecurityContextWhenGetAccessTokenThenNull(){ + Assertions.assertNull(SecurityUtils.getAccessToken()); + } + + @Test + void givenNoAuthenticationWhenGetAccessTokenThenNull(){ + SecurityContextHolder.setContext(new SecurityContextImpl()); + Assertions.assertNull(SecurityUtils.getAccessToken()); + } + + @Test + void givenJwtWhenGetAccessTokenThenReturnToken(){ + // Given + String jwt = "TOKENHEADER.TOKENPAYLOAD.TOKENDIGEST"; + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(Jwt + .withTokenValue(jwt) + .header("", "") + .claim("", "") + .build()))); + + // When + String result = SecurityUtils.getAccessToken(); + + // Then + Assertions.assertSame(jwt, result); + } +//endregion +}