Это еще один пример, который попадет в проект Akka Patterns. Но сейчас вам придется согласиться на небольшую запись в блоге, объясняющую, как Akka Patterns собирается реализовать двухфазный вход в систему с помощью текстовых сообщений. Просто предостережение — это длинный пост, поэтому возьмите чай / кофе / $YOUR_FAVOURITE_POISON
.
Поток
Теперь мы хотели бы добавить двухфазную аутентификацию, когда пользователь входит в систему со своим именем пользователя и паролем, но затем должен ввести какой-то секретный код, который он или она получили в текстовом сообщении. Клиентское приложение обращается к конечным точкам REST /login
и /login2
REST для достижения этого:
Поток начинается с 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 на следующей неделе. Я нахожусь в отпуске, так что будет много вещей, которые я накопил, которые будут доступны.