From df23e9e6b8a40fbd14984fcf20355790f33502cc Mon Sep 17 00:00:00 2001 From: farrell-m <168534673+farrell-m@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:03:59 +0100 Subject: [PATCH] Story: [CCLS 2191] Authentication Starter (#4) * add auth starter * publish library * return JSON response, add logging * log specific failure reason * clean up logging and documentation * fix log * add access denied handler * improved logging * add mermaid graph * update mermaid graph for clarity * address review comments * add openapi security scheme configuration, cleanup --- build.gradle | 5 + .../groovy/gradle-plugin-conventions.gradle | 3 +- .../spring-boot-starter-conventions.gradle | 2 + laa-ccms-java-gradle-plugin/build.gradle | 5 - .../build.gradle | 10 +- .../README.md | 170 ++++++++++++++++++ .../build.gradle | 39 ++++ .../auth/ApiAccessDeniedHandler.java | 64 +++++++ .../auth/ApiAuthenticationFilter.java | 74 ++++++++ .../auth/ApiAuthenticationService.java | 140 +++++++++++++++ .../auth/ApiAuthenticationToken.java | 35 ++++ .../auth/AuthenticationProperties.java | 44 +++++ .../ccms/springboot/auth/AuthorizedRole.java | 9 + .../springboot/auth/ClientCredential.java | 13 ++ .../ccms/springboot/auth/ErrorResponse.java | 26 +++ .../auth/MissingCredentialsException.java | 27 +++ .../SecurityFilterChainAutoConfiguration.java | 144 +++++++++++++++ .../auth/config/OpenApiConfiguration.java | 39 ++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../auth/ApiEndpointAuthenticationTest.java | 98 ++++++++++ .../gov/laa/ccms/springboot/auth/TestAPI.java | 52 ++++++ .../auth/config/OpenApiConfigurationTest.java | 59 ++++++ .../src/test/resources/application.yml | 45 +++++ settings.gradle | 3 +- 24 files changed, 1091 insertions(+), 16 deletions(-) create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/README.md create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/build.gradle create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAccessDeniedHandler.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationFilter.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationService.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationToken.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthenticationProperties.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthorizedRole.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ClientCredential.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ErrorResponse.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/MissingCredentialsException.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/SecurityFilterChainAutoConfiguration.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfiguration.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/ApiEndpointAuthenticationTest.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/TestAPI.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfigurationTest.java create mode 100644 laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/resources/application.yml diff --git a/build.gradle b/build.gradle index 5c1f7f6..22efbf1 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,11 @@ subprojects { it.jvmArgs = ["--add-opens=java.base/java.lang=ALL-UNNAMED"] } + repositories { + mavenCentral() + gradlePluginPortal() + } + apply plugin: 'maven-publish' publishing.repositories { diff --git a/buildSrc/src/main/groovy/gradle-plugin-conventions.gradle b/buildSrc/src/main/groovy/gradle-plugin-conventions.gradle index abc6128..2c3e1dc 100644 --- a/buildSrc/src/main/groovy/gradle-plugin-conventions.gradle +++ b/buildSrc/src/main/groovy/gradle-plugin-conventions.gradle @@ -1,5 +1,6 @@ plugins { id 'groovy' + id 'java-gradle-plugin' } java { @@ -7,8 +8,6 @@ java { } dependencies { - implementation gradleApi() - implementation localGroovy() testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' } diff --git a/buildSrc/src/main/groovy/spring-boot-starter-conventions.gradle b/buildSrc/src/main/groovy/spring-boot-starter-conventions.gradle index 46e4150..4feebdd 100644 --- a/buildSrc/src/main/groovy/spring-boot-starter-conventions.gradle +++ b/buildSrc/src/main/groovy/spring-boot-starter-conventions.gradle @@ -2,6 +2,8 @@ plugins { id 'java-library' } +group = 'uk.gov.laa.ccms.springboot' + java { toolchain.languageVersion.set(JavaLanguageVersion.of(javaVersion)) } diff --git a/laa-ccms-java-gradle-plugin/build.gradle b/laa-ccms-java-gradle-plugin/build.gradle index 262f189..ebe28aa 100644 --- a/laa-ccms-java-gradle-plugin/build.gradle +++ b/laa-ccms-java-gradle-plugin/build.gradle @@ -1,15 +1,10 @@ plugins { id 'gradle-plugin-conventions' - id 'java-gradle-plugin' id 'maven-publish' } group = 'uk.gov.laa.ccms.java' -repositories { - gradlePluginPortal() -} - dependencies { implementation "com.github.ben-manes:gradle-versions-plugin:${gradleVersionsPluginVersion}" } diff --git a/laa-ccms-spring-boot-gradle-plugin/build.gradle b/laa-ccms-spring-boot-gradle-plugin/build.gradle index 771f43f..0d0c44d 100644 --- a/laa-ccms-spring-boot-gradle-plugin/build.gradle +++ b/laa-ccms-spring-boot-gradle-plugin/build.gradle @@ -1,23 +1,17 @@ plugins { - id 'java-gradle-plugin' - id 'maven-publish' id 'java' id 'groovy' + id 'java-gradle-plugin' + id 'maven-publish' } group = 'uk.gov.laa.ccms.springboot' -repositories { - gradlePluginPortal() -} - java { toolchain.languageVersion.set(JavaLanguageVersion.of(javaVersion)) } dependencies { - implementation gradleApi() - implementation localGroovy() // Make sure we're using the same version of the Java plugin that we're adding into the starters implementation project(':laa-ccms-java-gradle-plugin') diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/README.md b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/README.md new file mode 100644 index 0000000..9b81853 --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/README.md @@ -0,0 +1,170 @@ +# LAA CCMS SpringBoot Authentication Starter + +This starter will enable authentication on endpoints you have specified in your application configuration. +Roles can be defined to categorise groups of endpoints under different levels of access. These roles can then be assigned +to clients. + +## Usage + +### Declare the dependency + +To enable this in your application, declare the following: + +```groovy +dependencies { + implementation 'uk.gov.laa.ccms.springboot:laa-ccms-spring-boot-starter-auth' +} +``` + +### Configure via application properties + +Here you will need to define several properties to ensure authentication behaves as expected: + +- `authentication-header` - The name of the HTTP header used to send and receive the API access token. +- `authorized-clients` - The list of clients who are authorized to access the API, and their roles. This is a JSON formatted string, with the top level being a list and each contained item representing a client's credentials, containing the name of the client, the roles it has access to and the access token associated with it. +- `authorized-roles` - The list of roles that can be used to access the API, and the URIs they enable access to. This is a JSON formatted string, with the top level being a list and each contained item representing an authorized role, containing the name of the role and the URIs that it enables access to. +- `unprotected-uris` - The list of URIs which do not require any authentication. These may be relating to API documentation, static resources or any other content which is not sensitive. + +Access tokens should be generated as a `UUID4` string. + +```yaml +laa.ccms.springboot.starter.auth: + authentication-header: "Authorization" + authorized-clients: '[ + { + "name": "client1", + "roles": [ + "GROUP1" + ], + "token": "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf" + }, + { + "name": "client2", + "roles": [ + "GROUP2" + ], + "token": "1fd84ad9-760d-401f-8cf0-7a80aa42566c" + }, + { + "name": "client3", + "roles": [ + "GROUP1", + "GROUP2" + ], + "token": "5d925478-a8a2-4b76-863a-3fb87dcbcb95" + } + ]' + authorized-roles: '[ + { + "name": "GROUP1", + "URIs": [ + "/resource1/requires-group1-role/**" + ] + }, + { + "name": "GROUP2", + "URIs": [ + "/*/requires-group2-role/**" + ] + } + ]' + unprotected-uris: [ "/actuator/**", "/resource1/unrestricted/**" ] +``` + +## Behaviour + +Authentication of endpoints will behave as follows. + +### Unprotected URIs + +Unprotected URIs will not require any authentication. Authentication headers will be ignored. + +### Protected URIs + +If a client attempts to access a protected URI, they will receive one of 3 responses depending on the scenario: + +- Invalid or no access token present / wrong header used: 401 Unauthorized +- Valid access token present, client's role **does not** permit access to the requested URI or the URI does not exist: 403 Forbidden +- Valid access token present, client's role **does** permit access to the requested URI: 2XX (Success) / normal response + +```mermaid +graph + + subgraph key["Key"] + green["Spring Security"] + blue["Auth Starter"] + end + + client["Client"] + + subgraph api["API"] + + subgraph filterChain["Filter Chain"] + authenticationFilter["API Authentication Filter"] + authorizationFilter["Authorization Filter"] + end + + authenticationService["API Authentication Service"] + + authorizationM["Authorization Manager"] + rmdAuthorizationM["RequestMatcherDelegatingAuthorizationManager"] + authorityAuthorizationM["Authority Authorization Manager"] + + authorizationCheck{"Client
Authorized?"} + accessDeniedHandler["Access Denied Handler"] + businessLogic["Business Logic"] + + subgraph securityContext["Security Context"] + creds["Credentials"] + end + + authenticationCheck{"Client
Authenticated?"} + + end + + client -- START
1. Request (protected endpoint) --> authenticationFilter + + authenticationFilter -- 2. Create authentication token --> authenticationService + + authenticationFilter -- 3. check authentication --> authenticationCheck + + authenticationCheck -- 4a. Yes - Store authentication token --> creds + + authenticationCheck -- 4b. No - 401 Unauthorized --> client + + authenticationFilter -- 5. doFilter --> authorizationFilter + + authorizationFilter -- 6. Get authentication token --> creds + + authorizationFilter -- 7. Check authorization --> authorizationM + + authorizationM --> rmdAuthorizationM + + rmdAuthorizationM -- 8. Identify matching request mapping--> rmdAuthorizationM + + rmdAuthorizationM --> authorityAuthorizationM + + authorityAuthorizationM -- 9. Compare client's role
against role required
for endpoint --> authorityAuthorizationM + + authorityAuthorizationM --> authorizationCheck + + authorizationCheck -- 10a. No --> accessDeniedHandler + accessDeniedHandler -- 11a. 403 Forbidden --> client + + authorizationCheck -- 10b. Yes --> businessLogic + businessLogic -- 11b. Normal response --> client + + + classDef green fill:#206020,stroke:#333,stroke-width:2px; + classDef blue fill:#002db3,stroke:#333,stroke-width:2px; + class green,authorizationFilter,authM,authP,providerM,securityContext,creds,authorizationM,rmdAuthorizationM,authorizationCheck,authorityAuthorizationM green + class blue,authenticationFilter,authenticationService,accessDeniedHandler,authenticationCheck blue + linkStyle 0 stroke-width:3px,stroke:black,color:black + + linkStyle 4 stroke:red,color:red + linkStyle 14 stroke:red,color:red + linkStyle 16 stroke:green,color:green + +``` + + diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/build.gradle b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/build.gradle new file mode 100644 index 0000000..0776a33 --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'spring-boot-starter-conventions' +} + +dependencies { + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + implementation(project(':laa-ccms-java-gradle-plugin')) { + transitive = false + } + + implementation 'io.swagger.core.v3:swagger-models:2.2.22' + + implementation 'org.springframework.boot:spring-boot-starter-web' + + implementation 'jakarta.servlet:jakarta.servlet-api' + + implementation 'jakarta.ws.rs:jakarta.ws.rs-api' + + implementation 'com.fasterxml.jackson.jakarta.rs:jackson-jakarta-rs-json-provider' + + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + testImplementation 'org.assertj:assertj-core:3.4.1' +} + +publishing.publications { + library(MavenPublication) { + from components.java + } +} + + +test { + useJUnitPlatform() +} diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAccessDeniedHandler.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAccessDeniedHandler.java new file mode 100644 index 0000000..bde9820 --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAccessDeniedHandler.java @@ -0,0 +1,64 @@ +package uk.gov.laa.ccms.springboot.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * Exception Handler for requests that have been authenticated, but do not have sufficient privileges to access + * the requested endpoint. + */ +@Slf4j +@Component +public class ApiAccessDeniedHandler implements AccessDeniedHandler { + + ObjectMapper objectMapper; + + /** + * Creates an instance of the handler, with an object mapper to write the request body. + * + * @param objectMapper for writing the request body. + */ + @Autowired + ApiAccessDeniedHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Constructs the response object to return to the client, with a 403 Forbidden status and matching + * response body using the {@link ErrorResponse} model. + * + * @param request that resulted in an AccessDeniedException + * @param response so that the client can be advised of the failure + * @param accessDeniedException that caused the invocation + * @throws IOException - + * @throws ServletException - + */ + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + int code = HttpServletResponse.SC_FORBIDDEN; + response.setStatus(code); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + String status = Response.Status.FORBIDDEN.getReasonPhrase(); + String message = accessDeniedException.getMessage(); + + ErrorResponse errorResponse = new ErrorResponse(code, status, message); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + + log.info("Request rejected for endpoint '{} {}': {}", request.getMethod(), request.getRequestURI(), message); + } + +} diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationFilter.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationFilter.java new file mode 100644 index 0000000..008a85f --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationFilter.java @@ -0,0 +1,74 @@ +package uk.gov.laa.ccms.springboot.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; + +import java.io.IOException; + +/** + * API access token authentication filter. + */ +@Slf4j +public class ApiAuthenticationFilter extends GenericFilterBean { + + ApiAuthenticationService authenticationService; + + ObjectMapper objectMapper; + + @Autowired + protected ApiAuthenticationFilter(ApiAuthenticationService authenticationService, ObjectMapper objectMapper) { + this.authenticationService = authenticationService; + this.objectMapper = objectMapper; + } + + /** + * Filter reponsible for authenticating the client which made the request. Successful authentication results in the + * authentication details being stored in the security context for further processing, and continuation of the + * filter chain. Unsuccessful authentication results in a 401 UNAUTHORIZED response. + * + * @param request the http request object + * @param response the http response object + * @param filterChain the current filter chain + * @throws IOException - + * @throws ServletException - + */ + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) + throws IOException, ServletException { + try { + Authentication authentication = authenticationService.getAuthentication((HttpServletRequest) request); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("Endpoint '{} {}' requested by {}.", ((HttpServletRequest) request).getMethod(), + ((HttpServletRequest) request).getRequestURI(), authentication.getPrincipal().toString()); + filterChain.doFilter(request, response); + } catch (AuthenticationException ex) { + int code = HttpServletResponse.SC_UNAUTHORIZED; + HttpServletResponse httpResponse = (HttpServletResponse) response; + httpResponse.setStatus(code); + httpResponse.setContentType(MediaType.APPLICATION_JSON_VALUE); + + String status = Response.Status.UNAUTHORIZED.getReasonPhrase(); + String message = ex.getMessage(); + + ErrorResponse errorResponse = new ErrorResponse(code, status, message); + + httpResponse.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + + log.info("Request rejected for endpoint '{} {}': {}", ((HttpServletRequest) request).getMethod(), + ((HttpServletRequest) request).getRequestURI(), message); + } + } +} \ No newline at end of file diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationService.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationService.java new file mode 100644 index 0000000..e42525c --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationService.java @@ -0,0 +1,140 @@ +package uk.gov.laa.ccms.springboot.auth; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.InvalidPropertyException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * API authentication service responsible for determining the authentication outcome for a given client request. + */ +@Slf4j +@Component +@EnableConfigurationProperties(AuthenticationProperties.class) +public class ApiAuthenticationService { + + private final AuthenticationProperties authenticationProperties; + + private Set clientCredentials; + + @Autowired + protected ApiAuthenticationService(AuthenticationProperties authenticationProperties) { + this.authenticationProperties = authenticationProperties; + } + + /** + * Initialise a set of {@link ClientCredential} from those configured as a JSON string in the application + * properties. + */ + @PostConstruct + private void initialise() { + try { + clientCredentials = new ObjectMapper().readValue(authenticationProperties.getAuthorizedClients() + , new TypeReference>(){}); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + + if (clientCredentials.isEmpty()) throw new InvalidPropertyException(AuthenticationProperties.class, + "authorizedClients", "At least one authorized client must be provided."); + + for (ClientCredential clientCredential : clientCredentials) { + log.info("Authorized Client Registered: '{}' Roles: {}", clientCredential.name(), clientCredential.roles().toString()); + } + } + + /** + * Authenticate the HTTP request, comparing the access token provided by the client against the list of authorized + * client details configured in the application properties. + * + * @param request the HTTP request made to the API + * @return {@link Authentication} outcome with the list of roles assumed by the client if successful + * @throws BadCredentialsException when authentication fails + */ + protected Authentication getAuthentication(HttpServletRequest request) { + String accessToken = request.getHeader(authenticationProperties.getAuthenticationHeader()); + + if (accessToken == null) { + throw new MissingCredentialsException("No API access token provided."); + } + + if (!isAuthorizedAccessToken(accessToken)) { + throw new BadCredentialsException("Invalid API access token provided."); + } + + List grantedAuthorities = getClientRoles(accessToken) + .stream() + .map(role -> "ROLE_" + role) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toUnmodifiableList()); + + if (grantedAuthorities.isEmpty()) grantedAuthorities = AuthorityUtils.NO_AUTHORITIES; + + return new ApiAuthenticationToken(getPrincipal(accessToken), accessToken, grantedAuthorities); + } + + /** + * Retrieve a list of roles associated with the client, based on the access token provided. If the client is + * not in the authorized list, no roles are returned. + * + * @param accessToken the client-provided access token + * @return the list of roles associated with the access token, if authorized + */ + private Set getClientRoles(String accessToken) { + return getMatchingClientCredential(accessToken) + .map(ClientCredential::roles) + .orElse(Collections.emptySet()); + } + + /** + * Determine whether an access token is authorized. + * + * @param accessToken the client-provided access token + * @return {@code true} if the access token is authorized and {@code false} otherwise. + */ + private boolean isAuthorizedAccessToken(String accessToken) { + return getMatchingClientCredential(accessToken) + .isPresent(); + } + + /** + * Retrieve the principal (client name) based on the access token provided. + * + * @param accessToken the client-provided access token + * @return the principal (client name) associated with the access token + */ + private String getPrincipal(String accessToken) { + return getMatchingClientCredential(accessToken) + .map(ClientCredential::name) + .orElse(null); + } + + /** + * Retrieve the client details based on the access token provided. + * + * @param accessToken the client-provided access token + * @return the {@link ClientCredential} associated with the access token + */ + private Optional getMatchingClientCredential(String accessToken) { + return clientCredentials.stream() + .filter(credential -> credential.token().equals(accessToken)) + .findFirst(); + } +} diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationToken.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationToken.java new file mode 100644 index 0000000..9ac44fe --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ApiAuthenticationToken.java @@ -0,0 +1,35 @@ +package uk.gov.laa.ccms.springboot.auth; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +/** + * The API {@link Authentication} token representing a successfully authenticated client. + */ +public class ApiAuthenticationToken extends AbstractAuthenticationToken { + + private final String clientName; + private final String accessToken; + + public ApiAuthenticationToken(String clientName, String accessToken, + Collection authorities) { + super(authorities); + this.clientName = clientName; + this.accessToken = accessToken; + this.setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return accessToken; + } + + @Override + public Object getPrincipal() { + return clientName; + } + +} diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthenticationProperties.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthenticationProperties.java new file mode 100644 index 0000000..611f483 --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthenticationProperties.java @@ -0,0 +1,44 @@ +package uk.gov.laa.ccms.springboot.auth; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Authentication properties that must be configured by an API that is implementing authentication using this library. + */ +@Getter +@Setter +@ConfigurationProperties(prefix = "laa.ccms.springboot.starter.auth") +public class AuthenticationProperties { + + /** + * The name of the HTTP header used to store the API access token. + */ + @NotBlank(message = "authenticationHeader is required") + private String authenticationHeader; + + /** + * The list of clients who are authorized to access the API, and their roles + * JSON formatted string, with the top level being a list and each contained item + * representing a {@link ClientCredential}. + */ + @NotBlank(message = "authorizedClients is required") + private String authorizedClients; + + /** + * The list of roles that can be used to access the API, and the URIs they enable access to. + * JSON formatted string, with the top level being a list and each contained item representing + * an {@link AuthorizedRole}. + */ + @NotBlank(message = "authorizedRoles is required") + private String authorizedRoles; + + /** + * The list of URIs which do not require any authentication. + */ + @NotBlank(message = "unprotectedURIs is required") + private String[] unprotectedURIs; + +} diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthorizedRole.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthorizedRole.java new file mode 100644 index 0000000..486249a --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/AuthorizedRole.java @@ -0,0 +1,9 @@ +package uk.gov.laa.ccms.springboot.auth; + +/** + * Holds a list of URIs available for a given role. + * + * @param name the name of the role + * @param URIs the URIs that are accessible to clients that have this role + */ +public record AuthorizedRole(String name, String[] URIs) { } diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ClientCredential.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ClientCredential.java new file mode 100644 index 0000000..0cb6e2e --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ClientCredential.java @@ -0,0 +1,13 @@ +package uk.gov.laa.ccms.springboot.auth; + +import java.util.Set; + +/** + * Holds reference authentication information for one client, populated from details configured in application + * properties. + * + * @param name the name of the client + * @param token the access token designated to the client + * @param roles the roles designated to the client, which determine level of access + */ +public record ClientCredential(String name, String token, Set roles) { } diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ErrorResponse.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ErrorResponse.java new file mode 100644 index 0000000..b0de1b2 --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/ErrorResponse.java @@ -0,0 +1,26 @@ +package uk.gov.laa.ccms.springboot.auth; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class ErrorResponse { + + /** + * The HTTP Status code + */ + private int code; + + /** + * The HTTP Status description + */ + private String status; + + /** + * A message providing further information about the error + */ + private String message; +} diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/MissingCredentialsException.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/MissingCredentialsException.java new file mode 100644 index 0000000..9d5ade4 --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/MissingCredentialsException.java @@ -0,0 +1,27 @@ +package uk.gov.laa.ccms.springboot.auth; + + +import org.springframework.security.core.AuthenticationException; + +public class MissingCredentialsException extends AuthenticationException { + + /** + * Creates an instance of {@Code MissingCredentialsException} with a message and original cause. + * + * @param msg a message providing information about the exception + * @param cause the original cause of this exception + */ + public MissingCredentialsException(String msg, Throwable cause) { + super(msg, cause); + } + + /** + * Creates an instance of {@Code MissingCredentialsException} with a message. + * + * @param msg a message providing information about the exception + */ + public MissingCredentialsException(String msg) { + super(msg); + } + +} diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/SecurityFilterChainAutoConfiguration.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/SecurityFilterChainAutoConfiguration.java new file mode 100644 index 0000000..8a22977 --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/SecurityFilterChainAutoConfiguration.java @@ -0,0 +1,144 @@ +package uk.gov.laa.ccms.springboot.auth; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.InvalidPropertyException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +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; + +import java.util.Set; + + +/** + * Configuration of security filter chains to determine authentication behavior per endpoint (group). + * See HTTP Security Configuration. + */ +@Slf4j +@AutoConfiguration +@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) +@ComponentScan +@EnableConfigurationProperties(AuthenticationProperties.class) +public class SecurityFilterChainAutoConfiguration { + + private final AuthenticationProperties authenticationProperties; + + private final ApiAuthenticationService apiAuthenticationService; + + private Set authorizedRoles; + + private final ObjectMapper objectMapper; + + @Autowired + public SecurityFilterChainAutoConfiguration(AuthenticationProperties authenticationProperties, + ApiAuthenticationService apiAuthenticationService, + ObjectMapper objectMapper) { + this.authenticationProperties = authenticationProperties; + this.apiAuthenticationService = apiAuthenticationService; + this.objectMapper = objectMapper; + } + + /** + * Initialise a set of {@link AuthorizedRole} from those configured as a JSON string in the application properties. + */ + @PostConstruct + private void initialise() { + + try { + authorizedRoles = new ObjectMapper().readValue(authenticationProperties.getAuthorizedRoles() + , new TypeReference>(){}); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + + if (authorizedRoles.isEmpty()) throw new InvalidPropertyException(AuthenticationProperties.class, + "authorizedRoles", "At least one authorized role must be provided."); + + for (AuthorizedRole authorizedRole : authorizedRoles) { + log.info("Authorized Role Registered: '{}'", authorizedRole.name()); + } + } + + + /** + * First security filter chain to allow requests to unprotected URLs regardless of whether authentication + * credentials have been provided. + * + * @param httpSecurity web based security configuration customizer + * @return The {@link SecurityFilterChain} to continue with successive security filters. + * @throws Exception - + */ + @Bean + @Order(1) + public SecurityFilterChain filterUnprotectedURIs(HttpSecurity httpSecurity) throws Exception { + + httpSecurity.securityMatcher(authenticationProperties.getUnprotectedURIs()) + .authorizeHttpRequests(authorize -> authorize + .anyRequest().permitAll() + ) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagement -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ); + + return httpSecurity.build(); + + } + + /** + * Second security filter chain to authenticate against endpoints based on roles configured in application + * properties. + * + * @param httpSecurity web based security configuration customizer + * @return The {@link SecurityFilterChain} to continue with successive security filters. + * @throws Exception - + */ + @Bean + @Order(2) + public SecurityFilterChain filterProtectedURIs(HttpSecurity httpSecurity) throws Exception { + + httpSecurity.authorizeHttpRequests(customizer -> { + for (AuthorizedRole authorizedRole : authorizedRoles) { + customizer.requestMatchers(authorizedRole.URIs()).hasRole(authorizedRole.name()); + } + // Deny requests to any other endpoint by default + customizer.anyRequest().denyAll(); + } + ); + + ApiAuthenticationFilter apiAuthenticationFilter = new ApiAuthenticationFilter(apiAuthenticationService, + objectMapper); + + httpSecurity.csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagement -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .addFilterBefore(apiAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exceptionHandling -> { + exceptionHandling.accessDeniedHandler(new ApiAccessDeniedHandler(objectMapper)); + }); + + return httpSecurity.build(); + + } + +} diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfiguration.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfiguration.java new file mode 100644 index 0000000..38c2824 --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfiguration.java @@ -0,0 +1,39 @@ +package uk.gov.laa.ccms.springboot.auth.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +@ConditionalOnProperty(value = "laa.ccms.springboot.starter.open-api.security-scheme.enabled", matchIfMissing = true) +public class OpenApiConfiguration { + + @Value("${laa.ccms.springboot.starter.auth.authentication-header}") + String authenticationHeader; + + @Bean + public OpenAPI openAPI() { + String securitySchemeName = "ApiKeyAuth"; + OpenAPI openApiSpec = new OpenAPI() + .components( + new Components() + .addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name(authenticationHeader))) + .addSecurityItem( + new SecurityRequirement() + .addList(securitySchemeName)); + log.info("OpenAPI Security Scheme '{}' added for all endpoints.", securitySchemeName); + return openApiSpec; + } + +} diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..e32432a --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +uk.gov.laa.ccms.springboot.auth.SecurityFilterChainAutoConfiguration \ No newline at end of file diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/ApiEndpointAuthenticationTest.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/ApiEndpointAuthenticationTest.java new file mode 100644 index 0000000..ded5c92 --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/ApiEndpointAuthenticationTest.java @@ -0,0 +1,98 @@ +package uk.gov.laa.ccms.springboot.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +public class ApiEndpointAuthenticationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + public void testNonExistentEndpointUnauthorized() throws Exception { + mockMvc.perform(get("/resource1/restricted/invalid")) + .andExpect(status().is(HttpStatus.UNAUTHORIZED.value())); + } + + @Test + public void testInvalidUnrestrictedEndpointNotFound() throws Exception { + mockMvc.perform(get("/resource1/unrestricted/invalid")) + .andExpect(status().is(HttpStatus.NOT_FOUND.value())); + } + + @Test + public void testRestrictedEndpointInvalidTokenUnauthorized() throws Exception { + mockMvc.perform(get("/resource1/restricted").header(HttpHeaders.AUTHORIZATION, "invalid-token")) + .andExpect(status().is(HttpStatus.UNAUTHORIZED.value())); + } + + @Test + public void testRestrictedEndpointInvalidRoleForbidden() throws Exception { + mockMvc.perform(get("/resource1/restricted").header(HttpHeaders.AUTHORIZATION, "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf")) + .andExpect(status().is(HttpStatus.FORBIDDEN.value())); + } + + @Test + public void testGroup1EndpointValidTokenAuthorized() throws Exception { + mockMvc.perform(get("/resource1/requires-group1-role").header(HttpHeaders.AUTHORIZATION, "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf")) + .andExpect(status().is(HttpStatus.NO_CONTENT.value())); + } + + @Test + public void testGroup1EndpointInvalidRoleForbidden() throws Exception { + mockMvc.perform(get("/resource1/requires-group1-role").header(HttpHeaders.AUTHORIZATION, "1fd84ad9-760d-401f-8cf0-7a80aa42566c")) + .andExpect(status().is(HttpStatus.FORBIDDEN.value())); + } + + @Test + public void testGroup2EndpointValidTokenAuthorized() throws Exception { + mockMvc.perform(get("/resource1/requires-group2-role").header(HttpHeaders.AUTHORIZATION, "1fd84ad9-760d-401f-8cf0-7a80aa42566c")) + .andExpect(status().is(HttpStatus.NO_CONTENT.value())); + } + + @Test + public void testGroup2EndpointInvalidRoleForbidden() throws Exception { + mockMvc.perform(get("/resource1/requires-group2-role").header(HttpHeaders.AUTHORIZATION, "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf")) + .andExpect(status().is(HttpStatus.FORBIDDEN.value())); + } + + @Test + public void testUnrestrictedEndpointNoTokenAuthorized() throws Exception { + mockMvc.perform(get("/resource1/unrestricted")) + .andExpect(status().is(HttpStatus.NO_CONTENT.value())); + } + + @Test + public void testClientWithMultipleRolesAuthorized() throws Exception { + mockMvc.perform(get("/resource1/requires-group1-role").header(HttpHeaders.AUTHORIZATION, "5d925478-a8a2-4b76-863a-3fb87dcbcb95")) + .andExpect(status().is(HttpStatus.NO_CONTENT.value())); + + mockMvc.perform(get("/resource2/requires-group2-role").header(HttpHeaders.AUTHORIZATION, "5d925478-a8a2-4b76-863a-3fb87dcbcb95")) + .andExpect(status().is(HttpStatus.NO_CONTENT.value())); + } + + @Test + public void testRoleWithMultipleEndpointsAuthorized() throws Exception { + mockMvc.perform(get("/resource1/requires-group2-role").header(HttpHeaders.AUTHORIZATION, "1fd84ad9-760d-401f-8cf0-7a80aa42566c")) + .andExpect(status().is(HttpStatus.NO_CONTENT.value())); + + mockMvc.perform(get("/resource2/requires-group2-role").header(HttpHeaders.AUTHORIZATION, "1fd84ad9-760d-401f-8cf0-7a80aa42566c")) + .andExpect(status().is(HttpStatus.NO_CONTENT.value())); + } + + @Test void testNonExistentRestrictedEndpointNotFound() throws Exception { + mockMvc.perform(get("/resource1/requires-group1-role/does-not-exist").header(HttpHeaders.AUTHORIZATION, "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf")) + .andExpect(status().is(HttpStatus.NOT_FOUND.value())); + } + +} \ No newline at end of file diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/TestAPI.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/TestAPI.java new file mode 100644 index 0000000..aba11f1 --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/TestAPI.java @@ -0,0 +1,52 @@ +package uk.gov.laa.ccms.springboot.auth; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootApplication +public class TestAPI { + + @RestController + @RequestMapping(value = "/resource1") + public class TestResource1Controller { + + @GetMapping(path = "/unrestricted") + public ResponseEntity returnUnrestricted() { + return noContent(); + } + + @GetMapping(path = "/restricted") + public ResponseEntity returnRestricted() { + return noContent(); + } + + @GetMapping(path = "/requires-group1-role") + public ResponseEntity returnGroup1RoleRestricted() { + return noContent(); + } + + @GetMapping(path = "/requires-group2-role") + public ResponseEntity returnGroup2RoleRestricted() { + return noContent(); + } + + } + + @RestController + @RequestMapping(value = "/resource2") + public class TestResource2Controller { + + @GetMapping(path = "/requires-group2-role") + public ResponseEntity returnGroup2RoleRestricted() { + return noContent(); + } + + } + + private ResponseEntity noContent() { + return ResponseEntity.noContent().build(); + } +} diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfigurationTest.java b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfigurationTest.java new file mode 100644 index 0000000..f34b8ff --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/java/uk/gov/laa/ccms/springboot/auth/config/OpenApiConfigurationTest.java @@ -0,0 +1,59 @@ +package uk.gov.laa.ccms.springboot.auth.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class OpenApiConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withBean(OpenApiConfiguration.class) + .withPropertyValues("laa.ccms.springboot.starter.auth.authentication-header=Authorization"); + + private final String OPEN_API_CONFIGURATION_BEAN = "openApiConfiguration"; + private final String OPEN_API_BEAN = "openAPI"; + private final String SECURITY_SCHEME_NAME = "ApiKeyAuth"; + + @Test + void testOpenApiBeanIsCreatedWhenApplicationPropertyOmitted() { + contextRunner.run((context) -> { + assertThat(context).hasBean(OPEN_API_CONFIGURATION_BEAN); + assertSecuritySchemeApplied(context); + }); + } + + @Test + void testOpenApiBeanIsCreatedWhenApplicationPropertyEnabled() { + contextRunner.withPropertyValues("laa.ccms.springboot.starter.open-api.security-scheme.enabled=true").run((context) -> { + assertThat(context).hasBean(OPEN_API_CONFIGURATION_BEAN); + assertSecuritySchemeApplied(context); + }); + } + + @Test + void testNoOpenApiBeanIsCreatedWhenApplicationPropertyDisabled() { + contextRunner.withPropertyValues("laa.ccms.springboot.starter.open-api.security-scheme.enabled=false").run((context) -> { + assertThat(context).doesNotHaveBean(OPEN_API_CONFIGURATION_BEAN); + assertThat(context).doesNotHaveBean(OPEN_API_BEAN); + }); + } + + private void assertSecuritySchemeApplied(AssertableApplicationContext context) { + OpenAPI openApiSpec = context.getBean(OPEN_API_BEAN, OpenAPI.class); + assertThat(openApiSpec.getComponents().getSecuritySchemes()).isEqualTo(Map.of(SECURITY_SCHEME_NAME, getSecurityScheme())); + } + + private SecurityScheme getSecurityScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + } + +} diff --git a/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/resources/application.yml b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/resources/application.yml new file mode 100644 index 0000000..7a453be --- /dev/null +++ b/laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/src/test/resources/application.yml @@ -0,0 +1,45 @@ +laa.ccms.springboot.starter.auth: + authentication-header: "Authorization" + authorized-clients: '[ + { + "name": "client1", + "roles": [ + "GROUP1" + ], + "token": "b7bbdb3d-d0b9-4632-b752-b2e0f9486baf" + }, + { + "name": "client2", + "roles": [ + "GROUP2" + ], + "token": "1fd84ad9-760d-401f-8cf0-7a80aa42566c" + }, + { + "name": "client3", + "roles": [ + "GROUP1", + "GROUP2" + ], + "token": "5d925478-a8a2-4b76-863a-3fb87dcbcb95" + } + ]' + authorized-roles: '[ + { + "name": "GROUP1", + "URIs": [ + "/resource1/requires-group1-role/**" + ] + }, + { + "name": "GROUP2", + "URIs": [ + "/*/requires-group2-role/**" + ] + } + ]' + unprotected-uris: [ "/actuator/**", "/resource1/unrestricted/**" ] + +logging: + level: + root: INFO \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 8042e0e..fc9a209 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,5 +2,6 @@ rootProject.name = 'laa-ccms-spring-boot-common' include ':laa-ccms-spring-boot-dependencies' include ':laa-ccms-spring-boot-gradle-plugin' -include ':laa-ccms-spring-boot-starters' include ':laa-ccms-java-gradle-plugin' +include ':laa-ccms-spring-boot-starters' +include ':laa-ccms-spring-boot-starters:laa-ccms-spring-boot-starter-auth'