securesocialを使ってPlay2.3でユーザー登録機能をつくってみた

今回はsecuresocialとplay2.3を使ってユーザー登録機能を作ったので、それを忘れないうちにブログに書いておきます。

今回はFacebookをつないでユーザー登録ができるようにします。
なのでFacebookの開発用アプリは事前に作成しておいてください。
こちらでは今回は説明しませんのでとばします。

まず今回使うのはこのsecuresocialです。
SecureSocial - Authentication for Play Framework Applications

↓のブログの通り安定版ではPlay2.3に対応していませんので、sampleコードなどを参考に実装していきます。
Play2.3でSecureSocialを使う(2014年6月時点) - らびたろちゃんにっき

securesocialのレポジトリ
https://github.com/jaliss/securesocial/tree/master/samples/scala/demo


今回やることとしてはこんな感じです。
流れとしてはこのリンクのページ通りすすめていきますが、一部play2.3で変更した部分があるのでそれに関してはレポジトリの最新のsampleを見て対応していきます。
http://securesocial.ws/guide/getting-started.html

まずはsbtのインストールです。
では早速実装をしていきましょう。

まずはsecuresocialを使えるようにするために、built.sbtに取り込みます。
securesocialとresolversの部分が今回追加した記述です。

libraryDependencies ++= Seq(
  jdbc,
  "org.squeryl" %% "squeryl" % "0.9.5-7",
  "postgresql" % "postgresql" % "9.1-901.jdbc4",
  "io.backchat.jerkson" % "jerkson_2.9.2" % "0.7.0",
  "ws.securesocial" %% "securesocial" % "master-SNAPSHOT"
)

resolvers += Resolver.sonatypeRepo("snapshots")

application.confに↓の記述を追加

include "securesocial.conf"

routesも↓のたったこれだけの記述でOKです。

->      /auth   securesocial.Routes

securesocial.confファイルを作成し、

securesocial {
	#
	# Where to redirect the user if SecureSocial can't figure that out from
	# the request that was received before authenticating the user
	#
	onLoginGoTo=/

	#
	# Where to redirect the user when he logs out. If not set SecureSocial will redirect to the login page
	#
	onLogoutGoTo=/login

	#
	# Enable SSL 
	#
	ssl=false	

	#
	# The controller class for assets. This is optional, only required
	# when you use a custom class for Assets.
	#
	assetsController=controllers.ReverseMyCustomAssetsController

	 cookie {
            #name=id
            #path=/
            #domain=some_domain
            #httpOnly=true
            #idleTimeoutInMinutes=30
            #absoluteTimeoutInMinutes=720
    }
 
  userpass {
    withUserNameSupport=false
    sendWelcomeEmail=true
    enableGravatarSupport=true
    signupSkipLogin=true
    tokenDuration=60
    tokenDeleteInterval=5
    minimumPasswordLength=8
    enableTokenJob=true
    hasher=bcrypt
  }
//今回はfacebookのみ対応するので↓の記述だけでOK
  facebook {
    authorizationUrl="https://graph.facebook.com/oauth/authorize"
    accessTokenUrl="https://graph.facebook.com/oauth/access_token"
    clientId=input_your_app_id
    clientSecret=input_your_secret_key
    # this scope is the minimum SecureSocial requires.  You can add more if required by your app.
    scope=email
  }

}

ここまできたら一旦securesocialのマニュアルから離れて、DAOを作成します。
今回はapp/dao/FacebookDao.scalaファイルを作成して、
・ユーザーを検索するメソッド
・ユーザー情報をアップデートするメソッド
・該当ユーザーがいない場合、ユーザーを作成するメソッド
を作っていきます。

import models.UserData

import org.squeryl.PrimitiveTypeMode._
import org.squeryl.{ SessionFactory, Session }
import db._

object FacebookDao {
  //ユーザーを作成するメソッド
  def createUser(userData: UserData) = {
    transaction {
      val (user, facebookUser) = createUserFacebookUserByUserData(userData)
      AppDB.facebookUserTable.insert(facebookUser)
      AppDB.userTable.insert(user)
    }
  }
  //ユーザー情報をアップデートするメソッド
  def updateUser(userData: UserData) = {
    transaction {
      val (user, facebookUser) = createUserFacebookUserByUserData(userData)
      AppDB.facebookUserTable.update(facebookUser)
      AppDB.userTable.update(user)
    }
  }
  //facebook idを引数にユーザーを探すメソッド
  def findUser(facebookUserId: Long): Option[UserData] = {
    transaction {
      from(AppDB.userTable, AppDB.facebookUserTable)((u, fu) =>
        where(u.facebookUserId === facebookUserId) select(u, fu)).headOption.map {
        t => UserData(t._1.id, t._1.userName, t._1.mailAddress, t._1.facebookUserId, t._2.accessToken)
      }
    }
  }
  //ユーザーデータ型を引数にとり、User型とFacebookUser型が入ったタプルを返すメソッド
  def createUserFacebookUserByUserData(userData: UserData): (User, FacebookUser) = {
    val user = User(userData.id, userData.userName, userData.mailAddress, userData.facebookUserId)
    val facebookUser = FacebookUser(userData.facebookUserId, userData.accessToken)
    (user, facebookUser)
  }

}

上で記述されていたUserData型が何かというと、userテーブルとfacebook_userテーブルから必要なentityのフィールドだけを取ってきてモデル化したもの。

今回はapp/models/UserData.scalaファイルを作成し、そこの下記のように記述しました。

package models

case class UserData(
  id: Long,
  userName: Option[String],
  mailAddress: Option[String],
  facebookUserId: Long,
  accessToken: String) {
}

UserDataはそれ自身がテーブルを持つわけではないので、schemaの定義をする必要はありません。

今度は↓の部分をつくっていくのですが、play2.3ではこのページ通りに作成しても上手く動作しません。
http://securesocial.ws/guide/user-service.html

なので最新のレポジトリを参考にMyUserServiceを作っていきます。
https://github.com/jaliss/securesocial/blob/master/samples/scala/demo/app/service/InMemoryUserService.scala

ちなみに今回実装していないfindByEmailAndProviderメソッドとdeleteTokenメソッドはNotImplementedErrorを返すようにしています。

ここではさっき使ったDaoを利用しながらsecuresocialが求めるフォーマットで引数を渡して、返り値を返すというメソッドを作成していきます。
入り口と出口のフォーマットは決まっているので、それに合わせて処理の内容を記述していきます。
それでは例を見ていきましょう。

import models.UserData

import scala.concurrent.Future
import play.api.{Logger, Application}
import securesocial.core.{AuthenticationMethod, PasswordInfo, BasicProfile}
import securesocial.core.providers.MailToken
import securesocial.core.services.{SaveMode, UserService}
import FacebookDao._

class MyUserService extends UserService[UserData] {
  /**
   * Finds a user that maches the specified id
   *
   * @param providerId
   * @param facebookUserId
   * @return
   */
  def find(providerId: String, facebookUserId: String): Future[Option[BasicProfile]] = {
    val facebookId: Long = facebookUserId.toLong
    val s:Option[BasicProfile] = findUser(facebookId).map{
      result => BasicProfile(providerId, result.id.toString, None, None, result.userName, result.mailAddress, None, AuthenticationMethod.OAuth2)
        //BasicProfile(val providerId : scala.Predef.String, val userId : scala.Predef.String, val firstName : scala.Option[scala.Predef.String], val lastName : scala.Option[scala.Predef.String], val fullName : scala.Option[scala.Predef.String], val email : scala.Option[scala.Predef.String], val avatarUrl : scala.Option[scala.Predef.String], val authMethod : securesocial.core.AuthenticationMethod, val oAuth1Info : scala.Option[securesocial.core.OAuth1Info] = { /* compiled code */ }, val oAuth2Info : scala.Option[securesocial.core.OAuth2Info] = { /* compiled code */ }, val passwordInfo : scala.Option[securesocial.core.PasswordInfo] = { /* compiled code */ }) extends scala.AnyRef with securesocial.core.GenericProfile with scala.Product with scala.Serializable
    }
    Future.successful(s)
  }

  /**
   * Finds a user by email and provider id.
   *
   * Note: If you do not plan to use the UsernamePassword provider just provide en empty
   * implementation.
   *
   * @param email - the user email
   * @param providerId - the provider id
   * @return
   */
  def findByEmailAndProvider(email: String, providerId: String): Future[Option[BasicProfile]] =
  {
    Future.failed(new NotImplementedError())
  }

  /**
   * Saves the user.  This method gets called when a user logs in.
   * This is your chance to save the user information in your backing store.
   * @param prof
   */
  def save(prof: BasicProfile, mode: SaveMode): Future[UserData] = {
    //...
    findUser(prof.userId.toLong) match {
      case Some(x) => {
        val user: UserData
          = UserData(x.id, prof.fullName, prof.email, prof.userId.toLong, prof.oAuth2Info.get.accessToken)
        updateUser(user)
        Future.successful(user)
      }
      case None => {
        val user: UserData
          = UserData(0, prof.fullName, prof.email, prof.userId.toLong, prof.oAuth2Info.get.accessToken)
        createUser(user)
        Future.successful(user)
      }
    }

  }

  /**
   * Links the current user Identity to another
   *
   * @param currentUser The Identity of the current user
   * @param to The Identity that needs to be linked to the current user
   */
  def link(currentUser: UserData, to: BasicProfile): Future[UserData] = {
    Future.failed(new UnsupportedOperationException())
  }

  /**
   * Saves a token.  This is needed for users that
   * are creating an account in the system instead of using one in a 3rd party system.
   *
   * Note: If you do not plan to use the UsernamePassword provider just provide en empty
   * implementation
   *
   * @param token The token to save
   */
  def saveToken(token: MailToken): Future[MailToken] = {
    Future.failed(new UnsupportedOperationException())
  }

  /**
   * Finds a token
   *
   * Note: If you do not plan to use the UsernamePassword provider just provide en empty
   * implementation
   *
   * @param token the token id
   * @return
   */
  def findToken(token: String): Future[Option[MailToken]] = {
    Future.failed(new UnsupportedOperationException())
  }

  /**
   * Deletes a token
   *
   * Note: If you do not plan to use the UsernamePassword provider just provide en empty
   * implementation
   *
   * @param uuid the token id
   */
  def deleteToken(uuid: String): Future[Option[MailToken]] = {
    Future.failed(new NotImplementedError())
  }

  /**
   * Deletes all expired tokens
   *
   * Note: If you do not plan to use the UsernamePassword provider just provide en empty
   * implementation
   *
   */
  def deleteExpiredTokens() {
    throw new UnsupportedOperationException()
  }

  def passwordInfoFor(user: UserData): Future[Option[PasswordInfo]] = {
    Future.failed(new UnsupportedOperationException())
  }

  def updatePasswordInfo(user: UserData, info: PasswordInfo): Future[Option[BasicProfile]] = {
    Future.failed(new UnsupportedOperationException())
  }
}

Global.scalaに↓の記述を追加

object Global extends GlobalSettings {
  object MyRuntimeEnvironment extends RuntimeEnvironment.Default[UserData] {
    override lazy val userService: MyUserService = new MyUserService()
    override lazy val providers = ListMap(
      // oauth 2 client providers
      include(new FacebookProvider(routes, cacheService,
oauth2ClientFor(FacebookProvider.Facebook)))
    )
  }

  override def getControllerInstance[A](controllerClass: Class[A]): A = {
    val instance = controllerClass.getConstructors.find { c =>
      val params = c.getParameterTypes
      params.length == 1 && params(0) == classOf[RuntimeEnvironment[UserData]]
    }.map {
      _.asInstanceOf[Constructor[A]].newInstance(MyRuntimeEnvironment)
    }
    instance.getOrElse(super.getControllerInstance(controllerClass))
  }
}

ここまできてようやくcontrollersの設定を行っていきます。
これも↓のページを参考に
https://github.com/jaliss/securesocial/blob/master/samples/scala/demo/app/controllers/Application.scala

//クラスはこのように宣言
class AdminController(override implicit val env: RuntimeEnvironment[UserData]) extends Controller with securesocial.core.SecureSocial[UserData] {

ログインしているユーザー以外には見せないページは↓のようにActionをSecuredActionに変更したりしていきます。

  def index = SecuredAction { implicit request =>
    transaction {
      val posts = from(AppDB.postTable)(p => where(p.userId === request.user.id) select (p)).toList
      Ok(views.html.index(posts, request.user))
    }
  }

ここでactivatorを立ちあげrunします。
そして早速さっきSecuredActionを付与したページにアクセスします。
そこでこのように画面が表示されていたらOKです。f:id:hikonori07:20140930003333p:plain

これで関連DBをみて適切に値が入っていたらひとまずsecuresocialが正常に動いているでしょう。

だいぶ長くなりましたが、securesocialの導入方法でした。
今後はユーザーのコメント機能やユーザーと投稿の紐付けの部分を実装して記事に書いていきたいと思います。

参考ブログ
http://h-yamaguchi.hatenablog.com/entry/2014/09/22/160629