はじめに
ScalaとPlayFrameworkの勉強を兼ねて、少し実用的なものを作ってみようと思い立ち、今回の実装を行いました。
作成したのは、httpのbodyを使って暗号化された情報を送受信するものです。
用途としては、ゲーム等でプレイヤーに対して送受信情報を隠蔽したい場合を想定しています。
なお、暗号化の手法自身は今回取り扱っていません。現実に使用する場合は、暗号化以外にも改竄防止のHMAC乗っけたりとか、セッショントークンくっつけたりとかする必要があると思います。
また、筆者はScalaもPlayFrameworkも初心者なので、こんなのありえんとか、プププとか、頭の中Z80じゃねえのとかのご意見があれば大変喜びます。以上、よろしくお願いいたします。
実装
前提
今回は、暗号化代わりに単純にJSONを圧縮(Inflate)したデータを使用します。また、通信プロトコルもhttpを使用します。
また、JSONのエンコード・デコードには、circeを使用しました。
今回使用した各種バージョンは以下の通りです。
名前 | バージョン |
---|---|
Scala | 2.12.4 |
PlayFramework | 2.6.7 |
circe | 0.8.0 |
送受信データ
今回、送受信するデータは、以下の通りとします。
{"method":"hogehoge","param":"parameter"}
{"status":0}
build.sbt
JSONパーザとしてcirceを使用するので、build.sbtにライブラリを追加します。
val circeVersion = "0.8.0"
libraryDependencies ++= Seq(
"io.circe" %% "circe-core",
"io.circe" %% "circe-parser",
"io.circe" %% "circe-generic"
).map(_ % circeVersion)
データオブジェクト
送受信データに対応するcase classを作成します。
リクエスト用データオブジェクト。
package controllers.packet
case class RequestHoge(method: String, param: String)
レスポンス用データオブジェクト。
package controllers.packet
case class ResponseHoge(status: Int)
デコード
デコードはBodyParserをカスタマイズした独自パーザで行うことにします。このパーザでは、httpリクエストのbodyで来たデータをJSONに直した後、circeを使って上記のデータオブジェクトに変換するとこまで行います。
なお、実装は、公式ドキュメントやソースを参考にして作成しました。
デコードに使用するBodyParserは、リクエスト用データオブジェクトを型パラメータとして持ち、リクエスト毎にController内で作成することにします。そして、BodyParserを生成するためのクラスはDIを使ってControllerに渡すことにしました。
package services
import java.io.ByteArrayInputStream
import java.util.zip._
import javax.inject.{Inject, Singleton}
import akka.util.ByteString
import io.circe._
import io.circe.parser._
import play.api.mvc.{BodyParser, PlayBodyParsers, RawBuffer}
import scala.concurrent.ExecutionContext
import scala.io.Source
/**
* DIで挿入されるtrait
*/
trait BodyParserMaker {
def createBodyParser[A]()(implicit decoder: Decoder[A]): BodyParser[Either[String, A]]
}
/**
* 本体
*/
@Singleton
class EncryptedBodyParserMaker @Inject() (ec: ExecutionContext, p: PlayBodyParsers)
extends BodyParserMaker
{
override def createBodyParser[A]()(implicit decoder: Decoder[A]): BodyParser[Either[String, A]] =
p.raw.map[Either[String, A]](convert[A])(ec)
/**
* PlayBodyParsers.rawへのmap関数として渡される関数
*/
private def convert[A](data: RawBuffer)(implicit decoder: Decoder[A]): Either[String, A] = {
data.asBytes() match {
case Some(bytes) => parseBytes[A](bytes)
case None => Left("No data")
}
}
/**
* 入力バイト列を復号し、入力ストリームを作成
*/
private def parseBytes[A](bytes: ByteString): Either[String, InputStream] = {
try {
val byteArray: Array[Byte] = bytes.toArray
// 本当はここに暗号を復号する処理が入る
val text = Source.fromInputStream(InflaterInputStream(new ByteArrayInputStream(byteArray))).mkString
parse(text) match {
case Right(x) => x match {
case json => parseJson[A](json)
}
case Left(x) => Left(x.getMessage())
}
} catch {
case ex: Exception => Left(ex.getMessage)
}
}
private def parseJson[A](obj: Json)(implicit decoder: Decoder[A]): Either[String, A] = {
decoder.decodeJson(obj) match {
case Right(result) => Right(result)
case Left(failure) => Left(failure.getMessage())
}
}
}
BodyParserMakerとEncryptedBodyParserMakerを登録します。
import com.google.inject.AbstractModule
import services.{BodyParserMaker, EncryptedBodyParserMaker}
class Module extends AbstractModule {
override def configure(): Unit = {
bind(classOf[BodyParserMaker]).to(classOf[EncryptedBodyParserMaker])
}
}
後はControllerからカスタムBodyParserを生成して呼び出せばok。
package controllers
import javax.inject._
import controllers.packet.{RequestHoge, ResponseHoge}
import play.api.mvc._
import io.circe.generic.auto._
import io.circe.syntax._
import services.BodyParserMaker
@Singleton
class HogeController @Inject()(cc: ControllerComponents, bpm: BodyParserMaker) extends AbstractController(cc) {
def hogehoge = Action(bpm.createBodyParser[RequestHoge]()) { request =>
request.body match {
case Right(x) => { // xはRequestHoge
// なにか処理をして、値を返す
val response = x.method match {
case "hogehoge" => ResponseHoge(666)
case _ => ResponseHoge(0)
}
Ok(response.asJson.noSpaces)
}
case _ => InternalServerError
}
}
}
カスタムBodyParser作るのに、PlayBodyParsersのraw BodyParserを使っているけど、これで良いのだろうか・・?
エンコード
エンコードはフィルター機能を使って実装することにしました。
なお、上記のHogeControllerにあるように、レスポンス用データオブジェクトを作成したのち、それをJSON→テキスト化を手動(.asJson.noSpaces)で行った後でフィルタリングしています。
本当は、Ok(response)とやりたかったのですが、どうしてもうまくいかなかったため、こうなっています。何かいい方法ありますかね?
フィルターの実装は、以下の通りです。
package filters
import javax.inject.Inject
import java.io.ByteArrayOutputStream
import java.util.zip._
import akka.stream._
import akka.stream.scaladsl.{Flow, Source}
import akka.stream.stage.{GraphStage, GraphStageLogic, InHandler, OutHandler}
import akka.util.ByteString
import play.api.http.{ContentTypes, HttpEntity}
import play.api.libs.streams.Accumulator
import play.api.mvc._
import scala.concurrent.{ExecutionContext, Future}
class EncryptionFilter @Inject()(implicit val mat: Materializer)
extends EssentialFilter
{
/**
* map, mapFoldで参照される暗黙的パラメータ
*/
implicit val ec: ExecutionContext = mat.executionContext
/**
* フィルター本体
* @param nextFilter
* @return
*/
override def apply(nextFilter: EssentialAction) = EssentialAction { requestHeader =>
val accumulator: Accumulator[ByteString, Result] =
if (isEncryptedRequest(requestHeader)) {
// 既存のフィルター処理後、暗号化を行う
nextFilter(requestHeader).mapFuture[Result](handleResult)
} else {
nextFilter(requestHeader)
}
accumulator
}
/**
* 暗号化すべきリクエストかどうかの判定
* @param requestHeader
* @return
*/
private def isEncryptedRequest(requestHeader: RequestHeader) =
// ここではリクエストにContent-Encoding: encryptedがついていた場合のみ、レスポンスも暗号化して返すことにしている
requestHeader.headers.get("Content-Encoding") match {
case Some(x: String) if x == "encrypted" => true
case _ => false
}
/**
* 暗号化処理を行い、Bodyをリプレイスして返す。
* なお、現在はHttpEntity.Strictしか対応していない。大丈夫?
* @param result
* @return
*/
private def handleResult(result: Result): Future[Result] = {
result.body match {
case HttpEntity.Strict(_, _) =>
cryptStrict(result.body.dataStream).map(entity => result.copy(body = entity))
case _ =>
Future.failed(new Exception) // TODO: Exception
}
}
/**
* 暗号化用のFlowを適用し、新しいHttpEntityを返す
* @param source
* @return
*/
private def cryptStrict(source: Source[ByteString, Any]) = {
source.via(createCryptFlow()).runFold(ByteString.empty)(_ ++ _)
.map(data => HttpEntity.Strict(data, Some(ContentTypes.BINARY))) // "application/octet-stream")))
}
/**
* 暗号化用Flowの作成
* @return
*/
private def createCryptFlow(): Flow[ByteString, ByteString, _] = {
Flow.fromGraph {
new GraphStage[FlowShape[ByteString, ByteString]] {
private val in = Inlet[ByteString]("CryptGraph.in")
private val out = Outlet[ByteString]("CryptGraph.out")
private val stream = new DeflaterOutputStream(new ByteArrayOutputStream(2048))
override def shape: FlowShape[ByteString, ByteString] = FlowShape.of(in, out)
override def createLogic(inheritedAttributes: Attributes): GraphStageLogic =
new GraphStageLogic(shape) with InHandler with OutHandler {
override def onPush(): Unit = {
val data = grab(in)
if (data.nonEmpty)
stream.write(data.toArray)
else
pull(in)
}
override def onPull(): Unit = {
pull(in)
}
override def onUpstreamFinish(): Unit = {
stream.close()
val compressed = stream.toByteArray // 圧縮したデータ
// 本当はここに暗号化処理を書く
push(out, ByteString.fromArray(compressed)) // 結果の出力
super.onUpstreamFinish()
}
setHandlers(in, out, this)
}
}
}
}
}
Akka周りがよく分からなかったので、いろんなソースみたりしながら試行錯誤した結果。本当にこれでいいんだろうか感ありあり。
なお、ストリームサイズがマジックナンバーだったり、エラーハンドリングが雑だったりするので、このままでは使えないかな。
フィルターに関しては、application.confに記載しました。
play.filters.enabled += filters.EncryptionFilter