Skip to content

Commit

Permalink
add auth starter
Browse files Browse the repository at this point in the history
  • Loading branch information
farrell-m committed May 28, 2024
1 parent 0b2c9e1 commit d0469c3
Show file tree
Hide file tree
Showing 18 changed files with 712 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
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,72 @@
# 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 level 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/**" ]
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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 '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'
}

test {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package uk.gov.laa.ccms.springboot.auth;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import java.io.IOException;

/**
* API access token authentication filter.
*/
public class ApiAuthenticationFilter extends GenericFilterBean {

ApiAuthenticationService authenticationService;

@Autowired
protected ApiAuthenticationFilter(ApiAuthenticationService authenticationService) {
this.authenticationService = authenticationService;
}

/**
* Filter reponsible for authenticating the client the 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 -
*/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws IOException {
try {
Authentication authentication = authenticationService.getAuthentication((HttpServletRequest) request);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} catch (Exception ex) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpResponse.getWriter().write(ex.getMessage());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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 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.Service;

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.
*/
@Service
@EnableConfigurationProperties(AuthenticationProperties.class)
public class ApiAuthenticationService {

private final AuthenticationProperties authenticationProperties;

private Set<ClientCredential> 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<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.");
}

/**
* 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 || !isAuthorizedAccessToken(accessToken)) {
throw new BadCredentialsException("Missing or invalid API access token");
}

Optional<ClientCredential> clientCredential = getMatchingClientCredential(accessToken);

List<GrantedAuthority> 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<String> 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<ClientCredential> getMatchingClientCredential(String accessToken) {
return clientCredentials.stream()
.filter(credential -> credential.token().equals(accessToken))
.findFirst();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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<? extends GrantedAuthority> authorities) {
super(authorities);
this.clientName = clientName;
this.accessToken = accessToken;
this.setAuthenticated(true);
}

@Override
public Object getCredentials() {
return accessToken;
}

@Override
public Object getPrincipal() {
return clientName;
}

}
Loading

0 comments on commit d0469c3

Please sign in to comment.