has_secure_passwordのvalidationsをカスタマイズする



railsの`has_secure_password`で自分の思い通りにvalidationを操作できないかと思って書いたメモ。

## おさらい：has_secure_passwordとは？

ざっくり書くと、色んなwebサイトで使われている(そうな)パスワード認証周りの機能を実装してくれるメソッド。

公式ドキュメントではこのように説明されている。

[ActiveModel::SecurePassword::ClassMethods](http://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html)

- 用意したクラスにパスワードに関連する属性や検証を行うメソッドを付け加える。[^1]
- `password`属性に対して以下のvalidationsヘルパーを付け加える
  - レコード作成時にパスワードは空白でないこと
  - パスワード文字数は72文字以下であること
  - `:password`と`:password_confirmation`両方の値が等しいこと
- 紐づけるモデルに`:password_digest`属性が必要

## 環境

- ruby: 2.5.0rc1
- rails: 5.1.5
- bcrypt: 3.1.11

## やりたい事

Userモデルは下記の通り

| Column | Type |
|--------|------|
|name   |string   |
|password_digest   |string   |

- Userクラスに`:password`と`:password_confirmation`属性を加えてパスワードの検証を行えるようにしたい
- でも検証自体はレコード作成時ではなく更新時に行いたい
- has_secure_passwordに`on: :update`付けても反応してくれない

## has_secure_passwordは引数を受け取ってくれる

公式ドキュメントではvalidationsの後にこのような説明が書かれている。
> validationsは`validations: false`を引数として渡すと無効化される。

つまり、下のように引数を与えればよい。

```ruby

class User < ApplicationRecord
  has_secure_password validations: false

end
```

これによってUserクラスに`:password`と`:password_confirmation`属性が付加され、validationsを行わないで済むようになった。

しかし、レコードの更新時にパスワードの値を検証したいのにこのままではどんな値も受け付けてしまう。

## :passwordに紐づけられているvalidationsを引っ張り出してくる

一度メソッドのソースコードを覗いてみる事に。

[rails/activemodel/lib/active_model/secure_password.rb](https://github.com/rails/rails/blob/master/activemodel/lib/active_model/secure_password.rb)

```ruby

def has_secure_password(options = {})
  # Load bcrypt gem only when has_secure_password is used.
  # This is to avoid ActiveModel (and by extension the entire framework)
  # being dependent on a binary library.
  begin
    require "bcrypt"
  rescue LoadError
    $stderr.puts "You don't have bcrypt installed in your application. Please add it to your Gemfile and run bundle install"
    raise
  end

  include InstanceMethodsOnActivation

  if options.fetch(:validations, true)
    include ActiveModel::Validations

    # This ensures the model has a password by checking whether the password_digest
    # is present, so that this works with both new and existing records. However,
    # when there is an error, the message is added to the password attribute instead
    # so that the error message will make sense to the end-user.
    validate do |record|
      record.errors.add(:password, :blank) unless record.password_digest.present?
    end

    validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
    validates_confirmation_of :password, allow_blank: true
  end
end
```

ピックアップしたいのは`ActiveModel::Validations`モジュールをインクルードする箇所から。`:password`属性に対して3つの検証を行っている。(上記のドキュメントと同じ)

- 空白でないこと
- 文字数は72文字以下であること
- `:password_confirmation`と値を比較し、同じであること

これをこのままUserモデルに貼り付け、各validationに`on: :update`を加える。

```ruby

class User < ApplicationRecord
  has_secure_password validations: false

  validate(on: :update) do |record|
    record.errors.add(:password, :blank) unless record.password_digest.present?
  end

  validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED, on: :update
  validates_confirmation_of :password, allow_blank: true, on: :update
end
```

これでパスワード検証がレコード更新時のみ行われるようになった。`:password`と紐づけて独自のvalidationを加えることもできる。

## 所感

普段からAPIの使い方やエラー対策に関して公式ドキュメント、qiitaやstackoverflowに頼りきり(ほとんどがstackoverflow)だったが、ソースコードも普段から見るべきだなと感じた。


[^1]: `password`と`password_confirmation`は仮想的な属性で外部から入力されたパスワードの検証と`:password_digest`の値を生成する時に使われる。データベース上のUserモデルにカラムが追加されているわけではない。値の保存は`:password_digest`が引き受ける。