Akka-CQRS(15)- Http標準安全解決方案:OAuth2+JWT

雪川大蟲發表於2019-07-09

  上期討論過OAuth2, 是一種身份認證+資源授權使用模式。通過身份認證後發放授權憑證。使用者憑授權憑證呼叫資源。這個憑證就是一種令牌,基本上是一段沒什麼意義的加密文,或者理解成金鑰也可以。服務方通過這個令牌來獲取使用者身份資訊,也就是說服務端必須維護一個已經獲得身份驗證的使用者資訊清單。研究了一下JWT,發現它本身可以攜帶加密後的一些資訊包括使用者資訊,而這些資訊又可以通過同樣的加密演算法解密恢復。也就是說服務端是可以直接對收到的JWT解密恢復使用者資訊,這樣用起來就方便多了。還記著我們的POS例子裡客戶端必須構建一個指令,如:http://www.pos.com/logIn?shopid=1001&userid=234 這個Uri裡的shopid是明碼的,會造成很大安全風險。使用JWT後,我們可以把shopid,單號什麼的都放在JWT裡就安全多了。

先了解一下JWT:JWT也是一個行業標準:RFC7519,是一個用Json格式傳遞加密資訊的方式。JWT的結構如下:

header.payload.signiture 如:hhhhh.ppppp.ssssss

header:由兩部分組成:1、令牌型別,在這裡是JWT, 2、簽名演算法如 HMAC SHA256 or RSA, 下面是個header例子:

{
  "alg": "HS256",
  "typ": "JWT"
}

payload:可以用來承載使用者自定義資訊,如userid, shopid, vchnum ...

{
  "shopid": "1101",
  "userid": "102",
  "vchnum": 12
}

signiture: 就是把 加密後的header+加密後的payload+secret 用header提供的簽名演算法簽名,如下:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

我的目標是把一些用來辨識使用者、許可權以及狀態資訊加密存在JWT內傳送給使用者,使用者在請求中提交他的JWT,服務端再解密並取出內部資訊然後確定如何處理使用者請求。

JWT本身原理並不複雜,應用場景也不是很多,所以不想花太多精力研究它。剛好,找到一個開源的scala JWT工具庫jwt-scala. 下面就利用專案原始碼來了解一下JWT的操作,包括:加密、解密、驗證、獲取payload內部claims值。

JWT encode 方法如下:

  /** Encode a JSON Web Token from its different parts. Both the header and the claim will be encoded to Base64 url-safe, then a signature will be eventually generated from it if you did pass a key and an algorithm, and finally, those three parts will be merged as a single string, using dots as separator.
    *
    * @return $token
    * @param header $headerString
    * @param claim $claimString
    * @param key $key
    * @param algorithm $algo
    */
  def encode(header: String, claim: String, key: String, algorithm: JwtAlgorithm): String = {
    val data = JwtBase64.encodeString(header) + "." + JwtBase64.encodeString(claim)
    data + "." + JwtBase64.encodeString(JwtUtils.sign(data, key, algorithm))
  }

所以產生JWT的元素都在引數裡了。我們可以直接用payload.claims來構建JWT:

 /** An alias to `encode` which will provide an automatically generated header.
    *
    * @return $token
    * @param claim $claimString
    */
  def encode(claim: String): String = encode(JwtHeader().toJson, claim)

  /** An alias to `encode` which will provide an automatically generated header and setting both key and algorithm
    * to None.
    *
    * @return $token
    * @param claim the claim of the JSON Web Token
    */
  def encode(claim: JwtClaim): String = encode(claim.toJson)

 def encode(header: String, claim: String): String = {
    JwtBase64.encodeString(header) + "." + JwtBase64.encodeString(claim) + "."
  }

這樣看一個正確的JWT可以沒有簽名那部分的:hhhhh.ppppp。想想還是要用簽名,安全點。用下面這個函式就可以了:

  /** An alias to `encode` which will provide an automatically generated header and allowing you to get rid of Option
    * for the key and the algorithm.
    *
    * @return $token
    * @param claim $claimString
    * @param key $key
    * @param algorithm $algo
    */
  def encode(claim: String, key: String, algorithm: JwtAlgorithm): String =
    encode(JwtHeader(algorithm).toJson, claim, key, algorithm)

/** Deserialize an algorithm from its string equivalent. Only real algorithms supported,
    * if you need to support "none", use "optionFromString".
    *
    * @return the actual instance of the algorithm
    * @param algo the name of the algorithm (e.g. HS256 or HmacSHA256)
    * @throws JwtNonSupportedAlgorithm in case the string doesn't match any known algorithm
    */
  def fromString(algo: String): JwtAlgorithm = algo match {
    case "HMD5"        => HMD5
    case "HS224"       => HS224
    case "HS256"       => HS256
    case "HS384"       => HS384
    case "HS512"       => HS512
    case "RS256"       => RS256
    case "RS384"       => RS384
    case "RS512"       => RS512
    case "ES256"       => ES256
    case "ES384"       => ES384
    case "ES512"       => ES512
    case _             => throw new JwtNonSupportedAlgorithm(algo)
    // Missing PS256 PS384 PS512
  }

key可以是任意字串。

JWT decode 程式碼如下:

  /** Will try to decode a JSON Web Token to raw strings using a HMAC algorithm
    *
    * @return if successful, a tuple of 3 strings, the header, the claim and the signature
    * @param token $token
    * @param key $key
    * @param algorithms $algos
    */
  def decodeRawAll(token: String, key: String, algorithms: Seq[JwtHmacAlgorithm], options: JwtOptions): Try[(String, String, String)] = Try {
    val (header64, header, claim64, claim, signature) = splitToken(token)
    validate(header64, parseHeader(header), claim64, parseClaim(claim), signature, key, algorithms, options)
    (header, claim, signature)
  }

  def decodeRawAll(token: String, key: String, algorithms: Seq[JwtHmacAlgorithm]): Try[(String, String, String)] =
    decodeRawAll(token, key, algorithms, JwtOptions.DEFAULT)

另外,驗證JWT方法如下:

  /** An alias for `isValid` if you want to directly pass a string as the key for HMAC algorithms
    *
    * @return a boolean value indicating if the token is valid or not
    * @param token $token
    * @param key $key
    * @param algorithms $algos
    */
  def isValid(token: String, key: String, algorithms: Seq[JwtHmacAlgorithm], options: JwtOptions): Boolean =
    try {
      validate(token, key, algorithms, options)
      true
    } catch {
      case _ : Throwable => false
    }

  def isValid(token: String, key: String, algorithms: Seq[JwtHmacAlgorithm]): Boolean = isValid(token, key, algorithms, JwtOptions.DEFAULT)

下面是一段示範程式碼:

import pdi.jwt._
import org.json4s._
import org.json4s.jackson.JsonMethods._

object JwtDemo extends App{

  import scala.util._

  var clms = JwtClaim() ++ ("shopid" -> "1101") ++ ("userid" -> "102") ++ ("vchnum" -> 23)
  val token = Jwt.encode(clms,"OpenSesame", JwtAlgorithm.HS256)
  println(token)
  println(Jwt.isValid(token,"OpenSesame",Seq(JwtAlgorithm.HS256)))
  val claims = Jwt.decodeRawAll(token,"OpenSesame",Seq(JwtAlgorithm.HS256))
  println(claims)

  claims match {
    case Success(json) => println(((parse(json._2).asInstanceOf[JObject]) \ "shopid").values)
    case Failure(err) => println(s"Error: ${err.getMessage}")
  }

}

現在我們把上次的OAuth2示範程式碼改改,用JWT替換access_token:

import akka.actor._
import akka.stream._
import akka.http.scaladsl.Http
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.directives.Credentials
import pdi.jwt._
import org.json4s._
import org.json4s.jackson.JsonMethods._
import scala.util._

//import akka.http.scaladsl.marshallers.sprayjson._
//import spray.json._

object JsonMarshaller { // extends  SprayJsonSupport with DefaultJsonProtocol {

  case class UserInfo(username: String, password: String, appInfo: (String,String))

  /* 用JWT替代
  case class AuthToken(access_token: String = java.util.UUID.randomUUID().toString,
                        token_type: String = "bearer",
                        expires_in: Int = 3600)
  */
  /* 無需維護這個驗證後使用者清單了
  case class AuthUser(credentials: UserInfo,
                      token: AuthToken = new AuthToken(expires_in = 60 * 60 * 8),
                          loggedInAt: String = LocalDateTime.now().toString)

   val loggedInUsers = mutable.ArrayBuffer.empty[AuthUser]

   */


  val validUsers = Seq(UserInfo("johnny", "p4ssw0rd",("1101", "101"))
                       ,UserInfo("tiger", "secret", ("1101" , "102")))


  def getValidUser(credentials: Credentials): Option[UserInfo] =
    credentials match {
      case p @ Credentials.Provided(_) =>
        validUsers.find(user => user.username == p.identifier && p.verify(user.password))
      case _ => None
    }
   /*收到的是JWT
   def authenticateUser(credentials: Credentials): Option[(String,String)] =
    credentials match {
      case p @ Credentials.Provided(_) =>
        loggedInUsers.find(user => p.verify(user.token.access_token))
      case _ => None
    } */

  def authenticateJwt(credentials: Credentials): Option[String] =
    credentials match {
      case Credentials.Provided(token) =>
        Jwt.isValid(token,"OpenSesame",Seq(JwtAlgorithm.HS256)) match {
          case true => Some(token)
          case _ => None
        }
      case _ => None
    }
/*
  implicit val fmtCredentials = jsonFormat2(UserInfo.apply)
  implicit val fmtToken = jsonFormat3(AuthToken.apply)
  implicit val fmtUser = jsonFormat3(AuthUser.apply)

 */
}


object Oauth2ServerDemo extends App {

  implicit val httpSys = ActorSystem("httpSystem")
  implicit val httpMat = ActorMaterializer()
  implicit val httpEC = httpSys.dispatcher


  import JsonMarshaller._


  val route =
    pathEndOrSingleSlash {
      get {
        complete("Welcome!")
      }
    } ~
      path("auth") {
        authenticateBasic(realm = "auth", getValidUser) { user =>
          post {
            val claims = JwtClaim() + ("appInfo" , (user.appInfo._1,user.appInfo._2))
            complete(Jwt.encode(claims,"OpenSesame",JwtAlgorithm.HS256))
          }
        }
      } ~
      path("api") {
        authenticateOAuth2(realm = "api", authenticateJwt) { validToken =>

          val pi = Jwt.decodeRawAll(validToken,"OpenSesame",Seq(JwtAlgorithm.HS256)) match {
            case Success(parts) => Some(((parse(parts._2).asInstanceOf[JObject]) \ "appInfo").values.asInstanceOf[Map[String,String]].toList.head)
            case Failure(_) => None
          }
          complete(s"It worked! token = $validToken, appInfo = ${pi}")
        }
      }


  val (port, host) = (50081,"192.168.11.189")

  val bindingFuture = Http().bindAndHandle(route,host,port)

  println(s"Server running at $host $port. Press any key to exit ...")

  scala.io.StdIn.readLine()


  bindingFuture.flatMap(_.unbind())
    .onComplete(_ => httpSys.terminate())


}

下面是客戶端測試程式碼:

import akka.actor._
import akka.stream._
import akka.http.scaladsl.Http
import akka.http.scaladsl.model.headers._
import scala.concurrent._
import akka.http.scaladsl.model._
import pdi.jwt._
import org.json4s._
import org.json4s.jackson.JsonMethods._
import scala.util._
import scala.concurrent.duration._

object Oauth2Client  {
    def main(args: Array[String]): Unit = {
      implicit val system = ActorSystem()
      implicit val materializer = ActorMaterializer()
      // needed for the future flatMap/onComplete in the end
      implicit val executionContext = system.dispatcher

      val helloRequest = HttpRequest(uri = "http://192.168.11.189:50081/")

      val authorization = headers.Authorization(BasicHttpCredentials("johnny", "p4ssw0rd"))
      val authRequest = HttpRequest(
        HttpMethods.POST,
        uri = "http://192.168.11.189:50081/auth",
        headers = List(authorization)
      )

      val futToken: Future[HttpResponse] = Http().singleRequest(authRequest)

      val respToken = for {
        resp <- futToken
        jstr <- resp.entity.dataBytes.runFold("") {(s,b) => s + b.utf8String}
      } yield jstr

      val jstr =  Await.result[String](respToken,2 seconds)
      println(jstr)

      scala.io.StdIn.readLine()

      val parts = Jwt.decodeRawAll(jstr, "OpenSesame", Seq(JwtAlgorithm.HS256)) match {
        case Failure(exception) => println(s"Error: ${exception.getMessage}")
        case Success(value) =>
          val tt: (String,String) = ((parse(value._2).asInstanceOf[JObject]) \ "appInfo").values.asInstanceOf[Map[String,String]].toList.head
          println(tt)
      }

      scala.io.StdIn.readLine()

      val authentication = headers.Authorization(OAuth2BearerToken(jstr))
      val apiRequest = HttpRequest(
        HttpMethods.POST,
        uri = "http://192.168.11.189:50081/api",
      ).addHeader(authentication)

      val futAuth: Future[HttpResponse] = Http().singleRequest(apiRequest)

      println(Await.result(futAuth,2 seconds))


      scala.io.StdIn.readLine()
      system.terminate()
    }

  }

執行後輸出結果:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhcHBJbmZvIjp7IjExMDEiOiIxMDEifX0.i46FUinT0n1brYGInFZz-6embOj15SKpIpO9QHkpSZs

(1101,101)

HttpResponse(200 OK,List(Server: akka-http/10.1.8, Date: Tue, 09 Jul 2019 04:02:12 GMT),HttpEntity.Strict(text/plain; charset=UTF-8,It worked! token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhcHBJbmZvIjp7IjExMDEiOiIxMDEifX0.i46FUinT0n1brYGInFZz-6embOj15SKpIpO9QHkpSZs, appInfo = Some((1101,101))),HttpProtocol(HTTP/1.1))

Process finished with exit code 130 (interrupted by signal 2: SIGINT)

構建環境 build.sbt:

name := "oauth2"

version := "0.1"

scalaVersion := "2.12.8"

libraryDependencies ++= Seq(
  "com.typesafe.akka" %% "akka-http"   % "10.1.8",
  "com.typesafe.akka" %% "akka-stream" % "2.5.23",
  "com.pauldijou" %% "jwt-core" % "3.0.1",
  "de.heikoseeberger" %% "akka-http-json4s" % "1.22.0",
  "org.json4s" %% "json4s-native" % "3.6.1",
  "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.8",
  "com.typesafe.scala-logging" %% "scala-logging" % "3.9.0",
  "org.slf4j" % "slf4j-simple" % "1.7.25",
  "org.json4s" %% "json4s-jackson" % "3.6.7"
)

 

相關文章