Статьи

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

У нас есть HTTPS, что еще нам нужно?

Когда вы говорите о безопасности для 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.

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

1
2
3
4
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, с помощью которого мы можем протестировать наш Сервис.

01
02
03
04
05
06
07
08
09
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
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;
 }
}

А затем используйте алгоритм HMAC для создания подписи на основе общего секрета.

01
02
03
04
05
06
07
08
09
10
11
12
13
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();
  }
 }

После того, как мы вычислили значение HMAC, нам нужно отправить его на сервер. Мы делаем это, предоставляя собственный заголовок:

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

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

01
02
03
04
05
06
07
08
09
10
11
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}}

Внедрение в Scala / Play

До сих пор мы видели, что клиент должен сделать, чтобы предоставить нам правильные заголовки. Поставщики услуг часто предлагают специальные библиотеки на нескольких языках, которые обрабатывают детали подписания сообщения. Но, как видите, делать это вручную не так уж и сложно. Теперь давайте посмотрим на сторону сервера, где мы используем scala вместе с платформой Play 2.0, чтобы проверить, содержит ли предоставленный заголовок правильную информацию. Для получения дополнительной информации о настройке правильной среды scala для тестирования этого кода посмотрите мой предыдущий пост о scala ( http://www.smartjava.org/content/play-20-akka-rest-json-and-dependencies ).

Первое, что нужно сделать, это настроить правильные маршруты для поддержки этой операции POST. Мы делаем это в файле conf / routs

1
POST /resources/rest/geo/comment   controllers.Application.addComment

Это базовая функциональность Play. Все вызовы POST по URL-адресу / resource / rest / geo / comment будут переданы указанному контроллеру. Давайте посмотрим, как выглядит эта операция:

01
02
03
04
05
06
07
08
09
10
11
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)
      }
  }

Теперь все становится немного сложнее. Как вы можете видеть в приведенном выше листинге, мы определили операцию addComment. Но вместо того, чтобы непосредственно определять действие как это:

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

Вместо этого мы определяем это так:

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

Здесь мы создаем составное действие ( http://www.playframework.org/documentation/2.0/ScalaActionsComposition ). Мы можем легко сделать это, так как Scala — функциональный язык. Ссылка «Аутентифицированная», которую вы видите здесь, является простой ссылкой на простую функцию, которая принимает в качестве аргумента другую функцию. В функции «Аутентифицировано» мы проверим подпись HMAC. Вы можете прочитать это как использование аннотаций, но теперь без необходимости каких-либо специальных конструкций. Итак, как выглядит наша проверка HMAC.

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
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))
  }
}

Это много кода, но большинство из них будет довольно легко понять. Методы CalculayHMAC и CalculateMD5 — это просто базовые оболочки Scala для функциональности Java. Документации внутри этого класса должно быть достаточно, чтобы понять, что происходит. Однако я хочу выделить несколько интересных концепций в этом коде. Первым делом это метод signatue:

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

Это означает, что сам метод Authenticated принимает в качестве аргументов другой метод (или функцию, если вы хотите это так называть). Если вы посмотрите на цель нашего маршрута, вы увидите, что мы делаем именно это:

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

Что происходит, когда вызывается этот метод «Аутентифицированный»? Первое, что мы делаем, это проверяем, существует ли заголовок HMAC и имеет ли он правильный формат:

01
02
03
04
05
06
07
08
09
10
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

Мы делаем это, используя сопоставление с заголовком HMAC. Если оно содержит значение в правильном формате, мы обрабатываем заголовок и вычисляем значение HMAC так же, как наш клиент. Если нет, мы возвращаем 401. Если значение HMAC является правильным, мы делегируем предоставленной функции, используя этот код:

1
2
3
4
5
if (calculatedHMAC == headerParts(1)) {
                f(userInfo, request)
              } else {
                 Unauthorized
              }

И это в значительной степени так. С помощью этого кода вы можете легко использовать HMAC, чтобы проверить, изменилось ли сообщение при передаче и действительно ли ваш клиент вам известен. Очень просто, как вы можете видеть. Небольшая заметка об использовании JSON в Play 2.0. Если вы посмотрите на код действия, вы увидите, что я использую стандартную функциональность JSON:

01
02
03
04
05
06
07
08
09
10
11
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)
      }
  }

Сначала мы анализируем полученный JSON, используя json.parse, в класс comment, затем сохраняем комментарий и преобразуем объект команды обратно в строковое значение. Не самый полезный код, но он действительно демонстрирует некоторые функции JSON, предоставляемые Play 2.0. Для преобразования из JSON в объект и обратно используется нечто, называемое «неявное преобразование». Я не буду вдаваться в подробности, но хорошее объяснение можно найти здесь: http://www.codecommit.com/blog/ruby/implicit-conversions-more-powerful-t… . Здесь происходит то, что JSON.parse и метод Json.toJson ищут определенный метод в классе Comment. И если он не может найти его там, он ищет конкретную операцию в своей области видимости. Чтобы увидеть, как это работает для анализа JSON, давайте посмотрим на класс Comment и его сопутствующий объект:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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) {}

Здесь вы видите, что в объекте-компаньоне мы создаем новый объект «Формат». Операции «чтения» и «записи» в этом объекте теперь будут использоваться операцией JSON для преобразования из и в JSON при работе с классом «Комментарий». Очень мощный материал, хотя и немного магический 😉 Для получения дополнительной информации о среде Scala / Play, которую я использовал для этого примера, см. Мои предыдущие посты:
http://www.smartjava.org/content/play-20-akka-rest-json-and-dependencies
http://www.smartjava.org/content/using-querulous-scala-postgresql

Ссылка: Защитите службу REST с помощью HMAC (Play 2.0) от нашего партнера по JCG Йоса Дирксена в блоге Smart Java .