Rails 5 API + devise でパスワードのリセット

Rails 5 API + devise でパスワードのリセット

前回: http://uraway.hatenablog.com/entry/2016/07/11/090206

Rails 5 API + devise の資料があんまりなかったのでメモ。前提として devise を使ったユーザー登録、ログイン機能を持っている API を作成しているとします。

また、 この一連のチュートリアルでは devise_token_auth は使用しません。

ソースコードGitHub にあるので、gem のバージョンやフォルダ構成などの参考にしてください。

  • rails (5.2.2)
  • devise (4.5.0)

mailcatcher の導入

今回はメーラーを使用するので、mailcatcher を導入します。設定が楽なので Docker を使いますが必須ではないです。

# Dockerfile
FROM ruby:2.5

ENV APP_ROOT /usr/src/devise-api

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs
# SQLite
RUN apt-get install sqlite3 libsqlite3-dev -y

RUN mkdir $APP_ROOT
WORKDIR $APP_ROOT
COPY Gemfile $APP_ROOT/Gemfile
COPY Gemfile.lock $APP_ROOT/Gemfile.lock
RUN bundle install
COPY . $APP_ROOT
# docker-compose.yml
version: "3"
services:
  app:
    build: ./
    command: bundle exec rails s -p 3000 -b '0.0.0.0'
    volumes:
      - .:/usr/src/devise-api
    ports:
      - "3000:3000"
    tty: true
    stdin_open: true
  mailcatcher:
    image: schickling/mailcatcher
    ports:
      - "1080:1080"
      - "1025:1025"

mailcatcher の設定を書き加えます。Docker を使用している場合はホスト名にコンテナ名を使用することができます。

# config/environments/development.rb
  config.action_mailer.smtp_settings = {
    address: 'mailcatcher',
    port: 1025
  }

docker-compose up して、http://localhost:1080 にアクセスしましょう。

パスワードリセットメールを送信する

devise の recoverable をオンにします。

# app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable, :validatable, :recoverable

passwords_controller.rbを新たに作り、ルートを割り当てます。send_reset_password_instructions は devise が用意したメソッドで、ユーザーにパスワードリセットメールを送信します。

このとき注意したいのがレスポンスに関してで、リクエストパラメータのメールアドレスを持つユーザーが存在しないとき、その有無のエラーメッセージを返すのではなく、有無にかかわらず同じメッセージを返したほうが良いでしょう。悪意のある送信者に、そのメールアドレスのユーザーが登録されているかを知られることを防ぐことができます。

# app/controllers/passwords_controller.rb
module V1
  class PasswordsController < ApplicationController
    skip_before_action :authenticate_user_from_token!, only: [:create]

    def create
      user = User.find_by(email: create_params[:email])
      user&.send_reset_password_instructions
      render json: {}
    end

    private

    def create_params
      params.require(:user).permit(:email)
    end
  end
end
# config/routes.rb
    ...
    resource :passwords, only: [:create]
    ...

send_reset_password_instructionsで送信されるメールテンプレートは、app/views/devise/mailers/reset_password_instructions.html.erb にて次のように定義されています。

<p>Hello <%= @resource.email %>!</p>

<p>
  Someone has requested a link to change your password. You can do this through
  the link below.
</p>

<p>
  <%= link_to 'Change my password', edit_password_url(@resource,
  reset_password_token: @token) %>
</p>

<p>If you didn't request this, please ignore this email.</p>
<p>
  Your password won't change until you access the link above and create a new
  one.
</p>

edit_password_url を token 付きのフロントエンドのパスワードリセットページ URL に変えます。

<p>
  <%= link_to 'Change my password', "https://yourfrontend.com?token=#{@token}"%>
</p>

mailcatcher を使ってメールが送信されることを確かめてみましょう。メールアドレスが user@example.com のユーザーが存在するとします。

$ curl -X POST -H "Content-Type: application/json" localhost:3000/v1/passwords --data '{"user": { "email": "user@example.com" } }'

{}

http://localhost:1080 にアクセスして、メールの内容を確認してみます。

Change my password をクリックすると、次のようなリセットパスワードトークン付きの URL が取得できます: https://yourfrontend.com/?token=8XAXEzhFxCnn6zidyb9B

トークンを受け取ってパスワードリセットする

リセットパスワードトークンは暗号化されてデータベースに保存されるため、リセットパスワードトークンからユーザーを見つけ出し、パスワードをリセットするには、devise によって追加されたクラスメソッド User.reset_password_by_token を使用します。このメソッドは reset_password_token/password/password_confirmationを受け取ります。

リセットパスワードトークンと新しいパスワードを受け取り、パスワードを更新するコントローラーを定義しましょう。

# app/controllers/passwords_controller.rb
module V1
  class PasswordsController < ApplicationController
    skip_before_action :authenticate_user_from_token!, only: [:create, :update]

    ...

    def update
      user = User.reset_password_by_token(update_params)
      render json: user, status: :ok, serializer: V1::UserSerializer
    end

    private

    ...

    def update_params
      params.require(:user).permit(:password, :password_confirmation, :reset_password_token)
    end
  end
end
# config/routes.rb
    ...
    resource :passwords, only: [:create, :update]
    ...

パスワードのリセットを試してみましょう。先程送信したメールのクエリにあるトークンと、新しく設定したいパスワードをデータに PUT リクエストを実行します。

$ curl -X PUT -H "Content-Type: application/json" localhost:3000/v1/passwords --data '{"user": {"password": "newpass" ,"reset_password_token": "nysLs8eSLHHZm3eNw2fg"}}'

{"email":"user@example.com"}

パスワードが新しく設定できたかどうかも確認してみましょう。

$ curl -X POST -H "Content-Type: application/json" localhost:3000/v1/login --data '{"email": "user@example.com", "password": "newpass"}'

{"email":"user@example.com","token_type":"Bearer","user_id":1,"access_token":"1:yyjsJ_4oLN6k6z9_rZxE"}

rspec を書いてみよう

リセットパスワードトークンはその期限が config/initializers/devise.rb にて定義されており、任意に変更できます。

  # Time interval you can reset your password with a reset password key.
  # Don't put a too small interval or your users won't have the time to
  # change their passwords.
  config.reset_password_within = 6.hours

rspec を書いてみることで、トークンの期限が正しいかを確認しましょう。rspec 自体の導入方法については割愛します。

Gemfile に以下の 2 つの gem を追記して、bundle install します:

gem 'factory_bot_rails', '~> 4.0'
gem 'rspec-rails', '~> 3.8'

TimeHelpers#travel_to を使って、次のようにリセットパスワードトークンが正しく期限切れになるかどうか確認することができます。

# spec/requests/passwords_spec.rb
require 'rails_helper'

include ActiveSupport::Testing::TimeHelpers

  ...

  describe 'PUT /v1/passwords' do
    before do
      @raw, enc = Devise.token_generator.generate(User, :reset_password_token)
      user.reset_password_token   = enc
      user.reset_password_sent_at = Time.now.utc
      user.save(validate: false)
    end

    context 'when params have a new password and a valid token' do
      it 'resets password' do
        travel_to(Time.current + 5.hours)
        params = { user: { password: 'newpass', password_confirmation: 'newpass', reset_password_token: @raw } }
        put '/v1/passwords', params: params
        expect(User.first.valid_password? 'newpass').to eq true
      end
    end

    context 'when params have invalid token' do
      it 'does not reset password with expired token' do
        travel_to(Time.current + 7.hours)
        params = { user: { password: 'newpass', password_confirmation: 'newpass', reset_password_token: @raw } }
        put '/v1/passwords', params: params
        expect(User.first.valid_password? 'newpass').to eq false
      end
    end

    ...

  end