Conteúdo


Desenvolvimento divertido na web, Parte 1

Gerenciar a autenticação do usuário com Play Framework e Scala

Implementando uma UI de autenticação do iniciador com Play, Silhouette e MongoDB

Comments

Conteúdos da série:

Esse conteúdo é a parte # de # na série: Desenvolvimento divertido na web, Parte 1

Fique ligado em conteúdos adicionais dessa série.

Esse conteúdo é parte da série:Desenvolvimento divertido na web, Parte 1

Fique ligado em conteúdos adicionais dessa série.

A implementação da autenticação em aplicativos da web modernos pode ocasionar uma quantia significativa de trabalho. É necessário permitir aos usuários autenticar por meio de diferentes mecanismos (credenciais, provedores sociais usando OAuth1, OAuth2 ou OpenID). O registro do usuário e a reconfiguração de senha geralmente exigem fluxos baseados em email. Além disso, as solicitações e visualizações precisam estar cientes da identidade (se houver) do usuário conectado.

Este tutorial apresenta um aplicativo de autenticação de iniciador desenvolvido com o Play Framework. O Play é um membro de uma nova geração de estruturas da web reativas , tais como Node.js e Vert.x, que são projetadas pensando na escalabilidade. Ele também contém recursos de fácil desenvolvimento como manipulação de XML e JSON nativos, relatório de erro no navegador no modo de desenvolvimento, auxiliares de teste integrados e integração do Selenium. É possível escrever aplicativos Play no Java™ ou no Scala, mas o Scala é mais recomendado. Linguagens funcionais são mais adequadas para um estilo de programação reativo. Embora o Java tenha finalmente adotado conceitos de programação funcional na versão 8, perde para a extensa provisão de recursos funcionais do Scala.

Meu aplicativo iniciador mostra o Scala e o Play em ação ao implementar:

  • Inscrição de usuário com base em email
  • Credenciais (email e senha) e autenticação do Twitter por meio de OAuth1
  • Reconfiguração de senha com base em email
  • Credenciais e vinculação de conta do Twitter
  • Exemplos de visualizações com reconhecimento do usuário, solicitações de HTTP e chamadas do Ajax

O aplicativo usa o Silhouette para o trabalho de autenticação e o MongoDB para persistência do usuário. Todo o processamento de solicitação e as interações com o MongoDB são completamente assíncronos. É possível usar esse aplicativo como um valor inicial para seus próprios projetos para se poupar do esforço de implementar a autenticação do zero.

Suponho que você tem uma familiaridade básica com a linguagem do Scala. (Caso tenha conhecimento prévio do Java e precise de uma introdução ao Scala, sugiro a leitura da série do developerWorks The busy Java developer's guide to Scala .) Também suponho, pelo menos, uma experiência mínima com o Play; um conhecimento básico sobre controladores, rotas e visualizações é suficiente. Para uma introdução ao Play, consulte a seção Introdução da documentação do Play. (O código neste tutorial usa o Play Framework versão 2.4.2; portanto, leia a documentação para essa versão.) Lembre-se de conferir a Parte 2 desta série, na qual eu mostro como implementar aplicativos Play no IBM Cloud™.

Fazendo a configuração

Para desenvolver e executar o aplicativo iniciador, é necessário o Play 2.4.2 ou uma versão mais recente e o MongoDB no seu sistema.

Instalar o MongoDB e o Play

Instale o MongoDB seguindo as instruções para sua plataforma na seção downloads do site do MongoDB. Na maioria das distribuições do Linux, é possível instalar o MongoDB utilizando o gerenciador de pacote correspondente. Para Mac, é possível usar o Homebrew; para Windows, um instalador MSI.

O Play Framework (desde a versão 2.4.0) exige Java 8; portanto, certifique-se de ter um SDK do Java SE 8 instalado. Para instalar o Play, faça o download e descompacte o ativador mínimo — um arquivo zip de 1 MB que contém um script inicial que fará o download do código do Play e das dependências (cerca de 450 MB) quando for executado pela primeira vez. Por uma questão de conveniência, você poderá querer incluir a pasta do ativador no seu caminho do sistema.

Fazer download e executar os aplicativos

O projeto do aplicativo de amostra está hospedado em IBM Cloud DevOps Services. É possível obter o código-fonte clonando o repositório Git do projeto (você precisa se conectar ou se registrar primeiro). Como alternativa, consulte Download para obter o aplicativo como um arquivo zip. Quando tiver o código, execute o aplicativo realizando estas etapas:

  1. Registre um aplicativo no site Twitter Application Management . Insira http://dwdemo.com:9000/auth/social/twitter como URL de retorno de chamada.
  2. Incluir 127.0.0.1 dwdemo.com nos seus arquivos host para que o aplicativo possa ser direcionado pelo mesmo domínio registrado no Twitter.
  3. Configure as propriedades da chave do consumidor e do segredo do consumidor ao copiar os valores correspondentes da página do aplicativo no Twitter para as linhas 28 e 29 do arquivo conf/silhouette.conf.
  4. Inicie o servidor do MongoDB excutando mongod --dbpath folder, em que folder é um diretório no qual o MongoDB armazenará os arquivos de banco de dados.
  5. Para iniciar o aplicativo, acesse a pasta raiz em que clonou o código e execute activator run nela.

Depois que o aplicativo iniciar (essa etapa levará algum tempo na primeira vez), abra-o acessando http://dwdemo.com:9000. Você verá uma tela de boas-vindas na qual os usuários podem se inscrever para criar uma conta no aplicativo.

Configuração do aplicativo

Com seu ambiente configurado, você está pronto para se aprofundar com relação à configuração do aplicativo.

Arquivo de configuração principal

O arquivo de configuração principal é conf/application.conf, o local padrão onde o Play procura propriedades de configuração. Lista 1 mostra as partes relevantes.

Lista 1. conf/application.conf
play.modules.enabled += "play.modules.reactivemongo.ReactiveMongoModule"
play.modules.enabled += "module.Module"

mongodb.uri = "mongodb://localhost:27017/demodb"

mail.from="dwplaydemo <mailrobot@dwplaydemo.net>"
mail.reply="No reply <noreply@dwplaydemo.net>"

play.mailer {
  mock = true
  host = localhost
}

play.http.filters = "utils.Filters"
play.http.errorHandler = "utils.ErrorHandler"

include "silhouette.conf"

O arquivo application.conf declara duas classes que configuram ligações de injeção de dependência. ReactiveMongoModule torna as ligações reativas do Mongo injetáveis em classes de aplicativos. A classe module.Module especifica ligações de injeção para o aplicativo, principalmente para classes do Silhouette. A propriedade mongodb.uri define como se conectar com o MongoDB e o play.mailer configura um serviço de correspondência simulado para testar os fluxos de inscrição e reconfiguração de senha no modo de desenvolvimento. Um correio simulado registra os emails para o console; na produção, o aplicativo precisa utilizar um servidor SMTP.

A classe utils.Filters define o pipeline do filtro para o aplicativo. Atualmente, essa classe usa o filtro Cross-Site Request Forgery (CSRF) do Play para proteger solicitações POST . A classe utils.ErrorHandler tem a função de configurar a política global de manipulação de erros de solicitação; ela define redirecionamentos para páginas de erro para condições de servidor de erro e página não encontrada. Principalmente, a classe também define redirecionamentos para a página de conexão para tentativas não autorizadas ou não autenticadas de acessar recursos protegidos. O arquivo silhouette.conf incluso declara configurações específicas do Silhouette. Examinarei essas configurações na seção " O Silhouette, em poucas palavras ".

Rotas do aplicativo

O arquivo conf/routes define as rotas para as páginas e fluxos de autenticação específicos do aplicativo. Lista 2 mostra as rotas específicas do aplicativo.

Lista 2. Rotas específicas do aplicativo
# Application
GET     /                           controllers.Application.index
GET     /profile                    controllers.Application.profile

# Rest api
GET     /rest/profile               controllers.RestApi.profile

# Public assets
GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)
GET     /webjars/*file              controllers.WebJarAssets.at(file)

O aplicativo consiste de duas páginas. A página de índice reconhece o usuário e funciona com um usuário conectado ou se for acessada anonimamente. A página do perfil é protegida, exigindo acesso autorizado (caso contrário, redireciona para a página de índice). A URL /rest/profile mapeia para um terminal de API REST seguro que devolve informações do perfil para o usuário conectado em formato JSON. As duas últimas URLs são as rotas padrão para ativos públicos, como folhas de estilo, imagens e código JavaScript. Lista 3 mostra as rotas de autenticação e fluxos.

Lista 3. Rotas de autenticação
GET     /auth/signup                controllers.Auth.startSignUp
POST    /auth/signup                controllers.Auth.handleStartSignUp
GET     /auth/signup/:token         controllers.Auth.signUp(token:String)

GET     /auth/reset                 controllers.Auth.startResetPassword
POST    /auth/reset                 controllers.Auth.handleStartResetPassword
GET     /auth/reset/:token          controllers.Auth.resetPassword(token:String)
POST    /auth/reset/:token          controllers.Auth.handleResetPassword(token:String)

GET     /auth/signin                controllers.Auth.signIn
POST    /auth/authenticate          controllers.Auth.authenticate
GET     /auth/social/:providerId    controllers.Auth.socialAuthenticate(providerId:String)
GET     /auth/signout               controllers.Auth.signOut

O fluxo de inscrição começa com uma solicitação GET para a página de inscrição. Nessa página, os usuários postam (POST ) em suas informações de inscrição (endereço de email, senha, nome e sobrenome) para /auth/signup. O aplicativo processa a postagem ao enviar uma mensagem de email com uma URL /auth/signup/:token que os usuários precisam seguir para concluir a operação de inscrição. O fluxo para a reconfiguração de senha é semelhante: os usuários recebem (GET ) a página de reconfiguração de senha, na qual postam (POST ) um endereço de email para /auth/reset. Essa postagem gera uma mensagem de email que contém uma URL /auth/reset/:token . A URL conduz os usuários a uma página na qual inserem e postam (POST ) uma nova senha para /auth/reset/:token a fim de concluir o processo.

A rota /auth/signin dá acesso à página de conexão. A rota auth/authenticate é o terminal da URL para autenticação de credencial e /auth/social/:providerId serve para autenticação social. Atualmente, o único provedor social que recebe suporte é o Twitter, via OAuth1. A rota /auth/signout expõe o terminal para sair do aplicativo.

Usuários de modelagem e perfis de identidade

Como o aplicativo implementa a vinculação de conta, os usuários são associados a diferentes perfis de identidade originários de diferentes provedores de autenticação (neste aplicativo, credenciais ou Twitter OAuth1). Eu represento os perfis de identidade por meio de um Profile . Um User tem uma lista de perfis de identidade. Lista 4 mostra o código relevante em app/models/User.scala.

Lista 4. Modelo de usuário
case class Profile(
  loginInfo:LoginInfo,
  confirmed: Boolean,
  email:Option[String],
  firstName: Option[String], 
  lastName: Option[String],
  fullName: Option[String], 
  passwordInfo:Option[PasswordInfo], 
  oauth1Info: Option[OAuth1Info],
  avatarUrl: Option[String])

case class User(id: UUID, profiles: List[Profile]) extends Identity {
  def profileFor(loginInfo:LoginInfo) = profiles.find(_.loginInfo == loginInfo)
  def fullName(loginInfo:LoginInfo) = profileFor(loginInfo).flatMap(_.fullName)
}

object User {
  implicit val passwordInfoJsonFormat = Json.format[PasswordInfo]
  implicit val oauth1InfoJsonFormat = Json.format[OAuth1Info]
  implicit val profileJsonFormat = Json.format[Profile]
  implicit val userJsonFormat = Json.format[User]
}

Perfis de identidade (e, portanto, usuários) são identificados com exclusividade pela classe LoginInfo do Silhouette — essencialmente, uma tupla (ID do usuário, ID do provedor). Um perfil pode já estar confirmado ou à espera de confirmação. Esse recurso é útil para os perfis associados ao provedor de credenciais, que precisa ser confirmado como a última etapa do processo de inscrição. Os perfis também têm algumas informações de identificação básicas (endereço de email, nome do usuário e URL do avatar); tudo isso é opcional, já que as informações de identificação irão variar entre os provedores. Um perfil associado ao provedor de credenciais armazena um objeto PasswordInfo do Silhouette que contém a senha em hash. Um perfil criado pelo provedor do Twitter OAuth1 armazena uma instância OAuth1Info do Silhouette com token de autenticação e dados de segredo. Para fornecer suporte a outros provedores de autenticação, a classe Profile precisa ser estendida com campos adicionais (por exemplo, uma propriedade oauth2Info:OAuth2Info para OAuth2).

A classe User é um wrapper em torno de uma lista de perfis que fornece dois acessores de conveniência para o perfil, bem como o nome completo associado a determinado LoginInfo. O objeto acompanhante User declara conversões automáticas a partir das classes de modelo de e para JSON — isso é necessário porque o driver do MongoDB funciona com objetos JSON.

Persistência do modelo

O aplicativo contém o código de persistência em objetos de acesso a dados (DAOs) para as classes User, PasswordInfoe OAuth1Info . Em app/daos/UserDao.scala, você encontrará o traço UserDao , mostrado em Lista 5.

Lista 5. UserDao como traço
trait UserDao {
  def save(user:User):Future[User]
  def find(loginInfo:LoginInfo):Future[Option[User]]
  def find(userId:UUID):Future[Option[User]]
  def confirm(loginInfo:LoginInfo):Future[User]
  def link(user:User, profile:Profile):Future[User]
  def update(profile:Profile):Future[User]
}

Os usuários podem persistir e ser consultados por ID ou LoginInfo. O DAO também implementa operações para confirmar um perfil de identidade, vinculando um novo perfil de identidade a um usuário e atualizando um perfil de identidade. Observe a natureza assíncrona do DAO: todas as operações devolvem uma instância de Future, a classe padrão do Scala para modelar cálculos que serão concluídos mais cedo ou mais tarde. Além disso, no app/daos/UserDao.scala, é possível localizar a implementação do MongoDB do traço UserDao , mostrado em Lista 6.

Lista 6. MongoUserDao como classe
class MongoUserDao extends UserDao {
  lazy val reactiveMongoApi = current.injector.instanceOf[ReactiveMongoApi]
  val users = reactiveMongoApi.db.collection[JSONCollection]("users")

  def find(loginInfo:LoginInfo):Future[Option[User]] = 
    users.find(Json.obj("profiles.loginInfo" -> loginInfo)).one[User]

  def find(userId:UUID):Future[Option[User]] =
    users.find(Json.obj("id" -> userId)).one[User]
  
  def save(user:User):Future[User] =
    users.insert(user).map(_ => user)

  def confirm(loginInfo:LoginInfo):Future[User] = for {
    _ <- users.update(Json.obj(
      "profiles.loginInfo" -> loginInfo
    ), Json.obj("$set" -> Json.obj("profiles.$.confirmed" -> true)))
    user <- find(loginInfo)
  } yield user.get

  def link(user:User, profile:Profile) = for {
    _ <- users.update(Json.obj(
      "id" -> user.id
    ), Json.obj("$push" -> Json.obj("profiles" -> profile)))
    user <- find(user.id)
  } yield user.get

  def update(profile:Profile) = for {
    _ <- users.update(Json.obj(
      "profiles.loginInfo" -> profile.loginInfo
    ), Json.obj("$set" -> Json.obj("profiles.$" -> profile)))
    user <- find(profile.loginInfo)
  } yield user.get
}

A classe MongoUserDao obtém um gancho para a API reativa do Mongo por meio de um injetor de dependência do Play e consegue uma referência para a coleção que armazena usuários. A partir dali, a classe opera utilizando uma API de coleção do MongoDB com os objetos JSON do Play. O Silhouette também exige DAOs para as classes PasswordInfo e OAuth1Info . Sua implementação é semelhante ao MongoUserDao . É possível localizar os DAOs em app/daos/PasswordInfoDao.scala e app/daos/OAuth1InfoDao.scala no código-fonte integral.

Testando os DAOs

O código de persistência é a base do mecanismo de autenticação; portanto, é uma boa ideia garantir que esteja funcionando corretamente antes de seguir em frente. O Play fornece auxiliares e stubs que simplificam a composição de teste. Para testar o código de persistência, utilizarei o FakeApplication do Play. Essa classe funcionará usando a mesma configuração que o aplicativo real, exceto pela propriedade mongodb.uri , que aponta para um banco de dados de teste. Lista 7 mostra o código, que está no test/daos/DaoSpecResources.scala.

Lista 7. Criando um aplicativo de teste falsificado
def fakeApp = FakeApplication(additionalConfiguration = 
     Map("mongodb.uri" -> "mongodb://localhost:27017/test"))

def withUserDao[T](t:MongoUserDao => T):T = running(fakeApp) {
  val userDao = new MongoUserDao
  Await.ready(userDao.users.drop(), timeout)
  t(userDao)
}

Depois de declarar um aplicativo falsificado, o código define um método withUserDao genérico, que recebe uma função que pega um MongoUserDao e realiza o teste real. Essa função é executada no contexto do aplicativo falsificado, depois que a coleção users no banco de dados de teste do aplicativo falsificado é finalizada. Um método withUserDao pode ser utilizado para executar um conjunto de testes specs2 , como o de test/daos/UserSpecDao.scala, mostrado em Lista 8.

Lista 8. specs2 exemplo de teste de DAO do usuário
"UserDao" should {
  "save users and find them by userId" in withUserDao { userDao =>
    val future = for {
      _ <- userDao.save(credentialsTestUser)
      maybeUser <- userDao.find(credentialsTestUser.id)
    } yield maybeUser.map(_ == credentialsTestUser)
    Await.result(future, timeout) must beSome(true)
  }
}

Serviço do usuário

O Silhouette requer uma implementação de um traço IdentityService para fazer o trabalho de autenticação. Lista 9 mostra a implementação — um wrapper em torno de um UserDao— injetado em app/services/UserService.scala.

Lista 9. Classe de serviço do usuário
class UserService @Inject() (userDao:UserDao) extends IdentityService[User] {
  def retrieve(loginInfo:LoginInfo) = userDao.find(loginInfo)
  def save(user:User) = userDao.save(user)
  def find(id:UUID) = userDao.find(id)
  def confirm(loginInfo:LoginInfo) = userDao.confirm(loginInfo)
  def link(user:User, socialProfile:CommonSocialProfile) = {
    val profile = toProfile(socialProfile)
    if (user.profiles.exists(_.loginInfo == profile.loginInfo)) 
      Future.successful(user) else userDao.link(user, profile)
  }

  def save(socialProfile:CommonSocialProfile) = {
    val profile = toProfile(socialProfile)
    userDao.find(profile.loginInfo).flatMap {
      case None => userDao.save(User(UUID.randomUUID(), List(profile)))
      case Some(user) => userDao.update(profile)
    }
  }

  private def toProfile(p:CommonSocialProfile) = Profile(
    loginInfo = p.loginInfo,
    confirmed = true,
    email = p.email,
    firstName = p.firstName,
    lastName = p.lastName,
    fullName = p.fullName,
    passwordInfo = None,
    oauth1Info = None,
    avatarUrl = p.avatarURL
  )
}

O método save(user:User) faz um usuário persistir durante o fluxo de inscrição. O método save(p:CommonSocialProfile) manipula o caso quando um usuário foi autenticado por meio de um provedor social. Neste caso, o aplicativo cria um novo usuário caso não exista nenhum usuário com o perfil especificado; caso contrário, ele atualiza o perfil de identidade correspondente.

Tokens do usuário

O aplicativo gera tokens do usuário como parte dos fluxos de inscrição e reconfiguração de senha. Os tokens do usuário são enviados por email para o usuário, que deve acessar uma URL baseada nos IDs de token enviados para continuar o fluxo. O arquivo models/UserToken.scala implementa tokens como uma classe que contém os IDs de usuário e token e dados de expiração, tal como mostrado em Lista 10.

Lista 10. Token do usuário
case class UserToken(id:UUID, userId:UUID, email:String, expirationTime:DateTime, isSignUp:Boolean) {
  def isExpired = expirationTime.isBeforeNow
}

object UserToken {
  implicit val toJson = Json.format[UserToken]
  
  def create(userId:UUID, email:String, isSignUp:Boolean) = 
    UserToken(UUID.randomUUID(), userId, email, new DateTime().plusHours(12), isSignUp)
}

Os tokens do usuário persistem para uma coleção do MongoDB; portanto, o objeto acompanhante define a formatação JSON necessária. A partir dali, as coisas acontecem exatamente como acontecem com os usuários. O aplicativo manipula tokens usando a classe UserTokenService (em services/UserTokenService.scala). Essa classe de serviço agrupa um DAO de token do usuário injetado, como mostrado em Lista 11.

Lista 11. Serviço de token do usuário
class UserTokenService @Inject() (userTokenDao:UserTokenDao) {
  def find(id:UUID) = userTokenDao.find(id)
  def save(token:UserToken) = userTokenDao.save(token)
  def remove(id:UUID) = userTokenDao.remove(id)
}

UserTokenDao é um traço implementado por um MongoUserTokenDao. O código UserTokenDao é semelhante ao DAO do usuário e pode ser encontrado em daos/UserTokenDao.scala.

O Silhouette, em poucas palavras

A principal característica da estrutura do Silhouette é a flexibilidade. O Silhouette implementa um conjunto de componentes de autenticação independentes e cabe ao desenvolvedor configurá-los e combiná-los para desenvolver uma lógica de autenticação. Os principais componentes são:

  • Serviço de Identidade: O Silhouette depende de uma implementação de um traço IdentityService para manipular todas as operações relacionadas à recuperação de usuários. Desse modo, o gerenciamento de usuários é completamente desacoplado da estrutura. A classe UserService , descrita na seção "Serviço do usuário" , implementa um serviço de identidade respaldado pelo MongoDB.
  • AuthInfoRepository: O Silhouette precisa saber como fazer as credenciais do usuário persistirem. A estrutura delega essa tarefa para uma implementação do traço AuthInfoRepository . O aplicativo utiliza um repositório composto que combina as classes PasswordInfoDao e OAuth1InfoDao descritas na seção "Persistência do modelo ".
  • Autenticador: Os autenticadores controlam um usuário depois de uma autenticação de sucesso. São tokens que armazenam dados como o status de validade e as informações de login para um usuário. O Silhouette tem implementações baseadas em cookies, a sessão stateless do Play, cabeçalhos de HTTP e JSON Web Tokens (JWT).
  • Serviço do Autenticador: Cada autenticador possui um serviço do autenticador associado que é responsável pelo ciclo de vida de um autenticador: criação, inicialização, atualizações, renovação e expiração.
  • Ambiente: O ambiente define os principais componentes exigidos por um aplicativo Silhouette. É parametrizado por tipo pelos tipos de usuário e autenticador (no aplicativo, a classe User definida no Lista 4 e um CookieAuthenticator). O ambiente é construído ao passar a implementação do serviço de identidade (UserService) e a implementação do serviço do autenticador. Estou usando a classe CookieAuthenticatorService , exigida pelo tipo CookieAuthenticator .
  • Provedor: Um provedor é um serviço que manipula a autenticação de um usuário. O aplicativo usa o CredentialsProvider do Silhouette para autenticação local e o OAuth1 TwitterProvider.
  • SocialProviderRegistry: É um item temporário para todos os provedores sociais que recebem suporte do aplicativo. Neste caso, contém a instância TwitterProvider .

Configurando componentes do Silhouette

Os componentes do Silhouette são configurados e combinados por meio de injeção de dependência. O Play usa o Google Guice como implementação de injeção de dependência padrão. (É possível efetuar plug-in em outras implementações, se desejar.) A classe do Guice module.Module define as ligações que o Silhouette requer, começando com as declarações básicas mostradas em Lista 12.

Lista 12. Ligações de injeção de dependência
class Module extends AbstractModule with ScalaModule {

  def configure() {
    bind[IdentityService[User]].to[UserService]
    bind[UserDao].to[MongoUserDao]
    bind[UserTokenDao].to[MongoUserTokenDao]
    bind[DelegableAuthInfoDAO[PasswordInfo]].to[PasswordInfoDao]
    bind[DelegableAuthInfoDAO[OAuth1Info]].to[OAuth1InfoDao]
    bind[IDGenerator].toInstance(new SecureRandomIDGenerator())
    bind[PasswordHasher].toInstance(new BCryptPasswordHasher)
    bind[FingerprintGenerator].toInstance(new DefaultFingerprintGenerator(false))
    bind[EventBus].toInstance(EventBus())
    bind[Clock].toInstance(Clock())
  }
  
  // ... Bindings for Silhouette components follow
}

O código Lista 12 define ligações para o serviço de identidade do Silhouette; o usuário, senha e OAuth1 DAOs; e alguns objetos exigidos pelos componentes principais do Silhouette. Lista 13 mostra a definição para esses componentes, também em module.Module.

Lista 13. Ligações de injeção de dependência (continuação)
@Provides def provideEnvironment(
    identityService: IdentityService[User],
    authenticatorService: AuthenticatorService[CookieAuthenticator],
    eventBus: EventBus): Environment[User, CookieAuthenticator] = {
  Environment[User, CookieAuthenticator](identityService, authenticatorService, Seq(), eventBus)
}

@Provides def provideAuthenticatorService(
    fingerprintGenerator: FingerprintGenerator,
    idGenerator: IDGenerator,
    configuration: Configuration,
    clock: Clock): AuthenticatorService[CookieAuthenticator] = {
  val config = configuration.underlying.as[CookieAuthenticatorSettings]("silhouette.authenticator")
  new CookieAuthenticatorService(config, None, fingerprintGenerator, idGenerator, clock)
}

@Provides def provideCredentialsProvider(
    authInfoRepository: AuthInfoRepository,
    passwordHasher: PasswordHasher): CredentialsProvider = {
  new CredentialsProvider(authInfoRepository, passwordHasher, Seq(passwordHasher))
}

@Provides def provideAuthInfoRepository(
  passwordInfoDAO: DelegableAuthInfoDAO[PasswordInfo],
  oauth1InfoDAO: DelegableAuthInfoDAO[OAuth1Info]): AuthInfoRepository = {
   new DelegableAuthInfoRepository(passwordInfoDAO, oauth1InfoDAO)
}

@Provides def provideTwitterProvider(
    httpLayer: HTTPLayer,
    tokenSecretProvider: OAuth1TokenSecretProvider,
    configuration: Configuration): TwitterProvider = {
  val settings = configuration.underlying.as[OAuth1Settings]("silhouette.twitter")
  new TwitterProvider(httpLayer, new PlayOAuth1Service(settings), tokenSecretProvider, settings)
}

@Provides def provideOAuth1TokenSecretProvider(
    configuration: Configuration, clock: Clock): OAuth1TokenSecretProvider = {
  val cfg = configuration.underlying.as[CookieSecretSettings]("silhouette.oauth1TokenSecretProvider")
  new CookieSecretProvider(cfg, clock)
}

@Provides def provideSocialProviderRegistry(
    twitterProvider: TwitterProvider): SocialProviderRegistry = {
  SocialProviderRegistry(Seq(twitterProvider))
}

O Environment, que autentica instâncias de User por meio de um CookieAuthenticator, é instanciado com:

  • Um IdentityService ligado à classe UserService
  • Um AuthenticatorService ligado a um CookieAuthenticatorService
  • Uma lista vazia de provedores de solicitação (não é usado neste aplicativo)
  • Um EventBus que pode ser usado para transmitir eventos de autenticação (não é usado neste aplicativo)

O CredentialsProvider é criado ao injetar um AuthInfoRepository apoiado por uma implementação do DelegableAuthInfoRepository que delega a persistência de credenciais para as classes PasswordInfoDao e Oauth1InfoDao . A classe TwitterProvider exige uma implementação do traço OAuth1TokenSecretProvider , que define como fazer os segredos de token persistir durante a dança do OAuth1. Por fim, o aplicativo define um SocialProviderRegistry que lista o TwitterProvider como o único provedor social disponível.

O arquivo de configuração do Silhouette

As ligações para o serviço do autenticador de cookie, o provedor do Twitter e as propriedades de configuração de acesso do provedor de segredos de token do OAuth1 que são definidos no arquivo conf/silhouette.conf (mostrado em Lista 14), que é incluído pelo arquivo conf/application.conf principal (consulte Lista 1).

Lista 14. Arquivo de configuração do Silhouette
authenticator.cookieName="authenticator"
authenticator.cookiePath="/"
authenticator.secureCookie=false
authenticator.httpOnlyCookie=true
authenticator.useFingerprinting=true
authenticator.authenticatorIdleTimeout=30 minutes
authenticator.authenticatorExpiry=12 hours

oauth1TokenSecretProvider.cookieName="OAuth1TokenSecret"
oauth1TokenSecretProvider.cookiePath="/"
oauth1TokenSecretProvider.secureCookie=false
oauth1TokenSecretProvider.httpOnlyCookie=true
oauth1TokenSecretProvider.expirationTime=5 minutes

twitter.requestTokenURL="https://api.twitter.com/oauth/request_token"
twitter.accessTokenURL="https://api.twitter.com/oauth/access_token"
twitter.authorizationURL="https://api.twitter.com/oauth/authorize"
twitter.callbackURL="http://dwdemo.com:9000/auth/social/twitter"
twitter.consumerKey=${?TWITTER_CONSUMER_KEY}
twitter.consumerSecret=${?TWITTER_CONSUMER_SECRET}

Consulte a documentação do Silhouette para encontrar uma explicação detalhada dessas propriedades. Observe o uso das variáveis de ambiente TWITTER_CONSUMER_KEY e TWITTER_CONSUMER_SECRET para evitar a exposição dos segredos do OAuth1 no código-fonte.

Ações seguras

O Silhouette define duas ações ad hoc úteis para implementar manipuladores de solicitação protegidos. Essas ações estão disponíveis para controladores que combinam o traço do controlador do Silhouette :

  • UserAwareAction: Essa ação pode ser executada por um usuário autenticado. A solicitação recebida pela ação terá uma propriedade identity do tipo Option[U] (para algum usuário dependente de aplicativo do tipo U) que será definida caso a solicitação tenha sido emitida por um usuário autenticado.
  • SecuredAction: Essa ação precisa ser executada por um usuário autenticado. Caso contrário, chamará o método onNotAuthorized do manipulador de erro de aplicativo (no meu aplicativo, redireciona para a página de conexão, tal como explicado na seção "Configuração do aplicativo"). Essa ação configura uma propriedade identity de solicitação do tipo U (para algum usuário dependente de aplicativo do tipo U), assim como mostrado em Lista 15.
    Lista 15. Solicitações protegidas no Silhouette
    class ApplicationController extends Silhouette {
      def someUserAwareAction = UserAwareAction.async {implicit request => 
        request.identity match {
          case None => // Request sent anonymously ...
          case Some(u) => // Request sent by authenticated user u
        }
      }
      
      def someSecureAction = SecureAction.async { implicit request =>
        logger.info(s"Logged user: ${request.identity}")
        ...
      }
    }

Gerenciamento e autenticação de usuários

Depois de ter passado pelo modelo de usuário e pela configuração do Silhouette, você está pronto para entender o código de autenticação. Examinará a inscrição e a autenticação em detalhes. Todos os fragmentos de código nesta seção vêm do controlador Auth no arquivo controllers/Auth.scala. O controlador Auth tem uma interface com todos os componentes do Silhouette descritos na seção " O Silhouette, em poucas palavras", além do usuário e dos serviços de token do usuário. Todos esses componentes precisam ser injetados no construtor do controlador. O controlador implementa manipuladores de solicitação seguros; portanto, combina no traço do controlador do Silhouette (consulte a seção "Ações seguras"). Lista 16 mostra o código Auth .

Lista 16. Auth como declaração de classe do controlador
class Auth @Inject() (
  val messagesApi: MessagesApi, 
  val env:Environment[User,CookieAuthenticator],
  socialProviderRegistry: SocialProviderRegistry,
  authInfoRepository: AuthInfoRepository,
  credentialsProvider: CredentialsProvider,
  userService: UserService,
  userTokenService: UserTokenService,
  avatarService: AvatarService,
  passwordHasher: PasswordHasher,
  configuration: Configuration,
  mailer: Mailer) extends Silhouette[User,CookieAuthenticator] {
  
    // ... auth controller code ...
}

Inscrição do usuário

O fluxo de inscrição começa no startSignUp . Tal como mostrado em Lista 17, startSignUp é um manipulador de solicitação assíncrona com reconhecimento do usuário.

Lista 17. O método startSignUp
def startSignUp = UserAwareAction.async { implicit request =>
  Future.successful(request.identity match {
    case Some(user) => Redirect(routes.Application.index)
    case None => Ok(views.html.auth.startSignUp(signUpForm))
  })
}

Se um usuário estiver associado com a solicitação, o método é redirecionado para a página de índice. Caso contrário, entrega a página de inscrição, como mostrado em Figura 1.

Figura 1. Página de inscrição
Screenshot of the sign-up page, asking for user email, first and last names, and password
Screenshot of the sign-up page, asking for user email, first and last names, and password

A página de inscrição consiste em um formulário que pede o email do usuário, seu nome e sobrenome e a senha (duas vezes para fins de verificação). Depois de enviado, o formulário é manipulado pelo método handleStartSignUp , mostrado em Lista 18.

Lista 18. O método handleStartSignUp
def handleStartSignUp = Action.async { implicit request =>
  signUpForm.bindFromRequest.fold(
    bogusForm => Future.successful(BadRequest(views.html.auth.startSignUp(bogusForm))),
    signUpData => {
      val loginInfo = LoginInfo(CredentialsProvider.ID, signUpData.email)
      userService.retrieve(loginInfo).flatMap {
        case Some(_) => 
          Future.successful(Redirect(routes.Auth.startSignUp()).flashing(
            "error" -> Messages("error.userExists", signUpData.email)))
        case None => 
          val profile = Profile(
            loginInfo = loginInfo, confirmed=false, email=Some(signUpData.email), 
            firstName=Some(signUpData.firstName), lastName=Some(signUpData.lastName), 
            fullName=Some(s"${signUpData.firstName} ${signUpData.lastName}"),
            passwordInfo = None, oauth1Info = None, avatarUrl = None)
          for {
            avatarUrl <- avatarService.retrieveURL(signUpData.email)
            user <- userService.save(User(id = UUID.randomUUID(),
              profiles = List(profile.copy(avatarUrl = avatarUrl))))
            _ <- authInfoRepository.add(loginInfo, passwordHasher.hash(signUpData.password))
            token <- userTokenService.save(UserToken.create(user.id, signUpData.email, true))
          } yield {
            mailer.welcome(profile, link = routes.Auth.signUp(token.id.toString).absoluteURL())
            Ok(views.html.auth.finishSignUp(profile))
          }
      }
    }
  )
}

O código na Lista 18 começa ligando o formulário de solicitação com um signUpForm . Se a ligação falhar porque o formulário é inválido (o endereço de email não é válido, nome ou sobrenome está vazio ou as senhas não correspondem), o método retornará para a página de inscrição, exibindo os erros de validação. Caso contrário, o método verifica primeiramente se o sistema tem um usuário registrado com o email recebido. Em caso afirmativo, mais uma vez o usuário é redirecionado para a página de inscrição com uma mensagem de erro.

Depois que o usuário passa todas as verificações, o método instancia um perfil de identidade com os dados de inscrição do formulário e faz persistir um usuário com esse perfil chamando userService.save. Em seguida, o método chama authInfoRepository.add (que delega para PasswordInfoDao.save) para fazer as credenciais persistirem e cria um token. O processo termina ao enviar um email de boas-vindas com o ID de token e redirecionar para a página de inscrição final, que instrui o usuário a verificar um email recebido. O email contém links para a rota /auth/signup/:token . Essa rota mapeia para o método signUp , mostrado em Lista 19, que conclui a operação de inscrição.

Lista 19. O método signUp
def signUp(tokenId:String) = Action.async { implicit request =>
  val id = UUID.fromString(tokenId)
  userTokenService.find(id).flatMap {
    case None => 
      Future.successful(NotFound(views.html.errors.notFound(request)))
    case Some(token) if token.isSignUp && !token.isExpired => 
      userService.find(token.userId).flatMap {
        case None => Future.failed(new IdentityNotFoundException(Messages("error.noUser")))
        case Some(user) => 
          val loginInfo = LoginInfo(CredentialsProvider.ID, token.email)
          for {
            authenticator <- env.authenticatorService.create(loginInfo)
            value <- env.authenticatorService.init(authenticator)
            _ <- userService.confirm(loginInfo)
            _ <- userTokenService.remove(id)
            result <- env.authenticatorService.embed(value, Redirect(routes.Application.index()))
          } yield result
      }
    case Some(token) => 
      userTokenService.remove(id).map {_ => NotFound(views.html.errors.notFound(request))}
  }
}

O método signUp começa ao verificar se o ID de token existe no banco de dados. Caso o ID de token não esteja no banco de dados, o método redireciona para a página de erro não encontrado do aplicativo. Em seguida, signUp verifica se o ID de token corresponde a um token de inscrição, se o token não expirou e se o usuário associado ao token existe. Se todas as verificações forem bem-sucedidas, o código passará a concluir o processo de inscrição e conectará o usuário, também. A inscrição é concluída ao registrar que o usuário confirmou a inscrição e ao excluir o token de inscrição. A conexão consiste em três etapas:

  1. Criar um autenticador (tal como explicado na seção " O Silhouette, em poucas palavras", um token que registra dados do usuário autenticado), chamando env.authenticatorService.create
  2. Inicializar o autenticador (env.authenticatorService.init)
  3. Integrar o autenticador na resposta do manipulador de solicitação e redirecionar para a página de índice (env.authenticatorService.embed)

Essa sequência conclui o processo de inscrição. As três etapas também aparecem no código de autenticação.

Autenticação por meio de credenciais

O método authenticate assíncrono, mostrado em Lista 20, implementa a lógica para usuários de autenticação com credenciais definidas durante a inscrição.

Lista 20. O método authenticate
def authenticate = Action.async { implicit request =>
  signInForm.bindFromRequest.fold(
    bogusForm => Future.successful(
      BadRequest(views.html.auth.signIn(bogusForm, socialProviderRegistry))),
    signInData => {
      val credentials = Credentials(signInData.email, signInData.password)
      credentialsProvider.authenticate(credentials).flatMap { loginInfo => 
        userService.retrieve(loginInfo).flatMap {
          case None => 
            Future.successful(Redirect(routes.Auth.signIn())
              .flashing("error" -> Messages("error.noUser")))
          case Some(user) if !user.profileFor(loginInfo).map(_.confirmed).getOrElse(false) =>
            Future.successful(Redirect(routes.Auth.signIn())
              .flashing("error" -> Messages("error.unregistered", signInData.email)))
          case Some(_) => for {
            authenticator <- env.authenticatorService.create(loginInfo).map { 
              case authenticator if signInData.rememberMe => authenticator.copy(...) // Extend lifetime
              case authenticator => authenticator
            }
            value <- env.authenticatorService.init(authenticator)
            result <- env.authenticatorService.embed(value, Redirect(routes.Application.index()))
          } yield result
        }
      }.recover {
        case e:ProviderException => 
          Redirect(routes.Auth.signIn()).flashing("error" -> Messages("error.invalidCredentials"))
      }
    }
  )
}

O método authenticate é chamado a partir da página de conexão mostrada em Figura 2.

Figura 2. Página de conexão
Screenshot of the sign-in page, asking for user email and                     password and showing the Twitter icon  for OAuth1 authentication
Screenshot of the sign-in page, asking for user email and password and showing the Twitter icon for OAuth1 authentication

A lógica do método authenticate é mais simples do que parece. Como de costume, o método tenta ligar a carga útil da solicitação com um signInForm (uma tupla com email e senha, além de uma bandeira "Remember Me"). Caso o formulário não seja válido, a autenticação termina com um redirecionamento para a página de conexão que mostra os erros de validação. Caso contrário, o método tenta autenticar chamando credentialsProvider.authenticate. Em caso de falha na autenticação, é gerado um Future que contém uma exceção, da qual o código se recupera ao voltar para a página de conexão com uma mensagem de erro adequada. Caso contrário,credentialsProvider.authenticate gera um Future com uma instância LoginInfo .A partir dali, o código verifica se o usuário associado com o LoginInfo existe e, em caso afirmativo, se esse usuário concluiu o registro. Se tais verificações passarem, o código realiza as três etapas descritas em Lista 19— ou seja, criar um autenticador, inicializá-lo e integrá-lo na resposta (um redirecionamento para a página de índice). Como etapa intermediária, se a caixa de seleção Remember me foi selecionada, o código modifica o autenticador ao criar uma cópia com um tempo de vida diferente. (Por uma questão de simplicidade, estou omitindo esses detalhes da listagem.)

Autenticação por meio do Twitter

A autenticação Twitter OAuth1 acontece quando o usuário clica no ícone do Twitter, localizado no canto inferior esquerdo da página de conexão (Figura 2), ou quando um usuário autenticado seleciona o Twitter na seção Available authentication providers da página do perfil do usuário (mostrada em Figura 3).

Figura 3. Página do perfil (disponível apenas para usuários autenticados)
Screenshot of the profile page, showing profile information for authenticated users and listing  providers that users can link into                     his account
Screenshot of the profile page, showing profile information for authenticated users and listing providers that users can link into his account

Nos dois casos, o aplicativo envia uma solicitação para a rota /auth/social/:providerId , com o providerId configurado como a cadeia de caracteres twitter . Lista 21 mostra o método socialAuthenticate associado com a rota.

Lista 21. Autenticação do Twitter
def socialAuthenticate(providerId:String) = UserAwareAction.async { implicit request =>
  (socialProviderRegistry.get[SocialProvider](providerId) match {
    case Some(p:SocialProvider with CommonSocialProfileBuilder) => p.authenticate.flatMap {
      case Left(result) => Future.successful(result)
      case Right(authInfo) => for {
        profile <- p.retrieveProfile(authInfo)
        user <- request.identity.fold(userService.save(profile))(userService.link(_,profile))
        authInfo <- authInfoRepository.save(profile.loginInfo, authInfo)
        authenticator <- env.authenticatorService.create(profile.loginInfo)
        value <- env.authenticatorService.init(authenticator)
        result <- env.authenticatorService.embed(value, Redirect(routes.Application.index()))
      } yield result
    } 
    case _ => Future.successful(
      Redirect(request.identity.fold(routes.Auth.signIn())(_ => routes.Application.profile()))
	    .flashing("error" -> Messages("error.noProvider", providerId))
    )
  }).recover {
    case e:ProviderException => 
      logger.error("Provider error", e)
      Redirect(request.identity.fold(routes.Auth.signIn())(_ => routes.Application.profile()))
        .flashing("error" -> Messages("error.notAuthenticated", providerId))
  }
}

O manipulador de solicitação reconhece o usuário. A lógica é conduzida por esta premissa: Se a solicitação for anônima, é uma operação de conexão; caso contrário, ela foi acionada a partir da página do perfil do usuário e denota uma operação de vinculação de conta. O código começa verificando se o ID do provedor está disponível no registro do provedor social. Em caso negativo, o método redireciona para a página de conexão ou do perfil, dependendo de a solicitação denotar uma operação de conexão ou de vinculação. Após essa verificação, o código chama o método authenticate do provedor e a autenticação começa:

  1. Na primeira vez em que a chamada authenticate é concluída, ela gera um valor Left(result) . Esse result é um redirecionamento para o site externo que está executando o processo de autenticação. Quando o site externo conclui a autenticação, retorna para o aplicativo por meio da URL de retorno de chamada configurada em silhouette.conf. Lista 14 mostra que o retorno de chamada aponta para a rota associada com o método socialAuthenticate ; consequentemente, ela é executada novamente.
  2. No momento da nova execução, o socialAuthenticate chama novamente o método authenticate do provedor, mas, desta vez, gerando um valor Right(authInfo) que é processado conforme segue:
    1. O aplicativo pede ao provedor social um perfil de identidade associado ao que foi obtido como authInfo.
    2. O aplicativo verifica se a solicitação é anônima. Se for anônima, a operação é considerada uma conexão e um usuário é criado ou atualizado (consulte Lista 9). Caso contrário, o usuário vincula contas e o perfil de identidade é vinculado ao usuário existente.
    3. O código salva o authInfo e termina com as três etapas usuais descritas em Lista 19: Criar um autenticador, inicializá-lo e integrá-lo na resposta (um redirecionamento para a página de índice).

Se, a qualquer momento durante a dança do OAuth1, ocorrer uma falha na chamada para authenticate , o método se recupera por meio de redirecionamento para a página de índice ou do perfil do usuário.

Protegendo chamadas API REST

Se um usuário tentar um acesso não autenticado para um recurso protegido, o aplicativo redireciona a solicitação para a página de índice (consulte a seção "Configuração do aplicativo "). Porém, essa política de erro não é aceitável para proteger os terminais de API REST. Uma resposta de erro de API REST precisa ter um status HTTP e uma carga útil que expligue a condição de erro, em vez de redirecionar o usuário para a página de um aplicativo. Os manipuladores de solicitação da API REST precisam substituir a política de erro padrão. O Silhouette facilita essa tarefa: os controladores que se combinam no traço do Silhouette herda, manipuladores de erros que, por padrão, não têm efeito. No entanto, podem ser substituídos para customizar a manipulação de erros de acordo com o controlador. Lista 22 mostra o controlador da API REST do aplicativo.

Lista 22. Controlador da API REST
class RestApi @Inject() (
  val messagesApi: MessagesApi, 
  val env:Environment[User,CookieAuthenticator]) extends Silhouette[User,CookieAuthenticator] {

  def profile = SecuredAction.async { implicit request =>
    val json = Json.toJson(request.identity.profileFor(request.authenticator.loginInfo).get)
    val prunedJson = json.transform(
      (__ \ 'loginInfo).json.prune andThen 
      (__ \ 'passordInfo).json.prune andThen 
      (__ \ 'oauth1Info).json.prune)
    prunedJson.fold(
      _ => Future.successful(InternalServerError(Json.obj("error" -> Messages("error.profileError")))),
      js => Future.successful(Ok(js))
    )
  }

  override def onNotAuthenticated(request:RequestHeader) = {
    Some(Future.successful(Unauthorized(Json.obj("error" -> Messages("error.profileUnauth")))))
  }
}

A API REST consiste em um único manipulador de solicitação seguro que devolve um objeto JSON com informações de perfil para o usuário conectado. O objeto JSON é removido das informações sensíveis antes de ser enviado para o responsável pela chamada. A classe substitui o método onNonAuthenticated ; portanto, chamadas anônimas para a API REST geram uma resposta com status não autorizado e uma carga útil com uma mensagem de erro.

Encerrando

Este tutorial explica como configurar um aplicativo Play básico, porém completo, que implementa gerenciamento e autenticação de usuários. Com mudanças mínimas e localizadas (por exemplo, implementar seus próprios DAOs para usar um mecanismo de persistência diferente do MongoDB), é possível adaptar o aplicativo conforme seus próprios projetos, sem precisar implementar o gerenciamento de usuários a partir do zero. Ao longo do caminho, o código mostra alguns dos recursos indicados para desenvolvedores que o Play oferece, tais como utilizar um aplicativo falsificado para testar DAOs, conversão automática de classes para JSON e ligação e validação automáticas de formulário.

Você sabia que os aplicativos Play podem ser executados no IBM Cloud? Leia o próximo tutorial desta série, no qual mostro como implementar os aplicativos de autenticação na nuvem IBM.


Recursos para download


Temas relacionados

  • LinkedIn, Kloute Coursera: Descubra por que essas empresas adotaram o Scala e o Play Framework.
  • The busy Java developer's guide to Scala (Ted Neward, developerWorks, janeiro de 2008): Confira esta série de artigos introdutórios sobre o Scala direcionados a desenvolvedores com conhecimento de Java.
  • Documentação do Play: Leia a seção Getting Started para ter uma visão rápida do Play Framework.
  • developerWorks Premium: Fornece um passe de acesso completo a ferramentas eficientes, biblioteca técnica curada de Safari Books Online, descontos e atas de conferências, créditos de SoftLayer e IBM Cloud e muito mais.
  • Silhouette: Acesse o website para o Silhouette, uma biblioteca de autenticação para aplicativos Play Framework com suporte para OAuth1, OAuth2, OpenID e esquemas de autenticação de credenciais.
  • specs2: Explore essa popular estrutura de teste com base em especificação para o Scala.
  • Google Guice: Informe-se sobre a implementação de injeção de dependência padrão do Play.
  • Play Framework: Faça o download do Play Framework e comece a implementar seus próprios aplicativos reativos.
  • MongoDB: Obtenha o MondoDB, um banco de dados NoSQL criado para armazenar, consultar e manipular com eficiência documentos semelhantes a JSON.

Comentários

Acesse ou registre-se para adicionar e acompanhar os comentários.

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=80
Zone=Tecnologia Java, Software livre
ArticleID=1026171
ArticleTitle=Desenvolvimento divertido na web, Parte 1: Gerenciar a autenticação do usuário com Play Framework e Scala
publish-date=01262016