Skip to content

Commit

Permalink
Task: [CCLS 2237] Improve accessibility for consuming apps (#5)
Browse files Browse the repository at this point in the history
* implement auth context holder, use checkstyle

* ignore casing in application properties

* update readme

* update readme
  • Loading branch information
farrell-m authored Jun 19, 2024
1 parent baf881b commit f5d1e2f
Show file tree
Hide file tree
Showing 20 changed files with 946 additions and 427 deletions.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,18 @@ This tells Gradle where to search for plugins. The plugins in this repository ar

Your credentials to the GitHub Packages repository need to be defined in your local `gradle.properties` file, which you can find in your home directory, e.g. `~/.gradle/gradle.properties`.

Please add the following parameters:
Before doing this, ensure you have [created a personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic)
in GitHub and configured it with `repo`, `write:packages` and `read:packages` access. The token must also be [authorized with (MoJ) SSO](https://docs.github.com/en/enterprise-cloud@latest/authentication/authenticating-with-saml-single-sign-on/authorizing-a-personal-access-token-for-use-with-saml-single-sign-on).

Once you have your personal access token, please add the following parameters to `~/.gradle/gradle.properties`:

```yaml
project.ext.gitPackageUser = <your GitHub username>
project.ext.gitPackageKey = <your GitHub access token>
```

Do not include `'` or `"` around your username or token as these are treated literally as part of the value by gradle.

### Applying the Plugin

In your (root) `build.gradle`, add the plugin dependency via the Gradle Plugin DSL, e.g:
Expand All @@ -96,6 +101,6 @@ apply plugin: 'uk.gov.laa.ccms.springboot.laa-ccms-spring-boot-gradle-plugin'

## Available Starters

- [Authentication](laa-ccms-spring-boot-starters/laa-ccms-spring-boot-starter-auth/README.md)
- _**[TODO]**_ Exception Handling
- _**[TODO]**_ Entity Converters
- _**[TODO]**_ Auth
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id 'net.researchgate.release'
id 'checkstyle'
}

subprojects {
Expand Down
382 changes: 382 additions & 0 deletions config/checkstyle/checkstyle.xml

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ springBootDependencyManagementPluginVersion = 1.1.5
# com.github.ben-manes:gradle-versions-plugin
gradleVersionsPluginVersion = 0.51.0

# checkstyle
checkstyleVersion = 10.12.4

javaVersion = 21
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,29 @@ graph

```

## OpenAPI Configuration

Included in the starter is an autoconfigured OpenAPI bean which will be set up with a security scheme for API key authentication that will be applied to all endpoints.

This will be enabled by default without any additional configuration, but can be disabled via application properties:

```yaml
laa.ccms.springboot.starter.open-api.security-scheme.enabled: false
```
This can be used if you would like to instead configure your own OpenAPI Bean.
However, if you would instead like to add to the provided bean, you can use OpenApiCustomizer from SpringDoc, e.g.
```java
@Bean
public OpenApiCustomizer customize() {
return openApi -> {
openApi.info(
new Info()
.title("API Title")
.description("API Description")
.version("API Version"));
};
}
```
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id 'spring-boot-starter-conventions'
id 'checkstyle'
}

dependencies {
Expand Down Expand Up @@ -27,6 +28,13 @@ dependencies {
testImplementation 'org.assertj:assertj-core:3.4.1'
}

checkstyle {
maxWarnings = 0
toolVersion = checkstyleVersion
sourceSets = sourceSets.main as SourceSetContainer
showViolations = true
}

publishing.publications {
library(MavenPublication) {
from components.java
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,60 +5,65 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
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.
* 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;
}
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 <code>AccessDeniedException</code>
* @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);
/**
* 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;
}

String status = Response.Status.FORBIDDEN.getReasonPhrase();
String message = accessDeniedException.getMessage();
/**
* 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 <code>AccessDeniedException</code>
* @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);

ErrorResponse errorResponse = new ErrorResponse(code, status, message);
String status = Response.Status.FORBIDDEN.getReasonPhrase();
String message = accessDeniedException.getMessage();

response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
ErrorResponse errorResponse = new ErrorResponse(code, status, message);

log.info("Request rejected for endpoint '{} {}': {}", request.getMethod(), request.getRequestURI(), message);
}
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));

log.info(
"Request rejected for endpoint '{} {}': {}",
request.getMethod(),
request.getRequestURI(),
message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
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 java.util.Collections;
import java.util.Optional;
import java.util.Set;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.InvalidPropertyException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
* Holds the authentication context for the API.
*/
@Slf4j
@Component
public class ApiAuthenticationContextHolder {

private final AuthenticationProperties authenticationProperties;

@Getter
private String authenticationHeader;

@Getter
private String[] unprotectedUris;

@Getter
private Set<ClientCredential> clientCredentials;

@Getter
private Set<AuthorizedRole> authorizedRoles;

@Autowired
public ApiAuthenticationContextHolder(AuthenticationProperties authenticationProperties) {
this.authenticationProperties = authenticationProperties;
}

/**
* Load authentication context, including authorized clients and roles from JSON.
*/
@PostConstruct
private void initialize() {
authenticationHeader = authenticationProperties.getAuthenticationHeader();
unprotectedUris = authenticationProperties.getUnprotectedUris();
initializeAuthorizedClients();
initializeAuthorizedRoles();
}

/**
* Initialise a set of {@link ClientCredential} from those configured as a JSON string in the
* application properties.
*/
private void initializeAuthorizedClients() {
try {
clientCredentials =
new ObjectMapper()
.readValue(
authenticationProperties.getAuthorizedClients(),
new TypeReference<Set<ClientCredential>>() {});
} 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());
}
}

/**
* Initialise a set of {@link AuthorizedRole} from those configured as a JSON string in the
* application properties.
*/
private void initializeAuthorizedRoles() {

try {
authorizedRoles =
new ObjectMapper()
.readValue(
authenticationProperties.getAuthorizedRoles(),
new TypeReference<Set<AuthorizedRole>>() {});
} 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());
}
}

/**
* 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
*/
public Optional<ClientCredential> getMatchingClientCredential(String accessToken) {
return clientCredentials.stream()
.filter(credential -> credential.token().equals(accessToken))
.findFirst();
}

/**
* 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 set of roles associated with the access token, if authorized
*/
public Set<String> getClientRoles(String accessToken) {
return getMatchingClientCredential(accessToken)
.map(ClientCredential::roles)
.orElse(Collections.emptySet());
}

/**
* Determine whether there is a client associated with the provided token.
*
* @param accessToken the client-provided access token
* @return {@code true} if the access token is authorized and {@code false} otherwise.
*/
public boolean clientExistsForToken(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
*/
public String getPrincipal(String accessToken) {
return getMatchingClientCredential(accessToken).map(ClientCredential::name).orElse(null);
}
}
Loading

0 comments on commit f5d1e2f

Please sign in to comment.