Akka-CQRS(15)- Http标准安全解决方案:OAuth2+JWT
? 上期討論過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" => HMD5case "HS224" => HS224case "HS256" => HS256case "HS384" => HS384case "HS512" => HS512case "RS256" => RS256case "RS384" => RS384case "RS512" => RS512case "ES256" => ES256case "ES384" => ES384case "ES512" => ES512case _ => 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}/*收到的是JWTdef 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.dispatcherimport 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 endimplicit val executionContext = system.dispatcherval 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 <- futTokenjstr <- resp.entity.dataBytes.runFold("") {(s,b) => s + b.utf8String}} yield jstrval 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.headprintln(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" )?
總結
以上是生活随笔為你收集整理的Akka-CQRS(15)- Http标准安全解决方案:OAuth2+JWT的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 大型石油公司联手银行推出能源商品交易区块
- 下一篇: keil转换c为汇编语言,如何用Keil