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:
@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:
@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
:
@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 implementingContainerRequestFilter
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).
@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 ofscope
, which we can use to allow users to get weather from specified cities.Let’s take a look at the code:
@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.
@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:
No comments:
Post a Comment