當您談論基于REST的API的安全性時,人們通常會指向HTTPS。 借助HTTPS,您可以使用每個人都熟悉的方法輕松保護您的服務免遭窺視。 但是,當您需要更高級別的安全性或HTTPS不可用時,您需要替代方法。 例如,您可能需要跟蹤每個客戶對API的使用情況,或者需要確切地知道誰在進行所有這些調用。 您可以將HTTPS與客戶端身份驗證一起使用,但這將需要設置完整的PKI基礎結構以及一種安全的方式來標識您的客戶并交換私鑰。 與基于SOAP的WS-Security服務相比,我們沒有可用于REST的標準。
解決此問題的常用方法(Microsoft,Amazon,Google和Yahoo采用此方法)是通過基于客戶端與服務之間的共享機密對消息進行簽名。 請注意,使用這種方法,我們僅對數據進行簽名,而不對數據進行加密。 在這種情況下,我們所討論的簽名通常稱為基于哈希的消息認證代碼(簡稱HMAC)。 使用HMAC,我們根據已交換的密鑰為請求創建消息認證碼(MAC)。
在本文中,我將向您展示如何為基于Play 2.0的REST服務實現此算法。 如果您使用其他技術,則步驟將幾乎相同。
HMAC方案
對于客戶端,我將僅使用基于HTTPClient的簡單應用程序。 要實現這一點,我們必須采取以下步驟:
- 首先,我們需要與外部客戶端交換共享機密。 通常,這是由API提供程序使用電子郵件發送給客戶端的,或者提供程序具有一個您可以在其中查找共享密鑰的網站。 請注意,此機密僅在您和服務之間共享,每個客戶端將具有不同的共享機密。 這不是像公用密鑰那樣共享的東西,
- 為了確保客戶端和服務在同一內容上計算簽名,我們需要對要簽名的請求進行規范化。 如果我們不這樣做,則服務器可能會以與客戶端不同的方式解釋空格,并得出簽名無效的結論。
- 基于此規范化消息,客戶端使用共享機密創建HMAC值。
- 現在,客戶端已準備好將請求發送到服務。 他將HMAC值添加到標頭中,還將一些內容標識為用戶。 例如,用戶名或其他公共值。
- 當服務收到請求時,它將從標頭中提取用戶名和HMAC值。
- 根據用戶名,服務知道應該使用哪個共享密鑰對消息進行簽名。 例如,該服務將從某處的數據存儲中檢索此信息。
- 現在,服務以與客戶端相同的方式對請求進行規范化,并為其自身計算HMAC值。
- 如果來自客戶端的HMAC與從服務器計算出的HMAC相匹配,則您將知道消息的完整性得到保證,并且客戶端就是他所說的身份。 如果提供了錯誤的用戶名,或者使用了錯誤的機密來計算標題,則HMAC值將不匹配。
要實現HMAC,我們需要做什么? 在以下部分中,我們將研究以下主題。
- 確定用于輸入的字段。
- 創建可以計算此HMAC的客戶端代碼并添加相應的標頭
- 創建基于Play 2.0的攔截器來檢查HMAC標頭
確定輸入字段
我們要做的第一件事是確定HMAC計算的輸入。 下表描述了我們將包括的元素:
領域 | 描述 |
---|---|
HTTP方法 | 使用REST,我們執行的HTTP方法定義了服務器端的行為。 對特定URL的刪除與對該URL的GET處理不同。 |
內容MD5 | 此HTTP標頭是標準HTTP標頭。 這是請求正文的MD5哈希。 如果我們將此標頭包含在HMAC代碼生成中,則會獲得一個HMAC值,該值會隨著請求正文的更改而更改。 |
Content-Type標頭 | 進行REST調用時,Content-Type標頭是重要的標頭。 服務器可以根據媒體類型對請求做出不同的響應,因此應將其包含在HMAC中。 |
日期標題 | 我們還包括創建請求以計算HMAC的日期。 在服務器端,我們可以確保日期在傳輸中沒有更改。 除此之外,我們可以在服務器上添加消息過期功能。 |
路徑 | 由于URI標識REST中的資源,因此調用的URL的路徑部分也用于HMAC計算。 |
我們將包括的幾乎是來自請求的以下信息:
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;}
}
然后使用HMAC算法基于共享機密創建簽名。
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值后,我們需要將其發送到服務器。 為此,我們提供了一個自定義標頭:
post.addHeader("hmac", username + ":" + hmac);
如您所見,我們還添加了用戶名。 服務器需要使用它來確定在服務器端使用哪個密鑰來計算HMAC值。 現在,當我們運行此代碼時,將執行一個簡單的POST操作,將以下請求發送到服務器:
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中實現/播放
到目前為止,我們已經看到客戶需要做什么才能為我們提供正確的標題。 服務提供商通常會提供多種語言的特定庫,用于處理消息簽名的詳細信息。 但是,正如您所看到的,手工完成并不困難。 現在,讓我們看一下服務器端,在此我們將scala與Play 2.0框架一起使用,以檢查提供的標頭是否包含正確的信息。 有關設置正確的Scala環境以測試此代碼的更多信息,請參閱我以前在scala上的帖子( http://www.smartjava.org/content/play-20-akka-rest-json-and-dependencies )。
首先要做的是設置正確的路由以支持此POST操作。 我們在conf / routes文件中執行此操作
POST /resources/rest/geo/comment controllers.Application.addComment
這是基本的Play功能。 對/ resource / rest / geo / comment URL的所有POST調用都將傳遞到指定的控制器。 讓我們看一下該操作的樣子:
def addComment() = Authenticated {(user, request) => {// convert the supplied json to a comment objectval comment = Json.parse(request.body.asInstanceOf[String]).as[Comment]// pass the comment object to a service for processingcommentService.storeComment(comment)println(Json.toJson(comment))Status(201)}}
現在,它變得更加復雜了。 如您在上面的清單中所見,我們定義了一個addComment操作。 但是,與其直接定義這樣的動作,不如:
def processGetAllRequest() = Action {val result = service.processGetAllRequest;Ok(result).as("application/json");}
我們改為這樣定義它:
def addComment() = Authenticated {(user, request) => {
我們在這里所做的是創建一個復合動作http://www.playframework.org/documentation/2.0/ScalaActionsComposition )。 因為Scala是一種功能語言,所以我們可以輕松地做到這一點。 您在此處看到的“已認證”引用只是對簡單函數的簡單引用,該函數以另一個函數作為參數。 在“已驗證”功能中,我們將檢查HMAC簽名。 您可以將其讀為使用批注,但現在無需任何特殊構造。 因此,我們的HMAC檢查是什么樣的。
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 manuallyAction(parse.tolerantText) {request =>{// get the header we're working withval sendHmac = request.headers.get(HMAC_HEADER);// Check whether we've recevied an hmac headersendHmac 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 hashval 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 thisval 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 signval toSign = input.map(a => {a match {case None => ""case a: Option[Any] => a.asInstanceOf[Option[Any]].getcase _ => a}}).mkString("\n")// use the input to calculate the hmacval calculatedHMAC = calculateHMAC(userInfo.secret, toSign)// if the supplied value and the received values are equal// return the response from the delegate action, else return// unauthorizedif (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))}
}
那是很多代碼,但是其中大多數將很容易理解。 “ calculateHMAC”和“ calculateMD5”方法只是圍繞Java功能的基本scala包裝器。 該類內的文檔應該足以了解正在發生的事情。 但是,我確實想在這段代碼中突出幾個有趣的概念。 首先是方法簽名:
def Authenticated(f: (User, Request[Any]) => Result) = {
這意味著Authenticated方法本身將另一個方法(或函數,如果要調用該方法)作為參數。 如果回頭看我們的路線目標,您會發現我們只是這樣做:
def addComment() = Authenticated {(user, request) => ...
現在,當調用此“已認證”方法時會發生什么? 我們要做的第一件事是檢查HMAC標頭是否存在并且格式正確:
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值正確,則使用以下代碼將其委托給提供的函數:
if (calculatedHMAC == headerParts(1)) {f(userInfo, request)} else {Unauthorized}
就是這樣。 使用此代碼,您可以輕松地使用HMAC來檢查郵件在傳輸過程中是否已更改,以及您的客戶是否真正為您所知。 如您所見,非常簡單。 只是Play 2.0中有關JSON使用情況的一小部分便條。 如果您查看操作代碼,則可以看到我使用了標準的JSON功能:
def addComment() = Authenticated {(user, request) => {// convert the supplied json to a comment objectval comment = Json.parse(request.body.asInstanceOf[String]).as[Comment]// pass the comment object to a service for processingcommentService.storeComment(comment)println(Json.toJson(comment))Status(201)}}
首先,我們使用'json.parse'將接收到的JSON解析為'comment'類,然后存儲注釋,并將命令對象轉換回字符串值。 不是最有用的代碼,但它很好地演示了Play 2.0提供的一些JSON功能。 為了從JSON轉換為對象并再次返回,使用了一種稱為“隱式轉換”的方法。 我不會在細節上過多介紹,但是可以在這里找到很好的解釋: http : //www.codecommit.com/blog/ruby/implicit-conversions-more-powerful-t… 。 這里發生的是JSON.parse和Json.toJson方法在Comment類上尋找特定的方法。 如果無法在此處找到它,它將在其范圍內查找特定的操作。 要查看此方法如何用于JSON解析,讓我們看一下Comment類及其配套對象:
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.JsArrayobject 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) {}
您在此處看到的是,在伴隨對象中,我們創建了一個新的“格式”對象。 現在,與“ Comment”類一起使用時,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
參考:來自Smart Java博客的JCG合作伙伴 Jos Dirksen 使用HMAC(Play 2.0)保護REST服務 。
翻譯自: https://www.javacodegeeks.com/2012/04/dzoneprotect-rest-service-using-hmac.html