Статьи

Защита службы REST с помощью HMAC (Play 2.0)

Когда вы говорите о безопасности для API на основе REST, люди часто указывают на HTTPS. С HTTPS вы можете легко защитить свои услуги от посторонних глаз, используя методы, с которыми все знакомы. Однако, когда вам требуется дополнительный уровень безопасности или HTTPS просто недоступен, вам нужна альтернатива. Например, вам может понадобиться отслеживать использование вашего API для каждого клиента или точно знать, кто выполняет все эти вызовы. Вы можете использовать HTTPS вместе с аутентификацией клиента, но для этого потребуется настроить полную инфраструктуру PKI и безопасный способ идентификации ваших клиентов и обмена секретными ключами. И в отличие от службы WS-Security для SOAP, для REST нет стандарта, который мы могли бы использовать.

Распространенный способ решить эту проблему (Microsoft, Amazon, Google и Yahoo используют этот подход) — подписать ваше сообщение на основе общего секрета между клиентом и службой. Обратите внимание, что при таком подходе мы только подписываем данные, но не шифруем их. Подпись, о которой мы говорим в этом случае, обычно называется кодом аутентификации сообщений на основе хэша (или HMAC для краткости). С помощью HMAC мы создаем код аутентификации сообщения (MAC) для запроса на основе секретного ключа, которым мы обменялись.

В этой статье я покажу вам, как вы можете реализовать этот алгоритм для сервиса REST на основе Play 2.0. Если вы используете другую технологию, шаги будут примерно такими же.

Сценарий HMAC

Для клиентской части я просто буду использовать простое приложение на основе HTTPClient. Чтобы реализовать это, мы должны предпринять следующие шаги:

  1. Во-первых, нам нужно обменяться секретом с клиентом. Часто это отправляется поставщиком API клиенту с помощью сообщения электронной почты, или у поставщика есть веб-сайт, на котором вы можете найти общий секрет. Обратите внимание, что этот секрет делится только между вами и службой, у каждого клиента будет свой общий секрет. Это не то, чем вы делитесь, как открытый ключ,
  2. Чтобы клиент и служба вычисляли подпись для одного и того же контента, нам нужно нормализовать запрос, который должен быть подписан. Если мы этого не сделаем, сервер может интерпретировать пробелы по-другому, как это делал клиент, и сделать вывод, что подпись недействительна.
  3. На основе этого нормализованного сообщения клиент создает значение HMAC с использованием общего секрета.
  4. Теперь клиент готов отправить запрос в сервис. Он добавляет значение HMAC к заголовкам, а также то, что идентифицирует его как пользователя. Например, имя пользователя или другое публичное значение.
  5. Когда служба получает запрос, она извлекает имя пользователя и значение HMAC из заголовков.
  6. Основываясь на имени пользователя, служба знает, какой общий секретный ключ должен был использоваться для подписи сообщения. Служба, например, будет извлекать это из хранилища данных где-то.
  7. Служба теперь нормализует запрос так же, как и клиент, и рассчитывает значение HMAC для себя.
  8. Если HMAC от клиента соответствует вычисленному HMAC от сервера, вы знаете, что целостность сообщения гарантирована, и что клиент — это тот, кем он себя называет. Если было указано неверное имя пользователя или неверный секрет для вычисления заголовков, значения HMAC не будут совпадать.

Что нам нужно сделать, чтобы внедрить HMAC? В следующем разделе мы рассмотрим следующие темы.

  • Определите поля, которые будут использоваться для ввода.
  • Создайте код клиента, который может рассчитать этот HMAC, и добавьте соответствующие заголовки.
  • Создать перехватчик на основе Play 2.0, который проверяет заголовки HMAC

Определите поля ввода

Первое, что нам нужно сделать, это определить вход для нашего расчета HMAC. В следующей таблице описаны элементы, которые мы будем включать:

поле Описание
HTTP метод При использовании REST тип HTTP-метода, который мы выполняем, определяет поведение на стороне сервера. УДАЛЕНИЕ к определенному URL обрабатывается иначе, чем GET для этого URL.
Content-MD5 Этот заголовок HTTP является стандартным заголовком HTTP. Это хэш MD5 тела запроса. Если мы включим этот заголовок в генерацию кода HMAC, мы получим значение HMAC, которое изменяется при изменении тела запроса.
Content-Type header Заголовок Content-Type является важным заголовком при выполнении вызовов REST. В зависимости от типа носителя сервер может по-разному отвечать на запрос, поэтому он должен быть включен в HMAC.
Заголовок даты Мы также включили дату создания запроса для расчета HMAC. На стороне сервера мы можем убедиться, что дата не была изменена в пути. Помимо этого мы можем добавить функцию истечения срока действия сообщения на сервере.
Путь Часть пути URL-адреса, которая была вызвана, также используется в вычислениях HMAC, поскольку URI идентифицирует ресурс в REST.

То, что мы включим, это в значительной степени следующая информация из запроса:

PUT /example/resource/1
Content-Md5: uf+Fg2jkrCZgzDcznsdwLg==
Content-Type: text/plain; charset=UTF-8
Date: Tue, 26 Apr 2011 19:59:03 CEST

Код клиента, который можно использовать для создания подписи HMAC

Ниже вы можете увидеть код клиента, с помощью которого мы будем звонить в защищенную службу HMAC. Это просто быстрый клиент на основе HTTPClient, с помощью которого мы можем протестировать наш Сервис.

public class HMACClient {

private final static String DATE_FORMAT = "EEE, d MMM yyyy HH:mm:ss z";
private final static String HMAC_SHA1_ALGORITHM = "HmacSHA1";

private final static String SECRET = "secretsecret";
private final static String USERNAME = "jos";

private static final Logger LOG = LoggerFactory.getLogger(HMACClient.class);

public static void main(String[] args) throws HttpException, IOException, NoSuchAlgorithmException {
HMACClient client = new HMACClient();
client.makeHTTPCallUsingHMAC(USERNAME);
}

public void makeHTTPCallUsingHMAC(String username) throws HttpException, IOException, NoSuchAlgorithmException {
String contentToEncode = "{\"comment\" : {\"message\":\"blaat\" , \"from\":\"blaat\" , \"commentFor\":123}}";
String contentType = "application/vnd.geo.comment+json";
//String contentType = "text/plain";
String currentDate = new SimpleDateFormat(DATE_FORMAT).format(new Date());

HttpPost post = new HttpPost("http://localhost:9000/resources/rest/geo/comment");
StringEntity data = new StringEntity(contentToEncode,contentType,"UTF-8");
post.setEntity(data);

String verb = post.getMethod();
String contentMd5 = calculateMD5(contentToEncode);
String toSign = verb + "\n" + contentMd5 + "\n"
+ data.getContentType().getValue() + "\n" + currentDate + "\n"
+ post.getURI().getPath();

String hmac = calculateHMAC(SECRET, toSign);

post.addHeader("hmac", username + ":" + hmac);
post.addHeader("Date", currentDate);
post.addHeader("Content-Md5", contentMd5);

HttpClient client = new DefaultHttpClient();
HttpResponse response = client.execute(post);

System.out.println("client response:" + response.getStatusLine().getStatusCode());
}

private String calculateHMAC(String secret, String data) {
try {
SecretKeySpec signingKey = new SecretKeySpec(secret.getBytes(),HMAC_SHA1_ALGORITHM);
Mac mac = Mac.getInstance(HMAC_SHA1_ALGORITHM);
mac.init(signingKey);
byte[] rawHmac = mac.doFinal(data.getBytes());
String result = new String(Base64.encodeBase64(rawHmac));
return result;
} catch (GeneralSecurityException e) {
LOG.warn("Unexpected error while creating hash: " + e.getMessage(),e);
throw new IllegalArgumentException();
}
}

private String calculateMD5(String contentToEncode) throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("MD5");
digest.update(contentToEncode.getBytes());
String result = new String(Base64.encodeBase64(digest.digest()));
return result;
}
}

We won’t dive into too much detail here, since the code isn’t that interesting. The only interesting part is where we create an HMAC value from the fields we discussed earlier. We do this in the following couple of lines:

We first create the string we’re going to sign:

String verb = post.getMethod();
String contentMd5 = calculateMD5(contentToEncode);
String toSign = verb + "\n" + contentMd5 + "\n"
+ data.getContentType().getValue() + "\n" + currentDate + "\n"
+ post.getURI().getPath();

And then use the HMAC algorithm to create a signature based on a shared secret.

private String calculateHMAC(String secret, String data) {
try {
SecretKeySpec signingKey = new SecretKeySpec(secret.getBytes(),HMAC_SHA1_ALGORITHM);
Mac mac = Mac.getInstance(HMAC_SHA1_ALGORITHM);
mac.init(signingKey);
byte[] rawHmac = mac.doFinal(data.getBytes());
String result = new String(Base64.encodeBase64(rawHmac));
return result;
} catch (GeneralSecurityException e) {
LOG.warn("Unexpected error while creating hash: " + e.getMessage(),e);
throw new IllegalArgumentException();
}
}

After we’ve calculcated the HMAC value, we need to send it to the server. We do this by providing a custom header:

post.addHeader("hmac", username + ":" + hmac);

As you can see, we also add our username. This is needed by the server to determine which secret to use to calculate the HMAC value on the server side. When we now run this code, a simple POST operation will be executed that sends the following request to the server:

POST /resources/rest/geo/comment HTTP/1.1[\r][\n]
hmac: jos:+9tn0CLfxXFbzPmbYwq/KYuUSUI=[\r][\n]
Date: Mon, 26 Mar 2012 21:34:33 CEST[\r][\n]
Content-Md5: r52FDQv6V2GHN4neZBvXLQ==[\r][\n]
Content-Length: 69[\r][\n]
Content-Type: application/vnd.geo.comment+json; charset=UTF-8[\r][\n]
Host: localhost:9000[\r][\n]
Connection: Keep-Alive[\r][\n]
User-Agent: Apache-HttpClient/4.1.3 (java 1.5)[\r][\n]
[\r][\n]
{"comment" : {"message":"blaat" , "from":"blaat" , "commentFor":123}}

Implementing in Scala / Play

So far we’ve seen what the client needs to do to provide us with the correct headers. Service providers often offer specific libraries, in multiple languages, that handle the details of signing the message. But as you can see, doing it by hand, isn’t that difficult. Now, let’s look at the server side, where we use scala together with the Play 2.0 framework to check whether the supplied header contains the correct information. For more information on setting up the correct scala environment to test this code look at my previous post on scala (http://www.smartjava.org/content/play-20-akka-rest-json-and-dependencies).
The first thing to do is setup the correct routes to support this POST operation. We do this in the conf/routes file

POST/resources/rest/geo/commentcontrollers.Application.addComment

This is basic Play functionality. All POST calls to the /resource/rest/geo/comment URL will be passed on to the specified controller. Let’s look at what this operation looks like:

 def addComment() = Authenticated {
    (user, request) => {
    // convert the supplied json to a comment object
    val comment = Json.parse(request.body.asInstanceOf[String]).as[Comment]

    // pass the comment object to a service for processing
    commentService.storeComment(comment)
    println(Json.toJson(comment))
        Status(201)
      }
  }

Now it gets a bit more complicated. As you can see in the listing above, we’ve defined an addComment operation. But, instead of directly defining an Action like this:

 def processGetAllRequest() = Action {
    val result = service.processGetAllRequest;
    Ok(result).as("application/json");
  }

We, instead, define it like this:

 def addComment() = Authenticated {
    (user, request) => {

What we do here is create a composite action (http://www.playframework.org/documentation/2.0/ScalaActionsComposition). We can easily do this, since Scala is a functional language. The ‘Authenticated’ reference you see here is just a simple reference to a simple function, that takes another function as its argument. In the ‘Authenticated’ function we’ll check the HMAC signature. You can read this as using annotations, but now without the need for any special constructs. So, what does our HMAC check look like.

import play.api.mvc.Action
import play.api.Logger
import play.api.mvc.RequestHeader
import play.api.mvc.Request
import play.api.mvc.AnyContent
import play.api.mvc.Result
import controllers.Application._
import java.security.MessageDigest
import javax.crypto.spec.SecretKeySpec
import javax.crypto.Mac
import org.apache.commons.codec.binary.Base64
import play.api.mvc.RawBuffer
import play.api.mvc.Codec

/**
 * Obejct contains security actions that can be applied to a specific action called from
 * a controller.
 */
object SecurityActions {

  val HMAC_HEADER = "hmac"
  val CONTENT_TYPE_HEADER = "content-type"
  val DATE_HEADER = "Date"

  val MD5 = "MD5"
  val HMACSHA1 = "HmacSHA1"

  /**
   * Function authenticated is defined as a function that takes as parameter
   * a function. This function takes as argumens a user and a request. The authenticated
   * function itself, returns a result.
   *
   * This Authenticated function will extract information from the request and calculate
   * an HMAC value.
   *
   *
   */
  def Authenticated(f: (User, Request[Any]) => Result) = {
    // we parse this as tolerant text, since our content type
    // is application/vnd.geo.comment+json, which isn't picked
    // up by the default body parsers. Alternative would be
    // to parse the RawBuffer manually
    Action(parse.tolerantText) {

      request =>
        {
          // get the header we're working with
          val sendHmac = request.headers.get(HMAC_HEADER);

          // Check whether we've recevied an hmac header
          sendHmac match {

            // if we've got a value that looks like our header 
            case Some(x) if x.contains(":") && x.split(":").length == 2 => {

              // first part is username, second part is hash
              val headerParts = x.split(":");
              val userInfo = User.find(headerParts(0))

              // Retrieve all the headers we're going to use, we parse the complete 
              // content-type header, since our client also does this
              val input = List(
                request.method,
                calculateMD5(request.body),
                request.headers.get(CONTENT_TYPE_HEADER),
                request.headers.get(DATE_HEADER),
                request.path)

              // create the string that we'll have to sign
              val toSign = input.map(
                a => {
                  a match {
                    case None => ""
                    case a: Option[Any] => a.asInstanceOf[Option[Any]].get
                    case _ => a
                  }
                }).mkString("\n")

              // use the input to calculate the hmac
              val calculatedHMAC = calculateHMAC(userInfo.secret, toSign)

              // if the supplied value and the received values are equal
              // return the response from the delegate action, else return
              // unauthorized
              if (calculatedHMAC == headerParts(1)) {
                f(userinfo, request)
              } else {
                 Unauthorized
              }
            }

            // All the other possibilities return to 401 
            case _ => Unauthorized
          }
        }
    }
  }

  /**
   * Calculate the MD5 hash for the specified content
   */
  private def calculateMD5(content: String): String = {
    val digest = MessageDigest.getInstance(MD5)
    digest.update(content.getBytes())
    new String(Base64.encodeBase64(digest.digest()))
  }

  /**
   * Calculate the HMAC for the specified data and the supplied secret
   */
  private def calculateHMAC(secret: String, toEncode: String): String = {
    val signingKey = new SecretKeySpec(secret.getBytes(), HMACSHA1)
    val mac = Mac.getInstance(HMACSHA1)
    mac.init(signingKey)
    val rawHmac = mac.doFinal(toEncode.getBytes())
    new String(Base64.encodeBase64(rawHmac))
  }
}

That’s a lot of code, but most of it will be pretty easy to understand. The ‘calculateHMAC’ and the ‘calculateMD5’ methods are just basic scala wrappers around Java functionality. The documentation inside this class should be enough to understand what is happening. I do, however, want to highlight a couple of interesting concepts in this code. The first thing is the method signature:

 def Authenticated(f: (User, Request[Any]) => Result) = {

What this means is that the Authenticated method itself, takes as it’s arguments another method (or function if you want to call it that). If you look back at the target of our route, you can see that we do just that:

def addComment() = Authenticated {
    (user, request) => ...

Now what happens when this ‘Authenticated’ method is called? The first thing we do, is check whether the HMAC header exists and is in the correct format:

val sendHmac = request.headers.get(HMAC_HEADER);
  sendHmac match {

            // if we've got a value that looks like our header 
            case Some(x) if x.contains(":") && x.split(":").length == 2 => {
            ...
            }

            // All the other possibilities return to 401 
            case _ => Unauthorized

We do this by using a match against the HMAC header. If it contains a value that is of the correct format, we process the header and calculate the HMAC value in the same manner as our client did. If not we return a 401. If the HMAC value is correct we delegate to the provided function using this code:

   if (calculatedHMAC == headerParts(1)) {
                f(userInfo, request)
              } else {
                 Unauthorized
              }

And that pretty much is it. With this code you can easily use an HMAC to check whether the message has changed in transit, and whether your client is really known to you. Very easy as you can see.

Just a small sidenote on JSON usage from Play 2.0. If you look at the action code, you can see I use the standard JSON functionality:

 def addComment() = Authenticated {
    (user, request) => {
    // convert the supplied json to a comment object
    val comment = Json.parse(request.body.asInstanceOf[String]).as[Comment]

    // pass the comment object to a service for processing
    commentService.storeComment(comment)
    println(Json.toJson(comment))
        Status(201)
      }
  }

First we parse the received JSON using ‘json.parse’ to a ‘comment’ class, then store the comment, and convert the command object back to a string value. Not the most useful code, but it does nicely demonstrate some of the JSON functionality provided by Play 2.0. To convert from JSON to an object and back again, something called «Implicit Conversions» is used. I won’t dive too much in the details, but a good explanation can be found here: http://www.codecommit.com/blog/ruby/implicit-conversions-more-powerful-t…. What happens here is that the JSON.parse and the Json.toJson method look for a specific method on the Comment class. And if it can’t find it there, it looks for the specific operation in its scope. To see how this works for the JSON parsing let’s look a the Comment class and its companion object:

import play.api.libs.json.Format
import play.api.libs.json.JsValue
import play.api.libs.json.JsObject
import play.api.libs.json.JsString
import play.api.libs.json.JsNumber
import play.api.libs.json.JsArray

object Comment {

  implicit object CommentFormat extends Format[Comment] {

    def reads(json: JsValue): Comment = {
      val root = (json \ "comment")

      Comment(
        (root \ "message").as[String],
        (root \ "from").as[String],
        (root \ "commentFor").as[Long])

    }

    def writes(comment: Comment): JsValue = {
      JsObject(List("comment" ->
        JsObject(Seq(
          "message" -> JsString(comment.message),
          "from" -> JsString(comment.message),
          "commentFor" -> JsNumber(comment.commentFor)))))
    }
  }

}

case class Comment(message: String, from: String, commentFor: Long) {}

What you see here is that in the companion object we create a new ‘Format’ object. The ‘reads’ and ‘writes’ operations in this object will now be used by the JSON operation to convert from and to JSON when working with the ‘Comment’ class. Very powerful stuff, even though it’s a bit magic 😉

For more information on the Scala/Play environment I used for this example see my previous posts:

http://www.smartjava.org/content/play-20-akka-rest-json-and-dependencies
http://www.smartjava.org/content/using-querulous-scala-postgresql