diff --git a/helm/values.yaml b/helm/values.yaml index c1373c1..396d277 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -61,6 +61,7 @@ microservice-chart: APPLICATIONINSIGHTS_PREVIEW_PROFILER_ENABLED: "false" ENABLE_AUDIT_APPENDER: "TRUE" + AUTH_SERVER_BASE_URL: "http://p4pa-auth-microservice-chart:8080/payhub" envSecret: APPLICATIONINSIGHTS_CONNECTION_STRING: appinsights-connection-string diff --git a/src/main/java/it/gov/pagopa/payhub/pdnd/dto/auth/UserInfoDTO.java b/src/main/java/it/gov/pagopa/payhub/pdnd/dto/auth/UserInfoDTO.java new file mode 100644 index 0000000..2580a49 --- /dev/null +++ b/src/main/java/it/gov/pagopa/payhub/pdnd/dto/auth/UserInfoDTO.java @@ -0,0 +1,33 @@ +package it.gov.pagopa.payhub.pdnd.dto.auth; + +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Data +@Builder +@AllArgsConstructor +@RequiredArgsConstructor +public class UserInfoDTO { + + private String userId; + private String mappedExternalUserId; + @ToString.Exclude + private String fiscalCode; + @ToString.Exclude + private String familyName; + @ToString.Exclude + private String name; + @ToString.Exclude + private String email; + private String issuer; + private String organizationAccess; + @Builder.Default + private List organizations = new ArrayList<>(); + +} + diff --git a/src/main/java/it/gov/pagopa/payhub/pdnd/dto/auth/UserOrganizationRolesDTO.java b/src/main/java/it/gov/pagopa/payhub/pdnd/dto/auth/UserOrganizationRolesDTO.java new file mode 100644 index 0000000..c6d13aa --- /dev/null +++ b/src/main/java/it/gov/pagopa/payhub/pdnd/dto/auth/UserOrganizationRolesDTO.java @@ -0,0 +1,25 @@ +package it.gov.pagopa.payhub.pdnd.dto.auth; + +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Data +@Builder +@AllArgsConstructor +@RequiredArgsConstructor +public class UserOrganizationRolesDTO { + + private String operatorId; + private String organizationIpaCode; + @ToString.Exclude + private String email; + @Builder.Default + private List roles = new ArrayList<>(); + +} + diff --git a/src/main/java/it/gov/pagopa/payhub/pdnd/exception/custom/InvalidAccessTokenException.java b/src/main/java/it/gov/pagopa/payhub/pdnd/exception/custom/InvalidAccessTokenException.java new file mode 100644 index 0000000..e3548da --- /dev/null +++ b/src/main/java/it/gov/pagopa/payhub/pdnd/exception/custom/InvalidAccessTokenException.java @@ -0,0 +1,7 @@ +package it.gov.pagopa.payhub.pdnd.exception.custom; + +public class InvalidAccessTokenException extends RuntimeException { + public InvalidAccessTokenException(String message) { + super(message); + } +} 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 new file mode 100644 index 0000000..3cbe1b9 --- /dev/null +++ b/src/main/java/it/gov/pagopa/payhub/pdnd/security/JwtAuthenticationFilter.java @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..06f7ea0 --- /dev/null +++ b/src/main/java/it/gov/pagopa/payhub/pdnd/security/WebSecurityConfig.java @@ -0,0 +1,47 @@ +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/service/auth/AuthorizationService.java b/src/main/java/it/gov/pagopa/payhub/pdnd/service/auth/AuthorizationService.java new file mode 100644 index 0000000..d4a77c5 --- /dev/null +++ b/src/main/java/it/gov/pagopa/payhub/pdnd/service/auth/AuthorizationService.java @@ -0,0 +1,50 @@ +package it.gov.pagopa.payhub.pdnd.service.auth; + +import it.gov.pagopa.payhub.pdnd.dto.auth.UserInfoDTO; +import it.gov.pagopa.payhub.pdnd.exception.custom.InvalidAccessTokenException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.DefaultUriBuilderFactory; + +@Service +@Slf4j +public class AuthorizationService { + + private final RestTemplate restTemplate; + + public AuthorizationService(@Value("${app.auth.base-url}") String authServerBaseUrl, + RestTemplateBuilder restTemplateBuilder) { + DefaultUriBuilderFactory ubf = new DefaultUriBuilderFactory(authServerBaseUrl); + ubf.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.VALUES_ONLY); + this.restTemplate = restTemplateBuilder + .uriTemplateHandler(ubf) + .build(); + } + + public UserInfoDTO validateToken(String accessToken){ + log.info("Requesting validate token"); + try{ + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); + return restTemplate.exchange("/auth/userinfo", HttpMethod.GET, new HttpEntity<>(headers), UserInfoDTO.class).getBody(); + } catch (HttpStatusCodeException ex){ + String errorMessage; + if(HttpStatus.UNAUTHORIZED.equals(ex.getStatusCode())){ + errorMessage="Bad Access Token provided"; + log.info(errorMessage); + } else { + errorMessage="Something gone wrong while validate token"; + log.error(errorMessage, ex); + } + throw new InvalidAccessTokenException(errorMessage); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7e82ace..096b5df 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,4 +36,6 @@ app: publicKey: "\${PDND_SERVICE_ANPR_C030_PUBLICKEY:\${PDND_SERVICE_ANPR_PUBLICKEY:\${PDND_SERVICE_PUBLICKEY:}}}" rest-client: connect.timeout.millis: "\${CONNECT_TIMEOUT_MILLIS:120000}" - read.timeout.millis: "\${READ_TIMEOUT_MILLIS:120000}" \ No newline at end of file + read.timeout.millis: "\${READ_TIMEOUT_MILLIS:120000}" + auth: + base-url: "\${AUTH_SERVER_BASE_URL:https://auth-server/api}" \ No newline at end of file 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 new file mode 100644 index 0000000..1cda4e2 --- /dev/null +++ b/src/test/java/it/gov/pagopa/payhub/pdnd/security/JwtAuthenticationFilterTest.java @@ -0,0 +1,123 @@ +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/service/auth/AuthorizationServiceTest.java b/src/test/java/it/gov/pagopa/payhub/pdnd/service/auth/AuthorizationServiceTest.java new file mode 100644 index 0000000..df3e0b7 --- /dev/null +++ b/src/test/java/it/gov/pagopa/payhub/pdnd/service/auth/AuthorizationServiceTest.java @@ -0,0 +1,95 @@ +package it.gov.pagopa.payhub.pdnd.service.auth; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.maciejwalkowiak.wiremock.spring.ConfigureWireMock; +import com.maciejwalkowiak.wiremock.spring.EnableWireMock; +import com.maciejwalkowiak.wiremock.spring.InjectWireMock; + +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 java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; + +@SpringBootTest( + classes = {AuthorizationService.class, AuthorizationServiceTest.AuthorizationServiceTestConfiguration.class}, + webEnvironment = SpringBootTest.WebEnvironment.NONE) +@EnableWireMock({ + @ConfigureWireMock(name = "auth-server", properties = "app.auth.base-url") +}) +@EnableConfigurationProperties +class AuthorizationServiceTest { + + @Autowired + private AuthorizationService authorizationService; + + @InjectWireMock(value = "auth-server") + private WireMockServer wireMockServer; + + @TestConfiguration + public static class AuthorizationServiceTestConfiguration { + @Bean + public RestTemplateBuilder restTemplateBuilder() { + return new RestTemplateBuilder(); + } + } + + @Test + void givenValidAccessTokenWhenValidateTokenThenOk() { + // When + UserInfoDTO result = authorizationService.validateToken("ACCESSTOKEN"); + + // Then + Assertions.assertEquals( + UserInfoDTO.builder() + .userId("e1d9c534-86a9-4039-80da-8aa7a33ac9e7") + .fiscalCode("DMEMPY15L21L736U") + .familyName("demo") + .name("demo") + .issuer("https://dev.selfcare.pagopa.it") + .organizationAccess("SELC_99999999990") + .organizations(List.of(UserOrganizationRolesDTO.builder() + .operatorId("133e9c1b-dfc5-43ea-98a7-f64f30613074") + .organizationIpaCode("SELC_99999999990") + .roles(List.of("ROLE_ADMIN")) + .build())) + .build(), + result + ); + } + + @Test + void givenInvalidAccessTokenWhenValidateTokenThenInvalidAccessTokenException() { + // When + InvalidAccessTokenException result = Assertions.assertThrows(InvalidAccessTokenException.class, + () -> authorizationService.validateToken("INVALIDACCESSTOKEN")); + + // Then + Assertions.assertEquals( + "Bad Access Token provided", + result.getMessage() + ); + } + + @Test + void givenUnexpectedErrorWhenValidateTokenThenIDInvalidAccessTokenException() { + // When + InvalidAccessTokenException result = Assertions.assertThrows(InvalidAccessTokenException.class, + () -> authorizationService.validateToken("EXCEPTION")); + + // Then + Assertions.assertEquals( + "Something gone wrong while validate token", + result.getMessage() + ); + } + + +} \ No newline at end of file diff --git a/src/test/resources/wiremock/auth-server/mappings/validateTokenException.json b/src/test/resources/wiremock/auth-server/mappings/validateTokenException.json new file mode 100644 index 0000000..ad862f9 --- /dev/null +++ b/src/test/resources/wiremock/auth-server/mappings/validateTokenException.json @@ -0,0 +1,14 @@ +{ + "request": { + "method": "GET", + "urlPath": "/auth/userinfo", + "headers": { + "Authorization": { + "equalTo": "Bearer EXCEPTION" + } + } + }, + "response": { + "status": "500" + } +} \ No newline at end of file diff --git a/src/test/resources/wiremock/auth-server/mappings/validateTokenForbidden.json b/src/test/resources/wiremock/auth-server/mappings/validateTokenForbidden.json new file mode 100644 index 0000000..daea8dd --- /dev/null +++ b/src/test/resources/wiremock/auth-server/mappings/validateTokenForbidden.json @@ -0,0 +1,14 @@ +{ + "request": { + "method": "GET", + "urlPath": "/auth/userinfo", + "headers": { + "Authorization": { + "equalTo": "Bearer INVALIDACCESSTOKEN" + } + } + }, + "response": { + "status": "401" + } +} \ No newline at end of file diff --git a/src/test/resources/wiremock/auth-server/mappings/validateTokenOK.json b/src/test/resources/wiremock/auth-server/mappings/validateTokenOK.json new file mode 100644 index 0000000..a9d597a --- /dev/null +++ b/src/test/resources/wiremock/auth-server/mappings/validateTokenOK.json @@ -0,0 +1,34 @@ +{ + "request": { + "method": "GET", + "urlPath": "/auth/userinfo", + "headers": { + "Authorization": { + "equalTo": "Bearer ACCESSTOKEN" + } + } + }, + "response": { + "status": "200", + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "userId": "e1d9c534-86a9-4039-80da-8aa7a33ac9e7", + "fiscalCode": "DMEMPY15L21L736U", + "familyName": "demo", + "name": "demo", + "issuer": "https://dev.selfcare.pagopa.it", + "organizationAccess": "SELC_99999999990", + "organizations": [ + { + "operatorId": "133e9c1b-dfc5-43ea-98a7-f64f30613074", + "organizationIpaCode": "SELC_99999999990", + "roles": [ + "ROLE_ADMIN" + ] + } + ] + } + } +} \ No newline at end of file