If you are a Java developer, then you are undoubtedly familiar with frameworks such as Spring, Play!, and Struts. While all three provide everything a web developer wants, I decided to write a RESTful web application using the Jersey framework. This sample app uses Java + Jersey on the back-end and Angular JS on the front-end.
Jersey annotation service makes it easy to do routing, injection, and other functions important to a RESTful web application. My goal was to demonstrate the use of the Stormpath Java SDK for user management and the protection of a REST endpoint using API Keys and Oauth Tokens, all while relying on Jersey.
You can check out the Stormpath Jersey sample app in github, and follow along here for the implementation details and concepts I found most important while building this application. I will explain Stormpath SDK calls, Jersey annotations, and the general flow of the application, so it the codebase is easy to decipher.
Let’s code!
Login
Stormpath provides username/password authentication in three lines of Java SDK method calls. That makes it very simple to launch a basic login form, securely.
As soon as a user enters their credentials and clicks the “Sign In” button, an AJAX request is made to the /login
endpoint. Let’s take a look at the server side login code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
@Path("/login") public class Login { @Context private HttpServletResponse servletResponse; @POST @Consumes(MediaType.APPLICATION_JSON) public void getDashboard(UserCredentials userCredentials) throws Exception { Application application = StormpathUtils.myClient.getResource( StormpathUtils.applicationHref, Application.class); AuthenticationRequest request = new UsernamePasswordRequest( userCredentials.getUsername(), userCredentials.getPassword()); Account authenticated; //Try to authenticate the account try { authenticated = application.authenticateAccount(request).getAccount(); } catch (ResourceException e) { System.out.println("Failed to authenticate user"); servletResponse.sendError(401); return; } Cookie myCookie = new Cookie("accountHref", authenticated.getHref()); myCookie.setMaxAge(60 * 60); myCookie.setPath("/"); myCookie.setHttpOnly(true); servletResponse.addCookie(myCookie); } } |
Here we see three examples of Jersey’s annotation feature. The @Path annotation acts as our router. The @Context annotation injects the HTTP Request object into our class. Finally, the @POST specifies the CRUD operation.
User authentication is done by first creating an Application
object, creating an AuthenticationRequest
object, and finally calling application.authenticationAccount(request)
to ask Stormpath to authenticate this account.
Create Account
Creating an account is just as simple as logging in to the service:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@Path("/makeStormpathAccount") public class StormpathAccount { @POST public void createAccount(UserAccount userAccount) throws Exception { Application application = StormpathUtils.myClient.getResource( StormpathUtils.applicationHref, Application.class); Account account = StormpathUtils.myClient.instantiate(Account.class); //Set account info and create the account account.setGivenName(userAccount.getFirstName()); account.setSurname(userAccount.getLastName()); account.setUsername(userAccount.getUserName()); account.setEmail(userAccount.getEmail()); account.setPassword(userAccount.getPassword()); application.createAccount(account); } } |
All we had to do here, was create an Application
and an Account
, set the Account
attributes, and call createAccount
.
Generating an API Key ID/Secret
Once a user logs in, they will be given API Key credentials. In this application, generation of the Keys is a simple AJAX call to /getApiKey
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
@Path("/getApiKey") public class Keys { @Context private HttpServletRequest servletRequest; @Context private HttpServletResponse servletResponse; @GET @Produces(MediaType.APPLICATION_JSON) public Map getApiKey(@CookieParam("accountHref") String accountHref) throws Exception { Account account = StormpathUtils.myClient.getResource(accountHref, Account.class); ApiKeyList apiKeyList = account.getApiKeys(); boolean hasApiKey = false; String apiKeyId = ""; String apiSecret = ""; //If account already has an API Key for(Iterator<ApiKey> iter = apiKeyList.iterator(); iter.hasNext();) { hasApiKey = true; ApiKey element = iter.next(); apiKeyId = element.getId(); apiSecret = element.getSecret(); } //If account doesn't have an API Key, generate one if(hasApiKey == false) { ApiKey newApiKey = account.createApiKey(); apiKeyId = newApiKey.getId(); apiSecret = newApiKey.getSecret(); } //Get the username of the account String username = account.getUsername(); //Make a JSON object with the key and secret to send back to the client Map<String, String> response = new HashMap<>(); response.put("api_key", apiKeyId); response.put("api_secret", apiSecret); response.put("username", username); return response; } } |
We use Jersey’s @CookieParam
annotation to grab the account Href from the Cookie that was created at login. We create an account, and an ApiKeyList
object. We then check if this account already has an API Key. If so, our job is to simply request it from Stormpath; if not, we tell Stormpath to make a new one for this account and return this back to the client. By Base64 encoding the API Key:Secret pair, a developer can now target our endpoint using Basic authentication:
Using a Jersey Filter
A cool feature of the Jersey framework is its Request filter. By implementing ContainerRequestFilter
we can intercept an HTTP request even before it gets to our endpoint. To demonstrate, I added an additional level of security around API Key generation. Before a user is allowed to target the /getApiKey
endpoint they must pass through the Jersey request filter, which will check if the client is actually logged in (a.k.a has a valid session in the form of a cookie).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
@Provider public class JerseyFilter implements ContainerRequestFilter { @Context private HttpServletResponse servletResponse; @Override public void filter(ContainerRequestContext requestContext) throws IOException { URI myURI = requestContext.getUriInfo().getAbsolutePath(); String myPath = myURI.getPath(); if(myPath.equals("/rest/getApiKey")) { Iterator it = requestContext.getCookies().entrySet().iterator(); String accountHref = ""; while(it.hasNext()) { Map.Entry pairs = (Map.Entry)it.next(); if(pairs.getKey().equals("accountHref")) { String hrefCookie = pairs.getValue().toString(); accountHref = hrefCookie.substring(hrefCookie.indexOf("https://")); } } if(!accountHref.equals("")) { //Cookie exists, continue. return; } else { System.out.println("Not logged in"); servletResponse.sendError(403); } } } } |
If a client is trying to get an API Key without being logged in, they will get a 403
before even reaching the actual endpoint.
Exchanging your API Keys for an Oauth Token
Want even more security? How about trading your API Key for an Oauth Token? Using Oauth also brings the functionality of scope
, which we can use to allow users to get weather from specified cities.
Let’s take a look at the code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
@Path("/oauthToken") public class OauthToken { @POST @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public String getToken(@Context HttpHeaders httpHeaders, @Context HttpServletRequest myRequest, @Context final HttpServletResponse servletResponse, @FormParam("grant_type") String grantType, @FormParam("scope") String scope) throws Exception { /*Jersey's request.getParameter() always returns null, so we have to reconstruct the entire request ourselves in order to keep data See: https://java.net/jira/browse/JERSEY-766 */ Map<String, String[]> headers = new HashMap<String, String[]>(); for(String httpHeaderName : httpHeaders.getRequestHeaders().keySet()) { //newBuilder.header(String, String[]); List<String> values = httpHeaders.getRequestHeader(httpHeaderName); String[] valueArray = new String[values.size()]; httpHeaders.getRequestHeader(httpHeaderName).toArray(valueArray); headers.put(httpHeaderName, valueArray); } Map<String, String[]> body = new HashMap<String, String[]>(); String[] grantTypeArray = {grantType}; String[] scopeArray = {scope}; body.put("grant_type", grantTypeArray ); body.put("scope", scopeArray); HttpRequest request = HttpRequests.method(HttpMethod.POST).headers( headers).parameters(body).build(); Application application = StormpathUtils.myClient.getResource( StormpathUtils.applicationHref, Application.class); //Build a scope factory ScopeFactory scopeFactory = new ScopeFactory(){ public Set createScope(AuthenticationResult result, Set requestedScopes) { //Initialize an empty set, and get the account HashSet returnedScopes = new HashSet(); Account account = result.getAccount(); /*** In this simple web application, the scopes that were sent in the body of the request are exactly the ones we want to return. If however we were building something more complex, and only wanted to allow a scope to be added if it was verified on the server side, then we would do something as shown in this for loop. The 'allowScopeForAccount()' method would contain the logic which would check if the scope is truly allowed for the given account. for(String scope: requestedScopes){ if(allowScopeForAccount(account, scope)){ returnedScopes.add(scope); } } ***/ return requestedScopes; } }; AccessTokenResult oauthResult = application.authenticateOauthRequest( request).using(scopeFactory).execute(); TokenResponse tokenResponse = oauthResult.getTokenResponse(); String json = tokenResponse.toJson(); return json; } } |
Notice the 10 lines of code right after the getToken()
declaration. This is a workaround for Jersey’s lack of providing us with a complete request object. Calling request.getParamter()
or request.getParameterMap()
will always return null, and since creating an AccessTokenResult
object requires the Request object with the body still intact, we must recreate the entire request ourselves.
Finally: Securing your REST endpoint
Ahh, the moment we’ve all been waiting for. Now that we have given our users the ability to target this weather endpoint using Basic and Oauth authentication, it is up to us to figure out which protocol they choose to use.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
@Path("/api/weather/{city}") public class WeatherApi { @Context private HttpServletRequest servletRequest; @Context private HttpServletResponse servletResponse; private String weatherResult; @GET @Produces(MediaType.APPLICATION_JSON) public String getWeatherApi(@PathParam("city") final String myCity) throws Exception { Application application = StormpathUtils.myClient.getResource( StormpathUtils.applicationHref, Application.class); System.out.println(servletRequest.getHeader("Authorization")); //Make sure this use is allowed to target is endpoint try { ApiAuthenticationResult authenticationResult = application.authenticateApiRequest(servletRequest); authenticationResult.accept(new AuthenticationResultVisitorAdapter() { public void visit(ApiAuthenticationResult result) { System.out.println("Basic request"); URL weatherURL = getURL(myCity); //Parse weather data into our POJO ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationConfig.Feature.FAIL_ON_UNKNOWN_PROPERTIES, false); City city = null; try { InputStream in = weatherURL.openStream(); city = mapper.readValue(in, City.class); } catch (IOException e) { e.printStackTrace(); } weatherResult = city.toString() + " °F"; } public void visit(OauthAuthenticationResult result) { //Check scopes if(result.getScope().contains("London") && myCity.equals("London")){ weatherResult = getWeather(myCity) + " °F";; } else if(result.getScope().contains("Berlin") && myCity.equals("Berlin")){ weatherResult = getWeather(myCity) + " °F";; } else if(result.getScope().contains("SanMateo") && myCity.equals("San Mateo")){ weatherResult = getWeather(myCity) + " °F";; } else if(result.getScope().contains("SanFrancisco") && myCity.equals("San Francisco")){ weatherResult = getWeather(myCity) + " °F";; } else { try { servletResponse.sendError(403); } catch (IOException e) { /* To change body of catch statement use File | Settings | File Templates.*/ e.printStackTrace(); } } } }); return weatherResult; } catch (ResourceException e) { System.out.println(e.getMessage()); servletResponse.sendError(403); return "Cannot authenticate user."; } } |
To do this we use a visitor
. We create a visitor for each type of authentication protocol that we expect our clients to use (in our case Basic and Oauth). Based on the type of the ApiAuthenticationResult
object, the appropriate visitor will be targeted. Notice how inside the OauthAuthenticationResult
visitor, we check the scope
of the Oauth token that we received, and appropriately give/forbid access to the requested cities.
When we generated our Oauth token in the sceenshot above, we gave access to view weather in London, Berlin, and San Francisco. Thus we can view London’s weather using Oauth:
However, since San Mateo was not included in the scope of the Oauth token, we cannot see its weather:
Conclusion
Jersey is yet another Java framework that seamlessly integrates with the Stormpath SDK to offer user management, API Key management, Oauth, and more. If you’d like to see more code and even run this application yourself please visit: https://github.com/rkazarin/sample-jersey-webapp