Skip to content

Commit

Permalink
feat: authentication and authorization with keycloak (#42)
Browse files Browse the repository at this point in the history
* feat: remove spring web flux and add spring web mvc

* feat: user repository unit tests working

* ci: autogenerated JaCoCo coverage badge

* doc: update readme to remove web flux

* feat: add authorization and authentication handlers

* feat: integration tests passing with mocked user and authentication

* feat: add documentation to security config class

* feat: add jwt client roles from auth - partial

* feat: handle roles from jwt decoded token

* ci: add variable to keycloak server and workflow update

* ci: fix missing keycloak server realm env var

* fix: build service image with keycloak server variable

* feat: add docker build args env var

* feat: add cors handling to spring security

* feat: correct version from main

* feat: remove run-with-bd script

* feat: add service authority to enable read and write from testing

* doc: add steps to build and run with docker and docker-compose

* feat: remove argument from docker image build

* feat: remove docker build args from the workflow

* feat: improve security config and enable csrf

* feat: add csrf handler

* feat: improve application properties file organization

* feat: collectors to list code smell related to immutability

* feat: remove unused import

* feat: improve keycloak realm variable name

Co-authored-by: Ci Bot <cibot@users.noreply.github.com>
  • Loading branch information
2 people authored and DerekRoberts committed May 4, 2023
1 parent b81ebc7 commit 86a51a3
Show file tree
Hide file tree
Showing 11 changed files with 164 additions and 52 deletions.
5 changes: 5 additions & 0 deletions .github/openshift/deploy.backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ parameters:
- name: DATABASE_PASSWORD
description: Password for the database connection
required: true
- name: KEYCLOAK_REALM_URL
description: Keycloak realm address
required: true
objects:
- apiVersion: v1
kind: ImageStream
Expand Down Expand Up @@ -119,6 +122,8 @@ objects:
value: ${DATABASE_USER}
- name: DATABASE_PASSWORD
value: ${DATABASE_PASSWORD}
- name: KEYCLOAK_REALM_URL
value: ${KEYCLOAK_REALM_URL}
ports:
- containerPort: 8090
protocol: TCP
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/merge-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ jobs:
-p DATABASE_USER=${{ secrets.DATABASE_USER }} \
-p DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} \
-p ALLOWED_ORIGINS=${{ secrets.ALLOWED_ORIGINS }} \
-p KEYCLOAK_REALM_URL=${{ secrets.KEYCLOAK_REALM_URL }} \
-p PROMOTE=${{ github.repository }}:${{ env.ZONE }}-service-api | oc apply -f -
# Follow any active rollouts (see deploymentconfigs)
#oc rollout status dc/${{ env.NAME }}-${{ env.ZONE }}-database -w
Expand Down Expand Up @@ -412,6 +413,7 @@ jobs:
-p DATABASE_USER=${{ secrets.DATABASE_USER }} \
-p DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} \
-p ALLOWED_ORIGINS=${{ secrets.ALLOWED_ORIGINS }} \
-p KEYCLOAK_REALM_URL=${{ secrets.KEYCLOAK_REALM_URL }} \
-p PROMOTE=${{ github.repository }}:${{ env.PREV }}-service-api | oc apply -f -
# Follow any active rollouts (see deploymentconfigs)
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/pr-open.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ on:
env:
REGISTRY: ghcr.io
NAME: nrbestapi
KEYCLOAK_REALM_URL: ${{ secrets.KEYCLOAK_REALM_URL }}

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down Expand Up @@ -364,6 +365,7 @@ jobs:
-p SERVICE_NAME=${{ secrets.SERVICE_NAME }} \
-p DATABASE_USER=${{ secrets.DATABASE_USER }} \
-p DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} \
-p KEYCLOAK_REALM_URL=${{ secrets.KEYCLOAK_REALM_URL }} \
-p PROMOTE=${{ github.repository }}:${{ env.ZONE }}-service-api | oc apply -f -
# Follow any active rollouts (see deploymentconfigs)
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
FROM maven:3.8.6-openjdk-18-slim AS build

COPY src /home/app/src
COPY pom.xml /home/app
RUN mvn --no-transfer-progress -f /home/app/pom.xml clean package
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,39 @@ the `status` property should have the value *UP*.
Before writing your first line of code, and learn more about the checks, including
tests, please take a moment and check out our [CONTRIBUTING](CONTRIBUTING.md) guide.

## Quick look

But if all you want is to take a quick look on the running service, you can do it by
using Docker.

Note that you'll need these environment variables:
```
NRBESTAPI_VERSION=local
DATABASE_HOST=<host>
DATABASE_PORT=<port>
SERVICE_NAME=<service-name>
DATABASE_USER=<user>
DATABASE_PASSWORD=<pass>
KEYCLOAK_REALM_URL=<realm-server-address>
```

Build the service:
```
docker build -t bcgov/nrbestapi-test-service-api:latest .
```

Then run with:
```
docker run -p 8090:8090 \
-e KEYCLOAK_REALM_URL=https://dev.loginproxy.gov.bc.ca/auth/realms/standard \
-t bcgov/nrbestapi-test-service-api:latest
```

However, if you have docker-compose you can do:
```
docker-compose --env-file .env -f ./docker-compose.yml up --build --force-recreate --no-deps
```

## Getting help

<<<<<<< HEAD
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ services:
SERVICE_NAME: ${SERVICE_NAME}
DATABASE_USER: ${DATABASE_USER}
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
KEYCLOAK_REALM_URL: ${KEYCLOAK_REALM_URL}
hostname: service-api
network_mode: "host"
dns: 142.22.202.100
ports:
- "8090:8090"
Expand Down
21 changes: 14 additions & 7 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -73,26 +73,28 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<!-- Authentication and Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

<!-- DevOps -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

<!-- DevOps -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<!-- Database -->
Expand All @@ -110,11 +112,16 @@
<artifactId>ojdbc11</artifactId>
</dependency>

<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>
</dependencies>

<build>
Expand Down
51 changes: 26 additions & 25 deletions src/main/java/ca/bc/gov/backendstartapi/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package ca.bc.gov.backendstartapi.config;

import ca.bc.gov.backendstartapi.util.ObjectUtil;
import com.nimbusds.jose.shaded.json.JSONArray;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
Expand All @@ -9,7 +11,7 @@
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.GrantedAuthority;
Expand All @@ -22,7 +24,7 @@
/** This class contains all configurations related to security and authentication. */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
Expand All @@ -42,10 +44,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.and()
.authorizeHttpRequests()
.requestMatchers("/api/**")
.authorizeRequests()
.antMatchers("/api/**")
.authenticated()
.requestMatchers(HttpMethod.OPTIONS, "/**")
.antMatchers(HttpMethod.OPTIONS, "/**")
.permitAll()
.anyRequest()
.permitAll()
Expand All @@ -62,28 +64,27 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

private Converter<Jwt, AbstractAuthenticationToken> converter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(roleConverter);
converter.setJwtGrantedAuthoritiesConverter(roleConverter());
return converter;
}

/**
* Parse the roles of a client from the JWT, if they're present; if not, subjects with service
* accounts are granted read and write permissions.
*/
private final Converter<Jwt, Collection<GrantedAuthority>> roleConverter =
jwt -> {
if (!jwt.getClaims().containsKey("client_roles")) {
String sub = String.valueOf(jwt.getClaims().get("sub"));
return (sub.startsWith("service-account-nr-fsa"))
? List.of(
new SimpleGrantedAuthority("ROLE_user_read"),
new SimpleGrantedAuthority("ROLE_user_write"))
: List.of();
private Converter<Jwt, Collection<GrantedAuthority>> roleConverter() {
return jwt -> {
final JSONArray realmAccess = (JSONArray) jwt.getClaims().get("client_roles");
List<GrantedAuthority> authorities = new ArrayList<>();
if (ObjectUtil.isEmptyOrNull(realmAccess)) {
String sub = String.valueOf(jwt.getClaims().get("sub"));
if (sub.startsWith("service-account-nr-fsa")) {
authorities.add(new SimpleGrantedAuthority("ROLE_user_read"));
authorities.add(new SimpleGrantedAuthority("ROLE_user_write"));
}
final List<String> realmAccess = (ArrayList<String>) jwt.getClaims().get("client_roles");
return realmAccess.stream()
.map(roleName -> "ROLE_" + roleName)
.map(roleName -> (GrantedAuthority) new SimpleGrantedAuthority(roleName))
.toList();
};
return authorities;
}
realmAccess.stream()
.map(roleName -> "ROLE_" + roleName)
.map(SimpleGrantedAuthority::new)
.forEach(authorities::add);
return authorities;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import lombok.Setter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand All @@ -22,7 +23,7 @@
/** This class exposes user related endpoints. */
@NoArgsConstructor
@RestController
@RequestMapping("/users")
@RequestMapping("/api/users")
@Setter
public class UserEndpoint {

Expand All @@ -42,6 +43,7 @@ public UserEndpoint(UserRepository userRepository) {
@PostMapping(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('user_write')")
public UserDto create(@Valid @RequestBody UserDto user) {
return userRepository.save(user);
}
Expand All @@ -55,6 +57,7 @@ public UserDto create(@Valid @RequestBody UserDto user) {
@GetMapping(
value = "/find-all-by-first-name/{firstName}",
produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('user_read')")
public List<UserDto> readByFirstName(@PathVariable("firstName") String firstName) {
List<UserDto> userList = userRepository.findAllByFirstName(firstName);
if (userList.isEmpty()) {
Expand All @@ -72,6 +75,7 @@ public List<UserDto> readByFirstName(@PathVariable("firstName") String firstName
@GetMapping(
value = "/find-all-by-last-name/{lastName}",
produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('user_read')")
public List<UserDto> readByLastName(@PathVariable("lastName") String lastName) {
List<UserDto> userList = userRepository.findAllByLastName(lastName);
if (userList.isEmpty()) {
Expand All @@ -88,6 +92,7 @@ public List<UserDto> readByLastName(@PathVariable("lastName") String lastName) {
* @return a UserDto instance containing the found user or a 404 if not found.
*/
@GetMapping(value = "/find/{firstName}/{lastName}", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('user_read')")
public UserDto readByUser(
@PathVariable("firstName") String firstName, @PathVariable("lastName") String lastName) {
Optional<UserDto> userDtoOp = userRepository.find(firstName, lastName);
Expand All @@ -104,6 +109,7 @@ public UserDto readByUser(
* @return a Collection containing all found users or a 404 if not found.
*/
@GetMapping(value = "/find-all", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('user_read')")
public Collection<UserDto> readAllUsers() {
return userRepository.findAll();
}
Expand All @@ -116,6 +122,7 @@ public Collection<UserDto> readAllUsers() {
* @return a UserDto instance containing the removed user info.
*/
@DeleteMapping(value = "/{firstName}/{lastName}", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('user_write')")
public UserDto deleteUser(
@PathVariable("firstName") String firstName, @PathVariable("lastName") String lastName) {
return userRepository.delete(new UserDto(firstName, lastName));
Expand Down
18 changes: 14 additions & 4 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
# Server and application
spring.application.name = nr-fsa-service-api-4139
server.port = 8090
nrbestapi.version = ${NRBESTAPI_VERSION:#{'dev'}}
server.allowed.cors.origins = ${ALLOWED_ORIGINS:#{'http://localhost:300*'}}

spring.datasource.driver-class-name=oracle.jdbc.driver.OracleDriver
spring.jpa.database-platform=org.hibernate.dialect.Oracle12cDialect
# Key Cloak, authentication and security
keycloak-auth = ${KEYCLOAK_REALM_URL:https://empty.com/auth}
spring.security.oauth2.resourceserver.jwt.issuer-uri = ${keycloak-auth}
spring.security.oauth2.resourceserver.jwt.jwk-set-uri = ${keycloak-auth}/protocol/openid-connect/certs

# Database, datasource and JPA
spring.datasource.driver-class-name=oracle.jdbc.driver.OracleDriver
spring.datasource.url = jdbc:oracle:thin:@tcps://${DATABASE_HOST}:${DATABASE_PORT}/${SERVICE_NAME}
spring.datasource.username = ${DATABASE_USER}
spring.datasource.password = ${DATABASE_PASSWORD}
spring.jpa.database-platform=org.hibernate.dialect.Oracle12cDialect

# Actuator and ops
management.endpoint.health.show-details = always

# Others
nrbestapi.version = ${NRBESTAPI_VERSION:#{'dev'}}
server.allowed.cors.origins = ${ALLOWED_ORIGINS:#{'http://localhost:300*'}}
Loading

0 comments on commit 86a51a3

Please sign in to comment.