Skip to content

Commit

Permalink
Story: [CCLS 2191] Authentication Starter (#4)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
farrell-m authored Jun 7, 2024
1 parent 0b2c9e1 commit df23e9e
Show file tree
Hide file tree
Showing 24 changed files with 1,091 additions and 16 deletions.
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ subprojects {
it.jvmArgs = ["--add-opens=java.base/java.lang=ALL-UNNAMED"]
}

repositories {
mavenCentral()
gradlePluginPortal()
}

apply plugin: 'maven-publish'

publishing.repositories {
Expand Down
3 changes: 1 addition & 2 deletions buildSrc/src/main/groovy/gradle-plugin-conventions.gradle
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
plugins {
id 'groovy'
id 'java-gradle-plugin'
}

java {
toolchain.languageVersion.set(JavaLanguageVersion.of(javaVersion))
}

dependencies {
implementation gradleApi()
implementation localGroovy()
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ plugins {
id 'java-library'
}

group = 'uk.gov.laa.ccms.springboot'

java {
toolchain.languageVersion.set(JavaLanguageVersion.of(javaVersion))
}
Expand Down
5 changes: 0 additions & 5 deletions laa-ccms-java-gradle-plugin/build.gradle
Original file line number Diff line number Diff line change
@@ -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}"
}
Expand Down
10 changes: 2 additions & 8 deletions laa-ccms-spring-boot-gradle-plugin/build.gradle
Original file line number Diff line number Diff line change
@@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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<br>Authorized?"}
accessDeniedHandler["Access Denied Handler"]
businessLogic["Business Logic"]

subgraph securityContext["Security Context"]
creds["Credentials"]
end

authenticationCheck{"Client<br>Authenticated?"}

end

client -- <span style='color:black;font-weight:bold;font-size:25px' style=''>START</span><br>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<br>against role required<br>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

```


Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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 <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);

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);
}

}
Loading

0 comments on commit df23e9e

Please sign in to comment.