Devise でロック解除したら、パスワード変更を強制する

Deviseを使うことで、簡単にログイン機能を実装できます。
関連してアカウントのロック機能や、メールの送信も実装できます。

よくあるロックされたアカウントの解除には、パスワードの変更がつきものです。
今回は、Devise をベースにロックを解除したら、パスワード変更を強制してみます。

目次

実装

準備

User モデルを作成し、ログインしなくても見ることのできる画面、ログインしないと見ることのできない画面を用意します。
User は、Devise で認証します。

認証前と認証後のページのコントローラとビューを作成

1
2
3
4
5
# 認証されていなくても見ることのできるページ
bundle exec rails g controller home index --skip-test-framework --skip-assets

# 認証しないと見えないページ
bundle exec rails g controller Certified index --skip-test-framework --skip-assets

gem インストール

以下の様に Gemfile に追記する。

Gemfile
1
gem 'devise'

devise の設定変更

devise の設定を変更する。
config/initializers/devise.rbについて以下の箇所を設定する。

config/initializers/devise.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
config.scoped_views = true
# 複数のmodelで個別のログイン画面を使いたいので変更する
config.sign_out_all_scopes = false
# 複数のモデルを扱う際に、一方をログアウトした時に、もう片方もログアウトすることを防ぐ

config.lock_strategy = :failed_attempts
# 失敗回数をもとにロックする
config.unlock_keys = [:email]
# 解除のキーはemailにする
config.unlock_strategy = :both
# 解除方法は時間経過と、メールにする(:both, :time, :email, :none を選ぶことができる)
config.maximum_attempts = 10
# 最大失敗回数を10回に設定
config.unlock_in = 1.hour
# 1 時間経過したら解除する

devise で User モデルを作成

以下のコマンドを実行し devise の model を作成。

1
bundle exec rails g devise user

今回は、ロック機能を使うので、Lockable のカラムを有効化します。
db/migrate/[数字列]_devise_create_users.rbを以下のようにしました。

db/migrate/[数字列]_devise_create_users.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[5.2]
def change
create_table :users do |t|
## Database authenticatable
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""

## Recoverable
t.string :reset_password_token
t.datetime :reset_password_sent_at

## Rememberable
t.datetime :remember_created_at


## Lockable
t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
t.string :unlock_token # Only if unlock strategy is :email or :both
t.datetime :locked_at


t.timestamps null: false
end

add_index :users, :email, unique: true
add_index :users, :reset_password_token, unique: true
add_index :users, :unlock_token, unique: true
end
end

作成できたら以下のコマンドでマイグレーション。

1
bundle exec rails db:migrate

User モデルの修正

app/models/user.rbを以下のようにし、:lockableを付与します。

app/models/user.rb
1
2
3
4
5
6
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :lockable
end

User 用の devise view を作成

次のコマンドでビューを作成。

1
bundle exec rails g devise:views users

User 用の devise controller を作成

次のコマンドでコントローラーを作成。

1
bundle exec rails generate devise:controllers users

ルーティング修正

config/routes.rbを以下のように修正します。

config/routes.rb
1
2
3
4
5
6
7
Rails.application.routes.draw do
devise_for :users, controllers: {
sessions: 'users/sessions'
}
get 'certified/index'
root to: 'home#index'
end

テンプレート修正

app/views/layouts/application.html.erbを以下のように修正する。

app/views/layouts/application.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html>
<html>
<head>
<title>Test170DeviseLockPassword</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>

<%= stylesheet_link_tag 'application', media: 'all' %>
<%= javascript_include_tag 'application' %>
</head>

<body>
<div>
<p class="notice"><%= notice %></p>
<p class="alert"><%= alert %></p>
</div>
<div>
<% if user_signed_in? %>
<%= "LogOnUser: #{current_user.email}" %>
<%= link_to 'SignOut', destroy_user_session_path, method: :delete %>
<%else%>
<%= link_to 'SingUp', new_user_registration_path %>
<%= link_to 'SignIn', new_user_session_path %>
<% end %>
</div>
<%= yield %>
</body>
</html>

Users::SessionsController を修正

ログインできたら、certified/indexにアクセスさせたいです。
app/controllers/users/sessions_controller.rbを以下のように変更します。

app/controllers/users/sessions_controller.rb
1
2
3
4
5
class Users::SessionsController < Devise::SessionsController
def after_sign_in_path_for(resource)
certified_index_path
end
end

認証を必須にする

app/controllers/certified_controller.rbを修正して、認証されていないと、リダイレクトするようにします。

app/controllers/certified_controller.rb
1
2
3
4
5
6
class CertifiedController < ApplicationController
before_action :authenticate_user!

def index
end
end

メールを設定

開発時は、SMTP サーバーを用意して送信メールを見ることは難しいです。
ブラウザで本来なら送信するメールを参照できるツールletter_opener_webを導入します。

1
2
3
group :development do
gem 'letter_opener_web', '~> 1.0'
end

上記を書いたらインストールします。

続いて、config/routes.rbを以下のように修正します。

config/routes.rb
1
2
3
4
5
6
7
8
9
10
Rails.application.routes.draw do
devise_for :users, controllers: {
sessions: 'users/sessions'
}
get 'certified/index'
root to: 'home#index'

# letter_opener_web用のパス
mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development?
end

config/environments/development.rbを編集します。2 か所追記します。

config/environments/development.rb
1
2
3
4
5
6
7
Rails.application.configure do
# 省略

# 以下を追加
config.action_mailer.default_url_options = { host: 'localhost:3000' }
config.action_mailer.delivery_method = :letter_opener_web
end

動作確認

bundle exec rails sで起動し動作確認。
http://localhost:3000/certified/indexにアクセスするとログイン要求画面に移り、ログインすると/certified/indexに遷移します。

一度ログアウトして、パスワードを 10 回間違えてみます。
アカウントがロックされます。

http://localhost:3000/letter_opener/にアクセスすると、本来なら送信しているメールを見ることができます。
リンクからアカウントのロックを解除できます。

ここまで出来たら、ロック解除の時にパスワード変更を強制する実装に移ります。

ロック解除時パスワード変更を強制する実装

それでは、ロック解除したときに、パスワード変更を強制する実装を行います。
考慮するのは 2 つです。

  • ロック解除し、パスワードの変更に進む(正常系)
  • ロック解除は行われたが、パスワード変更に進まなかった(異常系)
    =>次回ログイン時パスワード変更を強制することで対応します

ログイン後のパスワード変更を行うので、独自にパスワード変更画面を実装してゆきます。

モデル修正

パスワード変更要求を行うフラグになるカラムを増やします。

bundle exec rails g migration add_req_pass_change_to_usersを実行します。

db/migrate/[数字列]_add_req_pass_ch_to_users.rbを以下のように編集します。

db/migrate/[数字列]_add_req_pass_ch_to_users.rb
1
2
3
4
5
class AddReqPassChToUsers < ActiveRecord::Migration[5.2]
def change
add_column :users, :req_pass_change, :boolean, default: 0, null: false
end
end

マイグレーションしておきます。

コントローラの編集

ここまでの操作でapp\controllers\users以下には Devise から継承したコントローラーが用意されています。
編集するのは、以下の 2 つです。

  • app/controllers/users/unlocks_controller.rb
  • app/controllers/users/passwords_controller.rb

新規に作成するのは 1 つです。

  • app/controllers/users/non_token_passwords_controller.rb

また、app/controllers/application_controller.rbも編集します。

ユーザーのパスワードの変更を独自に実装します。

app/controllers/users/unlocks_controller.rbは以下のように編集します。
コメント部分は省略しています。

app/controllers/users/unlocks_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# frozen_string_literal: true

class Users::UnlocksController < Devise::UnlocksController
#ロック解除メールの送信を行う。
def create
# ホームから入力されたメールアドレスparams[:user][:email]が
# ロックされていなければ、ログイン画面にリダイレクトさせる
if resource_class.find_by(email: params[:user][:email]).locked_at.nil?
set_flash_message! :notice, :unlocked
redirect_to new_user_session_path
return
end

# devise標準のロック解除メール作成処理を行う
super
end

# ロック解除トークンを使用してのアクセス時の処理を行う
def show
# resource_class すなわち Userからparams[:unlock_token]を持つユーザーを探し、
# ロックを解除する。
self.resource = resource_class.unlock_access_by_token(params[:unlock_token])

yield resource if block_given?

# パスワードリセットを行うためのトークンを作成する
token = self.set_reset_password_token

# パスワード変更を行う要求フラグを立てる
self.resource.req_pass_change = true

# 現在操作しているオブジェクトが、新しいレコードでなければ
# =>既存のユーザーであれば、バリデーションを行わずに、保存する
self.resource.save(validate: false) unless self.resource.new_record?


if resource.errors.empty?
# エラーなくresourceすなわちUserを取得できていたら、
# token付きのリンクでパスワード変更画面へリダイレクト
set_flash_message! :notice, :unlocked
respond_with_navigational(resource){ redirect_to edit_user_password_path(reset_password_token: token) }
else
# エラーがある(大体、一度開いたロック解除メールを2度クリックしてトークンが無効になったなど)場合
# ロック解除メールの送信フォームへリダイレクト
respond_with_navigational(resource.errors, status: :unprocessable_entity){ render :new }
end
end
# パスワード編集画面用のアクセストークンを作成する。
# vendor/bundle/ruby/2.4.0/gems/devise-4.7.1/lib/devise/models/recoverable.rb
# の中に、同様の関数があるがprotectedのため本来呼び出せないので、手で移植する。(コピーして持ってくる)
def set_reset_password_token
raw, enc = Devise.token_generator.generate(resource_class, :reset_password_token)

self.resource.reset_password_token = enc
self.resource.reset_password_sent_at = Time.now.utc
self.resource.save(validate: false) unless self.resource.new_record?
raw
end
end

app/controllers/users/passwords_controller.rbは以下のように編集します。

app/controllers/users/passwords_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Users::PasswordsController < Devise::PasswordsController
# ユーザーのパスワード編集を行う
# /vendor/bundle/ruby/2.4.0/gems/devise-4.7.1/app/controllers/devise/passwords_controller.rb
# の内容を移植する。
def update
# トークンを基にパスワードの変更を行う。
self.resource = resource_class.reset_password_by_token(resource_params)
yield resource if block_given?

# パスワード変更を行う要求フラグをおろす
self.resource.req_pass_change=false;
self.resource.save(validate: false) unless self.resource.new_record?;

if resource.errors.empty?
resource.unlock_access! if unlockable?(resource)
if Devise.sign_in_after_reset_password
flash_message = resource.active_for_authentication? ? :updated : :updated_not_active
set_flash_message!(:notice, flash_message)
resource.after_database_authentication
sign_in(resource_name, resource)
else
set_flash_message!(:notice, :updated_not_active)
end
respond_with resource, location: after_resetting_password_path_for(resource)
else
set_minimum_password_length
respond_with resource
end
end
end

app/controllers/users/non_token_passwords_controller.rbは以下のように編集します。
ログインしたままパスワード編集をするための実装です。

app/controllers/users/non_token_passwords_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Users::NonTokenPasswordsController < ApplicationController
# パスワード編集画面
def edit
@user = current_user
end

# パスワード更新
def update
current_user.password = params[:user][:password]
current_user.req_pass_change=false
if current_user.save
# ログインした状態でパスワードを変更する仕組みのため
# 強制でログアウトさせられないように内部で再度ログインしておく
sign_in(current_user, bypass: true)
redirect_to root_path, notice: '更新しました'
else
render :edit_password_non_token
end
end
end

パスワード編集画面への遷移を強制するための仕組みをapp/controllers/application_controller.rbに作ります。
before_action :pass_chainged!だけだと、延々とリダイレクトを繰り返してし、ログアウトもできません。
対策として、:unless => :non_check_pass_chainged?を与えて、パスワード編集画面とログアウトではリダイレクトさせません。

app/controllers/application_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :pass_chainged!, :unless => :non_check_pass_chainged?

# パスワード変更画面へとリダイレクトさせる
def pass_chainged!
if current_user&.req_pass_change
redirect_to users_edit_non_token_password_path
end
end

# アクセス先がパスワード変更画面のURLおよびサインアウトのURLであるかを返す。
def non_check_pass_chainged?
request.path_info.match(/^\/users\/edit_non_token_password/) ||
request.path_info.match(/^\/users\/non_token_password/)||
request.path_info.match(/^\/users\/sign_out/)
end
end

view の追加

パスワードの編集画面を増やします。
以下を用意します。

  • app/views/users/non_token_passwordsディレクトリ
  • app/views/users/non_token_passwords/edit.html.erb

app/views/users/non_token_passwords/edit.html.erbは、Devise 標準のパスワード編集画面を移植します。
以下のようになります。

app/views/users/non_token_passwords/edit.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<h2>Change your password</h2>

<%= form_for(@user, url: users_non_token_password_path, html: { method: :put }) do |f| %>
<%= f.hidden_field :reset_password_token %>

<div class="field">
<%= f.label :password, "New password" %><br />
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em><br />
<% end %>
<%= f.password_field :password, autofocus: true, autocomplete: "new-password" %>
</div>

<div class="field">
<%= f.label :password_confirmation, "Confirm new password" %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>

<div class="actions">
<%= f.submit "Change my password" %>
</div>
<% end %>

ルーティング修正

/config/routes.rbは以下のようになります。

/config/routes.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Rails.application.routes.draw do
devise_for :users, controllers: {
sessions: 'users/sessions',
passwords: 'users/passwords',
registrations: 'users/registrations',
unlocks: 'users/unlocks'
}

namespace :users do
get :edit_non_token_password, to: 'non_token_passwords#edit'
put :non_token_password, to: 'non_token_passwords#update'
end

get 'certified/index'

root to: 'home#index'

mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development?
end

ここまでで実装完了です。

動作確認

bundle exec rails sで起動し動作確認。
http://localhost:3000/certified/indexにアクセスするとログイン要求画面に移り、ログインすると/certified/indexに遷移します。

一度ログアウトして、パスワードを 10 回間違えてまた、アカウントロックさせます。

http://localhost:3000/letter_opener/にアクセスして、リンクからアカウントのロックを解除します。

リンクを踏むと、パスワード変更画面に遷移します。

変更が終わるとログインします。

もし、パスワードの変更をせずに再度ロック解除のリンクを踏むとロック解除メールの送信画面に遷移します。
これはメールのリンクについているロック解除のトークンがすでに無効になっているからです。

すでにロック解除が終わっているので、ログイン画面に遷移します。
既存のパスワードでログインすると、パスワード変更画面に強制で遷移します。

ほかの画面に移動しようとしてもパスワード変更が終わるまで、何度でもこの画面が表示されます。


devise を使って、ロック解除と強制パスワード変更を連携させることができました。
追加の改修としては、パスワード変更の強制を延期る機能でしょうか?
いわゆる「次回行う」ってやつですね。

devise の

ではでは。