Akka-CQRS(14)- Http標準安全解決方案:OAuth2-資源使用授權
上一篇討論了SSL/TLS安全連線,主要是一套在通訊層面的資料加密解決方案。但我們更需要一套方案來驗證客戶端。要把不能通過驗證的網路請求過濾掉。
OAuth2是一套行業標準的網路資源使用授權協議,也就是為使用者提供一種授權憑證,使用者憑授權憑證來使用網路資源。申請憑證、然後使用憑證進行網路操作流程如下:
實際上OAuth2是一套3方授權模式,但我們只需要資源管理方授權,所以劃去了1、2兩個步驟。剩下的兩個步驟,包括:申請令牌,使用令牌,這些在官方檔案中有詳細描述。使用者身份和令牌的傳遞是通過Http Header實現的,具體情況可參考RFC2617,RFC6750
簡單來說:使用者向伺服器提交身份資訊申請令牌,下面是一個HttpRequest樣例:
POST /token HTTP/1.1 Host: server.example.com Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW Content-Type: application/x-www-form-urlencoded
上面Basic後面一串程式碼就是 user+password的加密文,它的產生方法示範如下:
final case class BasicHttpCredentials(username: String, password: String) extends jm.headers.BasicHttpCredentials { val cookie = { val userPass = username + ':' + password val bytes = userPass.getBytes(`UTF-8`.nioCharset) Base64.rfc2045.encodeToChar(bytes, false) } def render[R <: Rendering](r: R): r.type = r ~~ "Basic " ~~ cookie override def scheme: String = "Basic" override def token: String = String.valueOf(cookie) override def params: Map[String, String] = Map.empty }
注:在OAuth2版本中如果使用https://,則容許明文使用者和密碼。
服務端在返回的HttpResponse中返回令牌access_token:
{"access_token":"2e510027-0eb9-4367-b310-68e1bab9dc3d", "token_type":"bearer", "expires_in":3600}
注意:這個expires_in是應用系統自定義內部使用的引數,也就是說應用系統必須自備令牌過期失效處理機制。
得到令牌後每個使用網路資源的Request都必須在Authorization類Header裡附帶這個令牌,如:
GET /resource HTTP/1.1 Host: server.example.com Authorization: Bearer 2e510027-0eb9-4367-b310-68e1bab9dc3d
Bearer後就是服務端返回的令牌值。我們還是設計一個例子來示範整個授權使用過程。先看看下面一些基本操作程式碼:
object JsonMarshaller extends SprayJsonSupport with DefaultJsonProtocol { case class UserInfo(username: String, password: String) 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 validUsers = Seq(UserInfo("johnny", "p4ssw0rd"),UserInfo("tiger", "secret")) val loggedInUsers = mutable.ArrayBuffer.empty[AuthUser] 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 } def authenticateUser(credentials: Credentials): Option[AuthUser] = credentials match { case p @ Credentials.Provided(_) => loggedInUsers.find(user => p.verify(user.token.access_token)) case _ => None } implicit val fmtCredentials = jsonFormat2(UserInfo.apply) implicit val fmtToken = jsonFormat3(AuthToken.apply) implicit val fmtUser = jsonFormat3(AuthUser.apply) }
validUers: Seq[UserInfo] 模擬是個在服務端資料庫裡的使用者登記表,loggedInUsers是一個已經通過驗證的使用者請單。函式 getValidUser(credentials: Credentials) 用傳人蔘數Credentials來獲取使用者資訊Option[UserInfo]。Credentials是這樣定義的:
object Credentials { case object Missing extends Credentials abstract case class Provided(identifier: String) extends Credentials { /** * First applies the passed in `hasher` function to the received secret part of the Credentials * and then safely compares the passed in `secret` with the hashed received secret. * This method can be used if the secret is not stored in plain text. * Use of this method instead of manual String equality testing is recommended in order to guard against timing attacks. * * See also [[EnhancedString#secure_==]], for more information. */ def verify(secret: String, hasher: String ⇒ String): Boolean /** * Safely compares the passed in `secret` with the received secret part of the Credentials. * Use of this method instead of manual String equality testing is recommended in order to guard against timing attacks. * * See also [[EnhancedString#secure_==]], for more information. */ def verify(secret: String): Boolean = verify(secret, x ⇒ x) } def apply(cred: Option[HttpCredentials]): Credentials = { cred match { case Some(BasicHttpCredentials(username, receivedSecret)) ⇒ new Credentials.Provided(username) { def verify(secret: String, hasher: String ⇒ String): Boolean = secret secure_== hasher(receivedSecret) } case Some(OAuth2BearerToken(token)) ⇒ new Credentials.Provided(token) { def verify(secret: String, hasher: String ⇒ String): Boolean = secret secure_== hasher(token) } case Some(GenericHttpCredentials(scheme, token, params)) ⇒ throw new UnsupportedOperationException("cannot verify generic HTTP credentials") case None ⇒ Credentials.Missing } } }
在apply函式裡定義了verify函式功能。這個時候Credentials的實際型別是BasicHttpCredentials。另一個函式authenticateUser(credentials: Credentials)是用Crentials來驗證令牌的,那麼它的型別應該是OAuth2BearerToken了,具體驗證令牌的過程是從loggedInUser清單裡對比找出擁有相同令牌的使用者。這就意味著每次一個使用者通過驗證獲取令牌後服務端必須把使用者資訊和令牌值儲存起來方便以後對比。我們再來看看route的定義:
val route = pathEndOrSingleSlash { get { complete("Welcome!") } } ~ path("auth") { authenticateBasic(realm = "auth", getValidUser) { user => post { val loggedInUser = AuthUser(user) loggedInUsers.append(loggedInUser) complete(loggedInUser.token) } } } ~ path("api") { authenticateOAuth2(realm = "api", authenticateUser) { validToken => complete(s"It worked! user = $validToken") } }
現在這段程式碼就比較容易理解了:authenticateBasic(realm = "auth", getValidUser) {user => ...} 用上了自定義的geValidUser來產生user物件。而authenticateOAuth2(realm = "api", authenticateUser) { validToken =>...}則用了自定義的authenticateUser函式來驗證令牌。
下面我們寫一段客戶端程式碼來測試上面這個webserver的功能:
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 org.json4s._ import org.json4s.jackson.JsonMethods._ 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) val token = (parse(jstr).asInstanceOf[JObject] \ "access_token").values println(token) val authentication = headers.Authorization(OAuth2BearerToken(token.toString)) 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() } }
測試顯示結果如下:
{"access_token":"6280dcd7-71fe-4203-8163-8ac7dbd5450b","expires_in":28800,"token_type":"bearer"} 6280dcd7-71fe-4203-8163-8ac7dbd5450b HttpResponse(200 OK,List(Server: akka-http/10.1.8, Date: Wed, 03 Jul 2019 09:32:32 GMT),HttpEntity.Strict(text/plain; charset=UTF-8,It worked! user = AuthUser(UserInfo(johnny,p4ssw0rd),AuthToken(6280dcd7-71fe-4203-8163-8ac7dbd5450b,bearer,28800),2019-07-03T17:32:32.627)),HttpProtocol(HTTP/1.1))
下面是服務端原始碼:
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" )
OAuth2Server.scala
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 java.time.LocalDateTime import scala.collection.mutable import akka.http.scaladsl.marshallers.sprayjson._ import spray.json._ object JsonMarshaller extends SprayJsonSupport with DefaultJsonProtocol { case class UserInfo(username: String, password: String) 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 validUsers = Seq(UserInfo("johnny", "p4ssw0rd"),UserInfo("tiger", "secret")) val loggedInUsers = mutable.ArrayBuffer.empty[AuthUser] 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 } def authenticateUser(credentials: Credentials): Option[AuthUser] = credentials match { case p @ Credentials.Provided(_) => loggedInUsers.find(user => p.verify(user.token.access_token)) 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 loggedInUser = AuthUser(user) loggedInUsers.append(loggedInUser) complete(loggedInUser.token) } } } ~ path("api") { authenticateOAuth2(realm = "api", authenticateUser) { validToken => complete(s"It worked! user = $validToken") } } 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()) }
&n