In the previous article we created an initial user interface that enables Keycloak login and role recognition. The next step is to wire this up to a back end service to serve secure, role-based services.
Code for this article is located in Github here:
In a typical scenario I would implement 2 different types services:
- Services for store employees to use serving up employee specific functions
- Services for customer specific functions
I'd also implement multiple levels of integration. For now we will start with something simple, create a single Quarkus microservice and manage access through the roles we created.
So, let's kick it off!
Create the API Client
We are going back to the Keycloak admin UI to create a new client for the API to use:
Navigate to Clients, then click Create
:
Once the client is created, navigate to the Roles tab to create the roles that we want the client to assume:
And we want to map these back to our users as in the previous article.
Navigate to Groups -> store-employees -> Role Mappings and add in the api-employee
role:
Do the same for the api-customer
role:
Next we want to start work on our service api.
Creating our Service
We will start from scratch and create a standard Quarkus service:
$ mvn io.quarkus:quarkus-maven-plugin:1.7.2.Final:create \
-DprojectGroupId=com.brightfield.streams \
-DprojectArtifactId=petstore-api
Some added dependencies that will make life easier include:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-jackson</artifactId>
</dependency>
Before we secure our application, let's get something running.
Implementing the Endpoints
There are 3 endpoints we will implement:
- GET /pets
- accessible by both groups (store-employees and customers)
- GET /sales
- accessible only by the store-employees
- GET /rewards
- accessible by the logged in customer
In the project, let's create a couple of resources and representations:
PetResource.java
package com.cloudyengineering.pets;
import java.util.ArrayList;
import java.util.List;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
@Path("/v1/pets")
@Produces("application/json")
public class PetResource {
@GET
public Response getPets() {
List<Pet> pets = new ArrayList<>();
return Response.ok(pets).build();
}
}
SalesResource.java
package com.cloudyengineering.pets;
import java.util.ArrayList;
import java.util.List;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
@Path("/v1/admin")
@Produces("application/json")
public class SalesResource {
@GET
public Response getSales() {
List<Transaction> transactions = new ArrayList<>();
return Response.ok(transactions).build();
}
}
Pet.java
package com.cloudyengineering.pets;
import com.fasterxml.jackson.annotation.JsonProperty;
public class Pet {
@JsonProperty("pet_id")
private Integer petId;
@JsonProperty("pet_type")
private String petType;
@JsonProperty("pet_name")
private String petName;
@JsonProperty("pet_age")
private Integer petAge;
public Integer getPetId() {
return petId;
}
public void setPetId(Integer petId) {
this.petId = petId;
}
public String getPetType() {
return petType;
}
public void setPetType(String petType) {
this.petType = petType;
}
public String getPetName() {
return petName;
}
public void setPetName(String petName) {
this.petName = petName;
}
public Integer getPetAge() {
return petAge;
}
public void setPetAge(Integer petAge) {
this.petAge = petAge;
}
}
Transaction.java
package com.cloudyengineering.pets;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonProperty;
public class Transaction {
@JsonProperty("txn_id")
private String transactionId;
@JsonProperty("txn_amount")
private Double transactionAmount;
@JsonProperty("txn_date")
private Date transactionDate;
@JsonProperty("txn_method")
private String transactionMethod;
public String getTransactionId() {
return transactionId;
}
public void setTransactionId(String transactionId) {
this.transactionId = transactionId;
}
public Double getTransactionAmount() {
return transactionAmount;
}
public void setTransactionAmount(Double transactionAmount) {
this.transactionAmount = transactionAmount;
}
public Date getTransactionDate() {
return transactionDate;
}
public void setTransactionDate(Date transactionDate) {
this.transactionDate = transactionDate;
}
public String getTransactionMethod() {
return transactionMethod;
}
public void setTransactionMethod(String transactionMethod) {
this.transactionMethod = transactionMethod;
}
}
As you can see, these are just placeholders so let's fill in some logic:
@GET
public Response getPets() {
List<Pet> pets = new ArrayList<>();
Pet pet1 = new Pet();
pet1.setPetId(1);
pet1.setPetAge(6);
pet1.setPetName("Oliver");
pet1.setPetType("Dog");
Pet pet2 = new Pet();
pet2.setPetId(2);
pet2.setPetAge(1);
pet2.setPetName("Buster");
pet2.setPetType("Cat");
Pet pet3 = new Pet();
pet3.setPetId(3);
pet3.setPetAge(2);
pet3.setPetName("Violet");
pet3.setPetType("Bird");
pets = Lists.asList(pet1, new Pet[]{pet2, pet3});
return Response.ok(pets).build();
}
TransactionResource.java
@GET
public Response getSales() {
List<Transaction> transactions = new ArrayList<>();
Transaction txn1 = new Transaction();
txn1.setTransactionId(UUID.randomUUID().toString());
txn1.setTransactionAmount(12.56);
txn1.setTransactionDate(Date.from(Instant.now()));
txn1.setTransactionMethod("Cash");
Transaction txn2 = new Transaction();
txn2.setTransactionId(UUID.randomUUID().toString());
txn2.setTransactionAmount(56.16);
txn2.setTransactionDate(Date.from(Instant.now()));
txn2.setTransactionMethod("Credit Card");
Transaction txn3 = new Transaction();
txn3.setTransactionId(UUID.randomUUID().toString());
txn3.setTransactionAmount(88.99);
txn3.setTransactionDate(Date.from(Instant.now()));
txn3.setTransactionMethod("Credit Card");
transactions = Lists.asList(txn1, new Transaction[]{txn2, txn3});
return Response.ok(transactions).build();
}
Let's quickly give it a test!
$ ./mvnw quarkus:dev
Listening for transport dt_socket at address: 5005
__ ____ __ _____ ___ __ ____ ______
--/ __ \/ / / / _ | / _ \/ //_/ / / / __/
-/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2020-10-04 18:30:09,620 INFO [io.quarkus] (Quarkus Main Thread) pet-store-api 1.0-SNAPSHOT on JVM (powered by Quarkus 1.8.1.Final) started in 3.296s. Listening on: http://0.0.0.0:
8080
2020-10-04 18:30:09,622 INFO [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2020-10-04 18:30:09,622 INFO [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, resteasy, resteasy-jackson]
$ http :8080/v1/pets
HTTP/1.1 200 OK
Content-Length: 188
Content-Type: application/json
[
{
"pet_age": 6,
"pet_id": 1,
"pet_name": "Oliver",
"pet_type": "Dog"
},
{
"pet_age": 1,
"pet_id": 2,
"pet_name": "Buster",
"pet_type": "Cat"
},
{
"pet_age": 2,
"pet_id": 3,
"pet_name": "Violet",
"pet_type": "Bird"
}
]
$ http :8080/v1/admin
HTTP/1.1 200 OK
Content-Length: 357
Content-Type: application/json
[
{
"txn_amount": 12.56,
"txn_date": 1601862235453,
"txn_id": "cb09b51d-541a-45b5-9c27-13a09b480dfd",
"txn_method": "Cash"
},
{
"txn_amount": 56.16,
"txn_date": 1601862235454,
"txn_id": "2895fe10-31ae-417a-8d7d-28ccdf0fa08b",
"txn_method": "Credit Card"
},
{
"txn_amount": 88.99,
"txn_date": 1601862235454,
"txn_id": "afbec85c-b919-40e4-9b02-9e5779534b0b",
"txn_method": "Credit Card"
}
]
Hmmm... those dates don't look too friendly, let's change them:
Transaction.java
@JsonProperty("txn_date")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy hh:mm:ss")
private Date transactionDate;
And again...
$ http :8080/v1/admin
HTTP/1.1 200 OK
Content-Length: 381
Content-Type: application/json
[
{
"txn_amount": 12.56,
"txn_date": "05-10-2020 01:46:17",
"txn_id": "8d5e875b-06ec-4cf6-b357-4e14525b831c",
"txn_method": "Cash"
},
{
"txn_amount": 56.16,
"txn_date": "05-10-2020 01:46:17",
"txn_id": "5764bef6-1c78-4efd-8016-119759b263c5",
"txn_method": "Credit Card"
},
{
"txn_amount": 88.99,
"txn_date": "05-10-2020 01:46:17",
"txn_id": "67a5f766-4a9f-4fa4-8e22-59ecf9171e55",
"txn_method": "Credit Card"
}
]
Nice! It works but is isn't quite secure yet.
Securing the API
There will be 2 dependencies we will be adding to secure our service:
pom.xml
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-keycloak-authorization</artifactId>
</dependency>
</dependencies>
application.properties
quarkus.oidc.auth-server-url=http://localhost:8081/auth/realms/petshop-realm
quarkus.oidc.client-id=pet-store-api
quarkus.oidc.credentials.secret=itsasecret
quarkus.oidc.authentication.scopes=profile
quarkus.http.cors.origins=http://localhost:4200
quarkus.http.cors.methods=GET,OPTIONS
quarkus.http.cors=true
As you can see we have declared the client id petshop-api
but we've also supplied the secret itsasecret
, let's make sure that is configured!
Navigate to: Clients -> pet-store-api
Change Access Type to bearer only
and hit save. You should see the tabs above change!
Next navigate to Credentials in the tabs above and you should see your client secret:
Let's copy this generated secret to our Pet Store API:
quarkus.oidc.auth-server-url=http://localhost:8081/auth/realms/petshop-realm
quarkus.oidc.client-id=pet-store-api
quarkus.oidc.credentials.secret=05da844f-c975-4571-8767-cbc8078e7b64
quarkus.oidc.authentication.scopes=profile
If we run our API now and try and access it we should get a 401 Unauthorized:
$ http :8080/v1/admin
HTTP/1.1 200 OK
Content-Length: 381
Content-Type: application/json
[
{
"txn_amount": 12.56,
"txn_date": "06-10-2020 09:34:58",
"txn_id": "a19f4089-5f20-4355-ab4a-a18367347d6d",
"txn_method": "Cash"
},
{
"txn_amount": 56.16,
"txn_date": "06-10-2020 09:34:58",
"txn_id": "6457746c-9c07-4cea-b8c5-fb30dc4e2f3d",
"txn_method": "Credit Card"
},
{
"txn_amount": 88.99,
"txn_date": "06-10-2020 09:34:58",
"txn_id": "09125408-0787-41d0-9976-8e56c5937bb3",
"txn_method": "Credit Card"
}
]
Wait a minute! That's not right! Oh I remember, we haven't specified security around our endpoints! Let's update the code:
SalesResource.java
@Path("/v1/admin")
@Produces("application/json")
public class SalesResource {
@GET
@RolesAllowed({"api-employee"})
public Response getSales() {
List<Transaction> transactions;
Transaction txn1 = new Transaction();
txn1.setTransactionId(UUID.randomUUID().toString());
txn1.setTransactionAmount(12.56);
txn1.setTransactionDate(Date.from(Instant.now()));
txn1.setTransactionMethod("Cash");
Transaction txn2 = new Transaction();
txn2.setTransactionId(UUID.randomUUID().toString());
txn2.setTransactionAmount(56.16);
txn2.setTransactionDate(Date.from(Instant.now()));
txn2.setTransactionMethod("Credit Card");
Transaction txn3 = new Transaction();
txn3.setTransactionId(UUID.randomUUID().toString());
txn3.setTransactionAmount(88.99);
txn3.setTransactionDate(Date.from(Instant.now()));
txn3.setTransactionMethod("Credit Card");
transactions = Lists.asList(txn1, new Transaction[]{txn2, txn3});
return Response.ok(transactions).build();
}
}
PetResource.java
@Path("/v1/pets")
@Produces("application/json")
public class PetResource {
@GET
@RolesAllowed({"api-customer"})
public Response getPets() {
List<Pet> pets;
Pet pet1 = new Pet();
pet1.setPetId(1);
pet1.setPetAge(6);
pet1.setPetName("Oliver");
pet1.setPetType("Dog");
Pet pet2 = new Pet();
pet2.setPetId(2);
pet2.setPetAge(1);
pet2.setPetName("Buster");
pet2.setPetType("Cat");
Pet pet3 = new Pet();
pet3.setPetId(3);
pet3.setPetAge(2);
pet3.setPetName("Violet");
pet3.setPetType("Bird");
pets = Lists.asList(pet1, new Pet[]{pet2, pet3});
return Response.ok(pets).build();
}
}
And try again...
$ http :8080/v1/admin
HTTP/1.1 401 Unauthorized
Content-Length: 0
$ http :8080/v1/pets
HTTP/1.1 401 Unauthorized
Content-Length: 0
Great! It's secure!
Ready to integrate the User Interface?
Connecting our UI with the secure services
When we last visited the User Interface, we were able to get user login working as well as ensure that different roles had different views. The changes we are going to make to the UI include:
- Add a new view to list Pets for customers and employees
- Add a new view to list Sales for employees
- Add a new view to list Rewards for customers
- Add in an AuthGuard to ensure different roles don't try and cheat and bypass the URIs
Creating the Views
Let's start creating new views!
Start by creating the new PetsComponent, SalesComoponent and RewardsComoponent:
$ ng g c pet
CREATE src/app/pet/pet.component.css (0 bytes)
CREATE src/app/pet/pet.component.html (18 bytes)
CREATE src/app/pet/pet.component.spec.ts (605 bytes)
CREATE src/app/pet/pet.component.ts (263 bytes)
UPDATE src/app/app.module.ts (947 bytes)
$ ng g c sales
CREATE src/app/sales/sales.component.css (0 bytes)
CREATE src/app/sales/sales.component.html (20 bytes)
CREATE src/app/sales/sales.component.spec.ts (619 bytes)
CREATE src/app/sales/sales.component.ts (271 bytes)
UPDATE src/app/app.module.ts (1025 bytes)
$ ng g c rewards
CREATE src/app/rewards/rewards.component.css (0 bytes)
CREATE src/app/rewards/rewards.component.html (22 bytes)
CREATE src/app/rewards/rewards.component.spec.ts (633 bytes)
CREATE src/app/rewards/rewards.component.ts (279 bytes)
UPDATE src/app/app.module.ts (1111 bytes)
$ ng g guard auth
? Which interfaces would you like to implement? CanActivate
CREATE src/app/auth.guard.spec.ts (331 bytes)
CREATE src/app/auth.guard.ts (457 bytes)
I'm not going to go full on MVC approach here so we will create 1 service to call the different APIs:
$ ng g s store
CREATE src/app/store.service.spec.ts (352 bytes)
CREATE src/app/store.service.ts (134 bytes)
Let's focus on the 2 main areas:
- StoreService
- AuthGuard
StoreService
Based on our original endpoints, lets create 3 functions:
getPets(): Observable<Pet[]>
getSales(): Observable<Transaction[]>
getRewards(): Observable<Reward[]>
store.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Pet, Reward, Transaction } from './_model';
import { environment as env } from '../environments/environment';
import { HttpClient } from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class StoreService {
constructor(private http : HttpClient) { }
getPets(): Observable<Pet[]> {
const uri = `${env.api_host}/v1/pet`;
return this.http.get<Pet[]>(uri);
}
getSales(): Observable<Transaction[]> {
const uri = `${env.api_host}/v1/sales`;
return this.http.get<Transaction[]>(uri);
}
getRewards(): Observable<Reward[]> {
const uri = `${env.api_host}/v1/rewards`;
return this.http.get<Reward[]>(uri);
}
}
Let's wire in the service to our components and render the results:
sales.component.ts
import { Component, OnInit } from '@angular/core';
import { StoreService } from '../store.service';
import { map, catchError } from 'rxjs/operators';
import { Transaction } from '../_model';
import { of } from 'rxjs';
@Component({
selector: 'app-sales',
templateUrl: './sales.component.html',
styleUrls: ['./sales.component.css']
})
export class SalesComponent implements OnInit {
sales: Transaction[];
constructor(private store: StoreService) { }
ngOnInit(): void {
const sale$ = this.store.getSales().pipe(
map(results => {
this.sales = results;
}),
catchError(error => {
console.log(error);
return of([]);
})
);
sale$.subscribe(data => data);
}
}
sale.component.html
<p>sales works!</p>
<table>
<tr>
<th>Transaction ID</th>
<th>Transaction Date</th>
<th>Transaction Amount</th>
<th>Payment Method</th>
</tr>
<tr *ngFor="let sale of sales">
<td>{{sale.txn_id}}</td>
<td>{{sale.txn_date}}</td>
<td>{{sale.txn_amount}}</td>
<td>{{sale.txn_method}}</td>
</tr>
<tr *ngIf="sales === undefined">
<td colspan="4">No data</td>
</tr>
</table>
pet.component.ts
import { Component, OnInit } from '@angular/core';
import { StoreService } from '../store.service';
import { map } from 'rxjs/operators';
import { Reward } from '../_model';
@Component({
selector: 'app-rewards',
templateUrl: './rewards.component.html',
styleUrls: ['./rewards.component.css']
})
export class RewardsComponent implements OnInit {
rewards: Reward[];
constructor(private store: StoreService) { }
ngOnInit(): void {
this.store.getRewards().pipe(
map(results => {
this.rewards = results;
})
);
}
}
pet.component.html
<p>pet works!</p>
<table>
<tr>
<th>Pet ID</th>
<th>Pet Type</th>
<th>Pet Age</th>
<th>Pet Name</th>
</tr>
<tr *ngFor="let pet of pets">
<td>{{pet.pet_id}}</td>
<td>{{pet.pet_type}}</td>
<td>{{pet.pet_age}}</td>
<td>{{pet.pet_name}}</td>
</tr>
<tr *ngIf="pets === undefined">
<td colspan="4">No data</td>
</tr>
</table>
As you can see we are storing the hostname for the api in the environment
object and constructing our URIs from it.
You can see the source code for the structure of the model classes.
Let's log in as Charlene Masters and see what resolves:
As you can see we get a result based upon the allocated roles. But how dos the API know what roles the user has? If you open the developer console and select the Network tab, take a look at the XHR Request to the server:
If you take a look, you can see the bearer token being passed as part of the API request!
Because we've mapped the pet-store-api
client roles to the Group, these roles are passed across as part of the bearer token to the API, honoring the access the user has.
Setting up our Guard
So in any standard application, Charlene should not be able to access the Sales view at all. In the next section we are going to setup the AuthGuard to check her roles and ensure we decline access to the SalesComponent.
First in order to use Keycloak to guard our route, we need to make some changes to our AuthGuard:
auth.guard.ts
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { KeycloakAuthGuard, KeycloakService } from 'keycloak-angular';
@Injectable({
providedIn: 'root'
})
export class AuthGuard extends KeycloakAuthGuard {
constructor(protected router: Router, protected service: KeycloakService) {
super(router, service)
}
isAccessAllowed(route: ActivatedRouteSnapshot,state: RouterStateSnapshot) : Promise<boolean> {
return new Promise((resolve, reject) => {
resolve(true)
});
}
}
Here we are using the Keycloak wrapper to ensure the correct authenticated calls back to the Keycloak server are made.
What's important in this class is the isAccessAllowed()
method. This is the entrypoint that will dictate if the user can activate the component.
Let's do a check on the path and see if they should access the sales data:
auth.guard.ts
isAccessAllowed(route: ActivatedRouteSnapshot,state: RouterStateSnapshot) : Promise<boolean> {
return new Promise((resolve, reject) => {
const userRoles: string[] = this.service.getUserRoles();
console.log(`Roles: ${userRoles}`);
if (state.url === '/sales' && userRoles.indexOf('employee') >= 0) {
console.log('Permission allowed');
resolve(true)
} else {
console.log('Permission not allowed');
resolve(false);
}
});
Setting up this guard in our app-routing.module.ts
is as simple as:
const routes: Routes = [
{ path: 'pets', component: PetComponent },
{ path: 'rewards', component: RewardsComponent },
{ path: 'sales', component: SalesComponent, canActivate: [AuthGuard] }
];
Now if you access the sales.component
as Charlene you should be able to see a message Permission not allowed
in the console and the component is never activated.
NOTE
You can see we are printing out the roles the user has and if you take a look you'll notice that
Charlene has the following roles: api-customer,customer,manage-account,manage-account-links,view-profile,offline_access,uma_authorization
So we've now been able to secure our User Interface and our backend services. There is, of course, more you can do with this but it should give you a head start on secure applcations.
In Part 3, we will wire up the RewardsComponent and load user specific information!
Top comments (0)