サーバーサイド初心者が Play Framework 2.4 + Scala + Silhouette で認証認可動かしてメール認証を自前実装した話

なんか結果的に Play Framework の主要な機能について触れることになったっぽいので、Play + Scala の入門というかチュートリアル的成長譚。Web サービスとかどう作っていいかわからん!という私向け。

Play Framework 2.4 + Scala + Silhouette(認証認可ライブラリ) を使ってWebサービスの基本になりそうなところまで作ってみたお話。永続化は mongoDB でやったよ。

完成形は github で公開してます。

Play Framework ぜんぜんわからん。2.4系から変わりすぎ。Webよくわからんよー。認証認可どうしたらいいいんだ、ライブラリ使ったらいいのか。永続化とかどうするんですかね… という私みたいな初心者向け弾丸ツアーです。特に認証認可ライブラリ Silhouette については日本語での解説が全然ないので、役に立ったらいいな(宣伝)。

対象

この記事は、

  • プログラミングは多少わかるけど、Web サービスとかは全然わからない
  • Play Framework / Scala に興味があるが、まとまった日本語情報がなくて勉強しはじめるのに抵抗がある
  • Play Framework の公式チュートリアル読んだけど、実際つくるにはどこから手を付けたらいいのかわからない
  • Play Framework が2.3系以前と2.4系以後で変わりすぎていてイミワカンナイ!
  • Play Framework での認証・認可ってどうやるの?スタンダードなライブラリってどれ?
  • Play + Scala 初心者の @Biacco42 がアホなことを言ってるのでマサカリを投げたい

という感じの人達が対象です。まぁ、Web サービスとか全然わからない!って人は普通 Ruby on Rails とか行く気がするんですけどね…

概要・ゴール

認証・認可ができる Web サービスの雛形を作ります。メールアドレスとパスワードでアカウントを登録すると、メールアドレス確認メールが飛んできて、そのリンクをクリックするとメールアドレスの認証もできるよくあるアレです。

と言っても、基本的な雛形はすでに提供されているので、永続化のやり方やメールアドレスの認証部分の作り方を通して、Web サービス/Play アプリケーションってだいたいこんな感じでつくられてるのか〜〜〜〜〜と学ぶのが目的です。

つくる

Play Framework 導入

まず Play Framework の導入をします。Play Framework は Java/Scala で利用できる軽量とされる?らしい?Webフレームワークです。Scala や Akka を管理している Odersky 博士の会社 Lighbend が管理してます。いわゆる Ruby on Rails 的なやつ。

Play Framework

ここからダウンロードしてきて適当なところに展開して Path 通せばおk。activator コマンドが実行できるようになったら成功です。実際は sbt コマンドをラップしているので、sbtが起動する。

この sbt が優秀なので Scala の開発環境を構築する必要はありません。これで完了。すばらしい。(Javaの開発環境は事前に構築しておいてください)

Scala とは

Java の親戚みたいな新しめの言語です。最近話題の関数型と Java 的なオブジェクト指向プログラミングを統合するという名目のいいとこ取り言語で、サーバーサイドでもポスト Java と目される有力言語です。今回触る Play Framework は Java/Scala 向けの Web フレームワークで、Java でもつかえるけどどちらかと言えば Scala 寄りな設計がされているので、Play + Scala でやりましょう。

言語自体の解説はここですると長すぎるので省略。@IT さんの入門記事ドワンゴさんの新卒向け Scala チュートリアルが丁寧なので、Scala未経験の人は読んでみるといいと思います。

Silhouette とは

Silhouette は Play Framework 向けの認証認可ライブラリです。Play Framework にはいくつか有名な認証認可ライブラリがあって、その中で機能も充実してて有名なのは Secure Social なんだけど、いかんせん Play 2.4 系の大変更についてこられてない感じで、ドキュメントも2.1.x 系をベースに書かれていて大混乱不可避。で、ここですでに心折れかかっていたけれど、Play Framework の認証認可ライブラリとしては比較的新しい Silhouette を発見。

Silhouette は前述の Secure Social をベースとして改良したライブラリで、Web ド素人の自分でもわりとわかりやすいドキュメントが書かれていて、開発・メンテも活発で Play 2.4 系、動的DIもばっちり対応ということで選びました。

まだ新しい方のライブラリだからか、Play 2.4 使ってる人口が少ないのか、Web上で話題は少なめだけど、今から Play 2.4 以降を触る人で認証認可ライブラリ探している人は Silhouette が迷いなくできて良いのではという気がする。

play-silhouette-reactivemongo-seed を clone

Silhouette を通常の Play project に取り込んでもいいんだけれど、ベースとなる seed project がいろいろサンプルとして提供されている。最初はこれを見たほうがわかりやすいと思う(& コンパクトに開発できる)ので、seed project を Clone してくる。今回はSQLまで気が回らないから、永続化を mongoDB でやりたいということで、Reactive Mongo を取り込んだ seed project を clone する。

play-silhouette-reactivemongo-seed

メール認証を実装した seed project もあるんだけど、実装がかなり独特というか個性的というか…だったので、標準的な Play、Silhouette の感じでメール認証実装は自前でやる。

ちなみにSNSのOAuthにもSilhouetteは対応してるけど、このサンプルだと永続化はしてません。サービスとの接続も扱いません。あしからず。あくまでメール認証だけやるよ。

IntelliJ IDEA にプロジェクトをインポート

IntelliJ IDEA 16.1 で確認。事前に Scala プラグインをインストールしておく(SBT プラグインは本体に取り込まれたのでインストールする必要なし)。インポートオプションとして Auto-import にチェックを入れておくと、build.sbt を書き換えた時に自動的に SBT の更新・依存解消が走るので、チェックしておくのがオススメ。

activator というか Play Framework というかの実態は sbt プロジェクトなので、IntelliJ IDEA 上で import -> プロジェクトルートディレクトリ選択 -> SBT で特に問題なく取り込める。ただ、取り込んだデフォルトの状態では target/scala-2.11がpathから外されているので、reverse router の routes パッケージや view パッケージなどの自動生成されるクラスをIntelliJが解決できない(SBT では問題ないのでビルドは通る)。ので、File -> Project Stracture… -> Modules -> root の Sources タブで、Excluded Folders 配下にある target を消す。これで、Source Folders に target/scala-2.11/routes と target/scala-2.11/twirl が追加されていれば、シンタックスエラーは解除されるはず。

スクリーンショット 2016-04-11 15.36.15

これで準備は完了。

起動してみる(mongoDB との接続確認)

mongoDBはいい感じにインストールしてデーモンを適当に起動しておいてください(投げやり)。
まずはローカルでやってみる前提。

Play + Scala と mongoDB との接続は Reactive Mongo で行います。mongo は Relational じゃないから ORM ではないと思うんだけど、Reactive Mongo は ORM 的なライブラリ。

この seed project ではすでに Reactive Mongo が導入してあるので、とりあえず起動して動作確認してみる。mongoDBの設定を特に変更していなければ、port 27017 を聞いているので、この seed project も localhost の port 27017 をサーバーとして設定している。もし別のサーバー/デーモンと接続したい場合は、application.conf の mongodb.server を変更すればよい(はず)。

Play のデバッグモードでの起動は、プロジェクトルートディレクトリで activator run コマンドを実行するか、intelliJ IDEA の Run configuration を変更して実行できる。ちなみに activator ui をプロジェクトのルートディレクトリで実行すると、GUIのツールがブラウザ上に立ち上がるので、便利といえば便利。おこのみで。

コンパイルエラーなく無事起動したら、localhost:9000 で現在起動しているサービスにアクセスできる。サインアップ・サインイン・ログアウト、サーバーの再起動後に再接続しても先ほどのログイン情報でログインできるのが確認できたら、とりあえずスタート地点は完了。スクリーンショット 2016-04-11 17.15.18mongo のシェルで、エントリを確認してみるのもいいかも確認した。

スクリーンショット 2016-04-11 17.38.48

こんな感じのJSONが吐かれる。DAOのコードとすりあわせてみるとわかりやすかった。Scala の case class 偉大。

もし [PrimaryUnavailableException$: MongoError[‘No primary node is available!’]] が出た場合は、mongoDB と接続できていない or mongoDB のデーモンが起動していないので、その辺を確認する。

プロジェクトを眺めて Play Framework の概要を理解する

ここまでいったん動いた/動きを見たら、Play のプロジェクトを眺めてみて、だいたいなにをやっているか、どこを変更したらなにができるかを確認してみる。

app/controllers

ApplicationController を見てみる。

package controllers

import ...

/**
 * The basic application controller.
 *
 * @param messagesApi The Play messages API.
 * @param env The Silhouette environment.
 * @param socialProviderRegistry The social provider registry.
 */
class ApplicationController @Inject() (
  val messagesApi: MessagesApi,
  val env: Environment[User, CookieAuthenticator],
  socialProviderRegistry: SocialProviderRegistry)
  extends Silhouette[User, CookieAuthenticator] {

  /**
   * Handles the index action.
   *
   * @return The result to display.
   */
  def index = SecuredAction.async { implicit request =>
    Future.successful(Ok(views.html.home(request.identity)))
  }

  /**
   * Handles the Sign In action.
   *
   * @return The result to display.
   */
  def signIn = UserAwareAction.async { implicit request =>
    request.identity match {
      case Some(user) => Future.successful(Redirect(routes.ApplicationController.index()))
      case None => Future.successful(Ok(views.html.signIn(SignInForm.form, socialProviderRegistry)))
    }
  }
  ...
}

というわけで、Controller は HTTP の Request を受け取って応答を返すもののようですね。SecuredAction や UserAwareAction のように、認可の種別によって Action を切り替えて、Ok やRedirect など HTTP の応答を返しています。

app/models

モデル層ってやつです。データそのものの定義や、ビジネスロジックとかいうのを書いたりするようです。実際このプロジェクトでは、User モデルを定義して、それの永続化として UserDAO (Data Access Object) や、User を利用する UserService などを実装しています。基本的には Play には依存しないようにできれば書きたいところなので、あんまり特殊なことはない(はず)。

Reactive Mongo の利用法については力尽きたのでソースコード読んで Reactive Mongo のチュートリアル読んでください。今回は case class を使う方法を利用しています。(直接BSONを触らない方法)

app/modules

Silhouette の各種モジュール定義が SilhouetteModule にされています。特に重要なのが configure メソッドで、ここで Guice DI の依存性解消の仕方を教えています。Guice による動的DIをする場合には、configure でインターフェースに対して、実装をバインディングしてあげます。

app/utils

Silhouette を利用する場合、認証・認可できなかった場合のエラーケースを DefaultHttpErrorHandler の拡張として実装すると、エラーした場合の振る舞いを決められる。とか。

app/views

Web フレームワークの重要なポイント、テンプレートエンジンに渡す view を定義している。.scala.html とかいう見慣れない拡張子で、ほぼほぼ html ライクな書き方をしている。実はこれも Scala としてコンパイルされるので、コンパイル時に静的に解析が入るのが良いらしい。詳しくは後述。

いわゆる Web ページをつくるための HTML はここで記述することになるので、ページの内容を変えるにはここのファイル群を編集することになる。

conf

Play application の設定ファイル application.conf や、多言語用のテキストを格納する messages ファイル、ルーティングを記述する routes ファイルなどがある。わりと重要なので routes の中身をちょっと見る。

# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

# Home page
GET         /                                controllers.ApplicationController.index
GET         /signIn                          controllers.ApplicationController.signIn
GET         /signUp                          controllers.ApplicationController.signUp
GET         /signOut                         controllers.ApplicationController.signOut
GET         /authenticate/:provider          controllers.SocialAuthController.authenticate(provider)
POST        /authenticate/credentials        controllers.CredentialsAuthController.authenticate
POST        /signUp                          controllers.SignUpController.signUp

# Map static resources from the /public folder to the /assets URL path
GET         /assets/*file                    controllers.Assets.at(path="/public", file)
GET         /webjars/*file                   controllers.WebJarAssets.at(file)

見て分かる通り リクエストメソッド 相対パス リクエストを処理するコントローラ を記述する。:hoge はそのパスをパースして引数にアサインすることができる。

この routes ファイルも実は最終的に Scala としてコンパイルされるので、コンパイルでシグネチャのエラー検出などができる。よい。

ざっとこんな感じで Play のプロジェクトが構成されてることがわかったので、いよいよ追加機能の実装をやる。

メールが送れるように、play-mailer を導入する

この seed の状態だと、メールアドレスによるアカウント登録はザルで、適当な文字列でもメールアドレスの形式を満たしていれば登録できてしまう。そこで、よくあるメールアドレスの認証を行うようにする。ここからが本丸です前置き長くてすいません。

まず、メールを送れるように play-mailer を導入する。build.sbt に以下を追記する。

libraryDependencies ++= Seq(
  "com.typesafe.play" %% "play-mailer" % "4.0.0"
)

続いて、conf/application.conf に play-mailer 用の設定を追記する。

play.mailer {
  host = smtp.gmail.com
  port = 465
  user = "my_service@gmail.com"
  password = "yourpassword"
  ssl = true
  mock = true
  from = "my_service@gmail.com"
}

設定値の mock を true にしておくと、実際にメールを送信せずにコンソール上にメールの内容を出力してくれるのでデバッグ段階では true にしておきましょう。

ここまでで play-mailer の基本設定はおしまい。

メール送信ロジックを実装する

続いて、実際にメールを送信する機能、そのメールでメールアドレスの認証を行う機能の実装をする。まず、送信するメールの内容をつくって、その後にメール認証の仕組み、最後にメールの送信部分を実装する。

メール送信用リソースの準備

以下の様な内容のメールが、アカウント登録時に送られてくるようにする。

<html>
<head><meta charset="utf-8"></head>
<body>
<p>Thank you for using new service, Hoge.</p>
<p>Please confirm your mail address to click the link below.</p>
<a href="http://hoge.com/mailConfirm/57eb8445-1fcc-4119-a64d-f02e5aef87ae">http://hoge.com/mailConfirm/57eb8445-1fcc-4119-a64d-f02e5aef87ae</a>
<p>Thank you for your signing up this service.</p>
<p>New service team.</p>
</body>
</html>

この HTML をリソースとして用意する。この際に、Web サービスとして欠かせない HTML の生成方法としてのテンプレートエンジンと、多言語化のための Messages を利用する。

テンプレートエンジン

Web サービスといえば、基本的に HTML か JSON を返すのが避けて通れない。そして Web フレームワークには、テンプレートエンジンという HTML を生成するのに便利な機能が付いている。普通はこれを Web ページの表示に使うんだけれど、今回は上記の HTML メールの本文を生成するために利用する。

Play のテンプレートエンジンを利用するためには、app/views に .scala.html という拡張子でファイルを作成し、テンプレートエンジン用の DSL で記述する。ためしに、このプロジェクトに最初から含まれている home.scala.html を見てみる。

@(user: models.User)(implicit messages: Messages)

@main(Messages("home.title"), Some(user)) { /* html */ }

ところどころに @hoge みたいなのがあるが、これがテンプレートエンジン向けのコマンドとして認識される。他の部分は見ての通り通常の HTML になっている。1行目を見てみると、早速@を使って

@(user: models.User)(implicit messages: Messages)

と書かれている。この部分がこの view の”引数”になっている。ちゃんと型もつけられる。この view では user を受け取っている。messages は暗黙にフレームワークから渡されるので気にしなくていい。続いて、

@main(Messages("home.title"), Some(user)) { /* html */ }

という感じで書かれている。@hoge() で他の view が呼び出せるようになっており、これは、main.scala.html で生成される view を引数付きで呼び出している。ちなみに main のシグネチャは

@(title: String, user: Option[models.User] = None)(content: Html)(implicit messages: Messages)

となっていて、二番目の引数群で Html 型のデータを受け取るようになっており、呼び出し元の home.scala.html ではこの部分に{}で囲われた Html 型のリテラルを記述して渡している。

他の部分でも @hoge のところは Scala 的な感じで書けるようになっている。詳しく書いてるとそれだけで記事がかけてしまうので省略。だいたい雰囲気はわかったかな〜〜〜〜〜?

今回は、app/views/mails ディレクトリを作成して、そこに welcome.scala.html を作成した。とりあえず、作成したファイルの内容はこんな感じ。

@(name: String, link: String)(implicit messages: Messages)
<html>
<head><meta charset="utf-8"></head>
<body>
<p>@Messages("mail.confirm.hello", name)</p>
<p>@Messages("mail.confirm.prelink")</p>
<a href="@link">@link</a>
<p>@Messages("mail.confirm.postlink")</p>
<p>@Messages("mail.sign")</p>
</body>
</html>

HTML のタグ構造としては、目的とする最終形と同じタグ構造になっているのがわかる。ただ、テキストが埋まっているべきところについてはすべて @Messages(“hoge.piyo”) みたいな形になっている。なぜ直接テキストを書かないのか。Messages とはなんなのか。その秘密を探るけれど南米は遠いので飛ばない。

Messages

結論から言うと Messages は I18n と呼ばれる Play Framework の多言語対応のための仕組みです。たとえば、上記の welcome.scala.html を最終形に合わせてベタ書きしたと考えてみる。最初は、英語で全世界向けにサービス展開したので問題なかった、が、日本人があまりにも英語が読めないので、日本語対応してほしいという要求が来たとする。さてどうするか。

ここでもう一つ welcome_ja.scala.html を作って、リクエストヘッダの情報から view を振り分けてもいいが、これが多言語に対応するとなるととてもじゃないがメンテナンスできない。したくない。

そこで出てくるのが I18n で、app/config に messages.en や messages.ja のようなプロパティファイル(テキスト形式)を置いておくと、Play がリクエストに応じて自動的に言語を切り替えてくれる。便利! Play では Messages という API でラップされており、Messages(“message.id”, …) という形で呼び出せる。たとえば

messages.en

mail.test = This is test string.

messages.ja

mail.test = これはテスト文字列だよ。

とすると、Messages(“mail.test”) を呼び出した際に、英語環境と判断されたら This is test string. が、日本語環境と判断されたら これはテスト文字列だよ が出力される。

ということで早速、前述の welcome.scala.html で呼ばれている Messages の ID のテキストを記述しておく。

mail.from = new_service@hoge.com
mail.sign = New service team.
mail.confirm.title = New service. Confirm your mail address.
mail.confirm.hello = Thank you for using new service, {0}.
mail.confirm.prelink = Please confirm your mail address to click the link below.
mail.confirm.postlink = Thank you for your signing up this service.

{0}というのは、代替文字列で、Messages の追加の引数をそこに当てはめてくれる。たとえば

Messages("mail.confirm.hello", "Hoge Taro")

と呼び出せば、Thank you for using new service, Hoge Taro. と出力される。便利。

というわけで、認証メールに必要なリソースがだいたい揃った。text形式のメール本文も一応用意しておく。

welcomeTxt.scala.html

@(name: String, link: String)(implicit messages: Messages)
@Messages("mail.confirm.hello", name)

@Messages("mail.confirm.prelink")

@link

@Messages("mail.confirm.postlink")

@Messages("mail.sign")

次はメール認証に必要なトークンをつくる。

メール認証用トークンをつくる

メール認証をするために、一意なトークンを発行して、そのトークンを用いたアクセスがあった場合にそのトークンとメールアドレス・ユーザーを突き合わせる仕組みが必要になるので実装するゾイ。

どう考えてもメール認証用トークンを保存する必要があるので、メール認証用トークンの model と DAO を用意する。

メール認証用トークンには

  • トークンとして使える識別子
  • トークンからユーザーを引くためのユーザー識別子
  • トークンの寿命
  • トークンの種類

が必要なので、これらをプロパティとして持つ MailToken model を作成する。

package models

import java.util.UUID

import org.joda.time.DateTime
import play.api.libs.json.Json

case class MailToken(
  id: UUID,
  userId: UUID,
  expirationDate: DateTime,
  tokenKind: String)

object MailToken {

  /**
    * Create MailToken instance easily.
    * @param user The user.
    * @param tokenKind The token kind which corresponds "confirm" or "reset"
    * @return New mail token instance.
    */
  def create(user: User, tokenKind: String): MailToken = MailToken(UUID.randomUUID(), user.userID, new DateTime().plusDays(1), tokenKind)

  /**
    * Converts the [MailToken] object to Json and vice versa.
    */
  implicit val jsonFormat = Json.format[MailToken]

}

MailToken 自体は case class として簡単に定義できる。このクラスと同じ名前の object をコンパニオンオブジェクトと呼ぶけれど、このコンパニオンオブジェクトにユーティリティーメッソドを書いておく。今回は、1つはファクトリメソッドで、1つはこのクラスを JSON と相互変換するためのものです。これで MailToken の定義は完了。

続いて DAO を実装する。

package models.daos
import ...

class MailTokenDAOImpl @Inject() (db: DB) extends MailTokenDAO {

  def collection: JSONCollection = db.collection[JSONCollection]("mailToken")

  override def create(token: MailToken): Future[WriteResult] = collection.insert(token)

  override def delete(tokenId: UUID): Future[WriteResult] = collection.remove(Json.obj("id" -> tokenId))

  override def read(tokenId: UUID): Future[Option[MailToken]] = collection.find(Json.obj("id" -> tokenId)).one[MailToken]

}

Reactive Mongo を使って CRUD を実装しただけ。

MailService を実装する

MailToken model と DAO ができたので、それらを利用して MailToken をつくって保存してついでにメールを送ってくれたり、与えられた MailToken を突き合わせてユーザー識別子を返したりしてくれる MailService を定義・実装する。MailService のインターフェースは

package models.services

import ...

trait MailService {

  /**
    * Send address confirmation mail to [User].
    * @param user The user who the mail send to.
    */
  def sendConfirm(user: User)(implicit request: RequestHeader): Future[String]

  /**
    * Find the mail token and remove it from repo.
    * If this method find the token, returns Future(Some(userId)), otherwise returns Future(None).
    * @param tokenId The token id.
    * @param kind The token kind which corresponds to "confirm" or "reset".
    * @return The UUID of the User which corresponds to mail token.
    */
  def consumeToken(tokenId: UUID, kind: String): Future[Option[UUID]]

}

こんな感じでいいでしょう。早速実装すると。

package models.services

import ...

class MailServiceImpl @Inject() (
  mailerClient: MailerClient,
  val messagesApi: MessagesApi,
  mailTokenDAO: MailTokenDAO) extends MailService with I18nSupport {

  def sendConfirm(user: User)(implicit request: RequestHeader): Future[String] = {
    val mailToken = MailToken.create(user, "confirm")
    mailTokenDAO.create(mailToken)
    val link = routes.SignUpController.mailConfirm(mailToken.id.toString).absoluteURL()
    Future(mailerClient.send(confirmMail(user, link)))
  }

  def confirmMail(user: User, link: String): Email = {
    Email(subject = Messages("mail.confirm.title"),
      from = Messages("mail.from"),
      to = Seq(user.email.getOrElse(throw new Exception("User.email is None."))),
      bodyText = Some(mails.welcomeTxt(user.firstName.getOrElse("User.firstname is None."), link).toString),
      bodyHtml = Some(mails.welcome(user.firstName.getOrElse("User.firstname is None."), link).toString))
  }

  def consumeToken(tokenId: UUID, kind: String): Future[Option[UUID]] = {
    mailTokenDAO.read(tokenId).map{
      case Some(MailToken(dbTokenId, userId, expirationDate, tokenKind)) =>
          mailTokenDAO.delete(dbTokenId)
          tokenValidation(userId, expirationDate, tokenKind == kind)
      case _ => None
    }
  }

  def saveToken(token: MailToken): Future[WriteResult] = mailTokenDAO.create(token)

  def tokenValidation(userId: UUID, expirationDate: DateTime, kindMatch: Boolean): Option[UUID] = {
    if (expirationDate.isAfterNow && kindMatch) Option(userId) else None
  }

}

こんな感じになりました。読めばわかる!(疲れてきた)

読んでわからなさそうなところは、青字にした

x.map{
  case Hoge(y) => Option(y)
  case _ => None
}

的なところかと思います。これについては拙著の記事があるのでそちらを読んでみてください。

途中の

val link = routes.SignUpController.mailConfirm(mailToken.id.toString).absoluteURL()

部分で、reverse router を使っています。reverse router は名前の通り、通常アクセスされたURLから呼び出されるメソッドに対する routing を行っている router を使って、逆に、ある Controller のメソッドのURLを取得できる仕組みです。

以前の章でも書いたとおり、routes ファイルは Scala としてコンパイルされるので、ちゃんと reverse router も静的に解決されます。逆に言うと、今 SignUpController には mailConfirm(mailToken: String) というメソッドが存在しないので、この routes.SignUpCotroller.mailConfirm(_) というところは解決できません。なので、仮実装を埋めちゃいましょう。

SignUpController に以下のように追記しておきます。

def mailConfirm(token: String) = Action.async { implicit request =>
  Future(Redirect(routes.ApplicationController.index()))
}

特になんもせず Redirect するだけです。

これにあわせて conf/routes も編集しておきましょう。以下を追加しておきます。

# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

# Home page
GET         /                            controllers.ApplicationController.index
GET         /signIn                      controllers.ApplicationController.signIn
GET         /signUp                      controllers.ApplicationController.signUp
GET         /signOut                     controllers.ApplicationController.signOut
GET         /authenticate/:provider      controllers.SocialAuthController.authenticate(provider)
POST        /authenticate/credentials    controllers.CredentialsAuthController.authenticate
POST        /signUp                      controllers.SignUpController.signUp
GET         /mailConfirm/:token          controllers.SignUpController.mailConfirm(token: String)

# Map static resources from the /public folder to the /assets URL path
GET         /assets/*file                controllers.Assets.at(path="/public", file)
GET         /webjars/*file               controllers.WebJarAssets.at(file)

DI のバインディングを行う

ここまで、インターフェース(trait)と実装を分離してきたので、この関係を Guice に教えてあげます。app/modules/SilhouetteModule の configure を変更する。これ書いておかないと実装がねーよ!って実行時に怒られるので最初悩んだ。

def configure() {
  bind[UserService].to[UserServiceImpl]
  bind[UserDAO].to[UserDAOImpl]
  bind[MailService].to[MailServiceImpl]
  bind[MailTokenDAO].to[MailTokenDAOImpl]
  bind[DB].toInstance {
    import com.typesafe.config.ConfigFactory
    import scala.concurrent.ExecutionContext.Implicits.global
    import scala.collection.JavaConversions._

    val config = ConfigFactory.load
    val driver = new MongoDriver
    val connection = driver.connection(
      config.getStringList("mongodb.servers"),
      MongoConnectionOptions(),
      Seq()
    )
    connection.db(config.getString("mongodb.db"))
  }
  ...
}

これでメール認証に関する機能は実装完了。

User モデルの修正

まだ誰も使わない抽象的なメールサービスができたので、次はメール認証済みかどうかのフラグを User model に追加します。

case class User(
  userID: UUID,
  loginInfo: LoginInfo,
  firstName: Option[String],
  lastName: Option[String],
  fullName: Option[String],
  email: Option[String],
  avatarURL: Option[String],
  mailConfirmed: Option[Boolean]) extends Identity

また、UserDAO についても後で使うために少し機能を追加します。

UserDAO

def update(user: User): Future[User]

UserDAOImpl

def update(user: User): Future[User] = {
  collection.update(Json.obj("userID" -> user.userID), user)
  Future.successful(user)
}

これで、User.userID が同じデータを更新できるようになりました。

これにともなって、UserService の定義 と UserServiceImplの実装も修正します。

まず、UserService に UUID で User を取得できるように UserService.retrieve(userId: UUID) を追加します。

def retrieve(userId: UUID): Future[Option[User]]

続いて UserServiceImpl の修正・追加をします。変更箇所は3ヶ所で、save(user: User) と save(profile: CommonSocialProfile) をそれぞれ修正、retrieve(userId: UUID) を追加します。

def save(user: User) = {
  userDAO.find(user.userID).flatMap{
    case Some(u) => // Update except User.userID
      userDAO.update(u.copy(
        loginInfo = user.loginInfo,
        firstName = user.firstName,
        lastName = user.lastName,
        fullName = user.fullName,
        email = user.email,
        avatarURL = user.avatarURL,
        mailConfirmed = user.mailConfirmed))
    case None => userDAO.save(user)
  }
}

def save(profile: CommonSocialProfile) = {
  userDAO.find(profile.loginInfo).flatMap {
    case Some(user) => // Update user with profile
      userDAO.save(user.copy(
        firstName = profile.firstName,
        lastName = profile.lastName,
        fullName = profile.fullName,
        email = profile.email,
        avatarURL = profile.avatarURL
      ))
    case None => // Insert a new user
      userDAO.save(User(
        userID = UUID.randomUUID(),
        loginInfo = profile.loginInfo,
        firstName = profile.firstName,
        lastName = profile.lastName,
        fullName = profile.fullName,
        email = profile.email,
        avatarURL = profile.avatarURL,
        mailConfirmed = None
      ))
  }
}

def retrieve(userId: UUID): Future[Option[User]] = userDAO.find(userId)

これでメールアドレスが認証されたかどうかが保持されるようになりました。しかし、Service に影響するし、Model はあとから変更したくないなぁ…

これで User model の修正は完了。

SignUpController でメール送信・認証処理実装

アカウント登録時のメール送信

続いて、アカウント作成時に認証用のメールを送信するようにします。まず、MailService を SignUpController で使えるように Inject しましょう。

class SignUpController @Inject() (
  val messagesApi: MessagesApi,
  val env: Environment[User, CookieAuthenticator],
  userService: UserService,
  authInfoRepository: AuthInfoRepository,
  avatarService: AvatarService,
  passwordHasher: PasswordHasher,
  mailService: MailService)
  extends Silhouette[User, CookieAuthenticator] {
  ...

続いて、アカウント作成の処理にメール送信を混ぜ込みます。

def signUp = Action.async { implicit request =>
  SignUpForm.form.bindFromRequest.fold(
    form => Future.successful(BadRequest(views.html.signUp(form))),
    data => {
      val loginInfo = LoginInfo(CredentialsProvider.ID, data.email)
      userService.retrieve(loginInfo).flatMap {
        case Some(user) =>
          Future.successful(Redirect(routes.ApplicationController.signUp()).flashing("error" -> Messages("user.exists")))
        case None =>
          val authInfo = passwordHasher.hash(data.password)
          val user = User(
            userID = UUID.randomUUID(),
            loginInfo = loginInfo,
            firstName = Some(data.firstName),
            lastName = Some(data.lastName),
            fullName = Some(data.firstName + " " + data.lastName),
            email = Some(data.email),
            avatarURL = None,
            mailConfirmed = None
          )
          for { // 認証に必要な authenticator を作成するところにメール送信を追加
            avatar <- avatarService.retrieveURL(data.email)
            user <- userService.save(user.copy(avatarURL = avatar))
            _ <- mailService.sendConfirm(user)
            authInfo <- authInfoRepository.add(loginInfo, authInfo)
            authenticator <- env.authenticatorService.create(loginInfo)
            value <- env.authenticatorService.init(authenticator)
            result <- env.authenticatorService.embed(value, Redirect(routes.ApplicationController.index()))
          } yield {
            env.eventBus.publish(SignUpEvent(user, request, request2Messages))
            env.eventBus.publish(LoginEvent(user, request, request2Messages))
            result
          }
      }
    }
  )
}

この signUp で新しいアカウントをつくるための準備処理を for式 でばしばしつなげています。この準備処理は Future 型の非同期処理が組み合わさっているので、このプロジェクトでは for式を利用して、flatMap・map 地獄にならないように書いています。

その一部に、アカウント作成に必要なメール送信処理 sendconfirm(user: User) を混ぜています(sendconfirm の戻り値も Future)。ただ、戻り値は特に使わないのでプレースホルダで捨てています。これで、アカウント作成時にメールが送信されるようになりましたとさ。

実際に動かしてサインアップしてみると、コンソールにメール本体が出力されているのが確認できると思う。

メール認証アドレスにアクセスされた

続いて、メール認証用のアドレスにアクセスがあった場合に、User.mailConfirmed を変更するようにします。成功した場合にはそのまま index に redirect するようにし、エラーが有った場合には一律エラーページに遷移させるようにします。本当はもっとちゃんとエラー処理したいけど、今回は割り切り。

まず、エラーページの view をつくってみます。app/views/mailConfirmError.scala.html を追加します。(WordPressがクソで貼り付けると壊れるのでテキストは github で参照してください…)

スクリーンショット 2016-04-26 11.15.11.png

これに合わせて、mesages にも追記します。

error.mailconfirm = Something wrong with confirming your address. Sorry.

これで view ができました。この view を表示するように ApplicationController を編集します。

def error = Action { implicit request =>
  Ok(views.html.mailConfirmError())
}

たぶんこれチュートリアル的には一番最初にやるやつだ…!

このままだと、だれもこのエラーページにアクセス出来ないので、routes に教えてあげます。

# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

# Home page
GET         /                                controllers.ApplicationController.index
GET         /signIn                          controllers.ApplicationController.signIn
GET         /signUp                          controllers.ApplicationController.signUp
GET         /signOut                         controllers.ApplicationController.signOut
GET         /error/mailConfirm               controllers.ApplicationController.error
GET         /authenticate/:provider          controllers.SocialAuthController.authenticate(provider)
POST        /authenticate/credentials        controllers.CredentialsAuthController.authenticate
POST        /signUp                          controllers.SignUpController.signUp
GET         /mailConfirm/:token              controllers.SignUpController.mailConfirm(token: String)

# Map static resources from the /public folder to the /assets URL path
GET         /assets/*file                    controllers.Assets.at(path="/public", file)
GET         /webjars/*file                   controllers.WebJarAssets.at(file)

たぶんこれチュートリアル的には(ry

最後にこれらを使って User.mailConfirmed を更新します。SignUpController で仮置きしていた mailConfirm(token: String) を以下のように書き換えます。

def mailConfirm(token: String) = Action.async { implicit request =>
  mailService.consumeToken(UUID.fromString(token), "confirm").
    flatMap{
      case Some(userId) =>
        userService.retrieve(userId).map{
          case Some(user) =>
            userService.save(user.copy(mailConfirmed = Some(true)))
            Redirect(routes.ApplicationController.index())
          case None => Redirect(routes.ApplicationController.error())
        }
      case None => Future(Redirect(routes.ApplicationController.error()))
    }
}

ちょっとまどろっこしい感じになってしまったのですが、

  1. consumeToken から Future[Option[UUID]] を得る
  2. 得られた UUID に対応する Future[Option[User]] を得る
  3. User に対して mailConfirmed を書き換えて DB を更新する
  4. index に飛ばす

ということをしています。None が帰ってきた場合には一括してエラーケースとして全部エラーページに飛ばしています。とりあえず練習だから許して。

ここまで実装して、

  1. 新規アカウント作成
  2. コンソールにメール表示
  3. メールに表示されているアドレスにアクセス(うまくいけばindexに戻される)
  4. mongo の端末でデータを確認すると “mailConfirmed” : true になってるはず

までできました。できたはず。

メール認証状態の表示

ただ、これだけだとあんまりなので、メール認証したかどうかをヘッダーに表示するように変更する。ヘッダー部分のような共通部は main.scala.html が持っているので、これを編集する。

<ul class="nav navbar-nav navbar-right">
    @user.map { u =>
        <li><a href="@routes.ApplicationController.index">@u.fullName</a></li>
        <li><a href="@routes.ApplicationController.index">@u.mailConfirmed.map{ mc =>
                @if(mc) {
                    @Messages("user.stats.mailconfirmed")
                } else {
                    @Messages("user.stats.mailnotconfirmed")
                }
            }.getOrElse{@Messages("user.stats.mailnotconfirmed")}</a></li>
        <li><a href="@routes.ApplicationController.signOut">@Messages("sign.out")</a></li>
    }.getOrElse {
        <li><a href="@routes.ApplicationController.signIn">@Messages("sign.in")</a></li>
        <li><a href="@routes.ApplicationController.signUp">@Messages("sign.up")</a></li>
    }
</ul>

40行目あたりにある @user.map{…} というところでログイン時の右上の名前表示を出している。今回はそのとなりに、メール認証されていれば Adress confirmed、されていなければ Adress not confirmed と表示されるようにしてみる。やっていることはヘッダー用の HTML 構造(上下のを見よう見まね)して、表示する内容を User.mailConfirmed 要素を確認して切り替えている。簡単。

いつも通り、messages にも追記する。

user.stats.mailconfirmed = Address confirmed
user.stats.mailnotconfirmed = Address not confirmed

これで、ログインしているユーザーのメール認証状態がヘッダーに表示される様になる。

スクリーンショット 2016-04-26 14.30.14.png

ヤッター!

本当はここで Address not confirmed のリンクを押すと、再度メール認証用のメールが飛ぶとか実装するとユーザーフレンドリーだと思うけど、それは読者の課題とする(訳: もうだいたいの要素触ったからあとは自分で頑張れ疲れた)。

まとめ

というわけで、Play + Scala + mongo + Silhouette のスーパーダンガンツアーでした。

Controller とかにベタ書きしちゃったり、お世辞にも綺麗とはいえないコードになっているけど、とりあえず主要な要素を触った・動いたのでオシマイマイ。

広告

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト / 変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト / 変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト / 変更 )

Google+ フォト

Google+ アカウントを使ってコメントしています。 ログアウト / 変更 )

%s と連携中