内容


使用 Play 实现 Web 开发,第 1 部分

使用 Play Framework 和 Scala 管理用户身份验证

使用 Play、Silhouette 和 MongoDB 实现一个入门级身份验证 UI

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: 使用 Play 实现 Web 开发,第 1 部分

敬请期待该系列的后续内容。

此内容是该系列的一部分:使用 Play 实现 Web 开发,第 1 部分

敬请期待该系列的后续内容。

在现代 Web 应用程序中实现身份验证可能需要大量的工作。您需要使用户能够通过多种机制(凭据;使用 OAuth1、OAuth2 或 OpenID 的社交服务提供商)执行身份验证。用户注册和密码重置通常需要基于电子邮件的流。请求和查看也必须知道登录用户的身份(如果有)。

本教程介绍一个使用 Play Framework 构建的入门级身份验证应用程序。Play 是 在设计时考虑了可伸缩性的 新一代反应式 Web 框架(比如 Node.js 和 Vert.x)中的一员。Play 还带来了开发友好的特性,比如原生的 XML 和 JSON 处理,开发模式下的浏览器内错误报告,内置的测试帮助器,以及 Selenium 集成。您可在 Java™ 或 Scala 中编写 Play 应用程序,但首选 Scala。函数语言最适合反应式编程风格。尽管 Java 最终在第 8 版中接受了函数编程概念,但它落后于 Scala 丰富的函数特性储备。

我的入门级应用程序通过实现以下功能展示了 Scala 和 Play 的实际运用:

  • 基于电子邮件的用户注册
  • 凭据(电子邮件和密码)和通过 OAuth1 进行的 Twitter 身份验证
  • 基于电子邮件的密码重置
  • 凭据和 Twitter 帐户链接
  • 用户感知的视图、HTTP 请求和 Ajax 调用的示例

该应用程序使用 Silhouette 执行身份验证工作,使用 MongoDB 实现用户持久性。所有请求处理和与 MongoDB 的交互都是完全异步的。您可使用此应用程序作为您自己的项目的起点,省得自己从头实现身份验证。

我假设您基本熟悉 Scala 语言。(如果您拥有 Java 背景且需要了解 Scala,我建议您阅读 developerWorks 面向 Java 开发人员的 Scala 指南 系列。)我还假设您至少拥有最基本的 Play 经验;拥有控制器、路由和视图的基本知识就足够了。有关 Play 的介绍,请参阅 Play 文档 的 “入门” 小节。(本教程的代码使用 Play Framework 2.4.2 版,所以请阅读该版本的文档。)一定要查阅本系列的 第 2 部分,我将在其中展示如何将 Play 应用程序部署在 IBM Bluemix™ 上。

设置

要构建和运行这个入门级应用程序,您需要在系统上安装 Play 2.4.2 或更高版本和 MongoDB。

安装 MongoDB 和 Play

按照 MongoDB 网站的 下载 小节中针对您平台的说明,安装 MongoDB。在大多数 Linux 发行版上,您可使用相应的包管理器安装 MongoDB。对于 Mac,您可使用 Homebrew;对于 Windows,可使用 MSI 安装程序。

Play Framework(自 2.4.0 版开始)需要 Java 8,所以请确保您安装了 Java SE 8 SDK。要安装 Play,请 下载 并解压极小的激活程序 — 一个 1MB 的 ZIP 文件,包含将在第一次运行时下载 Play 的代码和依赖项(约 450MB)的启动脚本。为方便起见,您可能希望将激活程序的文件夹添加到您的系统路径中。

下载和运行应用程序

示例应用程序项目托管在 IBM Bluemix DevOps Services 上。您可克隆该项目的 Git 存储库(必须首先登录或注册)来获取源代码。也可参阅 下载 来获取应用程序的 ZIP 文件。拥有代码后,执行以下步骤来运行该应用程序:

  1. Twitter Application Management 网站上注册一个应用程序。输入 http://dwdemo.com:9000/auth/social/twitter 作为回调 URL。
  2. 127.0.0.1 dwdemo.com 添加到您的 hosts 文件中,以便该应用程序可被在 Twitter 上注册的同一个域找到。
  3. 通过将来自 Twitter 应用程序页上的相应值复制到 conf/silhouette.conf 文件中的第 28 和 29 行,设置用户密钥和用户机密属性。
  4. 运行 mongod --dbpath folder 来启动 MongoDB 服务器,其中 folder 是 MongoDB 将存储数据库文件的目录。
  5. 要启动该应用程序,转到您克隆了代码的根文件夹并对它执行 activator run

应用程序启动(首次执行这一步将需要一些时间)后,转到 http://dwdemo.com:9000 来打开它。您会看到一个欢迎屏幕,用户可在其中注册来在应用程序中创建一个帐户。

应用程序配置

设置好您的环境后,就可以钻研应用程序配置了。

主要配置文件

主要配置文件是 conf/application.conf,这是 Play 寻找配置属性的默认位置。清单 1 显示了相关部分。

清单 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"

application.conf 文件声明两个配置依赖注入绑定的类。ReactiveMongoModule 使反应式 Mongo 绑定可注入到应用程序类中。module.Module 类指定应用程序的注入绑定,主要针对 Silhouette 类。mongodb.uri 属性定义如何连接到 MongoDB,play.mailer 设置一个模拟邮件服务,用于在开发模式下测试注册和密码重置流。一个模拟的邮件收发器将电子邮件记录到控制台;在生产中,该应用程序必须使用一个 SMTP 服务器。

utils.Filters 类定义应用程序的过滤器管道。目前,这个类使用 Play 的跨站请求伪造 (CSRF) 过滤器来保护 POST 请求。utils.ErrorHandler 类拥有设置应用程序全局请求错误处理策略的作用;它为内部服务器和页面未找到条件定义错误页面重定向。更重要的是,对于对受保护资源的未授权或未验证的访问尝试,该类还定义了向登录页面的重定向。包含的 silhouette.conf 文件声明了特定于 Silhouette 的设置。我将在 “Silhouette 简介” 一节中介绍这些设置。

应用程序路由

conf/routes 文件定义了特定于应用程序的页面的路由和身份验证流。 清单 2 显示了特定于应用程序的路由。

清单 2. 特定于应用程序的路由
# 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)

该应用程序包含两个页面。索引页面是用户感知的,适用于登录的用户或匿名访问。概况页面是受保护的,需要授权才能访问(否则,它会重定向到索引页面)。/rest/profile URL 映射到一个安全的 REST API 端点,该端点以 JSON 格式返回登录用户的概况信息。最后两个 URL 是公共资产的标准路由,比如样式表、图像和 JavaScript 代码。 清单 3 显示了身份验证路由和流。

清单 3. 身份验证路由
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

注册流从对注册页面的 GET 请求开始。从该页面,用户通过 POST 将他们的注册信息(电子邮件地址、密码、姓名)提交给 /auth/signup。应用程序通过发送一封电子邮件来处理该提交,邮件中包含一个 /auth/signup/:token URL,用户必须访问该 URL 才能完成注册操作。密码重置流类似:用户通过 GET 获取密码重置页面,他们在其中通过 POST 将一个电子邮件地址提交给 /auth/reset。此提交会生成一封包含一个 /auth/reset/:token URL 的电子邮件。这个 URL 将用户带到一个页面,他们可在其中输入一个新密码并通过 POST 将其提交到 /auth/reset/:token 来完成该过程。

/auth/signin 路由提供了访问登录页面的路径。auth/authenticate 路由是执行凭据验证的 URL 端点,/auth/social/:providerId 是执行社交验证的端点。目前,唯一受支持的社交服务提供商是 Twitter,通过 OAuth1 实现。/auth/signout 路由公开用于从应用程序注销的端点。

建模用户和身份概况

该应用程序实现了帐户链接,所以用户与来自不同身份验证提供程序(在本应用程序中,包括凭据或 Twitter OAuth1)的多个身份概况相关联。我通过 Profile 类表示身份概况。一个 User 包含一个身份概况列表。 清单 4 显示了 app/models/User.scala 中的相关代码。

清单 4. 用户模型
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]
}

身份概况(以及用户)由 Silhouette 的 LoginInfo 类唯一标识 — 基本来讲是一个(用户 ID、提供商 ID)元组。一个概况可能已确认或正在等待确认。此特性对与凭据提供商关联的概况很方便,这些概况必须在注册流程的最后一步确认。概况也包含一些基本的身份信息(电子邮件地址、用户名和头像 URL),所有这些信息都是可选的,因为身份信息因提供商而异。一个与凭据提供商关联的概况存储一个 Silhouette PasswordInfo 对象,该对象持有经过哈希运算的密码。OAuth1 Twitter 提供程序创建的概况在一个 Silhouette OAuth1Info 实例中存储身份验证令牌和机密数据。要支持其他身份验证提供程序,Profile 类必须使用额外的字段来扩展(例如一个针对 OAuth2 的 oauth2Info:OAuth2Info 属性)。

User 类是一个概况列表的包装器,它为与一个给定 LoginInfo 关联的概况和全名提供了两个便捷访问器。User 配套对象声明模型类与 JSON 之间的自动转换 — 这很有必要,因为 MongoDB 驱动程序适用于 JSON 对象。

模型持久性

该应用程序将持久性代码封装在 UserPasswordInfoOAuth1Info 类的数据访问对象 (DAO) 中。在 app/daos/UserDao.scala 中,您将找到 UserDao 特征,如 清单 5 所示。

清单 5. UserDao 特征
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]
}

可按 ID 或 LoginInfo 持久化和查询用户。DAO 也实现了确认一个身份概况、将一个新身份概况链接到一个用户以及更新一个身份概况的操作。请注意 DAO 的异步性质:所有这些操作都返回一个 Future 实例,这是 Scala 建模最终将完成的计算的标准类。另外在 app/daos/UserDao.scala 中,您可以找到 UserDao 特征的 MongoDB 实现,如 清单 6 所示。

清单 6. MongoUserDao
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
}

MongoUserDao 类通过 Play 的依赖注入器获取反应式 Mongo API 的 hook,并获取存储用户的集合的引用。从这里,该类使用 MongoDB 的集合 API 对 Play 的 JSON 对象执行操作。Silhouette 还需要 PasswordInfoOAuth1Info 类的 DAO。它们的实现类似于 MongoUserDao 类。您可在 app/daos/PasswordInfoDao.scala 和 app/daos/OAuth1InfoDao.scala 中找到这些 DAO 的完整源代码。

测试 DAO

持久性代码是身份验证机制的基础,所以在继续之前确保它能正确地运行是个不错的主意。Play 提供了帮助器和存根,简化了测试的编写。为了测试持久性代码,我将使用 Play 的 FakeApplication 类。这个类将使用与实际应用程序相同的配置来运行,除了 mongodb.uri 属性,该属性指向一个测试数据库。 清单 7 显示了该代码,它位于 test/daos/DaoSpecResources.scala 中。

清单 7. 创建一个虚假的测试应用程序
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)
}

声明一个虚假应用程序后,该代码定义一个泛型 withUserDao 方法,该方法接受一个函数,而该函数接受一个 MongoUserDao 并执行实际测试。在清除虚假应用程序的测试数据库中的 users 集合后,该函数在虚假应用程序的上下文内运行。withUserDao 方法可用于运行一套 specs2 测试,比如 test/daos/UserSpecDao.scala 中的那套测试,如 清单 8 所示。

清单 8. specs2 示例用户 DAO 测试
"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)
  }
}

用户服务

Silhouette 需要一个 IdentityService 特征的实现来执行身份验证工作。清单 9 显示了 app/services/UserService.scala 中的该实现(围绕一个注入的 UserDao 的包装器)。

清单 9. 用户服务类
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
  )
}

save(user:User) 方法在注册流执行期间持久化一个用户。save(p:CommonSocialProfile) 方法处理用户通过社交服务提供商进行身份验证的情形。在此情况下,如果不存在具有指定概况的用户,该应用程序将创建一个新用户;否则,它会更新相应的身份概况。

用户令牌

作为注册和密码重置流的一部分,该应用程序会生成用户令牌。用户令牌通过电子邮件发送给用户,用户必须访问一个基于邮寄令牌 ID 的 URL 才能继续执行该流程。models/UserToken.scala 文件将令牌实现为一个类,该类存留用户和令牌 ID 及过期数据,如 清单 10 所示。

清单 10. 用户令牌
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)
}

用户令牌持久化到一个 MongoDB 集合中,所以配套的对象定义了需要的 JSON 格式。从这里,发生的事情都与用户相关。应用程序使用 UserTokenService 类(位于 services/UserTokenService.scala 中)处理令牌。这个服务类包装了一个注入的用户令牌 DAO,如 清单 11 所示。

清单 11. 用户令牌服务
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)
}

UserTokenDaoMongoUserTokenDao 实现的一个特征。UserTokenDao 代码类似于用户 DAO,您可在 daos/UserTokenDao.scala 中找到它。

Silhouette 简介

Silhouette 框架的首要特征是灵活性。Silhouette 实现了一组独立的身份验证组件,开发人员需要配置和组合它们来构建身份验证逻辑。主要组件包括:

  • 身份服务:Silhouette 依靠 IdentityService 特征的一个实现来处理所有与检索用户相关的操作。通过这种方式,用户管理完全与框架分离开来。UserService 类(已在 “用户服务” 一节中介绍)实现一个由 MongoDB 支持的身份服务。
  • AuthInfoRepository:Silhouette 需要知道如何持久化用户凭据。该框架将此工作委派给 AuthInfoRepository 特征的一个实现。该应用程序使用一个综合存储库,其中组合了 PasswordInfoDaoOAuth1InfoDao 类,这两个类都已在 “模型持久性” 一节中介绍。
  • 身份验证器:身份验证器在成功完成身份验证后跟踪用户。它们是存储数据的令牌,比如其有效性状态和用户的登录信息。Silhouette 拥有基于 cookie、Play 无状态会话、HTTP 标头和 JSON Web Tokens (JWT) 的实现。
  • 身份验证器服务:每个身份验证器都有一个关联的身份验证器服务,负责身份验证器的生命周期:创建、初始化、更新、续约和过期。
  • 环境:环境定义一个 Silhouette 应用程序需要的关键组件。它按用户和身份验证器类型(在该应用程序中,为 清单 4 中定义的 User 类和一个 CookieAuthenticator)来进行类型参数化。环境是通过传递身份服务实现 (UserService) 和身份验证器服务实现来构建的。我使用了 CookieAuthenticatorService 类,这是 CookieAuthenticator 类型所需要的。
  • 提供程序:提供程序是一个处理用户的身份验证的服务。该应用程序使用 Silhouette 的 CredentialsProvider 来执行本地身份验证,还使用了 OAuth1 TwitterProvider
  • SocialProviderRegistry:这是该应用程序支持的所有社交服务提供程序的占位符。在本例中,它包含 TwitterProvider 实例。

配置 Silhouette 组件

Silhouette 组件通过依赖注入的方式来配置和组合。Play 使用 Google Guice 作为默认的依赖注入实现。(如果您愿意,可以插入其他实现。)Guice module.Module 类定义了 Silhouette 需要的绑定,首先是基本声明,如 清单 12 所示。

清单 12. 依赖注入绑定
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
}

清单 12 代码定义了 Silhouette 的身份服务;用户、密码和 OAuth1 DAO;以及 Silhouette 的主要组件需要的一些对象的绑定。 清单 13 显示了这些组件的定义,也包含在 module.Module 中。

清单 13. 依赖注入绑定(续)
@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))
}

Environment(它通过一个 CookieAuthenticator 来验证 User 实例)通过以下实体来实例化:

  • 一个绑定到 UserService 类的 IdentityService
  • 一个绑定到 CookieAuthenticatorServiceAuthenticatorService
  • 一个空的请求提供程序列表(本应用程序中未使用)
  • 一个 EventBus,可用于广播身份验证事件(本应用程序中未使用)

CredentialsProvider 通过注入一个由 DelegableAuthInfoRepository 实现支持的 AuthInfoRepository 来创建,而该实现将凭据持久化任务委派给 PasswordInfoDaoOauth1InfoDao 类。TwitterProvider 类需要 OAuth1TokenSecretProvider 特征的实现,该实现定义如何在 OAuth1 运行期间持久化令牌机密。最后,应用程序定义了一个 SocialProviderRegistry,其中列出了 TwitterProvider 作为唯一可用的社交服务提供程序。

Silhouette 配置文件

cookie 身份验证器服务、Twitter 提供程序和 OAuth1 令牌机密提供程序的绑定访问在 conf/silhouette.conf 文件中定义的配置属性(如 清单 14 所示),该文件包含在主要 conf/application.conf 文件中(参见 清单 1)。

清单 14. 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}

请参阅 Silhouette 文档 查看这些属性的详细解释。可以注意到,其中使用了 TWITTER_CONSUMER_KEYTWITTER_CONSUMER_SECRET 环境变量来避免在源代码中公开 OAuth1 机密。

安全操作

Silhouette 定义了两个对实现受保护的请求处理函数很有用的临时操作。这些操作可用于 Silhouette 控制器特征中包含的控制器组合:

  • UserAwareAction:此操作可以由经过身份验证的用户执行。该操作收到的请求将具有一个 Option[U] 类型(对于一些依赖于应用程序的用户,类型为 U)的 identity 属性,如果请求是经过身份验证的用户发出的,将会定义该属性。
  • SecuredAction:此操作必须由经过身份验证的用户执行。否则,它将调用应用程序的错误处理函数的 onNotAuthorized 方法(在我的应用程序中,它重定向到登录页面,如 “应用程序配置” 一节中所述)。此操作设置了一个类型为 U 的请求 identity 属性(对于一些依赖于应用程序的用户,类型为 U),如 清单 15 所示。
    清单 15. 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}")
        ...
      }
    }

用户管理和身份验证

了解用户模型和 Silhouette 的配置后,您就可以理解身份验证代码了。我们将详细介绍注册和身份验证。本节中的所有代码段都来自 controllers/Auth.scala 文件中的 Auth 控制器。Auth 控制器与 “Silhouette 简介” 一节中介绍的所有 Silhouette 组件交互,还会与用户和用户令牌服务交互。所有这些组件必须注入到控制器的构造函数中。该控制器实现安全请求处理函数,所以它混合在 Silhouette 控制器特征中(参阅 “安全操作” 小节)。清单 16 显示了 Auth 代码。

清单 16. Auth 控制器类声明
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 ...
}

用户注册

注册流从 startSignUp 方法开始。如 清单 17 所示,startSignUp 是一个用户感知的匿名请求处理函数。

清单 17. 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))
  })
}

如果一个用户与该请求关联,该方法会重定向到索引页面。否则,它将提供注册页面,如 图 1 所示。

图 1. 注册页面
注册页面的屏幕截图,要求输入用户电子邮件、姓名和密码
注册页面的屏幕截图,要求输入用户电子邮件、姓名和密码

注册页面包含一个要求输入用户电子邮件、姓名和密码(出于验证用途,需要输入两次)的表单。提交时,该表单会由 handleStartSignUp 方法处理,如 清单 18 所示。

清单 18. 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))
          }
      }
    }
  )
}

清单 18 中的代码首先将请求表单绑定到一个 signUpForm 类。如果由于该表单无效(电子邮件地址无效、姓名为空或密码不匹配)而绑定失败,该方法会再次转到注册页面,显示验证错误。否则,该方法首先检查系统是否已有一个使用收到的电子邮件注册的用户。如果是,再次将用户重定向到注册页面并显示一条错误消息。

用户通过所有检查后,该方法使用表单的注册数据实例化一个身份概况,并通过调用 userService.save 来持久化一个具有该概况的用户。然后该方法调用 authInfoRepository.add(它委派给 PasswordInfoDao.save)来持久化凭据并创建一个令牌。在该过程的最后,发送一封包含该令牌 ID 的欢迎电子邮件并重定向到完成注册页面,该页面告诉用户检查收到的电子邮件。该电子邮件链接到 /auth/signup/:token 路由。该路由映射到 signUp 方法,如 清单 19 所示,注册操作到此就完成了。

清单 19. 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))}
  }
}

signUp 方法首先确认数据库中存在这个令牌 ID。如果该令牌 ID 不在数据库中,该方法将重定向到应用程序的 not-found 错误页面。然后 signUp 确认该令牌 ID 与一个注册令牌对应,该令牌没有过期,而且与该令牌关联的用户存在。如果所有验证都成功,代码将继续完成注册流程,还会登录该用户。在注册流程的最后,会记录该用户已确认注册并删除注册令牌。登录包含 3 个步骤:

  1. 调用 env.authenticatorService.create 来创建一个身份验证器(已在 “Silhouette 简介” 一节中介绍,这是一个记录经过验证的用户数据的令牌)
  2. 初始化身份验证器 (env.authenticatorService.init)
  3. 将身份验证器嵌入到请求处理函数的响应中,并重定向到索引页面 (env.authenticatorService.embed)

这个序列就是完整的登录流程。这 3 个步骤也包含在身份验证代码中。

通过凭据执行身份验证

异步 authenticate 方法(如 清单 20 所示)实现使用在注册期间定义的凭据验证用户的逻辑。

清单 20. 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"))
      }
    }
  )
}

authenticate 方法从登录页面调用,如 图 2 所示。

图 2. 登录页面
登录页面的屏幕截图,要求输入用户电子邮件和密码,并显示了用于 OAuth1 身份验证的 Twitter 图标
登录页面的屏幕截图,要求输入用户电子邮件和密码,并显示了用于 OAuth1 身份验证的 Twitter 图标

authenticate 方法的逻辑比看起来更简单。跟平常一样,该方法尝试将请求负载绑定到一个 signInForm(一个包含电子邮件、密码和一个 remember-me 标志的元组)。如果该表单无效,在身份验证的最后会重定向到一个显示了验证错误的登录页面。否则,该方法会尝试调用 credentialsProvider.authenticate 来执行身份验证。如果验证失败,它会返回一个包含异常的 Future,代码会通过返回到包含适当错误消息的登录页面而从异常中恢复。否则,credentialsProvider.authenticate 返回一个包含 LoginInfo 实例的 Future。从这里,代码检查与 LoginInfo 关联的用户是否存在,如果存在,则检查该用户是否完成了注册。如果这些检查通过,代码执行 清单 19 中列出的 3 个步骤 — 即创建一个身份验证器,初始化它,然后将它嵌入到响应中(重定向到索引页面)。一个中间步骤是,如果选择了 Remember me 复选框,代码会创建具有更长生存期的副本来修改该身份验证器。(为简单起见,我在清单中省略了这些细节。)

通过 Twitter 执行身份验证

Twitter OAuth1 身份验证会在以下时刻执行:用户单击登录页面左下角的 Twitter 图标时 (图 2) 或经过身份验证的用户从用户概况页面的 Available authentication providers 部分选择 Twitter(如 图 3 所示)时。

图 3. 概况页面(仅可用于经过身份验证的用户)
概况页面的屏幕截图,显示了经过身份验证的用户的概况信息并列出了用户可链接到其帐户中的提供程序
概况页面的屏幕截图,显示了经过身份验证的用户的概况信息并列出了用户可链接到其帐户中的提供程序

在两种情况下,该应用程序都将发送一个 /auth/social/:providerId 路由请求,并将 providerId 设置为 twitter 字符串。 清单 21 显示了与该路由关联的 socialAuthenticate 方法。

清单 21. 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))
  }
}

请求处理函数是用户感知的。该逻辑基于以下假设:如果请求是匿名的,它就是一个登录操作;否则,该请求从用户概况页面触发并指定一个帐户链接操作。该代码首先确认提供程序 ID 包含在社交服务提供程序注册表中。如果没有,该方法重定向到登录或概况页面,具体取决于请求指定登录还是链接操作。执行此检查后,代码调用提供程序的 authenticate 方法并开始执行身份验证:

  1. 第一次完成 authenticate 调用时,它返回一个 Left(result) 值。这个 result 重定向到执行身份验证流程的外部网站。外部网站完成身份验证时,它通过 silhouette.conf 中配置的回调 URL 而返回到应用程序。 清单 14 显示该回调指向与 socialAuthenticate 方法关联的路由,所以将重新执行该方法。
  2. 在重新执行时,socialAuthenticate 再次调用提供程序的 authenticate 方法,这一次返回一个经过如下处理的 Right(authInfo) 值:
    1. 应用程序向社交服务提供程序请求一个与获得的 authInfo 关联的身份概况。
    2. 应用程序检查请求是否是匿名的。如果它是匿名的,则将该操作视为一次登录,并创建或更新一个用户(参见 清单 9)。否则,该用户链接帐户,身份概况链接到现有的用户。
    3. 该代码保存 authInfo,最后执行 清单 19 中介绍的 3 个常见的骤:创建一个身份验证器,初始化它,然后将它嵌入到响应中(重定向到索引页面)。

如果在 OAuth1 运行过程的任何时候,对 authenticate 的调用失败,该方法会重定向到索引或用户概况页面来恢复执行。

保护 REST API 调用

如果用户尝试对受保护的资源执行未验证的访问,应用程序会将请求重定向到索引页面(参见 “应用程序配置” 小节)。但要保护 REST API 端点,就无法接受此错误策略。REST API 错误响应必须拥有一个正确的 HTTP 状态,以及一个解释错误状况的负载,而不是将用户重定向到应用程序的页面。REST API 请求处理函数必须覆盖默认的错误策略。Silhouette 简化了这一任务:Silhouette 特征中混合的控制器会继承默认没有效果的错误处理函数。但可覆盖它们来基于控制器自定义错误处理。 清单 22 显示了应用程序的 REST API 控制器。

清单 22. Rest API 控制器
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")))))
  }
}

REST API 包含一个安全请求处理函数,该函数返回一个包含登录用户的概况信息的 JSON 对象。在发送给调用方之前,该 JSON 对象被从敏感信息中删除。该类覆盖 onNonAuthenticated 方法,因此对 REST API 的匿名调用会返回一个响应,其中包含未授权的状态和一个包含错误消息的负载。

结束语

本教程介绍了如何设置一个基本但完整的 Play 应用程序,用以实现用户管理和身份验证。只需极少的局部更改(例如实现您自己的 DAO 来使用不同于 MongoDB 的持久性机制),您可针对您自己的项目调整该应用程序,而无需从头实现用户管理。期间我们用代码展示了 Play 提供的一些开发人员友好的特性,比如使用虚假应用程序来测试 DAO,自动将类转换为 JSON,以及自动绑定和验证表单。

您知道 Play 应用程序可在 Bluemix 上运行吗?查阅本系列的 下一篇教程,其中将展示如何将这个身份验证应用程序部署到 IBM 云。


下载资源


相关主题

  • LinkedInKloutCoursera:了解为什么这些公司采用了 Scala 和 Play Framework。
  • 面向 Java 开发人员的 Scala 指南(Ted Neward,developerWorks,2008 年 1 月)查阅这个面向具有 Java 背景的开发人员的介绍性 Scala 文章系列。
  • Play 文档:查阅 “入门” 小节来大体了解 Play Framework。
  • Silhouette:访问 Silhouette 的网站,这是一个针对 Play Framework 应用程序的身份验证库,支持 OAuth1、OAuth2、OpenID 和凭据验证模式。
  • specs2:探索这个基于规范的流行的 Scala 测试框架。
  • Google Guice:深入了解 Play 默认的依赖注入实现。
  • Play Framework:下载 Play Framework 并开始实现您自己的反应式应用程序。
  • MongoDB:获取 MondoDB,这是一个旨在高效地存储、查询和操作类似 JSON 的文档的 NoSQL 数据库。
  • developerWorks Web development 专区:通过专门关于 Web 技术的文章和教程,扩展您在网站开发方面的技能。
  • developerWorks Ajax 资源中心:这是有关 Ajax 编程模型信息的一站式中心,包括很多文档、教程、论坛、blog、wiki 和新闻。任何 Ajax 的新信息都能在这里找到。
  • 查看 HTML5 专题,了解更多和 HTML5 相关的知识和动向。

评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Web development, Java technology, Security, Open source
ArticleID=1024543
ArticleTitle=使用 Play 实现 Web 开发,第 1 部分: 使用 Play Framework 和 Scala 管理用户身份验证
publish-date=12212015