在现代 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 的实际运用:
通过使用异步 I/O,Play 采取了一种编程模型,其中应用程序代码对在 I/O 完成时触发的事件做出反应。在此期间,运行应用程序代码的线程不会拦截,而能够处理其他请求。此设计支持高效地使用处理器核心:Play 可使用少量线程处理大量的流量。而且 Play 不会存储任何服务器端会话状态,它通过消除会话亲缘性而帮助实现了水平可伸缩性:集群中的任何服务器都可受理一个请求。
该应用程序使用 Silhouette 执行身份验证工作,使用 MongoDB 实现用户持久性。所有请求处理和与 MongoDB 的交互都是完全异步的。您可使用此应用程序作为您自己的项目的起点,省得自己从头实现身份验证。
我假设您基本熟悉 Scala 语言。(如果您拥有 Java 背景且需要了解 Scala,我建议您阅读 developerWorks 面向 Java 开发人员的 Scala 指南 系列。)我还假设您至少拥有最基本的 Play 经验;拥有控制器、路由和视图的基本知识就足够了。有关 Play 的介绍,请参阅 Play 文档 的 “入门” 小节。(本教程的代码使用 Play Framework 2.4.2 版,所以请阅读该版本的文档。)一定要查阅本系列的 第 2 部分,我将在其中展示如何将 Play 应用程序部署在 IBM Bluemix™ 上。
采用 Play 的公司包括 LinkedIn、Klout 和 Coursera。撰写本文时,该项目在 GitHub 上已有 2,411 个分支,Stack Overflow 上有 10,308 个带 playframework 标记的问题。
要构建和运行这个入门级应用程序,您需要在系统上安装 Play 2.4.2 或更高版本和 MongoDB。
按照 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 文件。拥有代码后,执行以下步骤来运行该应用程序:
http://dwdemo.com:9000/auth/social/twitter
作为回调 URL。127.0.0.1 dwdemo.com
添加到您的 hosts 文件中,以便该应用程序可被在 Twitter 上注册的同一个域找到。mongod --dbpath folder
来启动 MongoDB 服务器,其中 folder
是 MongoDB 将存储数据库文件的目录。activator run
。应用程序启动(首次执行这一步将需要一些时间)后,转到 http://dwdemo.com:9000 来打开它。您会看到一个欢迎屏幕,用户可在其中注册来在应用程序中创建一个帐户。
设置好您的环境后,就可以钻研应用程序配置了。
主要配置文件是 conf/application.conf,这是 Play 寻找配置属性的默认位置。清单 1 显示了相关部分。
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 显示了特定于应用程序的路由。
# 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 显示了身份验证路由和流。
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 中的相关代码。
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 对象。
该应用程序将持久性代码封装在 User
、PasswordInfo
和 OAuth1Info
类的数据访问对象 (DAO) 中。在 app/daos/UserDao.scala 中,您将找到 UserDao
特征,如 清单 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 所示。
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 还需要 PasswordInfo
和 OAuth1Info
类的 DAO。它们的实现类似于 MongoUserDao
类。您可在 app/daos/PasswordInfoDao.scala 和 app/daos/OAuth1InfoDao.scala 中找到这些 DAO 的完整源代码。
持久性代码是身份验证机制的基础,所以在继续之前确保它能正确地运行是个不错的主意。Play 提供了帮助器和存根,简化了测试的编写。为了测试持久性代码,我将使用 Play 的 FakeApplication
类。这个类将使用与实际应用程序相同的配置来运行,除了 mongodb.uri
属性,该属性指向一个测试数据库。 清单 7 显示了该代码,它位于 test/daos/DaoSpecResources.scala 中。
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 所示。
"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
的包装器)。
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 所示。
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 所示。
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
是 MongoUserTokenDao
实现的一个特征。UserTokenDao
代码类似于用户 DAO,您可在 daos/UserTokenDao.scala 中找到它。
Silhouette 框架的首要特征是灵活性。Silhouette 实现了一组独立的身份验证组件,开发人员需要配置和组合它们来构建身份验证逻辑。主要组件包括:
IdentityService
特征的一个实现来处理所有与检索用户相关的操作。通过这种方式,用户管理完全与框架分离开来。UserService
类(已在 “用户服务” 一节中介绍)实现一个由 MongoDB 支持的身份服务。AuthInfoRepository
:Silhouette 需要知道如何持久化用户凭据。该框架将此工作委派给 AuthInfoRepository
特征的一个实现。该应用程序使用一个综合存储库,其中组合了 PasswordInfoDao
和 OAuth1InfoDao
类,这两个类都已在 “模型持久性” 一节中介绍。User
类和一个 CookieAuthenticator
)来进行类型参数化。环境是通过传递身份服务实现 (UserService
) 和身份验证器服务实现来构建的。我使用了 CookieAuthenticatorService
类,这是 CookieAuthenticator
类型所需要的。CredentialsProvider
来执行本地身份验证,还使用了 OAuth1 TwitterProvider
。SocialProviderRegistry
:这是该应用程序支持的所有社交服务提供程序的占位符。在本例中,它包含 TwitterProvider
实例。从 2.4.0 版开始,Play Framework 框架采用了依赖注入作为一种内置机制。旧版本采用了完全不同的机制来将参数传递给控制器。一些库仍无法用于 Play 2.4.x。由于 Play 相对未成熟,主要版本之间的变化可能非常大。
Silhouette 组件通过依赖注入的方式来配置和组合。Play 使用 Google Guice 作为默认的依赖注入实现。(如果您愿意,可以插入其他实现。)Guice module.Module
类定义了 Silhouette 需要的绑定,首先是基本声明,如 清单 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
中。
@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
CookieAuthenticatorService
的 AuthenticatorService
EventBus
,可用于广播身份验证事件(本应用程序中未使用)CredentialsProvider
通过注入一个由 DelegableAuthInfoRepository
实现支持的 AuthInfoRepository
来创建,而该实现将凭据持久化任务委派给 PasswordInfoDao
和 Oauth1InfoDao
类。TwitterProvider
类需要 OAuth1TokenSecretProvider
特征的实现,该实现定义如何在 OAuth1 运行期间持久化令牌机密。最后,应用程序定义了一个 SocialProviderRegistry
,其中列出了 TwitterProvider
作为唯一可用的社交服务提供程序。
cookie 身份验证器服务、Twitter 提供程序和 OAuth1 令牌机密提供程序的绑定访问在 conf/silhouette.conf 文件中定义的配置属性(如 清单 14 所示),该文件包含在主要 conf/application.conf 文件中(参见 清单 1)。
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_KEY
和 TWITTER_CONSUMER_SECRET
环境变量来避免在源代码中公开 OAuth1 机密。
Silhouette 定义了两个对实现受保护的请求处理函数很有用的临时操作。这些操作可用于 Silhouette
控制器特征中包含的控制器组合:
UserAwareAction
:此操作可以由经过身份验证的用户执行。该操作收到的请求将具有一个 Option[U]
类型(对于一些依赖于应用程序的用户,类型为 U
)的 identity
属性,如果请求是经过身份验证的用户发出的,将会定义该属性。SecuredAction
:此操作必须由经过身份验证的用户执行。否则,它将调用应用程序的错误处理函数的 onNotAuthorized
方法(在我的应用程序中,它重定向到登录页面,如 “应用程序配置” 一节中所述)。此操作设置了一个类型为 U
的请求 identity
属性(对于一些依赖于应用程序的用户,类型为 U
),如 清单 15 所示。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
代码。
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
是一个用户感知的匿名请求处理函数。
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 所示。
注册页面包含一个要求输入用户电子邮件、姓名和密码(出于验证用途,需要输入两次)的表单。提交时,该表单会由 handleStartSignUp
方法处理,如 清单 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 所示,注册操作到此就完成了。
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 个步骤:
env.authenticatorService.create
来创建一个身份验证器(已在 “Silhouette 简介” 一节中介绍,这是一个记录经过验证的用户数据的令牌)env.authenticatorService.init
)env.authenticatorService.embed
)这个序列就是完整的登录流程。这 3 个步骤也包含在身份验证代码中。
异步 authenticate
方法(如 清单 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 所示。
authenticate
方法的逻辑比看起来更简单。跟平常一样,该方法尝试将请求负载绑定到一个 signInForm
(一个包含电子邮件、密码和一个 remember-me 标志的元组)。如果该表单无效,在身份验证的最后会重定向到一个显示了验证错误的登录页面。否则,该方法会尝试调用 credentialsProvider.authenticate
来执行身份验证。如果验证失败,它会返回一个包含异常的 Future
,代码会通过返回到包含适当错误消息的登录页面而从异常中恢复。否则,credentialsProvider.authenticate
返回一个包含 LoginInfo
实例的 Future
。从这里,代码检查与 LoginInfo
关联的用户是否存在,如果存在,则检查该用户是否完成了注册。如果这些检查通过,代码执行 清单 19 中列出的 3 个步骤 — 即创建一个身份验证器,初始化它,然后将它嵌入到响应中(重定向到索引页面)。一个中间步骤是,如果选择了 Remember me 复选框,代码会创建具有更长生存期的副本来修改该身份验证器。(为简单起见,我在清单中省略了这些细节。)
Twitter OAuth1 身份验证会在以下时刻执行:用户单击登录页面左下角的 Twitter 图标时 (图 2) 或经过身份验证的用户从用户概况页面的 Available authentication providers 部分选择 Twitter(如 图 3 所示)时。
在两种情况下,该应用程序都将发送一个 /auth/social/:providerId
路由请求,并将 providerId
设置为 twitter
字符串。 清单 21 显示了与该路由关联的 socialAuthenticate
方法。
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
方法并开始执行身份验证:
authenticate
调用时,它返回一个 Left(result)
值。这个 result
重定向到执行身份验证流程的外部网站。外部网站完成身份验证时,它通过 silhouette.conf 中配置的回调 URL 而返回到应用程序。 清单 14 显示该回调指向与 socialAuthenticate
方法关联的路由,所以将重新执行该方法。socialAuthenticate
再次调用提供程序的 authenticate
方法,这一次返回一个经过如下处理的 Right(authInfo)
值:如果在 OAuth1 运行过程的任何时候,对 authenticate
的调用失败,该方法会重定向到索引或用户概况页面来恢复执行。
如果用户尝试对受保护的资源执行未验证的访问,应用程序会将请求重定向到索引页面(参见 “应用程序配置” 小节)。但要保护 REST API 端点,就无法接受此错误策略。REST API 错误响应必须拥有一个正确的 HTTP 状态,以及一个解释错误状况的负载,而不是将用户重定向到应用程序的页面。REST API 请求处理函数必须覆盖默认的错误策略。Silhouette 简化了这一任务:Silhouette
特征中混合的控制器会继承默认没有效果的错误处理函数。但可覆盖它们来基于控制器自定义错误处理。 清单 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 云。
描述 | 名字 | 大小 |
---|---|---|
应用程序代码 | dwPlayDemo.zip | 1070KB |