From b92052ad02fe781e3ee4dddf4e365b9e571ff519 Mon Sep 17 00:00:00 2001 From: gakuzzzz Date: Thu, 26 Mar 2015 21:34:18 +0900 Subject: [PATCH] Add project --- project/Build.scala | 60 ++++-- .../app/controllers/Application.scala | 172 ++++++++++++++++++ social-sample/app/models/User.scala | 168 +++++++++++++++++ social-sample/app/views/index.scala.html | 117 ++++++++++++ social-sample/conf/application.conf | 30 +++ .../migration/default/V1__create_tables.sql | 35 ++++ social-sample/conf/play.plugins | 2 + social-sample/conf/routes | 17 ++ .../AccessTokenRetrievalFailedException.scala | 8 + .../social/core/OAuth10aAuthenticator.scala | 27 +++ .../auth/social/core/OAuth10aController.scala | 89 +++++++++ .../social/core/OAuth2Authenticator.scala | 27 +++ .../auth/social/core/OAuth2Controller.scala | 82 +++++++++ .../auth/social/core/OAuthAuthenticator.scala | 7 + .../auth/social/core/OAuthController.scala | 20 ++ .../core/OAuthProviderUserSupport.scala | 12 ++ .../facebook/FacebookAuthenticator.scala | 73 ++++++++ .../facebook/FacebookController.scala | 13 ++ .../FacebookProviderUserSupport.scala | 36 ++++ .../providers/facebook/FacebookUser.scala | 8 + .../github/GitHubAuthenticator.scala | 64 +++++++ .../providers/github/GitHubController.scala | 13 ++ .../github/GitHubProviderUserSupport.scala | 32 ++++ .../social/providers/github/GitHubUser.scala | 7 + .../providers/slack/SlackAuthenticator.scala | 64 +++++++ .../providers/slack/SlackController.scala | 13 ++ .../twitter/TwitterAuthenticator.scala | 29 +++ .../providers/twitter/TwitterController.scala | 21 +++ .../twitter/TwitterOAuth10aAccessToken.scala | 5 + .../twitter/TwitterProviderUserSupport.scala | 39 ++++ .../providers/twitter/TwitterUser.scala | 10 + 31 files changed, 1286 insertions(+), 14 deletions(-) create mode 100644 social-sample/app/controllers/Application.scala create mode 100644 social-sample/app/models/User.scala create mode 100644 social-sample/app/views/index.scala.html create mode 100644 social-sample/conf/application.conf create mode 100644 social-sample/conf/db/migration/default/V1__create_tables.sql create mode 100644 social-sample/conf/play.plugins create mode 100644 social-sample/conf/routes create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/core/AccessTokenRetrievalFailedException.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth10aAuthenticator.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth10aController.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth2Authenticator.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth2Controller.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthAuthenticator.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthController.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthProviderUserSupport.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookAuthenticator.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookController.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookProviderUserSupport.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookUser.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubAuthenticator.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubController.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubProviderUserSupport.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubUser.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/slack/SlackAuthenticator.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/slack/SlackController.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterAuthenticator.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterController.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterOAuth10aAccessToken.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterProviderUserSupport.scala create mode 100644 social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterUser.scala diff --git a/project/Build.scala b/project/Build.scala index 445f1ea..5b0986d 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -13,15 +13,11 @@ object ApplicationBuild extends Build { scalaVersion := "2.11.6", crossScalaVersions := Seq("2.10.5", "2.11.6"), organization := "jp.t2v", - resolvers += Resolver.typesafeRepo("releases"), - resolvers += Resolver.sonatypeRepo("releases"), - resolvers ++= { - if (isSnapshot.value) { - Seq(Resolver.sonatypeRepo("snapshots")) - } else { - Nil - } - } + resolvers ++= + Resolver.typesafeRepo("releases") :: + Resolver.sonatypeRepo("releases") :: + Nil, + scalacOptions ++= Seq("-language:_", "-deprecation") ) lazy val appPublishMavenStyle = true @@ -58,8 +54,8 @@ object ApplicationBuild extends Build { lazy val core = Project("core", base = file("module")) - .settings(baseSettings) .settings( + baseSettings, libraryDependencies += "com.typesafe.play" %% "play" % playVersion % "provided", libraryDependencies += "com.typesafe.play" %% "play-cache" % playVersion % "provided", libraryDependencies += "jp.t2v" %% "stackable-controller" % "0.5.0", @@ -72,9 +68,9 @@ object ApplicationBuild extends Build { ) lazy val test = Project("test", base = file("test")) - .settings(baseSettings) .settings( - libraryDependencies += "com.typesafe.play" %% "play-test" % playVersion % "provided", + baseSettings, + libraryDependencies += "com.typesafe.play" %% "play-test" % playVersion, name := appName + "-test", publishMavenStyle := appPublishMavenStyle, publishArtifact in Test := appPublishArtifactInTest, @@ -85,8 +81,8 @@ object ApplicationBuild extends Build { lazy val sample = Project("sample", file("sample")) .enablePlugins(play.sbt.PlayScala) - .settings(baseSettings) .settings( + baseSettings, resolvers += "scalaz-bintray" at "https://dl.bintray.com/scalaz/releases", libraryDependencies += play.sbt.Play.autoImport.cache, libraryDependencies += play.sbt.Play.autoImport.specs2 % Test, @@ -116,6 +112,42 @@ object ApplicationBuild extends Build { ) .dependsOn(core, test % "test") + lazy val social = Project (id = "social", base = file ("social")) + .settings( + baseSettings, + name := appName + "-social", + libraryDependencies += "com.typesafe.play" %% "play" % playVersion % "provided", + libraryDependencies += "com.typesafe.play" %% "play-ws" % playVersion % "provided", + libraryDependencies += "com.typesafe.play" %% "play-cache" % playVersion % "provided" + ).dependsOn(core) + + lazy val socialSample = Project("social-sample", file("social-sample")) + .enablePlugins(play.sbt.PlayScala) + .settings( + baseSettings, + name := appName + "-social-sample", + resourceDirectories in Test += baseDirectory.value / "conf", + resolvers += "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots", + libraryDependencies ++= Seq( + "com.typesafe.play" %% "play-ws" % playVersion, + "com.typesafe.play" %% "play-cache" % playVersion, + "org.flywaydb" %% "flyway-play" % "2.0.0", + "org.scalikejdbc" %% "scalikejdbc" % "2.2.7", + "org.scalikejdbc" %% "scalikejdbc-config" % "2.2.7", + "org.scalikejdbc" %% "scalikejdbc-syntax-support-macro" % "2.2.7", + "org.scalikejdbc" %% "scalikejdbc-test" % "2.2.7" % "test", + "org.scalikejdbc" %% "scalikejdbc-play-initializer" % "2.4.0", + "org.scalikejdbc" %% "scalikejdbc-play-dbapi-adapter" % "2.4.0", + "org.scalikejdbc" %% "scalikejdbc-play-fixture" % "2.4.0" + ), + publish := { }, + publishArtifact := false, + packagedArtifacts := Map.empty, + publishTo <<=(version)(appPublishTo), + pomExtra := appPomExtra + ) + .dependsOn(core, social) + lazy val root = Project("root", base = file(".")) .settings(baseSettings) .settings( @@ -124,6 +156,6 @@ object ApplicationBuild extends Build { packagedArtifacts := Map.empty, publishTo <<=(version)(appPublishTo), pomExtra := appPomExtra - ).aggregate(core, test, sample) + ).aggregate(core, test, sample, social, socialSample) } diff --git a/social-sample/app/controllers/Application.scala b/social-sample/app/controllers/Application.scala new file mode 100644 index 0000000..9a2e7a7 --- /dev/null +++ b/social-sample/app/controllers/Application.scala @@ -0,0 +1,172 @@ +package controllers + +import models._ +import play.api.mvc.Results._ +import play.api.mvc._ +import scalikejdbc.DB + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.{ ExecutionContext, Future } +import scala.reflect.{ ClassTag, classTag } +import jp.t2v.lab.play2.auth._ +import jp.t2v.lab.play2.auth.social.providers.twitter.{TwitterProviderUserSupport, TwitterController} +import jp.t2v.lab.play2.auth.social.providers.facebook.{FacebookProviderUserSupport, FacebookController} +import jp.t2v.lab.play2.auth.social.providers.github.{GitHubProviderUserSupport, GitHubController} +import jp.t2v.lab.play2.auth.social.providers.slack.SlackController + +object Application extends Controller with OptionalAuthElement with AuthConfigImpl with Logout { + + def index = StackAction { implicit request => + DB.readOnly { implicit session => + val user = loggedIn + val gitHubUser = user.flatMap(u => GitHubUser.findByUserId(u.id)) + val facebookUser = user.flatMap(u => FacebookUser.findByUserId(u.id)) + val twitterUser = user.flatMap(u => TwitterUser.findByUserId(u.id)) + val slackAccessToken = user.flatMap(u => SlackAccessToken.findByUserId(u.id)) + Ok(views.html.index(user, gitHubUser, facebookUser, twitterUser, slackAccessToken)) + } + } + + def logout = Action.async { implicit request => + gotoLogoutSucceeded + } + +} + +trait AuthConfigImpl extends AuthConfig { + type Id = Long + type User = models.User + type Authority = models.Authority + val idTag: ClassTag[Id] = classTag[Id] + val sessionTimeoutInSeconds: Int = 3600 + + def resolveUser(id: Id)(implicit ctx: ExecutionContext): Future[Option[User]] = + Future.successful(DB.readOnly { implicit session => + User.find(id) + }) + + def loginSucceeded(request: RequestHeader)(implicit ctx: ExecutionContext): Future[Result] = + Future.successful(Redirect(routes.Application.index())) + + def logoutSucceeded(request: RequestHeader)(implicit ctx: ExecutionContext): Future[Result] = + Future.successful(Redirect(routes.Application.index)) + + def authenticationFailed(request: RequestHeader)(implicit ctx: ExecutionContext): Future[Result] = + Future.successful(Redirect(routes.Application.index)) + + override def authorizationFailed(request: RequestHeader, user: User, authority: Option[Authority])(implicit ctx: ExecutionContext) = + Future.successful(Forbidden("no permission")) + + def authorize(user: User, authority: Authority)(implicit ctx: ExecutionContext): Future[Boolean] = Future.successful { + true + } + +} + +object FacebookAuthController extends FacebookController + with AuthConfigImpl + with FacebookProviderUserSupport { + + override def onOAuthLinkSucceeded(token: AccessToken, consumerUser: User)(implicit request: RequestHeader, ctx: ExecutionContext): Future[Result] = { + retrieveProviderUser(token).map { providerUser => + DB.localTx { implicit session => + FacebookUser.save(consumerUser.id, providerUser) + Redirect(routes.Application.index) + } + } + } + + override def onOAuthLoginSucceeded(token: AccessToken)(implicit request: RequestHeader, ctx: ExecutionContext): Future[Result] = { + retrieveProviderUser(token).flatMap { providerUser => + DB.localTx { implicit session => + FacebookUser.findById(providerUser.id) match { + case None => + val id = User.create(providerUser.name, providerUser.coverUrl).id + FacebookUser.save(id, providerUser) + gotoLoginSucceeded(id) + case Some(fu) => + gotoLoginSucceeded(fu.userId) + } + } + } + } + +} + +object GitHubAuthController extends GitHubController + with AuthConfigImpl + with GitHubProviderUserSupport { + + override def onOAuthLinkSucceeded(token: AccessToken, consumerUser: User)(implicit request: RequestHeader, ctx: ExecutionContext): Future[Result] = { + retrieveProviderUser(token).map { providerUser => + DB.localTx { implicit session => + GitHubUser.save(consumerUser.id, providerUser) + Redirect(routes.Application.index) + } + } + } + + override def onOAuthLoginSucceeded(token: AccessToken)(implicit request: RequestHeader, ctx: ExecutionContext): Future[Result] = { + retrieveProviderUser(token).flatMap { providerUser => + DB.localTx { implicit session => + GitHubUser.findById(providerUser.id) match { + case None => + val id = User.create(providerUser.login, providerUser.avatarUrl).id + GitHubUser.save(id, providerUser) + gotoLoginSucceeded(id) + case Some(gh) => + gotoLoginSucceeded(gh.userId) + } + } + } + } + +} + +object TwitterAuthController extends TwitterController + with AuthConfigImpl + with TwitterProviderUserSupport { + + override def onOAuthLinkSucceeded(token: AccessToken, consumerUser: User)(implicit request: RequestHeader, ctx: ExecutionContext): Future[Result] = { + retrieveProviderUser(token).map { providerUser => + DB.localTx { implicit session => + TwitterUser.save(consumerUser.id, providerUser) + Redirect(routes.Application.index) + } + } + } + + override def onOAuthLoginSucceeded(token: AccessToken)(implicit request: RequestHeader, ctx: ExecutionContext): Future[Result] = { + retrieveProviderUser(token).flatMap { providerUser => + DB.localTx { implicit session => + TwitterUser.findById(providerUser.id) match { + case None => + val id = User.create(providerUser.screenName, providerUser.profileImageUrl).id + TwitterUser.save(id, providerUser) + gotoLoginSucceeded(id) + case Some(tu) => + gotoLoginSucceeded(tu.userId) + } + } + } + } + +} + +object SlackAuthController extends SlackController + with AuthConfigImpl { + + override def onOAuthLinkSucceeded(accessToken: AccessToken, consumerUser: User)(implicit request: RequestHeader, ctx: ExecutionContext): Future[Result] = { + Future.successful { + DB.localTx { implicit session => + SlackAccessToken.save(consumerUser.id, accessToken) + Redirect(routes.Application.index) + } + } + } + + override def onOAuthLoginSucceeded(accessToken: AccessToken)(implicit request: RequestHeader, ctx: ExecutionContext): Future[Result] = { + ??? + } + +} diff --git a/social-sample/app/models/User.scala b/social-sample/app/models/User.scala new file mode 100644 index 0000000..08c88f5 --- /dev/null +++ b/social-sample/app/models/User.scala @@ -0,0 +1,168 @@ +package models + +import jp.t2v.lab.play2.auth.social.providers +import scalikejdbc._ +import jp.t2v.lab.play2.auth.social.providers.{facebook, twitter} + +sealed trait Authority +case object Admin extends Authority +case object Normal extends Authority + +case class User(id: Long, name: String, avatarUrl: String) + +case class GitHubUser( + userId: Long, + id: Long, + login: String, + avatarUrl: String, + accessToken: String) + +case class FacebookUser( + userId: Long, + id: String, + name: String, + coverUrl: String, + accessToken: String) + +case class TwitterUser( + userId: Long, + id: Long, + screenName: String, + profileImageUrl: String, + accessToken: String, + accessTokenSecret: String) + +case class SlackAccessToken( + userId: Long, + accessToken: String) +object User { + + def *(rs: WrappedResultSet) = User( + rs.long("id"), + rs.string("name"), + rs.string("avatar_url") + ) + + def create(name: String, avatarUrl: String)(implicit session: DBSession): User = { + DB.localTx { implicit session => + val id = sql"INSERT INTO users(name, avatar_url) VALUES ($name, $avatarUrl)".updateAndReturnGeneratedKey.apply() + User(id, name, avatarUrl) + } + } + + def find(id: Long)(implicit session: DBSession): Option[User] = { + DB.readOnly { implicit session => + sql"SELECT * FROM users WHERE id = $id".map(*).single().apply() + } + } + +} + +object GitHubUser { + + def *(rs: WrappedResultSet) = GitHubUser( + rs.long("user_id"), + rs.long("id"), + rs.string("login"), + rs.string("avatar_url"), + rs.string("access_token") + ) + + def findById(id: Long)(implicit session: DBSession): Option[GitHubUser] = { + sql"SELECT * FROM github_users WHERE id = $id".map(*).single().apply() + } + + def findByUserId(userId: Long)(implicit session: DBSession): Option[GitHubUser] = { + sql"SELECT * FROM github_users WHERE user_id = $userId".map(*).single().apply() + } + + def save(userId: Long, gitHubUser: providers.github.GitHubUser)(implicit session: DBSession): GitHubUser = { + val id = gitHubUser.id + val login = gitHubUser.login + val avatarUrl = gitHubUser.avatarUrl + val accessToken = gitHubUser.accessToken + sql"INSERT INTO github_users(user_id, id, login, avatar_url, access_token) VALUES ($userId, $id, $login, $avatarUrl, $accessToken)".update.apply() + GitHubUser(userId, id, login, avatarUrl, accessToken) + } + +} + +object FacebookUser { + + def *(rs: WrappedResultSet) = FacebookUser( + rs.long("user_id"), + rs.string("id"), + rs.string("name"), + rs.string("cover_url"), + rs.string("access_token") + ) + + def findById(id: String)(implicit session: DBSession): Option[FacebookUser] = { + sql"SELECT * FROM facebook_users WHERE id = $id".map(*).single().apply() + } + + def findByUserId(userId: Long)(implicit session: DBSession): Option[FacebookUser] = { + sql"SELECT * FROM facebook_users WHERE user_id = $userId".map(*).single().apply() + } + + def save(userId: Long, facebookUser: facebook.FacebookUser)(implicit session: DBSession): FacebookUser = { + val id = facebookUser.id + val name = facebookUser.name + val coverUrl = facebookUser.coverUrl + val accessToken = facebookUser.accessToken + sql"INSERT INTO facebook_users(user_id, id, name, cover_url, access_token) VALUES ($userId, $id, $name, $coverUrl, $accessToken)".update.apply() + FacebookUser(userId, id, name, coverUrl, accessToken) + } + +} + +object TwitterUser { + + def *(rs: WrappedResultSet) = TwitterUser( + rs.long("user_id"), + rs.long("id"), + rs.string("screen_name"), + rs.string("profile_image_url"), + rs.string("access_token"), + rs.string("access_token_secret") + ) + + def findById(id: Long)(implicit session: DBSession): Option[TwitterUser] = { + sql"SELECT * FROM twitter_users WHERE id = $id".map(*).single().apply() + } + + def findByUserId(userId: Long)(implicit session: DBSession): Option[TwitterUser] = { + sql"SELECT * FROM twitter_users WHERE user_id = $userId".map(*).single().apply() + } + + def save(userId: Long, twitterUser: twitter.TwitterUser)(implicit session: DBSession): TwitterUser = { + val id = twitterUser.id + val screenName = twitterUser.screenName + val profileImageUrl = twitterUser.profileImageUrl + val accessToken = twitterUser.accessToken + val accessTokenSecret = twitterUser.accessTokenSecret + sql"""INSERT INTO twitter_users(user_id, id, screen_name, profile_image_url, access_token, access_token_secret) + VALUES ($userId, $id, $screenName, $profileImageUrl, $accessToken, $accessTokenSecret)""".update.apply() + TwitterUser(userId, id, screenName, profileImageUrl, accessToken, accessTokenSecret) + } + +} + +object SlackAccessToken { + + def *(rs: WrappedResultSet) = SlackAccessToken( + rs.long("user_id"), + rs.string("access_token") + ) + + def findByUserId(userId: Long)(implicit session: DBSession): Option[SlackAccessToken] = { + sql"SELECT * FROM slack_access_token WHERE user_id = $userId".map(*).single().apply() + } + + def save(userId: Long, accessToken: String)(implicit session: DBSession): SlackAccessToken = { + sql"""INSERT INTO slack_access_token(user_id, access_token) + VALUES ($userId, $accessToken)""".update.apply() + SlackAccessToken(userId, accessToken) + } + +} \ No newline at end of file diff --git a/social-sample/app/views/index.scala.html b/social-sample/app/views/index.scala.html new file mode 100644 index 0000000..1a5e2a1 --- /dev/null +++ b/social-sample/app/views/index.scala.html @@ -0,0 +1,117 @@ +@(login: Option[User], + gitHubUser: Option[GitHubUser], + facebookUser: Option[FacebookUser], + twitterUser: Option[TwitterUser], + slackAccessToken: Option[SlackAccessToken]) + + + + play-oauth-social + + + +
+ @login.map { u => +

+ logout +

+ +
+
+

GitHub

+ + @gitHubUser.map { u => +

+ @u.login +

+

+ +

+ }.getOrElse { + link GitHub + } +
+ +
+

Facebook

+ + @facebookUser.map { u => +

+ @u.name +

+

+ +

+ }.getOrElse { + link Facebook + } +
+ +
+

Twitter

+ @twitterUser.map { u => +

+ @u.screenName +

+

+ +

+ }.getOrElse { + link Twitter + } +
+ +
+

Slack

+ @slackAccessToken.map { u => +

+ @u.accessToken +

+ }.getOrElse { + link Slack + } +
+ +
+ + }.getOrElse { +

+ twitter login +

+

+ github login +

+

+ facebook login +

+ } +
+ + + diff --git a/social-sample/conf/application.conf b/social-sample/conf/application.conf new file mode 100644 index 0000000..3a16e02 --- /dev/null +++ b/social-sample/conf/application.conf @@ -0,0 +1,30 @@ +application.secret="79V2@SMp]7wuJo0weB7b4PUIk41OmsFwivxL01S?JyEluwPvB]bs/GLR5_O0;sor" + +twitter.consumerKey = ${TWITTER_CONSUMER_KEY} +twitter.consumerSecret = ${TWITTER_CONSUMER_SECRET} +twitter.callbackURL = ${TWITTER_CALLBACK_URL} + +github.clientId = ${GITHUB_CLIENT_ID} +github.clientSecret = ${GITHUB_CLIENT_SECRET} +github.callbackURL = ${GITHUB_CALLBACK_URL} + +facebook.clientId = ${FACEBOOK_CLIENT_ID} +facebook.clientSecret = ${FACEBOOK_CLIENT_SECRET} +facebook.callbackURL = ${FACEBOOK_CALLBACK_URL} + +slack.clientId = ${SLACK_CLIENT_ID} +slack.clientSecret = ${SLACK_CLIENT_SECRET} +slack.callbackURL = ${SLACK_CALLBACK_URL} + +logger.com.github.tototoshi.play.social = DEBUG + +db.default.driver=org.h2.Driver +db.default.url="jdbc:h2:mem:play;DB_CLOSE_DELAY=-1" +db.default.user=sa +db.default.password="" + +dbplugin=disabled +evolutionplugin=disabled + +# play.modules.enabled += "scalikejdbc.PlayModule" +# play.modules.enabled += "org.flywaydb.play.PlayModule" \ No newline at end of file diff --git a/social-sample/conf/db/migration/default/V1__create_tables.sql b/social-sample/conf/db/migration/default/V1__create_tables.sql new file mode 100644 index 0000000..49c0ee1 --- /dev/null +++ b/social-sample/conf/db/migration/default/V1__create_tables.sql @@ -0,0 +1,35 @@ +CREATE TABLE users ( + id serial PRIMARY KEY, + name VARCHAR(100) NOT NULL, + avatar_url VARCHAR(1000) NOT NULL +); + +CREATE TABLE github_users ( + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id), + id bigint PRIMARY KEY, + login VARCHAR(100) NOT NULL, + avatar_url VARCHAR(1000) NOT NULL, + access_token VARCHAR(1000) NOT NULL +); + +CREATE TABLE twitter_users ( + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id), + id bigint PRIMARY KEY, + screen_name VARCHAR(100) NOT NULL, + profile_image_url VARCHAR(1000) NOT NULL, + access_token VARCHAR(1000) NOT NULL, + access_token_secret VARCHAR(1000) NOT NULL +); + +CREATE TABLE facebook_users ( + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id), + id VARCHAR(100) PRIMARY KEY, + name VARCHAR(100) NOT NULL, + cover_url VARCHAR(1000) NOT NULL, + access_token VARCHAR(1000) NOT NULL +); + +CREATE TABLE slack_access_token ( + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id), + access_token VARCHAR(1000) NOT NULL +); \ No newline at end of file diff --git a/social-sample/conf/play.plugins b/social-sample/conf/play.plugins new file mode 100644 index 0000000..2a835e5 --- /dev/null +++ b/social-sample/conf/play.plugins @@ -0,0 +1,2 @@ +666:com.github.tototoshi.play2.flyway.Plugin +9999:scalikejdbc.PlayPlugin \ No newline at end of file diff --git a/social-sample/conf/routes b/social-sample/conf/routes new file mode 100644 index 0000000..2857fdc --- /dev/null +++ b/social-sample/conf/routes @@ -0,0 +1,17 @@ +GET / controllers.Application.index +GET /logout controllers.Application.logout + +GET /login/twitter controllers.TwitterAuthController.login +GET /link/twitter controllers.TwitterAuthController.link +GET /authorize/twitter controllers.TwitterAuthController.authorize + +GET /login/github controllers.GitHubAuthController.login(scope: String) +GET /link/github controllers.GitHubAuthController.link(scope: String) +GET /authorize/github controllers.GitHubAuthController.authorize + +GET /login/facebook controllers.FacebookAuthController.login(scope: String) +GET /link/facebook controllers.FacebookAuthController.link(scope: String) +GET /authorize/facebook controllers.FacebookAuthController.authorize + +GET /link/slack controllers.SlackAuthController.link(scope: String) +GET /authorize/slack controllers.SlackAuthController.authorize \ No newline at end of file diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/AccessTokenRetrievalFailedException.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/AccessTokenRetrievalFailedException.scala new file mode 100644 index 0000000..9f84b1a --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/AccessTokenRetrievalFailedException.scala @@ -0,0 +1,8 @@ +package jp.t2v.lab.play2.auth.social.core + +class AccessTokenRetrievalFailedException(message: String, exception: Throwable) + extends RuntimeException(message, exception) { + + def this(message: String) = this(message, null) + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth10aAuthenticator.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth10aAuthenticator.scala new file mode 100644 index 0000000..0163558 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth10aAuthenticator.scala @@ -0,0 +1,27 @@ +package jp.t2v.lab.play2.auth.social.core + +import play.api.libs.oauth._ + +trait OAuth10aAuthenticator extends OAuthAuthenticator { + + val callbackURL: String + + val requestTokenURL: String + + val accessTokenURL: String + + val authorizationURL: String + + val consumerKey: ConsumerKey + + lazy val serviceInfo: ServiceInfo = ServiceInfo( + requestTokenURL, + accessTokenURL, + authorizationURL, + consumerKey + ) + + lazy val oauth = OAuth(serviceInfo, use10a = true) + +} + diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth10aController.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth10aController.scala new file mode 100644 index 0000000..69385fe --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth10aController.scala @@ -0,0 +1,89 @@ +package jp.t2v.lab.play2.auth.social.core + +import jp.t2v.lab.play2.auth.{ AuthConfig, OptionalAuthElement } +import play.api._ +import play.api.data.Form +import play.api.data.Forms._ +import play.api.libs.oauth._ +import play.api.mvc._ + +import scala.concurrent.Future + +trait OAuth10aController extends Controller with OAuthController { + self: OptionalAuthElement with AuthConfig => + + protected val authenticator: OAuth10aAuthenticator + + private val RequestTokenSecretKey = "play.social.requestTokenSecret" + + def login = AsyncStack(ExecutionContextKey -> OAuthExecutionContext) { implicit request => + implicit val ec = StackActionExecutionContext + loggedIn match { + case Some(_) => loginSucceeded(request) + case None => authenticator.oauth.retrieveRequestToken(authenticator.callbackURL) match { + case Right(token) => + Future.successful( + Redirect(authenticator.oauth.redirectUrl(token.token)).withSession( + request.session + (RequestTokenSecretKey -> token.secret) + ) + ) + case Left(e) => + Logger(getClass).error(e.getMessage) + Future.successful(InternalServerError(e.getMessage)) + } + } + } + + def link = StackAction(ExecutionContextKey -> OAuthExecutionContext) { implicit request => + loggedIn match { + case Some(_) => + authenticator.oauth.retrieveRequestToken(authenticator.callbackURL) match { + case Right(token) => + Redirect(authenticator.oauth.redirectUrl(token.token)).withSession( + request.session + (RequestTokenSecretKey -> token.secret) + ) + case Left(e) => + Logger(getClass).error(e.getMessage) + InternalServerError(e.getMessage) + } + case None => + Unauthorized + } + } + + def authorize = AsyncStack(ExecutionContextKey -> OAuthExecutionContext) { implicit request => + implicit val ec = StackActionExecutionContext + val form = Form( + tuple( + "oauth_token" -> optional(nonEmptyText), + "oauth_verifier" -> optional(nonEmptyText), + "denied" -> optional(nonEmptyText) + ) + ) + + form.bindFromRequest.fold({ + formWithError => Future.successful(BadRequest) + }, { + case (Some(oauthToken), Some(oauthVerifier), None) => + val action: AccessToken => Future[Result] = loggedIn match { + case Some(consumerUser) => onOAuthLinkSucceeded(_, consumerUser) + case None => onOAuthLoginSucceeded + } + (for { + tokenSecret <- request.session.get(RequestTokenSecretKey) + requestToken = RequestToken(oauthToken, tokenSecret) + token <- authenticator.oauth.retrieveAccessToken(requestToken, oauthVerifier).right.toOption + } yield { + action(requestTokenToAccessToken(token)) + }).getOrElse(Future.successful(BadRequest)) + + case (None, None, Some(denied)) => Future.successful(Unauthorized) + case _ => Future.successful(BadRequest) + }).map(_.removingFromSession(RequestTokenSecretKey)) + + } + + def requestTokenToAccessToken(requestToken: RequestToken): AccessToken + +} + diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth2Authenticator.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth2Authenticator.scala new file mode 100644 index 0000000..bd76de6 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth2Authenticator.scala @@ -0,0 +1,27 @@ +package jp.t2v.lab.play2.auth.social.core + +import play.api.libs.ws.WSResponse + +import scala.concurrent.{ ExecutionContext, Future } + +trait OAuth2Authenticator extends OAuthAuthenticator { + + val providerName: String + + val callbackUrl: String + + val accessTokenUrl: String + + val authorizationUrl: String + + val clientId: String + + val clientSecret: String + + def retrieveAccessToken(code: String)(implicit ctx: ExecutionContext): Future[AccessToken] + + def getAuthorizationUrl(scope: String, state: String): String + + def parseAccessTokenResponse(response: WSResponse): String + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth2Controller.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth2Controller.scala new file mode 100644 index 0000000..90cfd27 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuth2Controller.scala @@ -0,0 +1,82 @@ +package jp.t2v.lab.play2.auth.social.core + +import java.util.UUID + +import jp.t2v.lab.play2.auth.{ AuthConfig, OptionalAuthElement } +import play.api._ +import play.api.data.Form +import play.api.data.Forms._ +import play.api.mvc._ + +import scala.concurrent.Future +import scala.util.control.NonFatal + +trait OAuth2Controller extends Controller with OAuthController { self: OptionalAuthElement with AuthConfig => + + protected val authenticator: OAuth2Authenticator + + private val OAuth2StateKey = "play.auth.social.oauth2.state" + + // TODO scope is optional in some services + // TODO some services have more optional parameter + def login(scope: String) = AsyncStack(ExecutionContextKey -> OAuthExecutionContext) { implicit request => + implicit val ec = StackActionExecutionContext + loggedIn match { + case Some(u) => + loginSucceeded(request) + case None => + // should be more random ? + val state = UUID.randomUUID().toString + Future.successful( + Redirect(authenticator.getAuthorizationUrl(scope, state)).withSession( + request.session + (OAuth2StateKey -> state) + ) + ) + } + } + + // TODO scope is optional in some services + // TODO some services have more optional parameter + def link(scope: String) = StackAction(ExecutionContextKey -> OAuthExecutionContext) { implicit request => + loggedIn match { + case Some(u) => + // TODO should it be more random ? + val state = UUID.randomUUID().toString + Redirect(authenticator.getAuthorizationUrl(scope, state)).withSession( + request.session + (OAuth2StateKey -> state) + ) + case None => + Unauthorized + } + } + + def authorize = AsyncStack(ExecutionContextKey -> OAuthExecutionContext) { implicit request => + implicit val ec = StackActionExecutionContext + val form = Form( + tuple( + "code" -> nonEmptyText, + "state" -> nonEmptyText.verifying(request.session.get(OAuth2StateKey).contains _) + ) + ) + + form.bindFromRequest.fold({ + formWithError => Future.successful(BadRequest) + }, { + case (code, _) => + val action: AccessToken => Future[Result] = loggedIn match { + case Some(consumerUser) => onOAuthLinkSucceeded(_, consumerUser) + case None => onOAuthLoginSucceeded + } + + (for { + token <- authenticator.retrieveAccessToken(code) + result <- action(token) + } yield result).recover { + case NonFatal(e) => + Logger(getClass).error(e.getMessage, e) + InternalServerError + } + }).map(_.removingFromSession(OAuth2StateKey)) + } + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthAuthenticator.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthAuthenticator.scala new file mode 100644 index 0000000..c3c5194 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthAuthenticator.scala @@ -0,0 +1,7 @@ +package jp.t2v.lab.play2.auth.social.core + +trait OAuthAuthenticator { + + type AccessToken + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthController.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthController.scala new file mode 100644 index 0000000..a972e93 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthController.scala @@ -0,0 +1,20 @@ +package jp.t2v.lab.play2.auth.social.core + +import jp.t2v.lab.play2.auth.{ AuthConfig, OptionalAuthElement } +import play.api.mvc.{ Result, RequestHeader } + +import scala.concurrent.{ ExecutionContext, Future } + +trait OAuthController { self: OptionalAuthElement with AuthConfig => + + protected val authenticator: OAuthAuthenticator + + type AccessToken = authenticator.AccessToken + + def onOAuthLoginSucceeded(token: AccessToken)(implicit request: RequestHeader, ctx: ExecutionContext): Future[Result] + + def onOAuthLinkSucceeded(token: AccessToken, consumerUser: User)(implicit request: RequestHeader, ctx: ExecutionContext): Future[Result] + + protected lazy val OAuthExecutionContext: ExecutionContext = play.api.libs.concurrent.Execution.defaultContext + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthProviderUserSupport.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthProviderUserSupport.scala new file mode 100644 index 0000000..a551e8e --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/core/OAuthProviderUserSupport.scala @@ -0,0 +1,12 @@ +package jp.t2v.lab.play2.auth.social.core + +import scala.concurrent.{ ExecutionContext, Future } + +trait OAuthProviderUserSupport { + self: OAuthController => + + type ProviderUser + + def retrieveProviderUser(accessToken: AccessToken)(implicit ctx: ExecutionContext): Future[ProviderUser] + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookAuthenticator.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookAuthenticator.scala new file mode 100644 index 0000000..bccc829 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookAuthenticator.scala @@ -0,0 +1,73 @@ +package jp.t2v.lab.play2.auth.social.providers.facebook + +import java.net.URLEncoder + +import jp.t2v.lab.play2.auth.social.core.{ AccessTokenRetrievalFailedException, OAuth2Authenticator } +import play.api.Logger +import play.api.Play.current +import play.api.http.{ HeaderNames, MimeTypes } +import play.api.libs.ws.{ WS, WSResponse } +import play.api.mvc.Results + +import scala.concurrent.{ ExecutionContext, Future } +import scala.util.control.NonFatal + +class FacebookAuthenticator extends OAuth2Authenticator { + + type AccessToken = String + + val providerName: String = "facebook" + + val accessTokenUrl = "https://graph.facebook.com/oauth/access_token" + + val authorizationUrl = "https://graph.facebook.com/oauth/authorize" + + lazy val clientId = current.configuration.getString("facebook.clientId").getOrElse(sys.error("facebook.clientId is missing")) + + lazy val clientSecret = current.configuration.getString("facebook.clientSecret").getOrElse(sys.error("facebook.clientSecret is missing")) + + lazy val callbackUrl = current.configuration.getString("facebook.callbackURL").getOrElse(sys.error("facebook.callbackURL is missing")) + + def retrieveAccessToken(code: String)(implicit ctx: ExecutionContext): Future[AccessToken] = { + WS.url(accessTokenUrl) + .withQueryString( + "client_id" -> clientId, + "client_secret" -> clientSecret, + "redirect_uri" -> callbackUrl, + "code" -> code) + .withHeaders(HeaderNames.ACCEPT -> MimeTypes.JSON) + .post(Results.EmptyContent()) + .map { response => + Logger(getClass).debug("Retrieving access token from provider API: " + response.body) + parseAccessTokenResponse(response) + } + } + + def getAuthorizationUrl(scope: String, state: String): String = { + val encodedClientId = URLEncoder.encode(clientId, "utf-8") + val encodedRedirectUri = URLEncoder.encode(callbackUrl, "utf-8") + val encodedScope = URLEncoder.encode(scope, "utf-8") + val encodedState = URLEncoder.encode(state, "utf-8") + s"${authorizationUrl}?client_id=${encodedClientId}&redirect_uri=${encodedRedirectUri}&scope=${encodedScope}&state=${encodedState}" + } + + def parseAccessTokenResponse(response: WSResponse): String = { + Logger(getClass).debug("Parsing access token response: " + response.body) + try { + (for { + params <- response.body.split("&").toList + key :: value :: Nil = params.split("=").toList + if key == "access_token" + } yield { + value + }).headOption.getOrElse { + throw new AccessTokenRetrievalFailedException(s"Failed to parse access token: ${response.body}") + } + } catch { + case NonFatal(e) => + throw new AccessTokenRetrievalFailedException(s"Failed to retrieve access token. ${response.body}", e) + } + + } + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookController.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookController.scala new file mode 100644 index 0000000..2be18c9 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookController.scala @@ -0,0 +1,13 @@ +package jp.t2v.lab.play2.auth.social.providers.facebook + +import jp.t2v.lab.play2.auth.social.core.OAuth2Controller +import jp.t2v.lab.play2.auth.{ AuthConfig, Login, OptionalAuthElement } + +trait FacebookController extends OAuth2Controller + with AuthConfig + with OptionalAuthElement + with Login { + + val authenticator = new FacebookAuthenticator + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookProviderUserSupport.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookProviderUserSupport.scala new file mode 100644 index 0000000..a1e87bf --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookProviderUserSupport.scala @@ -0,0 +1,36 @@ +package jp.t2v.lab.play2.auth.social.providers.facebook + +import jp.t2v.lab.play2.auth.social.core.OAuthProviderUserSupport +import play.api.Logger +import play.api.libs.ws.{ WS, WSResponse } +import play.api.Play.current +import scala.concurrent.{ ExecutionContext, Future } + +trait FacebookProviderUserSupport extends OAuthProviderUserSupport { + self: FacebookController => + + type ProviderUser = FacebookUser + + private def readProviderUser(accessToken: String, response: WSResponse): ProviderUser = { + val j = response.json + FacebookUser( + (j \ "id").as[String], + (j \ "name").as[String], + (j \ "email").as[String], + (j \ "picture" \ "data" \ "url").as[String], + accessToken + ) + } + + def retrieveProviderUser(accessToken: AccessToken)(implicit ctx: ExecutionContext): Future[ProviderUser] = { + for { + response <- WS.url("https://graph.facebook.com/me") + .withQueryString("access_token" -> accessToken, "fields" -> "name,first_name,last_name,picture.type(large),email") + .get() + } yield { + Logger(getClass).debug("Retrieving user info from provider API: " + response.body) + readProviderUser(accessToken, response) + } + } + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookUser.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookUser.scala new file mode 100644 index 0000000..e52f79e --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/facebook/FacebookUser.scala @@ -0,0 +1,8 @@ +package jp.t2v.lab.play2.auth.social.providers.facebook + +case class FacebookUser( + id: String, + name: String, + email: String, + coverUrl: String, + accessToken: String) diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubAuthenticator.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubAuthenticator.scala new file mode 100644 index 0000000..368fabd --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubAuthenticator.scala @@ -0,0 +1,64 @@ +package jp.t2v.lab.play2.auth.social.providers.github + +import java.net.URLEncoder + +import jp.t2v.lab.play2.auth.social.core.{ AccessTokenRetrievalFailedException, OAuth2Authenticator } +import play.api.Logger +import play.api.Play.current +import play.api.http.{ HeaderNames, MimeTypes } +import play.api.libs.ws.{ WS, WSResponse } +import play.api.mvc.Results + +import scala.concurrent.{ ExecutionContext, Future } +import scala.util.control.NonFatal + +class GitHubAuthenticator extends OAuth2Authenticator { + + type AccessToken = String + + val providerName: String = "github" + + val accessTokenUrl = "https://github.com/login/oauth/access_token" + + val authorizationUrl = "https://github.com/login/oauth/authorize" + + lazy val clientId = current.configuration.getString("github.clientId").getOrElse(sys.error("github.clientId is missing")) + + lazy val clientSecret = current.configuration.getString("github.clientSecret").getOrElse(sys.error("github.clientSecret is missing")) + + lazy val callbackUrl = current.configuration.getString("github.callbackURL").getOrElse(sys.error("github.callbackURL is missing")) + + def retrieveAccessToken(code: String)(implicit ctx: ExecutionContext): Future[AccessToken] = { + WS.url(accessTokenUrl) + .withQueryString( + "client_id" -> clientId, + "client_secret" -> clientSecret, + "code" -> code) + .withHeaders(HeaderNames.ACCEPT -> MimeTypes.JSON) + .post(Results.EmptyContent()) + .map { response => + Logger(getClass).debug("Retrieving access token from provider API: " + response.body) + parseAccessTokenResponse(response) + } + } + + def getAuthorizationUrl(scope: String, state: String): String = { + val encodedClientId = URLEncoder.encode(clientId, "utf-8") + val encodedRedirectUri = URLEncoder.encode(callbackUrl, "utf-8") + val encodedScope = URLEncoder.encode(scope, "utf-8") + val encodedState = URLEncoder.encode(state, "utf-8") + s"${authorizationUrl}?client_id=${encodedClientId}&redirect_uri=${encodedRedirectUri}&scope=${encodedScope}&state=${encodedState}" + } + + def parseAccessTokenResponse(response: WSResponse): String = { + Logger(getClass).debug("Parsing access token response: " + response.body) + try { + (response.json \ "access_token").as[String] + } catch { + case NonFatal(e) => + throw new AccessTokenRetrievalFailedException(s"Failed to parse access token: ${response.body}", e) + } + } + +} + diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubController.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubController.scala new file mode 100644 index 0000000..4cc9409 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubController.scala @@ -0,0 +1,13 @@ +package jp.t2v.lab.play2.auth.social.providers.github + +import jp.t2v.lab.play2.auth.social.core.OAuth2Controller +import jp.t2v.lab.play2.auth.{ AuthConfig, Login, OptionalAuthElement } + +trait GitHubController extends OAuth2Controller + with AuthConfig + with OptionalAuthElement + with Login { + + val authenticator = new GitHubAuthenticator + +} \ No newline at end of file diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubProviderUserSupport.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubProviderUserSupport.scala new file mode 100644 index 0000000..957d8b2 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubProviderUserSupport.scala @@ -0,0 +1,32 @@ +package jp.t2v.lab.play2.auth.social.providers.github + +import jp.t2v.lab.play2.auth.social.core.OAuthProviderUserSupport +import play.api.Play.current +import play.api.libs.ws.{ WS, WSResponse } + +import scala.concurrent.{ ExecutionContext, Future } + +trait GitHubProviderUserSupport extends OAuthProviderUserSupport { + self: GitHubController => + + type ProviderUser = GitHubUser + + private def readProviderUser(accessToken: String, response: WSResponse): ProviderUser = { + val j = response.json + GitHubUser( + (j \ "id").as[Long], + (j \ "login").as[String], + (j \ "avatar_url").as[String], + accessToken + ) + } + + def retrieveProviderUser(accessToken: String)(implicit ctx: ExecutionContext): Future[ProviderUser] = { + for { + response <- WS.url("https://api.github.com/user").withHeaders("Authorization" -> s"token ${accessToken}").get() + } yield { + readProviderUser(accessToken, response) + } + } + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubUser.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubUser.scala new file mode 100644 index 0000000..4fa4d7f --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/github/GitHubUser.scala @@ -0,0 +1,7 @@ +package jp.t2v.lab.play2.auth.social.providers.github + +case class GitHubUser( + id: Long, + login: String, + avatarUrl: String, + accessToken: String) diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/slack/SlackAuthenticator.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/slack/SlackAuthenticator.scala new file mode 100644 index 0000000..578320b --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/slack/SlackAuthenticator.scala @@ -0,0 +1,64 @@ +package jp.t2v.lab.play2.auth.social.providers.slack + +import java.net.URLEncoder + +import jp.t2v.lab.play2.auth.social.core.{ AccessTokenRetrievalFailedException, OAuth2Authenticator } +import play.api.Logger +import play.api.http.{ HeaderNames, MimeTypes } +import play.api.libs.ws.{ WS, WSResponse } +import play.api.Play.current +import play.api.mvc.Results + +import scala.concurrent.{ ExecutionContext, Future } +import scala.util.control.NonFatal + +class SlackAuthenticator extends OAuth2Authenticator { + + type AccessToken = String + + override val providerName: String = "slack" + + override val authorizationUrl: String = "https://slack.com/oauth/authorize" + + override val accessTokenUrl: String = "https://slack.com/api/oauth.access" + + override val clientId: String = current.configuration.getString("slack.clientId").getOrElse("slack.clientId is missing") + + override val clientSecret: String = current.configuration.getString("slack.clientSecret").getOrElse("slack.clientSecret is missing") + + override val callbackUrl: String = current.configuration.getString("slack.callbackURL").getOrElse("slack.callbackURL is missing") + + def getAuthorizationUrl(scope: String, state: String): String = { + val encodedClientId = URLEncoder.encode(clientId, "utf-8") + val encodedRedirectUri = URLEncoder.encode(callbackUrl, "utf-8") + val encodedScope = URLEncoder.encode(scope, "utf-8") + val encodedState = URLEncoder.encode(state, "utf-8") + s"${authorizationUrl}?client_id=${encodedClientId}&redirect_uri=${encodedRedirectUri}&scope=${encodedScope}&state=${encodedState}" + } + + override def retrieveAccessToken(code: String)(implicit ctx: ExecutionContext): Future[AccessToken] = { + WS.url(accessTokenUrl) + .withQueryString( + "client_id" -> clientId, + "client_secret" -> clientSecret, + "redirect_uri" -> callbackUrl, + "code" -> code) + .withHeaders(HeaderNames.ACCEPT -> MimeTypes.JSON) + .post(Results.EmptyContent()) + .map { response => + Logger(getClass).debug("Retrieving access token from provider API: " + response.body) + parseAccessTokenResponse(response) + } + } + + override def parseAccessTokenResponse(response: WSResponse): AccessToken = { + val j = response.json + try { + (j \ "access_token").as[String] + } catch { + case NonFatal(e) => + throw new AccessTokenRetrievalFailedException("Failed to retrieve access token", e) + } + } + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/slack/SlackController.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/slack/SlackController.scala new file mode 100644 index 0000000..71ce585 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/slack/SlackController.scala @@ -0,0 +1,13 @@ +package jp.t2v.lab.play2.auth.social.providers.slack + +import jp.t2v.lab.play2.auth.social.core.OAuth2Controller +import jp.t2v.lab.play2.auth.{ AuthConfig, Login, OptionalAuthElement } + +trait SlackController extends OAuth2Controller + with AuthConfig + with OptionalAuthElement + with Login { + + val authenticator = new SlackAuthenticator + +} \ No newline at end of file diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterAuthenticator.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterAuthenticator.scala new file mode 100644 index 0000000..2b65a71 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterAuthenticator.scala @@ -0,0 +1,29 @@ +package jp.t2v.lab.play2.auth.social.providers.twitter + +import jp.t2v.lab.play2.auth.social.core.OAuth10aAuthenticator +import play.api.Play.current +import play.api.libs.oauth.ConsumerKey + +class TwitterAuthenticator extends OAuth10aAuthenticator { + + type AccessToken = TwitterOAuth10aAccessToken + + val providerName: String = "twitter" + + val requestTokenURL = "https://api.twitter.com/oauth/request_token" + + val accessTokenURL = "https://api.twitter.com/oauth/access_token" + + val authorizationURL = "https://api.twitter.com/oauth/authorize" + + lazy val consumerKey = ConsumerKey( + current.configuration.getString("twitter.consumerKey").getOrElse(sys.error("twitter.consumerKey is missing")), + current.configuration.getString("twitter.consumerSecret").getOrElse(sys.error("twitter.consumerSecret is missing")) + ) + + lazy val callbackURL = current.configuration.getString("twitter.callbackURL").getOrElse( + sys.error("twitter.callbackURL is missing") + ) + +} + diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterController.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterController.scala new file mode 100644 index 0000000..dcd3730 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterController.scala @@ -0,0 +1,21 @@ +package jp.t2v.lab.play2.auth.social.providers.twitter + +import jp.t2v.lab.play2.auth.social.core.OAuth10aController +import jp.t2v.lab.play2.auth.{ AuthConfig, Login, OptionalAuthElement } +import play.api.libs.oauth.RequestToken + +trait TwitterController extends OAuth10aController + with AuthConfig + with OptionalAuthElement + with Login { + + val authenticator = new TwitterAuthenticator + + def requestTokenToAccessToken(requestToken: RequestToken): AccessToken = { + TwitterOAuth10aAccessToken( + requestToken.token, + requestToken.secret + ) + } + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterOAuth10aAccessToken.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterOAuth10aAccessToken.scala new file mode 100644 index 0000000..d4fb0d4 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterOAuth10aAccessToken.scala @@ -0,0 +1,5 @@ +package jp.t2v.lab.play2.auth.social.providers.twitter + +case class TwitterOAuth10aAccessToken( + token: String, + secret: String) diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterProviderUserSupport.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterProviderUserSupport.scala new file mode 100644 index 0000000..b934d27 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterProviderUserSupport.scala @@ -0,0 +1,39 @@ +package jp.t2v.lab.play2.auth.social.providers.twitter + +import jp.t2v.lab.play2.auth.social.core.OAuthProviderUserSupport +import play.api.Logger +import play.api.Play.current +import play.api.libs.oauth.{ OAuthCalculator, RequestToken } +import play.api.libs.ws.{ WS, WSResponse } + +import scala.concurrent.{ ExecutionContext, Future } + +trait TwitterProviderUserSupport extends OAuthProviderUserSupport { + self: TwitterController => + + type ProviderUser = TwitterUser + + private def readProviderUser(accessToken: AccessToken, response: WSResponse): ProviderUser = { + val j = response.json + TwitterUser( + (j \ "id").as[Long], + (j \ "screen_name").as[String], + (j \ "name").as[String], + (j \ "description").as[String], + (j \ "profile_image_url").as[String], + accessToken.token, + accessToken.secret + ) + } + + def retrieveProviderUser(accessToken: AccessToken)(implicit ctx: ExecutionContext): Future[ProviderUser] = { + for { + response <- WS.url("https://api.twitter.com/1.1/account/verify_credentials.json") + .sign(OAuthCalculator(authenticator.consumerKey, RequestToken(accessToken.token, accessToken.secret))).get() + } yield { + Logger(getClass).debug("Retrieving user info from Twitter API: " + response.body) + readProviderUser(accessToken, response) + } + } + +} diff --git a/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterUser.scala b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterUser.scala new file mode 100644 index 0000000..c0b9ea9 --- /dev/null +++ b/social/src/main/scala/jp/t2v/lab/play2/auth/social/providers/twitter/TwitterUser.scala @@ -0,0 +1,10 @@ +package jp.t2v.lab.play2.auth.social.providers.twitter + +case class TwitterUser( + id: Long, + screenName: String, + name: String, + description: String, + profileImageUrl: String, + accessToken: String, + accessTokenSecret: String)