diff --git a/build.gradle b/build.gradle index 90c9256..1d35c96 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.springframework.boot' version '3.3.5' id 'io.spring.dependency-management' version '1.1.6' id 'org.graalvm.buildtools.native' version '0.10.3' + id("org.openapi.generator") version "7.5.0" } group = 'it.gov.pagopa' @@ -25,9 +26,22 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // spring security + oauth2 resource server + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.security:spring-security-oauth2-jose' + + implementation("org.springframework.boot:spring-boot-starter-actuator") + // implementation 'com.azure.spring:spring-cloud-azure-starter-actuator' // implementation 'com.azure.spring:spring-cloud-azure-starter-data-cosmos' + implementation("io.swagger.core.v3:swagger-annotations:2.2.8") + implementation("org.openapitools:jackson-databind-nullable:0.2.6") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + implementation("org.springframework.boot:spring-boot-starter-validation") testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testImplementation 'io.projectreactor:reactor-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } @@ -41,3 +55,44 @@ dependencyManagement { tasks.named('test') { useJUnitPlatform() } + +tasks.compileJava { + dependsOn("openApiGenerate") +} + +sourceSets { + main { + java { + srcDir("$projectDir/build/generated/src/main/java") + } + } +} + +openApiGenerate { + generatorName.set("spring") + inputSpec.set("$rootDir/openapi/activation.openapi.yaml") + outputDir.set("$projectDir/build/generated") + apiPackage.set("it.gov.pagopa.rtp.activator.controller.generated") + modelPackage.set("it.gov.pagopa.rtp.activator.model.generated") + modelNameSuffix.set("Dto") + generateApiTests.set(false) + generateApiDocumentation.set(false) + generateApiTests.set(false) + generateModelTests.set(false) + library.set("spring-boot") + configOptions.set([ + "dateLibrary" : "java8", + "useSpringBoot3" : "true", + "interfaceOnly" : "true", + "useTags" : "true", + "useSwaggerUI" : "false", + "reactive" : "true", + "swaggerAnnotations" : "false", + "skipDefaultInterface" : "true", + "openApiNullable" : "true", + ]) + typeMappings.set([ + "DateTime" : "java.time.LocalDateTime", + "zoned-date-time" : "java.time.ZonedDateTime" + ]) +} diff --git a/openapi/activation.openapi.yaml b/openapi/activation.openapi.yaml new file mode 100644 index 0000000..403e49e --- /dev/null +++ b/openapi/activation.openapi.yaml @@ -0,0 +1,858 @@ +openapi: 3.0.3 + +info: + title: RTP Activation API. + version: 1.0.0 + description: | + API to handle RTP activations initiated by the Payer's RTP Service Provider. + contact: + name: PagoPA S.p.A. + email: rtp@pagopa.it + +servers: + - description: Development/Test + url: https://rtp.dev.cstar.pagopa.it + x-internal: true + - description: User Acceptance Test + url: https://rtp.uat.cstar.pagopa.it + x-internal: false + - description: Production + url: https://rtp.cstar.pagopa.it + x-internal: false + +tags: + - name: create + description: Create operation. + - name: read + description: Read operation. + - name: update + description: Update operation. + - name: delete + description: Delete operation. + +paths: + /activations: + post: + operationId: activate + summary: RTP activation initiated by the Payer's RTP Service Provider. + description: | + The operation is used by Payer's RTP Service Provider to enable the + Payee's RTP Service Provider to send RTP messages to the Payer's RTP + Service Provider. + + When the operation is used by not-admin subject, the system verifies + that the Payer's RTP Service Provider ID matches the subject claim of + the access token. `403 Forbidden` is returned on mismatch. + + `409 Conflict` is returned if an activation with the same Payer's ID + already exists. + tags: [create] + security: + - oAuth2: [admin_rtp_activations, write_rtp_activations] + parameters: + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Version' + requestBody: + $ref: '#/components/requestBodies/CreateOrUpdateActivation' + responses: + "201": + #description: Created. + $ref: '#/components/responses/CreateActivation' + "400": + #description: Bad request. + $ref: '#/components/responses/Error' + "401": + #description: Wrong credentials. + $ref: '#/components/responses/Error' + "403": + #description: Forbidden + $ref: '#/components/responses/Error' + "406": + #description: Not acceptable. Did you require application/json? + $ref: '#/components/responses/Error' + "409": + #description: Conflict. + $ref: '#/components/responses/Error' + "415": + #description: Unsupported media type. Did you provide application/json? + $ref: '#/components/responses/Error' + "429": + #description: Too many request. + $ref: '#/components/responses/Error' + "500": + #description: Server error. + $ref: '#/components/responses/Error' + default: + #description: Unexpected error. + $ref: '#/components/responses/Error' + + get: + operationId: getActivations + summary: Returns RTP activations. + description: | + The operation returns all the RTP activations stored by the system. + + When the operation is used by not-admin subject, the system returns + the RTP activations which have the Payer's RTP Service Provider ID that + matches the subject claim of the access token. + tags: [read] + security: + - oAuth2: [admin_rtp_activations, read_rtp_activations] + parameters: + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Version' + - $ref: '#/components/parameters/PageNumber' + - $ref: '#/components/parameters/PageSize' + responses: + "200": + #description: Ok. + $ref: '#/components/responses/PageOfActivations' + "400": + #description: Bad request. + $ref: '#/components/responses/Error' + "401": + #description: Access token is missing or invalid. + $ref: '#/components/responses/Error' + "403": + #description: Forbidden. + $ref: '#/components/responses/Error' + "406": + #description: Not acceptable. Did you require application/json? + $ref: '#/components/responses/Error' + "429": + #description: Too many request. + $ref: '#/components/responses/Error' + "500": + #description: Server error. + $ref: '#/components/responses/Error' + default: + #description: Unexpected error. + $ref: '#/components/responses/Error' + + /activations/{activationId}: + get: + operationId: getActivation + summary: Returns a RTP activation. + description: | + The operation finds a RTP activation by its ID. + + When the operation is used by not-admin subject, the system returns + the RTP activation only if its Payer's RTP Service Provider ID matches + the subject claim of the access token, otherwise `404 Not Found` is + returned. + tags: [read] + security: + - oAuth2: [admin_rtp_activations, read_rtp_activations] + parameters: + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Version' + - $ref: '#/components/parameters/ActivationId' + responses: + "200": + #description: Found. + $ref: '#/components/responses/Activation' + "400": + #description: Bad request. + $ref: '#/components/responses/Error' + "401": + #description: Access token is missing or invalid. + $ref: '#/components/responses/Error' + "403": + #description: Forbidden. + $ref: '#/components/responses/Error' + "404": + #description: Not found. + $ref: '#/components/responses/Error' + "406": + #description: Not acceptable. Did you require application/json? + $ref: '#/components/responses/Error' + "429": + #description: Too many request. + $ref: '#/components/responses/Error' + "500": + #description: Server error. + $ref: '#/components/responses/Error' + default: + #description: Unexpected error. + $ref: '#/components/responses/Error' + + put: + operationId: updateActivation + summary: Updates a RTP activation. + description: | + The operation updates a RTP actviation searching it by its ID. + + When the operation is used by not-admin subject: + - the system returns `404 Not Found` if the Payer's RTP Service + Provider ID of the activation doesn't match the subject claim of + the access token; + - the system returns `403 Forbidden` if the provided value of Payer's + RTP Service doesn't match the subject claim of the access token. + tags: [update] + security: + - oAuth2: [admin_rtp_activations, write_rtp_activations] + parameters: + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Version' + - $ref: '#/components/parameters/ActivationId' + requestBody: + $ref: '#/components/requestBodies/CreateOrUpdateActivation' + responses: + "204": + #description: No content + $ref: '#/components/responses/NoContent' + "400": + #description: Bad request + $ref: '#/components/responses/Error' + "401": + #description: Wrong credentials + $ref: '#/components/responses/Error' + "403": + #description: Forbidden + $ref: '#/components/responses/Error' + "404": + #description: Not found + $ref: '#/components/responses/Error' + "406": + #description: Not acceptable. Did you require application/json? + $ref: '#/components/responses/Error' + "415": + #description: Unsupported media type. Did you provide application/json? + $ref: '#/components/responses/Error' + "429": + #description: Too many request + $ref: '#/components/responses/Error' + "500": + #description: Server error + $ref: '#/components/responses/Error' + default: + #description: Unexpected error + $ref: '#/components/responses/Error' + + delete: + operationId: deleteActivation + summary: Deletes a RTP activation. + description: | + The operation deletes a RTP actviation searching it by its ID. + + When the operation is used by not-admin subject, the system returns + `404 Not Found` if the Payer's RTP Service Provider ID of the activation + doesn't match the subject claim of the access token. + tags: [delete] + security: + - oAuth2: [admin_rtp_activations, write_rtp_activations] + parameters: + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Version' + - $ref: '#/components/parameters/ActivationId' + responses: + "204": + #description: No content + $ref: '#/components/responses/NoContent' + "400": + #description: Bad request + $ref: '#/components/responses/Error' + "401": + #description: Access token is missing or invalid + $ref: '#/components/responses/Error' + "403": + #description: Forbidden + $ref: '#/components/responses/Error' + "404": + #description: Not found + $ref: '#/components/responses/Error' + "406": + #description: Not acceptable. Did you require application/json? + $ref: '#/components/responses/Error' + "429": + #description: Too many request + $ref: '#/components/responses/Error' + "500": + #description: Server error + $ref: '#/components/responses/Error' + default: + #description: Unexpected error + $ref: '#/components/responses/Error' + + /activations/findByPayerId: + get: + operationId: findActivationsByPayerId + summary: Finds a RTP activation by Payer ID. + description: | + The operation finds RTP activations by Payer ID. + + When the operation is used by not-admin subject, the system returns + the RTP activations with the Payer's RTP Service Provider ID that + matches the subject claim of the access token. + tags: [read] + security: + - oAuth2: [admin_rtp_activations, read_rtp_activations] + parameters: + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/Version' + - $ref: '#/components/parameters/PageNumber' + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/PayerId' + responses: + "200": + #description: Ok. + $ref: '#/components/responses/PageOfActivations' + "400": + #description: Bad request. + $ref: '#/components/responses/Error' + "401": + #description: Access token is missing or invalid. + $ref: '#/components/responses/Error' + "403": + #description: Forbidden. + $ref: '#/components/responses/Error' + "406": + #description: Not acceptable. Did you require application/json? + $ref: '#/components/responses/Error' + "429": + #description: Too many request. + $ref: '#/components/responses/Error' + "500": + #description: Server error. + $ref: '#/components/responses/Error' + default: + #description: Unexpected error. + $ref: '#/components/responses/Error' + +components: + # ============================================================================ + # Schemas. + # ============================================================================ + schemas: + # -------------------------------------------------------------------------- + # Basic types for CORS stuff. + # -------------------------------------------------------------------------- + AccessControlAllowOrigin: + description: | + Indicates whether the response can be shared with requesting code from + the given origin. + type: string + pattern: "^[ -~]{1,2048}$" + minLength: 1 + maxLength: 2048 + + # -------------------------------------------------------------------------- + # Basic types for rate limit handling. + # -------------------------------------------------------------------------- + RateLimitLimit: + description: The number of allowed requests in the current period. + type: integer + format: int32 + minimum: 1 + maximum: 240 + + RateLimitReset: + description: The number of seconds left in the current period. + type: integer + format: int32 + minimum: 1 + maximum: 60 + + RetryAfter: + description: | + The number of seconds to wait before allowing a follow-up request. + type: integer + format: int32 + minimum: 1 + maximum: 240 + + # -------------------------------------------------------------------------- + # Basic types for paging. + # -------------------------------------------------------------------------- + PageNumber: + description: Number of the page. + type: integer + format: int32 + minimum: 0 + maximum: 2147483647 + example: 1 + + PageSize: + description: Size of the page. + type: integer + format: int32 + minimum: 1 + maximum: 128 + example: 20 + + TotalElements: + description: Total elements. + type: integer + format: int64 + minimum: 0 + maximum: 9223372036854775807 + example: 20 + + TotalPages: + description: Total pages. + type: integer + format: int64 + minimum: 0 + maximum: 9223372036854775807 + example: 20 + + # -------------------------------------------------------------------------- + # Basic types for error handling. + # -------------------------------------------------------------------------- + ErrorCode: + description: Error code. + type: string + pattern: "^[A-F0-9]{9}$" + minLength: 9 + maxLength: 9 + example: "01000000F" + + ErrorDescription: + description: Error description. + type: string + pattern: "^[ -~]{0,256}$" + minLength: 0 + maxLength: 256 + example: "Wrong party identifier" + + # -------------------------------------------------------------------------- + # Basic types for technical stuff. + # -------------------------------------------------------------------------- + ActivationId: + description: Identifier of the RTP activation resource. + type: string + format: uuid + example: "d0d654e6-97da-4848-b568-99fedccb642b" + + ActivationLocation: + description: URL of the RTP activation resource. + type: string + format: uri + pattern: "^[ -~]{1,2048}$" + minLength: 1 + maxLength: 2048 + example: "https://rtp.cstar.pagopa.it/activations/d0d654e6-97da-4848-b568-99fedccb642b" + + RequestId: + description: Identifier of the request. + type: string + format: uuid + example: "bd615b4a-066d-443e-8dd2-a28a39931fef" + + Version: + description: Version of the required API. + type: string + pattern: "^[ -~]{1,64}$" + minLength: 1 + maxLength: 64 + example: "v1" + + # -------------------------------------------------------------------------- + # Domain specific basic types. + # -------------------------------------------------------------------------- + EffectiveActivationDate: + description: | + Effective activation date (B035). + + Date and time at which activation has been stored. + type: string + format: date-time + example: "2024-10-30T16:39:34+01:00" + + FiscalCode: + description: | + Fiscal (or tax) code. + + It is used as identifier of the Payer (P009) and of the Payee (E005). + type: string + pattern: "^(([A-Z]{6}\\d{2}[A-Z]\\d{2}[A-Z]\\d{3}[A-Z])|(\\d{11}))$" + minLength: 11 + maxLength: 16 + example: "RSSMRA85T10A562S" + + PartyId: + description: | + Unique and unambiguous identification of a party. + + It is used as identifier of the Payer’s RTP Service Provider (N001) and + for Payee’s RTP Service Provider (N002). + type: string + pattern: "^[ -~]{1,35}$" + minLength: 1 + maxLength: 35 + example: "12345678901" + + # -------------------------------------------------------------------------- + # Complex types for paging. + # -------------------------------------------------------------------------- + PageMetadata: + description: Metadata of a page of data. + type: object + additionalProperties: false + properties: + totalElements: + $ref: '#/components/schemas/TotalElements' + totalPages: + $ref: '#/components/schemas/TotalPages' + page: + $ref: '#/components/schemas/PageNumber' + size: + $ref: '#/components/schemas/PageSize' + required: + - totalElements + - totalPages + - page + - size + example: + totalElements: 198 + totalPages: 10 + page: 5 + size: 20 + + # -------------------------------------------------------------------------- + # Complex type for error handling. + # -------------------------------------------------------------------------- + Error: + description: Error details. + type: object + additionalProperties: false + properties: + code: + $ref: '#/components/schemas/ErrorCode' + description: + $ref: '#/components/schemas/ErrorDescription' + required: + - code + - description + example: + code: "01000000F" + description: "Wrong party identifier" + + Errors: + description: List of errors. + type: object + additionalProperties: false + properties: + errors: + type: array + minItems: 1 + maxItems: 32 + items: + $ref: '#/components/schemas/Error' + required: + - errors + example: + errors: + - code: "01000000F" + description: "Wrong party identifier" + + # ------------------------------------------------------ + # Domain specific complex types. + # ------------------------------------------------------ + Activation: + allOf: + - type: object + properties: + id: + $ref: '#/components/schemas/ActivationId' + effectiveActivationDate: + $ref: '#/components/schemas/EffectiveActivationDate' + required: + - id + - effectiveActivationDate + - $ref: '#/components/schemas/ActivationReq' + example: + id: "d0d654e6-97da-4848-b568-99fedccb642b" + effectiveActivationDate: "2024-10-30T16:39:34+01:00" + payer: + fiscalCode: "RSSMRA85T10A562S" + rtpSpId: "10987654321" + + Activations: + description: List of RTP activations. + type: array + minItems: 0 + maxItems: 128 + items: + $ref: '#/components/schemas/Activation' + example: + - id: "d0d654e6-97da-4848-b568-99fedccb642b" + effectiveActivationDate: "2024-10-30T16:39:34+01:00" + payer: + fiscalCode: "RSSMRA85T10A562S" + rtpSpId: "10987654321" + + ActivationReq: + description: | + Data of a RTP activation. + type: object + additionalProperties: true # It's extended by another object. + properties: + payer: + $ref: '#/components/schemas/Payer' + required: + - payer + example: + payer: + fiscalCode: "RSSMRA85T10A562S" + rtpSpId: "10987654321" + + PageOfActivations: + description: Page of RTP activations. + type: object + additionalProperties: false + properties: + activations: + $ref: '#/components/schemas/Activations' + page: + $ref: '#/components/schemas/PageMetadata' + required: + - activations + - page + example: + activations: + - id: "d0d654e6-97da-4848-b568-99fedccb642b" + effectiveActivationDate: "2024-10-30T16:39:34+01:00" + payer: + fiscalCode: "RSSMRA85T10A562S" + rtpSpId: "10987654321" + page: + totalElements: 2 + totalPages: 2 + page: 1 + size: 1 + + Payer: + description: | + Payer data. + type: object + additionalProperties: false + properties: + fiscalCode: + $ref: '#/components/schemas/FiscalCode' + rtpSpId: + $ref: '#/components/schemas/PartyId' + required: + - fiscalCode + - rtpSpId + example: + fiscalCode: "RSSMRA85T10A562S" + rtpSpId: "10987654321" + + # ============================================================================ + # Request bodies. + # ============================================================================ + requestBodies: + CreateOrUpdateActivation: + description: Request to create or update a RTP activation. + content: + application/json: + schema: + $ref: '#/components/schemas/ActivationReq' + + # ============================================================================ + # Parameters. + # ============================================================================ + parameters: + ActivationId: + name: activationId + in: path + description: Identifier of the RTP activation resource. + required: true + schema: + $ref: '#/components/schemas/ActivationId' + + PageNumber: + name: page + in: query + description: Number of the requested page of data. + required: true + schema: + $ref: '#/components/schemas/PageNumber' + + PageSize: + name: size + in: query + description: Size of the requested page of data. + required: true + schema: + $ref: '#/components/schemas/PageSize' + + PayerId: + name: PayerId + in: header + description: Identifier of the Payer. + required: true + schema: + $ref: '#/components/schemas/FiscalCode' + + RequestId: + name: RequestId + in: header + description: Identifier of the request. + required: true + schema: + $ref: '#/components/schemas/RequestId' + + Version: + name: Version + in: header + description: Version of the required API. + required: false + schema: + $ref: '#/components/schemas/Version' + + # ============================================================================ + # Responses + # ============================================================================ + responses: + Activation: + description: Response returned when RTP activation data is requested. + headers: + Access-Control-Allow-Origin: + description: | + Indicates whether the response can be shared with requesting code + from the given origin. + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + content: + application/json: + schema: + $ref: '#/components/schemas/Activation' + + CreateActivation: + description: Response returned when a RTP activation is requested. + headers: + Access-Control-Allow-Origin: + description: | + Indicates whether the response can be shared with requesting code + from the given origin. + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period. + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period. + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Location: + description: URL of the activation resource. + required: true + schema: + $ref: '#/components/schemas/ActivationLocation' + + Error: + description: Error response. + headers: + Access-Control-Allow-Origin: + description: | + Indicates whether the response can be shared with requesting code + from the given origin. + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period. + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: | + The number of seconds to wait before allowing a follow-up request. + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + content: + application/json: + schema: + $ref: '#/components/schemas/Errors' + text/*: + schema: + type: string + pattern: "^[ -~]{0,65535}$" + maxLength: 65535 + + NoContent: + description: No content response. + headers: + Access-Control-Allow-Origin: + description: | + Indicates whether the response can be shared with requesting code + from the given origin. + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period. + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period. + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + + PageOfActivations: + description: Response to the request to get RTP activations. + headers: + Access-Control-Allow-Origin: + description: | + Indicates whether the response can be shared with requesting code + from the given origin. + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period. + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period. + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + content: + application/json: + schema: + $ref: '#/components/schemas/PageOfActivations' + + # ============================================================================ + # Security schemes. + # ============================================================================ + securitySchemes: + oAuth2: + description: | + A bearer token in the format of a JWS and conforms to the specifications + included in RFC8725. + type: oauth2 + flows: + clientCredentials: + tokenUrl: /token + refreshUrl: /token + scopes: + admin_rtp_activations: Admin RPT activation. + write_rtp_activations: Create, update or delete RTP activation. + read_rtp_activations: Read RTP activation. diff --git a/src/main/java/it/gov/pagopa/rtp/activator/RtpActivatorApplication.java b/src/main/java/it/gov/pagopa/rtp/activator/RtpActivatorApplication.java index d258f9d..46fd9e2 100644 --- a/src/main/java/it/gov/pagopa/rtp/activator/RtpActivatorApplication.java +++ b/src/main/java/it/gov/pagopa/rtp/activator/RtpActivatorApplication.java @@ -2,11 +2,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import reactor.core.publisher.Hooks; @SpringBootApplication public class RtpActivatorApplication { public static void main(String[] args) { + Hooks.enableAutomaticContextPropagation(); SpringApplication.run(RtpActivatorApplication.class, args); } diff --git a/src/main/java/it/gov/pagopa/rtp/activator/configuration/NoSignatureJwtDecoder.java b/src/main/java/it/gov/pagopa/rtp/activator/configuration/NoSignatureJwtDecoder.java new file mode 100644 index 0000000..a7ea6ce --- /dev/null +++ b/src/main/java/it/gov/pagopa/rtp/activator/configuration/NoSignatureJwtDecoder.java @@ -0,0 +1,45 @@ +package it.gov.pagopa.rtp.activator.configuration; + +import com.nimbusds.jwt.JWTParser; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.*; + +import java.text.ParseException; +import java.util.Objects; + +import static java.util.Collections.emptyMap; + +public class NoSignatureJwtDecoder implements JwtDecoder { + + private final OAuth2TokenValidator verifier = JwtValidators.createDefault(); + private final MappedJwtClaimSetConverter claimMapper = MappedJwtClaimSetConverter.withDefaults(emptyMap()); + + @Override + public Jwt decode(String token) throws JwtException { + try { + final var parsedToken = JWTParser.parse(token); + // convert nimbus token to spring Jwt + final var convertedClaims = claimMapper.convert(parsedToken.getJWTClaimsSet().toJSONObject()); + + final var jwt = Jwt.withTokenValue(parsedToken.getParsedString()) + .headers(headers -> headers.putAll(parsedToken.getHeader().toJSONObject())) + .claims(claims -> claims.putAll(convertedClaims)) + .build(); + + final var validation = verifier.validate(jwt); + if (validation.hasErrors()) { + final var description = validation.getErrors().stream() + .filter(it -> Objects.nonNull(it) && !it.getDescription().isEmpty()) + .map(OAuth2Error::getDescription) + .findFirst() + .orElse("Invalid jwt token"); + throw new JwtValidationException(description, validation.getErrors()); + } + + return jwt; + } catch (ParseException e) { + throw new BadJwtException(e.getMessage()); + } + } +} diff --git a/src/main/java/it/gov/pagopa/rtp/activator/configuration/SecurityConfig.java b/src/main/java/it/gov/pagopa/rtp/activator/configuration/SecurityConfig.java new file mode 100644 index 0000000..ffb7a17 --- /dev/null +++ b/src/main/java/it/gov/pagopa/rtp/activator/configuration/SecurityConfig.java @@ -0,0 +1,60 @@ +package it.gov.pagopa.rtp.activator.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtGrantedAuthoritiesConverterAdapter; +import org.springframework.security.web.server.SecurityWebFilterChain; +import reactor.core.publisher.Mono; + + +@Configuration +@EnableWebFluxSecurity +@EnableReactiveMethodSecurity(proxyTargetClass = true) // allows to use @PreAuthorize with roles +public class SecurityConfig { + + @Bean + SecurityWebFilterChain securityWebFilterChain( + ServerHttpSecurity http, + ReactiveJwtAuthenticationConverter jwtConverter + ) { + return http + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .logout(ServerHttpSecurity.LogoutSpec::disable) + .authorizeExchange(it -> it + .pathMatchers("/actuator/**") + .permitAll() + .anyExchange() + .authenticated() + ) + .oauth2ResourceServer(oauth2 -> + oauth2.jwt(it -> it.jwtAuthenticationConverter(jwtConverter)) + ) + .build(); + } + + @Bean + ReactiveJwtAuthenticationConverter jwtAuthenticationConverter() { + final var authoritiesConverter = new JwtGrantedAuthoritiesConverter(); + authoritiesConverter.setAuthoritiesClaimName("groups"); // Map "groups" claim to authorities + authoritiesConverter.setAuthorityPrefix("ROLE_"); // Add "ROLE_" prefix for Spring Security + + final var reactiveConverter = new ReactiveJwtAuthenticationConverter(); + reactiveConverter.setJwtGrantedAuthoritiesConverter( + new ReactiveJwtGrantedAuthoritiesConverterAdapter(authoritiesConverter) + ); + return reactiveConverter; + } + + @Bean + ReactiveJwtDecoder jwtDecoder() { + final var decoder = new NoSignatureJwtDecoder(); + return token -> Mono.fromSupplier(() -> decoder.decode(token)); + } + +} diff --git a/src/main/java/it/gov/pagopa/rtp/activator/controller/ActivationAPIControllerImpl.java b/src/main/java/it/gov/pagopa/rtp/activator/controller/ActivationAPIControllerImpl.java new file mode 100644 index 0000000..c14d83a --- /dev/null +++ b/src/main/java/it/gov/pagopa/rtp/activator/controller/ActivationAPIControllerImpl.java @@ -0,0 +1,32 @@ +package it.gov.pagopa.rtp.activator.controller; + +import it.gov.pagopa.rtp.activator.controller.generated.CreateApi; +import it.gov.pagopa.rtp.activator.model.generated.ActivationReqDto; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.util.UUID; + +import static it.gov.pagopa.rtp.activator.utils.Authorizations.verifySubjectRequest; + +@RestController +@Validated +public class ActivationAPIControllerImpl implements CreateApi { + + @Override + @PreAuthorize("hasRole('write_rtp_activations')") + public Mono> activate( + UUID requestId, + String version, + Mono activationReqDto, + ServerWebExchange exchange + ) { + return verifySubjectRequest(activationReqDto, it -> it.getPayer().getRtpSpId()) + .map(request -> ResponseEntity.created(URI.create("http://localhost")).build()); + } +} diff --git a/src/main/java/it/gov/pagopa/rtp/activator/utils/Authorizations.java b/src/main/java/it/gov/pagopa/rtp/activator/utils/Authorizations.java new file mode 100644 index 0000000..4997d9a --- /dev/null +++ b/src/main/java/it/gov/pagopa/rtp/activator/utils/Authorizations.java @@ -0,0 +1,48 @@ +package it.gov.pagopa.rtp.activator.utils; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import reactor.core.publisher.Mono; + +import java.util.function.BiPredicate; +import java.util.function.Function; + +public final class Authorizations { + + private Authorizations(){} + + /** + * Verifies that the subject in the request matches the authenticated user's subject. + * It uses the provided {@code extractSubject} function to extract the subject from the request object, + * and compares it with the authenticated user's name. + * + * @param The type of the request body. + * @param requestBody A {@link Mono} containing the request body that needs to be verified. + * @param extractSubject A function that extracts the subject (e.g., user identifier) from the request body. + * @return A {@link Mono} containing the request body if the subjects match, or an error if they don't. + */ + public static Mono verifySubjectRequest(Mono requestBody, Function extractSubject) { + return verifyRequestBody(requestBody, (request, auth) -> extractSubject.apply(request).equals(auth.getName())); + } + + /** + * Verifies that the request body passes a custom verification function that involves the authenticated user. + * This method takes a {@link Mono} of the request body and checks the provided {@code verify} predicate to ensure + * the request meets the security requirements. If the predicate fails, an {@link AccessDeniedException} is thrown. + * + * @param The type of the request body. + * @param requestBody A {@link Mono} containing the request body that needs to be verified. + * @param verify A {@link BiPredicate} that performs a custom verification on the request body and the authenticated user. + * @return A {@link Mono} containing the request body if the verification succeeds. + */ + public static Mono verifyRequestBody(Mono requestBody, BiPredicate verify) { + return ReactiveSecurityContextHolder.getContext().flatMap(securityContext -> + requestBody.flatMap(request -> verify.test(request, securityContext.getAuthentication()) ? + Mono.just(request) : + Mono.error(new AccessDeniedException("Authenticated user doesn't have permission to perform this action.")) + ) + ); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cc8af0e..b8ef53d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,11 @@ +logging.level.root=INFO + + spring.application.name=rtp-activator + +# enable spring boot actuator health endpoint +management.endpoints.enabled-by-default=false +management.endpoints.web.exposure.include=health +management.endpoint.health.enabled=true +management.endpoint.health.probes.enabled=true + diff --git a/src/test/java/it/gov/pagopa/rtp/activator/configuration/NoSignatureJwtDecoderTest.java b/src/test/java/it/gov/pagopa/rtp/activator/configuration/NoSignatureJwtDecoderTest.java new file mode 100644 index 0000000..d55a84e --- /dev/null +++ b/src/test/java/it/gov/pagopa/rtp/activator/configuration/NoSignatureJwtDecoderTest.java @@ -0,0 +1,28 @@ +package it.gov.pagopa.rtp.activator.configuration; + +import com.nimbusds.jose.JOSEException; +import it.gov.pagopa.rtp.activator.utils.JwtUtils; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.JwtValidationException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class NoSignatureJwtDecoderTest { + + @Test + void givenSignedTokenMustDecodeWithoutVerifySignature() throws JOSEException { + final var decoder = new NoSignatureJwtDecoder(); + final var token = JwtUtils.generateToken("me", "none"); + assertThat(decoder.decode(token), Matchers.notNullValue()); + } + + @Test + void givenExpiredTokenMustThrowError() throws JOSEException { + final var decoder = new NoSignatureJwtDecoder(); + final var token = JwtUtils.generateExpiredToken("me", "none"); + assertThrows(JwtValidationException.class, () -> decoder.decode(token)); + } +} + diff --git a/src/test/java/it/gov/pagopa/rtp/activator/controller/ActivationAPIControllerImplTest.java b/src/test/java/it/gov/pagopa/rtp/activator/controller/ActivationAPIControllerImplTest.java new file mode 100644 index 0000000..46178e9 --- /dev/null +++ b/src/test/java/it/gov/pagopa/rtp/activator/controller/ActivationAPIControllerImplTest.java @@ -0,0 +1,84 @@ +package it.gov.pagopa.rtp.activator.controller; + +import it.gov.pagopa.rtp.activator.configuration.SecurityConfig; +import it.gov.pagopa.rtp.activator.model.generated.ActivationReqDto; +import it.gov.pagopa.rtp.activator.model.generated.PayerDto; +import it.gov.pagopa.rtp.activator.utils.Users; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.reactive.server.WebTestClient; + +import java.util.UUID; + +import static it.gov.pagopa.rtp.activator.utils.Users.SERVICE_PROVIDER_ID; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; + + +@ExtendWith(SpringExtension.class) +@WebFluxTest(controllers = {ActivationAPIControllerImpl.class}) +@Import(SecurityConfig.class) +class ActivationAPIControllerImplTest { + @Autowired + ApplicationContext context; + + WebTestClient web; + + @BeforeEach + public void setup() { + web = WebTestClient + .bindToApplicationContext(this.context) + .apply(springSecurity()) + .configureClient() + .build(); + } + + @Test + @Users.RtpWriter + void shouldCreateNewActivation() { + web.post() + .uri("/activations") + .header("RequestId", UUID.randomUUID().toString()) + .header("Version", "v1") + .bodyValue(generateActivationRequest()) + .exchange() + .expectStatus().isEqualTo(HttpStatus.CREATED) + .expectHeader().exists(HttpHeaders.LOCATION); + } + + @Test + @WithMockUser(value = "another", roles = Users.ACTIVATION_WRITE_ROLE) + void authorizedUserShouldNotActivateForAnotherServiceProvider() { + web.post() + .uri("/activations") + .header("RequestId", UUID.randomUUID().toString()) + .header("Version", "v1") + .bodyValue(generateActivationRequest()) + .exchange() + .expectStatus().isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @WithMockUser + void userWithoutEnoughPermissionShouldNotCreateNewActivation() { + web.post() + .uri("/activations") + .header("RequestId", UUID.randomUUID().toString()) + .header("Version", "v1") + .bodyValue(generateActivationRequest()) + .exchange() + .expectStatus().isEqualTo(HttpStatus.FORBIDDEN); + } + + private ActivationReqDto generateActivationRequest() { + return new ActivationReqDto(new PayerDto("RSSMRA85T10A562S", SERVICE_PROVIDER_ID)); + } +} \ No newline at end of file diff --git a/src/test/java/it/gov/pagopa/rtp/activator/utils/JwtUtils.java b/src/test/java/it/gov/pagopa/rtp/activator/utils/JwtUtils.java new file mode 100644 index 0000000..951cbb5 --- /dev/null +++ b/src/test/java/it/gov/pagopa/rtp/activator/utils/JwtUtils.java @@ -0,0 +1,45 @@ +package it.gov.pagopa.rtp.activator.utils; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import java.util.Date; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public final class JwtUtils { + + public static String generateToken(String subject, String... roles) throws JOSEException { + return generateToken(subject, new Date(new Date().getTime() + 60 * 60 * 1000), roles); // 1 hour + } + + public static String generateExpiredToken(String subject, String... roles) throws JOSEException { + return generateToken(subject, new Date(new Date().getTime() - 60 * 60 * 1000), roles); // 1 hour ago + } + + private static String generateToken(String subject, Date expirationTime, String... roles) throws JOSEException { + JWSSigner signer = new MACSigner( + IntStream.range(0, 256).mapToObj(Integer::toString).collect(Collectors.joining()) + ); + + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(subject) + .claim("groups", roles) + .issuer("pagopa.it") + .expirationTime(expirationTime) + .build(); + + SignedJWT signedJWT = new SignedJWT( + new JWSHeader(JWSAlgorithm.HS256), + claimsSet); + + signedJWT.sign(signer); + return signedJWT.serialize(); + } + +} diff --git a/src/test/java/it/gov/pagopa/rtp/activator/utils/Users.java b/src/test/java/it/gov/pagopa/rtp/activator/utils/Users.java new file mode 100644 index 0000000..3ef1898 --- /dev/null +++ b/src/test/java/it/gov/pagopa/rtp/activator/utils/Users.java @@ -0,0 +1,22 @@ +package it.gov.pagopa.rtp.activator.utils; + +import org.springframework.security.test.context.support.WithMockUser; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +public class Users { + + public static final String SERVICE_PROVIDER_ID = "1234"; + + public static final String ACTIVATION_WRITE_ROLE = "write_rtp_activations"; + public static final String ACTIVATION_READ_ROLE = "read_rtp_activations"; + + @Retention(RetentionPolicy.RUNTIME) + @WithMockUser(value = SERVICE_PROVIDER_ID, roles = ACTIVATION_WRITE_ROLE) + public @interface RtpWriter { } + + @Retention(RetentionPolicy.RUNTIME) + @WithMockUser(value = SERVICE_PROVIDER_ID, roles = ACTIVATION_READ_ROLE) + public @interface RtpReader { } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..cf39e7e --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1 @@ +logging.level.org.springframework.security=DEBUG