LoginSignup
59
54

More than 5 years have passed since last update.

【チュートリアル形式】サーバーサイド初心者が Play Framework 2.4 + Scala + Silhouette で認証認可・アカウント登録・メール認証を実装して Web サービス開発入門

Last updated at Posted at 2016-04-27

拙作のブログ記事をチュートリアルとして加筆修正したものになります。

Web サービスとかサーバーサイドとかどう手を付けていいかわからない!そもそも知らない概念が多すぎる!という方向けの弾丸チュートリアルです。初心者の私が実際に辿った道をまとめています。

チュートリアル形式ということで、体系的な知識というよりは実際に作ってみながら必要な要素を学ぶという形式になっています。ご了承ください。

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

対象

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

ゴール

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

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

利用技術の概要・導入

Play Framework の導入

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

今回は Play Framework 2.4.4 を使用しています。Play 2.4 以降であれば大丈夫だと思いますが、Play 2.3 以前と 2.4 以降だとかなり変更が入っているので、Play 2.4 以降が対象です。

Play Framework

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

この sbt は Scala のほぼデファクトスタンダードのビルドツールなんですが、優秀なので Scala の開発環境もこの sbt が解決してくれるので環境構築する必要はありません。すばらしい。(Javaの開発環境は事前に構築しておいてください)

ただ、初回は依存解消のために結構時間かかるかも。お茶でも淹れてのんびり待ちましょう。

Scala とは

Java の親戚みたいな新しめの言語です。JVM 向けのバイナリを吐くので、Javaの実行環境で動作します。似たような JVM 方言としては Clojure とか Kotlin なんかもありますね。

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

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

Silhouette とは

Silhouette は Play Framework 向けの認証認可ライブラリ(いわゆるアカウント管理的なやつ)です。Play Framework にはいくつか有名な認証認可ライブラリがあって、その中で機能も充実してて有名なのは Secure Social で、Google で検索しても認証認可ライブラリとしてはほぼこれがでてくるんだけど、いかんせん Play 2.4 系の大変更についてこられてない感じで、ドキュメントも 2.1.x 系をベースに書かれていて大混乱。

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

まだ新しい方のライブラリだからか、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 プラグインをインストールしておいてください。

Play Framework Application の実態は sbt プロジェクトなので、IntelliJ IDEA 上で import -> プロジェクトルートディレクトリ選択 -> SBT で特に問題なく取り込めます。インポートオプションとして Auto-import にチェックを入れておくと、build.sbt という sbt の設定ファイルを書き換えた時に自動的に 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 が追加されていれば、IntelliJ IDEA 上のシンタックスエラーは解除される。

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

これで準備は完了。

起動してみる

mongoDB はいい感じにインストールしてデーモンを適当に起動しておいてください。

~$ mongod --dbpath /your/db/path --logpath /your/log/path.log

Play + Scala と mongoDB との接続は Reactive Mongo で行います。mongo は Relational じゃないから ORM ではないと思うんだけど、Reactive Mongo は ORM 的なライブラリで、いい感じに DB アクセスをラッピングしてくれているようです。

この 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.18.png

サインアップしてユーザーデータを追加してみたら、mongoDB のデータを見てみましょう。(見づらいので一部整形してます)

~$ mongo
MongoDB shell version: 3.0.7
connecting to: test
> show dbs
local       0.078GB
silhouette  0.078GB
> use silhouette
switched to db silhouette
> show collections
password
system.indexes
user
> db.user.find()
{
  "_id" : ObjectId("571ef4c6183c3bdfaa4bc311"),
  "userID" : "b8704940-9792-4fbb-a19b-e83cf7b17d68",
  "loginInfo" : {
    "providerID" : "credentials",
    "providerKey" : "hoge@hoge.hoge"
  },
  "firstName" : "Hoge",
  "lastName" : "Hoge",
  "fullName" : "Hoge Hoge",
  "email" : "hoge@hoge.hoge"
}
> 

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

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

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

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

app/controllers

ApplicationController を見てみる。

ApplicationCntroller.scala
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 を受け取って応答を返すもののようですね。MVC における Controller です。

注目ポイントは2つで、@Injectdef hoge = HogeAction.async { ??? } です。

まずは @Inject から。Play Application では Controller は Class で、Play Framework によってインスタンス化されます。この際に、Play 2.4 以降では Guice という動的 DI の仕組みが導入されました。DI (Dependency Injection) はものすごく簡単に言うと、モジュールを疎結合にするために、依存対象となるモジュールを実装内部に抱えるのをやめて、外部から注入しようというものです。これによって、モジュールが疎結合になって単体テストがやりやすくなったり設計上よろしいということで、Play 2.4 から導入されました。

@Inject アノテーションはその Guice を利用するためのものです。これによって、コンストラクタ引数がいい感じに解決されて、依存性が注入されるということです。なので、Controller で使いたい自作 Class などをつくった時は、この @Inject のあとのコンストラクタ引数に自分の Class を引数として渡してあげればOKです。

続いて Action ですが、ある URL にアクセスした際にどういった処理をして View を返すかという Controller の具体的動作を実装します。SecuredAction や UserAwareAction のように、認可の種別によって Action を切り替えて、Ok や Redirect など HTTP の応答を返しています。この HogeAction は Silhouette によって提供されていて、request に含まれる authenticator の情報を元に Silhouette が自動的に認可を行ってくれます。便利。

app/models

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

DAO では Reactive Mongo を利用して永続化を行っているのですが、Reactive Mongo の利用法については力尽きたのでソースコード読んで Reactive Mongo のチュートリアル読んでください(そんなに難しくない)。今回は case class を使って DB アクセする方法を利用しています。

app/modules

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

SilhouetteModule.scala
package modules

import ...

/**
 * The Guice module which wires all Silhouette dependencies.
 */
class SilhouetteModule extends AbstractModule with ScalaModule {

  /**
   * Configures the module.
   */
  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"))
    }
    ...
  }
  ...
}

app/utils

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

app/views

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

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

conf

Play application の設定ファイル application.conf や、多言語用のテキストを格納する messages ファイル、ルーティングを記述する routes ファイルなどがある。すべての中身に触れてるときりがないので、重要な 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)

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

GET         /authenticate/:provider          controllers.SocialAuthController.authenticate(provider)

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

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

User モデルの修正

この雛形プロジェクトをまず触りましたが、とりあえずサインアップ・サインイン・サインアウトはすでに実装されています。このチュートリアルでは、これにサインアップ時のメールアドレス認証処理を追加していきます。まず、User model にメール認証したかどうかの状態を保持できるようにするために、User model を修正しましょう。

User のデータ構造を示す User クラスをまず修正します。

User.scala
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

最後の引数の mailConfirmed: Option[Boolean] を追記しました。Scala では case class という特殊な class があり、いろいろと便利なことをやってくれます。case class ではコンストラクタ引数が自動的にプロパティとして扱われるので、とくにコンストラクタを実装する必要はありません。また、この case class を Play がそのまま JSON に相互変換してくれます。

User.scala
object User {

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

}

case class サイコ~~~~~~~~。

続いて、UserDAO の定義と実装を修正していきます。以下をそれぞれに追加します。

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

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

これらをうけて、User を操作するための UserService の定義と実装も修正します。

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

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

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

UserServiceImpl.scala
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 の修正は完了。

メール認証を実装する

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

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

まず、メールを送れるように 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)) {
    <div class="user col-md-6 col-md-offset-3">
        <div class="row">
            <hr class="col-md-12" />
            <h4 class="col-md-8">@Messages("welcome.signed.in")</h4>
            <div class="col-md-4 text-right">
                <img src="@user.avatarURL.getOrElse(routes.Assets.at("images/silhouette.png"))" height="40px" />
            </div>
            <hr class="col-md-12" />
        </div>
        <div class="row data">
            <div class="col-md-12">
                <div class="row">
                    <p class="col-md-6"><strong>@Messages("first.name"):</strong></p><p class="col-md-6">@user.firstName.getOrElse("None")</p>
                </div>
                <div class="row">
                    <p class="col-md-6"><strong>@Messages("last.name"):</strong></p><p class="col-md-6">@user.lastName.getOrElse("None")</p>
                </div>
                <div class="row">
                    <p class="col-md-6"><strong>@Messages("full.name"):</strong></p><p class="col-md-6">@user.fullName.getOrElse("None")</p>
                </div>
                <div class="row">
                    <p class="col-md-6"><strong>@Messages("email"):</strong></p><p class="col-md-6">@user.email.getOrElse("None")</p>
                </div>
            </div>
        </div>
    </div>
}

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

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

と書かれている。この部分がこの view の”引数”になっている。ちゃんと型もつけられる。この view では models.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 として作成した。とりあえず、作成したファイルの内容はこんな感じ。

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 と呼ばれる多言語対応のための仕組みです。たとえば、上記の 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 のテキストを記述しておく。今回は英語だけです。

messages
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 を作成する。

MailToken.scala
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 を定義・実装する。

MailTokenDAO.scala
package models.daos

import java.util.UUID

import models.MailToken
import reactivemongo.api.commands.WriteResult

import scala.concurrent.Future

trait MailTokenDAO {

  def create(token: MailToken): Future[WriteResult]

  def read(tokenId: UUID): Future[Option[MailToken]]

  def delete(tokenId: UUID): Future[WriteResult]

}

MailTokenDAOImpl.scala
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 のインターフェースは

MailService.scala
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]]

}

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

MailServiceImpl.scala
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
  }

}

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

読んでわからなさそうなところは、

def sendConfirm(user: User)(implicit request: RequestHeader): Future[String]

(implicit request: RequestHeader) とか

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

的なところかと思います。結論から言うと

  • implicit の部分は暗黙のパラメータで、HTTP アクセスのリクエストを自動的に渡すために使っています
  • Scala では {case Pattern(x) => 処理1; case _ => 処理2} のような書き方で入力ごとに処理を切り分けた部分関数を定義できます

これらについては拙作の記事、

Play Framework 2.4 で reverse router の absoluteURL を Controller 外で使ったら RequestHeader がねーよって言われて解決した話

Scalaでdef hoge = { case _ => fuga() }がパターンマッチのように見えるけどどうなってるのかわからなかったので調べたら部分関数(PartialFunction)について学びがあった話

があるのでそちらを読んでみてください。

途中の

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(_: String) というところは解決できません。なので、仮実装を埋めちゃいましょう。

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 を変更する。これ書いておかないと実装がねーよ!って実行時に怒られるので最初悩んだ。

SilhouetteModule
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"))
  }
  ...
}

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

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

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

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

SignUpController.scala
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] {
  ...

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

SignUpController.scala
def signUp = Action.async { implicit request =>
  SignUpForm.form.bindFromRequest.fold( // リクエストを SignUpForm の形式でパース
    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( // フォームのデータから新規 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))
            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))
            mailService.sendConfirm(user) // ここでメール送信
            result
          }
      }
    }
  )
}

この signUp で新しいアカウントをつくり、認可に必要な authenticator 準備処理を for式 部分でばしばしつなげて作っています。authenticator は Silhouette の認可用のデータセットで、今後何らかの方法でクライアントは HTTP Request Header にこの authenticator を埋め込むことで、Play + Silhouette は認可 = 誰がアクセスしてきていて、そのアカウントはログイン済みで、アクセス対象にアクセス許可されているか、を検証します。authenticator についてはまだ自分も理解不足なので、あらためて Silhouette 別記事でまとめたいです。

for式 部分で無事 authenticator を作成できたら、yield で eventBus に SignUpEvent/LoginEvent を送出します。これらの Event をフックして処理を走らせたりできますが割愛。

その後、認証メール送信処理 sendconfirm(user: User) を追加しています。これで、アカウント作成時にメールが送信されるようになりました。

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

スクリーンショット 2016-04-28 01.38.47.png

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

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

まず、エラーページの View をつくってみます。app/views/mailConfirmError.scala.html を追加します。

mailConfirmError.scala.html
@()(implicit messages: Messages)

@main(Messages("home.title"), None) {
<div class="user col-md-6 col-md-offset-3">
    <div class="row">
        <hr class="col-md-12" />
        <h4 class="col-md-8">@Messages("error.mailconfirm")</h4>
        <hr class="col-md-12" />
    </div>
</div>
}

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

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

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

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

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

このままだと、だれもこのエラーページにアクセス出来ないので、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         /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) を以下のように書き換えます。

SignUpController.scala
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 が持っているので、これを編集する。

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>

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

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

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

59
54
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
59
54