Scala用のOAuth認証のライブラリを探してみると,通常の(他のユーザを専用のページへリダイレクトして承認してもらい,アクセストークン等を取得する)認証についてのものはいくつか見つかった.一方で,Twitterのような,自分のアクセストークンが分かっている場合の処理までREADMEに明記しているものは見つからなかった.
なので手動でAuthorizationヘッダを組み立てなければならないが,URLエンコードと暗号化でハマったので,メモしておく.
Java用のTwitterライブラリもあるが,Scalaライクなコードを書きたいので,あえて使わない(Scala初心者なので,改善点があれば教えてください).
ここでは,URLエンコード,Base64エンコード,HMAC-SHA1での暗号化と,ついでにTwitterのためのAuthorizationヘッダの作り方を書いていく.
実行環境
- macOS Sierra 10.12.2
- Scala 2.12.1
- sbt 0.13.13
URLエンコード
java.net.URLEncoder.encode( string, encoder ) を使う.string にはエンコードしたい文字列を,encoder には"UTF-8"を指定する.ただし,https://docs.oracle.com/javase/1.5.0/docs/api/java/net/URLEncoder.html によると,空白は,'%20'ではなく'+'へ変換されるので,replace("+", "%20")する必要がある:
def urlencode(s: String) = java.net.URLEncoder encode (s, "UTF-8") replace ("+", "%20")
Base64エンコード
org.apache.commons.codec.binary.Base64.encodeBase64( arrayOfBytes ) を使う.これはArray[Byte]を返す.build.sbt
には
libraryDependencies ++= Seq(
"commons-codec" % "commons-codec" % "1.3.0"
)
を書いておく.
暗号化(HMAC-SHA1)
key, data を引数として,以下のように書く.中身は理解していないが,http://technically.us/code/x/oauth-here-she-comes/ を参考にした.
def bytes(s: String) = s getBytes "UTF-8"
def encry(key: String, data: String) = {
val hmacShai = "HmacSHA1"
val keyStr = new javax.crypto.spec.SecretKeySpec(bytes(key), hmacShai)
val mac = javax.crypto.Mac.getInstance(hmacShai)
mac.init(keyStr)
new String(org.apache.commons.codec.binary.Base64.encodeBase64(mac.doFinal(bytes(data))))
}
署名の作成
以下,consumer key, consumer secret, token, token secretはすでに取得しているとし,これらをそれぞれ
consumerKey, consumerSecret, token, tokenSecret
(すべてString)とする.また,MapはすべてMap[String, String]を表し,
def addOauth(m: Map) = m map { case (k, v) => ("oauth_" + k, v) }
def sortMap(m: Map) = m.toList sortBy {_._1}
という関数を定義する.importは以下の通り(play.apiはTwitterのAPIを叩くために必要):
import scala.concurrent.ExecutionContext.Implicits.global
import java.net.URLEncoder
import javax.crypto.spec.SecretKeySpec
import javax.crypto.Mac
import org.apache.commons.codec.binary.Base64.encodeBase64
import play.api.libs.json._
import play.api.libs.ws.ning.NingWSClient
build.sbt
に以下を追加する:
libraryDependencies ++= Sep(
"com.typesafe.play" %% "play-ws" % "2.4.0-M2"
)
timestampは (System.currentTimeMillis / 1000).toString
,nonceは System.nanoTime.toString
とする.
implicit val credentials = Map(
"consumerKey" -> consumerKey,
...
)
というように,consumerKey, consumerSecret, token, tokenSecretを並べる.署名(String)を返す関数 calcSignature
は以下の通り(httpMethod
は"GET"等):
def calcSignature(httpMethod: String, url: String, query: Map, timestamp: String, nonce: String)(implicit credentials: Map)= {
val params = sortMap(addOauth(Map(
"consumer_key" -> credentials("consumerKey"),
"nonce" -> nonce,
"signature_method" -> "HMAC-SHA1",
"timestamp" -> timestamp,
"token" -> credentials("token"),
"version" -> "1.0"
)) ++ query )
val paramString = params map { case (k, v) =>
urlencode(k) + "=" + urlencode(v)
} mkString "&"
val signatureBase = httpMethod + "&" + urlencode(url) + "&" + urlencode(paramString)
val signatureKey = urlencode(credentials("consumerSecret")) + "&" + urlencode(credentials("tokenSecret"))
encry(signatureKey, signatureBase)
}
ヘッダの作成
あとは簡単.timestamp
と nonce
は適当な箇所で定義しておく.
def oauthHeader(httpMethod: String, url: String, query: Map)(implicit credentials: Map) = {
val header = sortMap(addOauth(Map(
"consumer_key" -> credentials("consumerKey"),
"token" -> credentials("token"),
"timestamp" -> timestamp,
"nonce" -> nonce,
"signature_method" -> "HMAC-SHA1",
"signature" -> calcSignature(httpMethod, url, query, timestamp, nonce),
"version" -> "1.0"
)))
"OAuth " + (header map { case (k, v) =>
urlencode(k) + "=" + urlencode(v)
} mkString ", ")
}
まとめ
結局コードはこうなる:
build.sbt
name := "twitter"
version := "1.0"
libraryDependencies ++= Seq(
"com.typesafe.play" %% "play-ws" % "2.4.0-M2",
"commons-codec" % "commons-codec" % "1.3.0"
)
twitter.scala
import scala.concurrent.ExecutionContext.Implicits.global
import java.net.URLEncoder
import javax.crypto.spec.SecretKeySpec
import javax.crypto.Mac
import org.apache.commons.codec.binary.Base64.{encodeBase64 => base64encode}
import play.api.libs.json._
import play.api.libs.ws.WSResponse
import play.api.libs.ws.ning.NingWSClient
object Twitter {
def main(args: Array[String]) = {
// obtain these keys beforehand
val consumerKey = "AAA"
val consumerSecret = "BBB"
val token = "CCC"
val tokenSecret = "DDD"
implicit val credentials: Map[String, String] = Map(
"consumerKey" -> consumerKey,
"consumerSecret" -> consumerSecret,
"token" -> token,
"tokenSecret" -> tokenSecret
)
// GET https://api.twitter.com/1.1/statuses/user_timeline.json?count=1&user_id=XXX
val host = "https://api.twitter.com"
val path = "/1.1/statuses/user_timeline.json"
val url = host + path
val id = "XXX"
val query: Map[String, String] = Map(
"count" -> "1",
"user_id" -> id
)
val headers: Map[String, String] = Map(
"Content-Type" -> "application/x-www-form-urlencoded;charset=UTF-8",
"Authorization" -> oauthHeader("GET", url, query)
)
// val ws = play.api.libs.ws.WSClient() dose not work outside the play framework,
// so I use play.api.libs.ws.ning.NingWSClient(). why?
implicit val ws: NingWSClient = NingWSClient()
val res: Future[WSResponse] = get(url, query, headers)
Thread.sleep(1000)
println(res.value.get.get.json)
ws.close()
}
def get(url: String, query: Map[String, String], headers: Map[String, String])(implicit ws: NingWSClient): Future[WSResponse] = {
ws.url(url).withQueryString(query.toList:_*).withHeaders(headers.toList:_*).get
}
def oauthHeader(httpMethod: String, url: String, query: Map[String, String])(implicit credentials: Map[String, String]): String = {
val consumerKey = credentials("consumerKey")
val token = credentials("token")
val ts = timestamp
val nc = nonce
val header: List[(String, String)] = sortMap(addOauth(Map(
"consumer_key" -> consumerKey,
"token" -> token,
"timestamp" -> ts,
"nonce" -> nc,
"signature_method" -> "HMAC-SHA1",
"signature" -> calcSignature(httpMethod, url, query, ts, nc),
"version" -> "1.0"
)))
"OAuth " + (header map { case (k, v) => urlencode(k) + "=" + urlencode(v) } mkString ", ")
}
private def calcSignature(httpMethod: String, url: String, query: Map[String, String], ts: String, nc: String)(implicit credentials: Map[String, String]): String = {
val consumerKey = credentials("consumerKey")
val token = credentials("token")
val consumerSecret = credentials("consumerSecret")
val tokenSecret = credentials("tokenSecret")
val signatureKey: String = urlencode(consumerSecret) + "&" + urlencode(tokenSecret)
val params: List[(String, String)] = sortMap(addOauth(Map(
"consumer_key" -> consumerKey,
"nonce" -> nc,
"signature_method" -> "HMAC-SHA1",
"timestamp" -> ts,
"token" -> token,
"version" -> "1.0"
)) ++ query)
val paramString: String = params map { case (k, v) => urlencode(k) + "=" + urlencode(v) } mkString "&"
val signatureBase: String = httpMethod + "&" + urlencode(url) + "&" + urlencode(paramString)
encry(signatureKey, signatureBase)
}
private def addOauth(m: Map[String, String]): Map[String, String] = {
m map { case (k, v) => ("oauth_" + k, v) }
}
private def encry(key: String, data: String): Strng = {
val hmacShai = "HmacSHA1"
val keyStr: SecretKeySpec = new SecretKeySpec(bytes(key), hmacShai)
val mac: Mac = Mac.getInstance(hmacShai)
mac.init(keyStr)
new String(base64encode(mac.doFinal(bytes(data))))
}
private def urlencode(s: String): String = URLEncoder encode (s, "UTF-8") replace ("+", "%20")
private def bytes(s: String): String = s getBytes "UTF-8"
private def timestamp(): String = (System.currentTimeMillis / 1000).toString
private def nonce(): String = System.nanoTime.toString
private def sortMap(m: Map[String, String]): List[(String, String)] = m.toList sortBy {_._1}
}