Spring Surf Development #2 - Secure your Spring WebScript REST API

In the previous blog post we added a WebScript that listed the different safaris offered by the Safari company. Definitively no company secret in other words. But what if we want to add a WebScript that lists internal company information in a manager section of the website or let a registered user buy a safari? Then we need to add security to our REST API. In this blog post we will continue and build upon the Safari use case using Spring WebScripts. We will:

Write 3 new secured webscripts: Create Trip, Create Booking & List User's Bookings that we can use in future when we create the Safari web shop ui.

Test that the WebScripts are secured

If you are only interested to get a quick glance at WebScript security, and don't want to bother about all the Safari use case code, consider only reading the following sections: 'Spring WebScript Security Basics', 'Securing the Spring WebScript runtime' and possibly 'Using HTTP Sessions in Spring WebScripts?'.

Spring WebScript Security Basics

.

Spring WebScripts can be hosted inside a number of different runtime environments:

Servlet Runtime (HTTP Access)

JSR-168 Runtime (Portlet Access)

JSF Runtime (JSF Component Access)

.As you have probaly guessed the Safari REST API is using the Servlet Runtime, something we declared by defining the WebScriptServlet in safari-rest/src/main/webapp/WEB-INF/web.xml in the last blog post. All runtimes support 4 roles or levels of authentication which is declared in the <authentication> element in each webscript's decriptor file (some-webscript.get.desc.xml):

'none' - No authentication is required (default)

'guest' - No authentication is required but the webscript could get access to additional read services

'user' - Authentication is required

'admin' - Authentication is required by a user with the admin role

The default authentication level, if the <authentication> element is omitted, is 'none'..

The 'use case' for the Safari Company

.

The Safari company currently only has 2 persons in its system:

'erik' - A registered user and therefore a member of the 'customer' group.

'roy' - An employee and therefore a member of the 'manager' group but sometimes also buys trips and therefore is a member of the 'customer' group as well.

.

The class that will handle users, groups and authentication is the IdentityService. First register it as a Spring bean inside safari-rest/src/main/webapp/WEB-INF/config/web-application-context.xml next to our old TravelService from the previous blog post.

Then create the actual implementation in safari-core/src/main/java/com/safari/core/travel/ and make it look like below:.

package com.safari.core.travel;

import com.safari.core.SafariContext;

import java.util.ArrayList;

import java.util.List;

/**

* In memory service for demo and testing purposes

*/

public class IdentityService {

public static final String MANAGER = 'manager';

public static final String CUSTOMER = 'customer';

public boolean authenticate(String username, String password) {

// Perform simple example authentication

if (username.equals(password)) {

SafariContext.setCurrentUser(username);

return true;

}

return false;

}

public List<String> getUsers() {

List<String> users = new ArrayList<String>();

users.add('erik'); // A registered customer

users.add('roy'); // Sales manager for the company who sometimes buy trips

return users;

}

public List<String> getGroups(String user) {

List<String> groups = new ArrayList<String>();

if (user.equals('erik')) {

groups.add(CUSTOMER);

}

else if (user.equals('roy')) {

groups.add(MANAGER);

groups.add(CUSTOMER);

}

return groups;

}

}

Notice that the IdentityService 'saves' the current username, if the authentication was successful, by calling SafariContext.setCurrentUser(username) which will make the username available elsewhere in the application. The implementation of SafariContext will be really simple and use the ThreadLocal class to make sure the correct username only is available for the current request/thread. Implement the SafariContext class in safari-core/src/main/java/com/safari/core/ and make it look like below:

So now we have the IdentityService that will handle the authentication (it will actually just do a silly authentication by making sure the username also is the password) and if so store the username so it is available from other parts of the application by doing a simple SafariContext.getCurrentUser().

.

But somehow we will need to make the IdentityService's authenticate method get called by the webscripts runtime and we also somehow must match the Safari company's groups ('customer' & 'manager') against the webscript 'roles' ('none', 'guest', 'user' & 'admin'). To do this we need to add our own custom authenticator factory to the webscripts runtime. (Don't worry it's very easy)

Securing the Spring WebScript runtime

As mentioned before the Safari REST API was configured to use the WebScript Servlet Runtime. To add authentication to it simply add the 'authenticator' init-param to the WebScriptServlet and safari-rest/src/main/webapp/WEB-INF/web.xml like below:

The value 'webscripts.authenticator.safari' is a spring bean reference to the authenticator factory we haven't created yet. The job for it will be to to decode the authentication headers from the HTTP request and decide if the username and the password matches (authentication) and if so decide if the user's privilige matches or exceeds what the privelige that the webscript require (authorization).

This is done by simply extending the org.springframework.extensions.webscripts.AbstractBasicHttpAuthenticatorFactory class (which will do the work of decoding the HTTP headers for us) and respond to its 2 abstract methods: doAuthenticate(String username, String password) and doAuthorize(String username, RequiredAuthentication role).

Name it SafariBasicHttpAuthenticatorFactory, place it in safari-rest/src/main/java/com/safari/rest/auth/ and make it look like below:

This is basically it! What will happen is that the doAuthenticate(username, password) method will be called if the authentication role for the webscript is 'user' or 'admin' (remember that 'none' and 'guest' didn't require any authentication). If the authentication is successful doAuthorize(username, role) will also be called in which the mapping between webscript authentication roles and safari groups happen.

.

As you might have noticed, if you tried to compile, the AbstractBasicHttpAuthenticatorFactory class is missing. To add it to your project simply modify the safari-rest/pom.xml maven file from the last tuorial:

<!--

Temporary include the 'spring-surf' artifact to get the AbstractBasicHttpAuthenticatorFactory.

When updating to RC2 or the final 1.0.0 release it will have moved to 'spring-webscripts'.

-->

<dependency>

<groupId>org.springframework.extensions.surf</groupId>

<artifactId>spring-surf</artifactId>

<version>1.0.0-RC1</version>

</dependency>

<!-- Include the Spring WebScripts runtime -->

<dependency>

<groupId>org.springframework.extensions.surf</groupId>

<artifactId>spring-webscripts</artifactId>

<version>1.0.0-RC1</version>

</dependency>

<!-- Include the Spring WebScript API so we can browse and list our webscripts on the server -->

<dependency>

<groupId>org.springframework.extensions.surf</groupId>

<artifactId>spring-webscripts-api</artifactId>

<version>1.0.0-RC1</version>

</dependency>

So by now you should be ready to build and redploy the entire project by changing into your projects top level directory do:

If you got an error try 'deploy' rather than 'redeploy' since the webapp might have been removed since you did the previous tutorial.

To test that we actually have succeded to secure the our WebScripts point your browser to http://localhost:8080/safari-rest/service/index which then shall make your browser display a login dialog. This url is pointing to the pages that lists all deployed webscripts, and in the last tutorial they were publically available since we hadn't secured the runtime. If you try to login with 'erik' (as both username and password) it shall NOT work, since he only is a 'user'. But if you try to login with 'roy' (as both username and password) it should work.

By now we are actually finished with the security part of this tutorial so lets do a quick recap what we did:

Told the WebScriptServlet in web.xml to use our authenticator: 'webscripts.authenticator.safari'.

Defined the authenticator bean in web-application-config.xml.

Implemented the authenticator by extending AbstractBasicHttpAuthenticatorFactory and respond to doAuthenticate(username, password) and doAuthorize(username, role).

… the rest is up to you and how your app is being implemented, perhaps you want to pass in the username and roles to another environment?

.

Using HTTP Sessions in Spring WebScripts?

What could be worth pointing out is that the only place where the authentication details are stored, using this approach, is in the request headers and that currently no sessions are used, in other words ideal for scaling. However if you want to use the HttpSession it's of course possible to access it both in a regular webscript and in your custom made authenticator class. Simply modify you authentication factory with the changes below:

Creating 3 secured WebScripts

The rest of this tutorial will be about creating a couple of secured WebScripts. We will use these WebScripts in future blog posts when we build a Spring Surf web application that will use them to display Safari trips and bookings.

First create a simple Booking pojo in safari-core/src/main/java/com/safari/core/travel/Booking.java that looks like below:

Then open the TravelService class that we implemeted in the previous tutorial, located in safari-core/src/main/java/com/safari/core/travel/, and update it so it looks like below:

package com.safari.core.travel;

import com.safari.core.SafariContext;

import java.util.ArrayList;

import java.util.List;

/**

* In memory service for testing purposes

*/

public class TravelService {

private static int tripIndex = 0;

private static List<Trip> trips = new ArrayList<Trip>();

private static List<Booking> bookings = new ArrayList<Booking>();

public TravelService() {

// Bootstrap data

trips.add(new Trip(++tripIndex, 'Masai Mara Adventurer'));

trips.add(new Trip(++tripIndex, 'Serengeti Explorer'));

trips.add(new Trip(++tripIndex, 'Kruger Wildlife'));

}

/**

* Returns a public list of all available trips

*

* @return a list of trips

*/

public List<Trip> getTrips() {

return trips;

}

/**

* Finds a trip by id

*

* @param tripId The trip id to look for

* @return the trip if found otherwise null

*/

public Trip getTrip(int tripId) {

// Find the trip

for (Trip trip : trips) {

if (trip.getId() == tripId) {

return trip;

}

}

return null;

}

/**

* Administration method to create new trips

*

* @param trip The trip to create

* @return The created trip with a unique id

*/

public Trip createTrip(Trip trip) {

// Give trip a unique id and add it

trip.setId(++tripIndex);

trips.add(trip);

return trip;

}

/**

* Lets the current user create a booking for a certain trip

*

* @param tripId The trip to create a booking for

*/

public Booking createBooking(int tripId) {

// Check that the trip exists

Trip trip = getTrip(tripId);

if (trip == null) {

throw new IllegalArgumentException('Cannot book trip with id '' + tripId + '' since it doesn't exist');

}

// Create booking and return it

Booking booking = new Booking();

booking.setTrip(trip);

booking.setUsername(SafariContext.getCurrentUser());

bookings.add(booking);

return booking;

}

/**

* Returns a list of the current users bookings

*/

public List<Booking> getBookings() {

List<Booking> userBookings = new ArrayList<Booking>();

for (Booking booking : bookings) {

if (booking.getUsername().equals(SafariContext.getCurrentUser())) {

userBookings.add(booking);

}

}

return userBookings;

}

}

The code should be pretty self explanatory, what could be worth pointing out is that the getBookings() method uses the SafariContext.getCurrentUser() when it makes sure that only the current users bookings are returned.

No let's create a new webscript that will create a Trip and only should be available to administrators. Create the descriptor file as safari-rest/src/main/resources/webscripts/com/safari/travel/trip.post.desc.xml. It's just like any other webscript except that the <authentication> element is set to contain the value of 'admin'.

<webscript>

<shortname>Create Trip</shortname>

<description>Creates a trip</description>

<url>/travel/trip</url>

<format default='json'>argument</format>

<authentication>admin</authentication>

</webscript>

Now let's continue with the admin WebScript for creating trips and implement the Java controller:

throw new WebScriptException(Status.STATUS_INTERNAL_SERVER_ERROR, 'Trip could not be created: ' + e.getMessage());

}

}

}

Note that the webscript expects the request body to contain JSON, its in other words not a regular HTML Form post that is being submitted. This is an approach that we will use for all of our webscripts. Also note that we are throwing a special exception if the request isn't correctly formatted: Status.STATUS_BAD_REQUEST this exception actually maps to the HTTP status code 400. When you create your webscript make sure to use the HTTP status codes to signal to the client what went wrong.Below is the response template that shall be defined in safari-rest/src/main/resources/webscripts/com/safari/travel/trip.post.json.ftl. It will return the new trip and the id it was assigned when 'persisted' in the service:

<#escape x as jsonUtils.encodeJSONString(x)>

{

'id': ${trip.id},

'name': '${trip.name}'

}

</#escape>

.

Below comes the code for 2 new webscripts for the Safari customers (in other words 'users'). First one that allows a user to create a booking and then one that lists the users bookings.

Create safari-rest/src/main/resources/webscripts/com/safari/travel/booking.post.desc.xml and make it look like below and note that the <authentication> element is set to 'user':

<webscript>

<shortname>Create Booking</shortname>

<description>Creates a booking</description>

<url>/travel/booking</url>

<format default='json'>argument</format>

<authentication>user</authentication>

</webscript>

Then create the Java controller in safari-rest/src/main/java/com/safari/rest/api/travel/BookingPost.java that also expects a json request body.

Now create the response template that lists the bookings in safari-rest/src/main/resources/webscripts/com/safari/travel/bookings.get.json.ftl:

<#escape x as jsonUtils.encodeJSONString(x)>

[<#list bookings as booking>

{

'id': ${booking.id},

'username': '${booking.username}',

'trip': {

'id': '${booking.trip.id}',

'name': '${booking.trip.name}'

}

}<#if booking_has_next>,</#if>

</#list>]

</#escape>

Finally, since they are java backed webscripts, we need to defined them as spring beans in safari-rest/src/main/webapp/WEB-INF/config/web-application-context.xm, place them under the TripsGet webscript so the whole list of webscripts look like below:

Testing you WebScripts

Now, to create a trip you will need to be logged in as admin and post a request with json on the request body. If you don't want to write some javascript that does this you can probably test it from your IDE. If not, there are a great variety of tools, such as curl (command line) or RESTClient (a free FireFox addon that is simple to use).

.

What ever tool you choose create a request like below to create a trip:

POST http://localhost:8080/safari-rest/service/travel/trip

{

'name': 'Spring Safari'

}

If you hadn't logged in do it with 'roy':'roy' and you shall get a response like below:

{

'id': 4,

'name': 'Spring Safari'

}

To list all of the Safari company's trips do a:

GET http://localhost:8080/safari-rest/service/travel/trips

That shall give you a list where your new trip is included. To go further and create a booking make sure to clear your cookies so you're not logged into the rest api. Then go ahead and make a request like below and login as 'erik':'erik'

POST http://localhost:8080/safari-rest/service/travel/booking

{

'tripId': 4

}

Which shall return:

{

'id': 0,

'username': 'roy',

'trip': {

'id': 4,

'name': 'Spring Safari'

}

}

To later list erik's bookings create a request like below:

GET http://localhost:8080/safari-rest/service/travel/bookings

Which shall return:

[

{

'id': 0,

'username': 'roy',

'trip': {

'id': 4,

'name': 'Spring Safari'

}

}

]

Ok, that's it. Hopefully you have found this blog post interesting. Next post will be about creating a Safari web application using Spring Surf.

Thanks for posting this article. I'm really interested in using the Web Script framework to host some services in standalone mode (i.e. not part of Alfresco).

Can you please explain how I can use script based controllers (e.g. Rhino JavaScript and Groovy)?

Also, I noticed that Web Scripts when used with Alfresco are available via '/service' (basic auth & ticketed login) and '/wcservice' (Alfresco Explorer auth - e.g. NTLM & Kerberos) endpoints. Does the standalone Web Scripts framework support the same authentication options? i.e. can I create a WebScript and make it available via basic auth, Kerberos, etc?

Hi Mark, good to hear you solved it, sorry I hadn't got back to you earlier.

Regarding your other questions, what if you continue to use BASIC HTTP between your client and the rest services and just modify IdentityService.java above to do an LDAP search instead of going against the test data? Would that be sufficient?

Cheers,

:: Erik

PS. Also note that if you use a javascript controller as you said you had done, you obviously don't need to define a spring bean in web-application-context.xml

I am creating a spring surf application. I am calling custom java class for authentication by extending the surf LoginController. I have added session variables in my class. Now i want to access these variables into my ftl file. How can i get the variables ?

Is it compulsory to call webscript(written in java) to get session variables ? if yes then how to call ?

If your .ftl file is part of a webscript backed by a Java class you can access the session variable like it was mentioned in the blog post and then put in in the webscript's model so it can be used from the .ftl file. In other words something like this: