【Rails × sorcery】Slackみたいなパスワードなしのメールだけログイン機能を実装してみる

  • 32
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。


(Image from auth0)

前提

やりたいこと

パスワードはそろそろしんどい

そろそろログインにパスワードっていらないんじゃないですか、
みたいな意識高い系の議論があったりします。

TechCrunch「Google、パスワードなしのログインのテストを開始

メール受け取れてれば、もう本人でいいじゃない

上記のGoogle/Facebookとはちょっと違うアプローチだけど、
パスワードをなくしてメールで都度トークン付きのURLを送り、
それで認証しちゃえばいいじゃないというのは、
シンプルですてきなアイデアに思えました。

おしゃれで有名なMediumとかがやってますね。

Mediumがパスワードなし・メールだけログイン導入。ワンタイムURLを毎回送信

またSlackも同様のトークン付きURLによるログインを
magic linkとして提供しています。

もちろんメールアカウント乗っ取りとかメール傍受とかのリスクはあるけど、
パスワード覚えられなくて簡単なのにしちゃったりするリスクと比べてどうでしょう、とは思う。
そしてGmailアカウント乗っ取られてるような状態になったら、
もうしょうがないよねとも思う。

実装方針

せっかくなのでシンプルにsorceryベースで

ゼロから作るのもしんどいので、
既存の仕組みに乗っかって楽する方法がないかを検討。

結果、シンプルな認証gem「sorcery」を拡張して実現してみることにしました。

よく言われてますが、このメールのみ認証って
パスワード忘れた時のワンタイムログインとやってることはほぼ同じ。

sorceryでもreset_passwordの仕組みが標準で実装されてるので
これをうまく使えばいけるんじゃないかという感じです。

deviseでもよかったんだけど

Railsで認証といえば全部盛りのdeviseが鉄板という感じだけど、
せっかくシンプルな認証システムにするのでベースのgemもシンプルにしたいよね、
ということで今回はsorceryをチョイス。

ちなみに、探したらnopasswordっていういい感じのgemがあったんだけど、
メンテされてなくて新しいRailsだと動かない。。ので断念。

alsmola/nopasword

MagicLogin拡張済みsorcery

というわけでパスワードなしのログインをmagic loginと名付け
(Slackがmagic linkという表現を使ってるので)
本家からforkしてこの機能を実装したブランチをGithubにあげています。

notsobad-jp/sorcery

magicloginブランチで実装しています。
そのうちプルリクもしてみたいけどどうだろう。。

以下このgemを使って実際にRailsアプリを作ってみます。

野良gemがいやだという人は、本家のgemそのままでも
reset_passwordモジュールをうまく上書きすれば同じことができると思います。
(というか最初そうしてたけど、さすがに気持ち悪くなって別モジュールにしたという。。)

実装

Railsアプリ作成

rails new

railsの新規アプリ作成は、それぞれ流派があると思うのでご自由に。

rails new APP_NAME --skip-bundle -T -d postgresql

今回はrails4.2.6で動作確認しています。
sorcery自体はrails3,4対応って言うてますね。

Gem追加

そのあとはGemfileにsourcery追加。
本家からforkして拡張したverです。

Gemfile
gem 'sorcery', git: 'notsobad-jp/sorcery', branch: 'magiclogin'

で、bundle実行。

bundle install --path vendor/bundle

DBセットアップ

rake db:create

sorcery初期設定

rails g sorcery:install core magic_login
rake db:migrate

基本これだけで問題なし。
デフォルトで色々いい感じに設定されています。

db.png

usersテーブル作成、email, crypted_password, saltあたりは
coreで追加されるカラム。

magic_login用に追加されるのは、以下の3カラム。

  • magic_login_token
  • magic_login_token_expires_at
  • magic_login_email_sent_at

もともとあるreset_passwordモジュールとほぼ同じ構造にしています。

もし各種パラメータの初期値(カラム名とか)を変えたい場合とかは、
migrateするまえにconfig/initializers/sorcery.rbをいじると吉。

Controller

Sorceryはあくまでlibraryなので、
自分のMVCの中で必要なメソッドを呼んでね、というスタンス。

なのでcontrollerとかはちゃんと自分で作ります。

rails g controller MagicTokens new create verify destroy

これで作成されるcontrollerをこんな感じに修正。

app/controllers/magic_tokens_controller.rb
class MagicTokensController < ApplicationController
  skip_before_filter :require_login, except: :destroy

  # show email form
  def new
  end

  # receive email from form submit
  def create
    @user = User.find_or_create_by(email: params[:email])
    @user.deliver_magic_login_instructions!
    redirect_to(root_path, notice: 'Instructions have been sent to your email.')
  end

  # verify token when user come from email link
  def auth
    @token = params[:token]
    @user = User.load_from_magic_login_token(params[:token])

    if @user.blank?
      not_authenticated
      return
    else
      auto_login(@user)
      @user.clear_magic_login_token!
      redirect_to(root_path, notice: 'Logged in successfully')
    end
  end

  # destroy user session
  def destroy
    logout
    redirect_to(root_path, notice: 'Logged out!')
  end
end

ログイン状態はUserSessionというリソースで管理するのがいいんだろうけど、
今回はちょっとぴったりこないのでMagicTokenをリソースにしてみました。
(ここでいうauthのタイミングがsession#create。でもgetだし、メール送信までの流れはsession関係ないし。。)

まぁこのあたりは全く制約ないので、気持ち悪い人は好きに変えていただければ。

ルーティング

こんな感じにしてみました。

config/routes.rb
Rails.application.routes.draw do
  resources :magic_tokens

  get 'login' => 'magic_tokens#new', as: :login
  get 'auth' => 'magic_tokens#auth', as: :auth
  post 'logout' => 'magic_tokens#destroy', as: :logout
end

処理の流れはこんな感じ。

  • magic_tokens#new : emailフォーム表示
  • magic_tokens#create : postでemailを受け取り、tokenをメール送信
  • magic_tokens#auth : メールのURLからのアクセス。リンクのパラメータとDBの値を突き合わせて一致すればログイン成功。

この辺もお好みでどうぞ。

メール設定

ActionMailer作成

まずはmailerを作成。

rails g mailer UserMailer magic_login_email

UserMailerを作って、magic_login_emailというメソッドを用意。

app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  def magic_login_email(user)
    @user = User.find user.id
    @url  = "http://localhost:3000" + auth_path(token: @user.magic_login_token)

    mail(to: @user.email, subject: "Magic Login")
  end
end

config修正

先ほど作成したmailerクラスを使用するようにconfigで指定します。

config/initializers/sorcery.rb
...
# mailer class. Needed.
# Default: `nil`
#
user.magic_login_mailer = UserMailer
...

開発環境ではとりあえずGmailで

localではさくっとテストしたいので、Gmailでお手軽に試す。

二段階認証してる場合はapp passwordの取得が必要でした。
してない場合も低セキュリティアプリの承認とかってプロセスが入るみたい。

【Rails】ActionMailer を用いて Gmail を使う際にハマった点

以下はapp passwordを取得・設定した場合の記述。

config/environments/development.rb
  ...
  # Don't care if the mailer can't send.
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :smtp
  config.action_mailer.smtp_settings = {
    :enable_starttls_auto => true,
    :address => 'smtp.gmail.com',
    :port => '587',
    :domain => 'smtp.gmail.com',
    :authentication => 'plain',
    :user_name => 'xxxx@gmail.com',
    :password => 'YOUR_APP_PASSWORD'
  }
  ...

このあたりで一度作成されたmailerをいじって
メールが実際に送信できることをテスト。

app/controllers/magic_tokens.rb
...
  def new
    # テスト用。動作確認したらすぐ削除。
    user = User.create(email: xxxx)
    UserMailer.magic_login_email(user).deliver_now
  end
...

とかって書いて、localhost:3000/loginにアクセス。
(webrickでrails sした場合)

deliverメソッドがduplicateになって、
すぐに送りたい場合はdeliver_nowだそうです。

無事にメールが送れたらもう一息。

View

ひととおり処理ができたので、最低限のviewを用意してつないでみます。

ログイン画面

メアドを送信するだけの超シンプルなフォームです。

app/views/magic_tokens/new.html.erb
<h1>Login</h1>
<%= form_tag magic_tokens_path, method: :post do %>
  <div class="field">
    <%= label_tag :email %><br />
    <%= text_field_tag :email %>
  </div>
  <div class="actions">
    <%= submit_tag "Magic Login" %>
  </div>
<% end %>

ホーム画面

メール送信後・token認証後にrootにリダイレクトさせてるので、
viewとして作成するのはさっきのログイン画面だけだったりします。

そのかわりflashメッセージを持ってリダイレクトさせてるので、
テスト用にそれを表示するホーム画面とcontrollerも用意してみます。

rails g controller static_pages index

でgenerateして、viewをこんな感じに修正。

app/views/static_pages/index.html.erb
<h1>Home</h1>

<!-- Flash -->
<% if flash[:notice] %>
  <div class="alert alert-success">
    <%= flash[:notice] %>
  </div>
<% end %>

<!-- when logged in -->
<% if current_user %>
  <p>Welcome <%= current_user.email %>!</p>
  <%= link_to "Logout", :logout, method: :post %>
<!-- when not logged in -->
<% else %>
  <p>Not logged in.</p>
  <%= link_to 'Login', login_path %>
<% end %>

あとrootがこの画面になるようにroutingも修正。

config/routes.rb
Rails.application.routes.draw do
  root :to => 'static_pages#index' 
  ...

メール文面

最後に実際に送信されるメールの文面も修正。

app/views/user_mailer/magic_login_email.text.erb
Hello, <%= @user.email %>
===============================================

You have requested a magic link for logging in.

To login, just follow the link below:

<%= @url %>

Have a great day!

これでワンタイムログインURLが送られるはず!

動作確認

ひととおりの流れ

ここまでできたらいよいよ動作確認です。

rails sとかで起動しといて、まずはlocalhost:3000とかにそのままアクセス。

home.png
味気ないホーム画面。
とりあえずログインリンクをクリック。

login.png
ログイン画面。
メアド入れて送信ボタンをクリック。

create.png
ホームに戻ってきました。
スタイルあたってなくてカッコ悪いけど、
flashメッセージでメールが送られたらしいことが分かります。
メールが届いたらリンクをクリック。

auth.png
無事にログイン成功。
どうやら機能してるようです。めでたい!

裏側のチェック

基本的な動作はできたので、
ほか気になるところをぱらぱらとチェック。

新規登録/ログイン

新規登録もログインも同じルートにしてるけど、
未登録のメアドなら新規登録、登録済みならログインって感じで処理できてます。
なのでユーザーは特に意識せずにメアド入れるだけ。

意図的に処理を分けたいこともあるやろうけど、
LoginとSignupのリンクが違ってイライラしたことがある人も少なくないよね。。

もし分けたい場合は、このあたりの制御も全部controller側でできるので、
カスタマイズも簡単です。

さっきのMagicTokensController#createで、

app/controllers/magic_tokens_controller.rb
def create
  @user = User.find_or_create_by(email: params[:email])
  ...

となってるところで処理を分岐させればオッケー。

tokenの有効期限

デフォルトでtokenの有効期限は15分。
期限過ぎてるtokenでアクセスしても、

app/controllers/magic_tokens_controller.rb
def auth
  @user = User.load_from_magic_login_token(params[:token])
  ...

のところでuserが返ってこないのでちゃんとログイン失敗します。
かしこい!

このへんはreset_passwordですでに仕組みがあったので
それにそのまま乗っかっています。

ログイン成功後のtoken clear

tokenの有効期限はデフォルトで15分になってるけど、
時間内でも一度ログインしたらそのtokenはclearされます。

まとめ

というわけで、色々はしょってますがいったんこんな感じで。
sorceryのおかげでかなりシンプルにできました。

メールだけログインもなかなか実案件ではなさそうやけど、
何か機会があれば使ってみたいです。

おしまい。