Статьи

Spray Client, Spray и текстовые сообщения

 Это еще один пример, который попадет в проект Akka Patterns. Но сейчас вам придется согласиться на небольшую запись в блоге, объясняющую, как Akka Patterns собирается реализовать двухфазный вход в систему с помощью текстовых сообщений. Просто предостережение — это длинный пост, поэтому возьмите чай / кофе / $YOUR_FAVOURITE_POISON.

Поток

Теперь мы хотели бы добавить двухфазную аутентификацию, когда пользователь входит в систему со своим именем пользователя и паролем, но затем должен ввести какой-то секретный код, который он или она получили в текстовом сообщении. Клиентское приложение обращается к конечным точкам REST /loginи /login2REST для достижения этого:

Поток начинается с POST {"username":"XXX", "password":"YYY"}to /login, который «возвращает»:

  • HTTP 200 с {"token":"a3372060-2b3b-11e2-81c1-0800200c9a66"} when the user is logged in fully.
  • HTTP 300 с {"token":"b2c05470-2b3b-11e2-81c1-0800200c9a66"} when the user is logged in partially and a secret code has been sent to their mobile.
  • HTTP 401, когда имя пользователя или пароль неверны.

Если HTTP-статус 200 или 401, веб-приложение отображает соответствующее сообщение об ошибке и снова запрашивает имя пользователя и пароль. Если код состояния равен 300, приложение запоминает временный токен и запрашивает у пользователя секрет. Когда пользователь вводит в тайне, посты веб — приложение {"token":"b2c05470-2b3b-11e2-81c1-0800200c9a66", "secret":"XYZ"}для /login2. Эта конечная точка проверяет секрет и отвечает:

  • HTTP 200 с {"token":"3169ddf0-2b3c-11e2-81c1-0800200c9a66"} when the user is logged in fully.
  • HTTP 401, когда секрет не совпадает, но пользователь может повторить попытку.
  • HTTP 403, когда секрет не совпадает и больше нет повторных попыток.

Давайте теперь посмотрим, как построить такую ​​вещь.

Сообщения

Давайте сначала посмотрим на сообщения, которые мы будем отправлять; и мы сразу перейдем к коду, который определяет сообщения для первой и второй фазы входа

case class FirstLogin(username: String, password: String)

case class SecondLogin(token: UUID, secret: String)

Далее у нас есть ответы. Они делятся примерно на две основные группы: вошли в систему и не вошли в систему , с более тонкими деталями, скрытыми внутри каждой из групп. В коде это выглядит так:

trait LoggedIn
case class LoggedInFully(token: UUID) extends LoggedIn
case class LoggedInPartially(token: UUID) extends LoggedIn

trait LoginFailure
trait FirstPhaseLoginFailure extends LoginFailure
trait SecondPhaseLoginFailure extends LoginFailure

case class BadUsernameOrPassword() extends FirstPhaseLoginFailure

case class BadPartialToken() extends SecondPhaseLoginFailure
case class TooManyBadSecrets() extends SecondPhaseLoginFailure

Это охватывает ответы. Сценарий солнечного дня нашего двухфазного входа в систему FistLogin-> LoggedInPartially-> SecondLogin-> LoggedInFully; дождливые дни лучше всего оставить неисследованными (видя, что зима на нас).

Хейко ( @hseeberger комментирует, что когда case classэкземпляры все одинаковые; то есть, когда они не несут параметров, лучше использовать case object. Это даст нам case object BadUsernameOrPasswordи подобное. (Код паттернов Akka на самом деле отправляет обратно usernameили token, в зависимости от ситуации,) , но для краткости я опустил здесь параметр.)

API

Мы начнем с уровня API, то есть с того, LoginServiceчто предоставляет маршрут для конечных точек REST. Как обычно, мы используем спрей.

trait LoginServiceMarshallers extends Marshalling {

  implicit val FirstLoginFormat = 
  	jsonFormat3(FirstLogin)
  implicit val SecondLoginFormat = 
  	jsonFormat2(SecondLogin)
  implicit val LoggedInPartiallyFormat = 
  	jsonFormat1(LoggedInPartially)
  implicit val LoggedInFullyFormat = 
  	jsonFormat1(LoggedInFully)

  implicit object LoginFailureMarshaller extends 
  	Marshaller[LoginFailure] {

    def apply(value: LoginFailure, ctx: MarshallingContext) {
      ctx.marshalTo(HttpEntity("Bad login"))
    }
  }

}


class LoginService(implicit val actorSystem: ActorSystem) 
  extends Directives with LoginServiceMarshallers 
  with MetaMarshallers with DefaultTimeout {

  def loginActor = actorSystem.actorFor("/user/application/login")

  import ExecutionContext.Implicits.global 

  /**
   * Processes the login message by sending it to the login actor
   * returns a function that handles the RequestContext
   * and, depending on the response, handles the context as:
   *
   * - 200 -> LoggedInFully(token)
   * - 300 -> LoggedInPartially(token, secret)
   * - 401 -> BadUsernameOrPassword() | BadPartialToken()
   * - 403 -> TooManyBadSecrets()
   *
   * @param msg the message to send to the login actor
   * @param ctx the RequestContext
   * @tparam A the type of the message
   * @return the function that completes the RequestContext appropriately
   */
  def loginFunction[A](msg: A)(ctx: RequestContext) {
     (loginActor ? msg).mapTo[Either[LoginFailure, LoggedIn]] onSuccess { 
      case Left(x: TooManyBadSecrets)     => 
      	ctx.complete(StatusCodes.Forbidden, x)
      case Left(x: BadUsernameOrPassword) => 
      	ctx.complete(StatusCodes.Unauthorized, x)
      case Left(x: BadPartialToken)       => 
      	ctx.complete(StatusCodes.Unauthorized, x)
      case Right(x: LoggedInPartially)    => 
      	ctx.complete(StatusCodes.MultipleChoices, x)
      case Right(x: LoggedInFully)        => 
      	ctx.complete(StatusCodes.OK, x)
      case _                              => 
      	ctx.complete(StatusCodes.InternalServerError)
    }
  }

  val route = {
    post {
      path("login") {
        entity(as[FirstLogin]) { loginFunction }
      } ~
      path("login2") {
        entity(as[SecondLogin]) { loginFunction }
      }
    }
  }
}

Актеры

То, LoginActorчто получает сообщения и реагирует на них, на самом деле довольно просто; интересная часть будет то, SecretDeliveryActorчто отправляет текстовые сообщения. Хорошо, давайте сразу перейдем к коду.

case class AuthenticationToken(
	userRef: UUID, 
	token: UUID, 
	expires: Date, 
	partial: Boolean, 
	retries: Int, 
	secret: Option[String]) {

  /**
   * Decides whether the token is valid with respect to the given secret.
   *
   * @param s the given secret
   * @return ``true`` if the token is valid
   */
  def isValid(s: String) = secret == Some(s)
}

// other bits and pieces here

class LoginActor(secretDelivery: ActorRef) extends 
  Actor with SecretGenerator with TokenOperations {

  def receive = {
    case FirstLogin(username, password) =>
      // check that username & password are OK
      val token = UUID.randomUUID()
      val secret = generateSecret

      // save the token
      create(AuthenticationToken(UUID.randomUUID(), token, 
      	new Date(), true, 2, Some(secret)))

      // deliver the secret to the user
      val deliveryAddress = DeliveryAddress(Some("44759*******"), None)

      secretDelivery ! DeliverSecret(deliveryAddress, secret)

      // we're logged in partially
      sender ! Right(LoggedInPartially(token))
    case SecondLogin(token, secret) =>
      find(token) match {
        case None =>
          sender ! Left(BadPartialToken())
        case Some(at) if !at.isValid(secret) && at.retries == 0 =>
          // no more retries
          delete(at.token)
          sender ! Left(TooManyBadSecrets())
        case Some(at) if !at.isValid(secret) && at.retries > 0 =>
          // bad secret, but retries still allowed
          update(at.copy(retries = at.retries - 1))
          sender ! Left(BadPartialToken())
        case Some(at) if at.isValid(secret) =>
          // delete the old one
          delete(at.token)

          // generate new token
          val newToken = UUID.randomUUID()

          // save the token
          create(AuthenticationToken(UUID.randomUUID(), newToken, 
          	new Date(), false, 1, None))

          sender ! Right(LoggedInFully(newToken))
      }
  }

}

Вперед SecretDeliveryActor, который использует спрей-клиент для выполнения сетевого ввода-вывода. Поскольку существует много разных поставщиков текстовых сообщений, мы можем захотеть использовать функциональность отправки текстовых сообщений по отдельным признакам. Мы выбрали провайдера nexmo.com . ОК, черта:

trait NexmoTextMessageDelivery {
  this: HttpIO =>

  /**
   * Returns the API key for Nexmo.
   * @return the API key
   */
  def apiKey: String

  /**
   * Returns the API secret for Nexmo
   * @return the API secret
   */
  def apiSecret: String

  private val pipeline = 
  	HttpConduit.sendReceive(makeHttpsConduit("rest.nexmo.com"))

  import scala.concurrent.ExecutionContext.Implicits.global

  /**
   * Delivers the text message ``secret`` to the phone number 
   * ``mobileNumber``. The ``mobileNumber`` needs to be in
   * full international format, without spaces, but without 
   * the leading "+", for example ``4477712345678`` for
   * a UK number ``0777 123 45678``
   *
   * @param mobileNumber the mobile number to send the message to
   * @param secret the secret to send
   */
  def deliverTextMessage(mobileNumber: String, secret: String) {
    val url = "/sms/json?api_key=%s&api_secret=%s&from=My%20App&to=%s&text=%s" 
    			format (apiKey, apiSecret, mobileNumber, secret)
    val request = HttpRequest(spray.http.HttpMethods.POST, url)
    pipeline(request) onSuccess  {
      case response =>
        // Sort out the response. 
        // Maybe bang to health agent if we're out of credits or some such
    }
  }

}

Актер доставки сообщений смешивает эту особенность и удовлетворяет аннотации самостоятельного типа HttpIO, смешивая в себе ActorHttpIO, например, так:

class SecretDeliveryActor extends 
  Actor with ActorHttpIO with NexmoTextMessageDelivery {

  def apiKey = "******"
  def apiSecret = "********"

  def receive = {
    case DeliverSecret(DeliveryAddress(Some(mobileNumber), _), secret) =>
      deliverTextMessage(mobileNumber, secret)
    case _ =>
      // we can only text for now
  }
}

Это оставляет нас с последним компонентом, HttpIOи ActorHttpIO. Это действительно новые звери, поэтому давайте посмотрим, как они вписываются в структуру нашего приложения.

Слегка измененная структура

Поскольку спрей-клиент, как и спрей, может использовать один и тот же механизм общей сети, а именно IOBridgeэкземпляр, мы вытащим их из Webпризнака и переместим их в HttpIOпризнак, который можно смешать с Webуровнем, но также использовать в компоненты, которые нужно IOBridge. Мы приходим к:

/**
 * Instantiates & provides access to Spray's ``IOBridge``.
 *
 * @author janmachacek
 */
trait HttpIO {
  implicit def actorSystem: ActorSystem
  
  lazy val ioBridge = new IOBridge(actorSystem).start()
  actorSystem.registerOnTermination(ioBridge.stop())

  private lazy val httpClient = actorSystem.actorOf(
    props = Props(new HttpClient(ioBridge, 
    	ConfigFactory.parseString("spray.can.client.ssl-encryption = on"))),
    name = "http-client"
  )

  def makeHttpsConduit(host: String) =
    actorSystem.actorOf(
    	Props(new HttpConduit(
    		httpClient, host, port = 443, sslEnabled = true)))

}

/**
 * Convenience ``HttpIO`` implementation that can be mixed in to actors.
 */
trait ActorHttpIO extends HttpIO {
  this: Actor =>

  final implicit def actorSystem = context.system
}

С этой последней модификацией мы можем увидеть, как NexmoTextMessageDeliveryможно получить базовый механизм HTTP, предоставляемый ядром Spray; и как мы можем легко подключить все приложение вместе. Наконец, если вы действительно зарегистрируетесь в учетной записи Nexmo, приложение Akka отправит вам текстовые сообщения. Насколько это блестяще?

Вместо обычного резюме, я просто скажу, что нужно подождать, пока полный код появится на Akka Patterns на следующей неделе. Я нахожусь в отпуске, так что будет много вещей, которые я накопил, которые будут доступны.