じぶんメモ

プログラミングのメモ、日常のメモとか。

Railsでgemなしでログイン機能を実装

はじめに

Railsでは便利なログイン機能を実装してくれるdeviseやsorceryといったgemが存在します。 導入するだけでsign_insign_outといったログインに必要なメソッドを自動的に生成してくれますが、 カスタマイズをする場合には、内部のソースを解読する必要があったりします。 Rails4ではhas_secure_passwordという便利なメソッドが導入され、ログイン機能くらいならgemなしでも簡単に実装できるのでまとめてみました。

ログインの仕様を考える

よくあるログイン画面では、Emailとパスワードを入力させ、 その組み合わせが正しいかでチェックをします。 今回も同様にEmaiとパスワードを持つUserモデルを例にとります。

またユーザの登録時に、パスワードと確認用のパスワードを入力させ、 内容が一致すれば登録させる、というバリデーションをとります。

ユーザ登録機能の実装

Modelの実装

まずテーブルの作成ですが、mailとpassword_digestを用意します。 password_digestには、暗号化されたパスワードが登録されます。 password_digestはhas_secure_passwordに使用される名前なので、他の名前にはしないで下さい。 remember_tokenはログイン時に発行するトークンを保持し、cookieにも同様の値を保持させ、 ログインしているかどうかを判別するのに使用します。

create_table "users", force: :cascade do |t|
  t.string   "name",              limit: 191,             null: false
  t.string   "mail",              limit: 191,             null: false
  t.string   "password_digest",   limit: 191,             null: false
  t.string   "remember_token",    limit: 191
  t.datetime "created_at",                                null: false
  t.datetime "updated_at",                                null: false
end

続いてモデルの作成です。

class User < ActiveRecord::Base
  has_secure_password validations: true

  validates :mail, presence: true, uniqueness: true
end

has_secure_passwordを宣言することで、password, password_confirmationをUserモデルのプロパティとして使用することができます(DB上での管理ではなくメモリ上で値を保持できるようになる)。 引数にオプションとしてvalidations: trueが与えられているが、trueを渡すことによって、 以下のバリデーションがUserモデルに追加されます。

  • userの新規登録時にpasswordの必須入力
  • passwordとpassword_confirmationの内容が合致すること

また、それとは別にメールアドレスを必須入力かつユニークになるようバリデーションを設定しています。

Viewの実装

ログインユーザの作成画面を作ります。

= form_for @user, url: users_path do |f|
  = form_error(@user)
  .form-group
    = form.label :name
    = form.text_field :name

  .form-group
    = form.label :mail
    = form.text_field :mail

  .form-group
    = form.label :password
    = form.text_field :password

  .form-group
    = form.label :password_confirmation
    = form.text_field :password_confirmation

  = f.submit '登録'

名前・メール・パスワード・パスワード確認の入力欄を設けます。

Controllerの実装

class UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      redirect_to login_path
    else
      render 'new'
    end
  end

  private

    def user_params
      params.require(:user).permit(:name, :mail, :password, :password_confirmation)
    end

end

入力されたパラメータを使用して登録をします。 この時、以下の検証が行われ、エラーがなければユーザが登録されます。

  • アドレスが未入力の場合はエラー
  • 入力されたアドレスが他のユーザに使用されている場合はエラー
  • パスワードが未入力の場合はエラー
  • パスワードとパスワード確認の内容が一致していない場合はエラー

入力されたパスワードは、password_digestに暗号化されて登録されます。

ログイン機能の実装

ユーザ登録が済んだら、ログイン機能の作成を行います。

ルーティング設定

Rails.application.routes.draw do
  # ログイン / ログアウト
  get     'login',   to: 'sessions#new'
  post    'login',   to: 'sessions#create'
  delete  'logout',  to: 'sessions#destroy'
end

メール・パスワードを入力させるsessions#new、 入力された情報を検証し、cookieにログイン情報を格納するsessions#create、 ログアウトさせるsessions#destroyをとります。

viewの作成

= form_for :session, url: login_path do |f|
  .form-group
    = f.text_field :mail
  .form-group
    = f.password_field :password

  = f.submit 'ログイン'

アドレスとパスワードを入力させます。

controllerの実装

class SessionsController < ApplicationController
  before_action :require_sign_in!, [:destroy]
  before_action :set_user, only: [:create]

  def new
  end

  def create
    if @user.authenticate(session_params[:password])
      sign_in(@user)
      redirect_to root_path
    else
      flash.now[:danger] = t('.flash.invalid_password')
      render 'new'
    end
  end

  def destroy
    sign_out
    redirect_to login_path
  end

  private

    def set_user
      @user = User.find_by!(mail: session_params[:mail])
    rescue
      flash.now[:danger] = t('.flash.invalid_mail')
      render action: 'new'
    end

    # 許可するパラメータ
    def session_params
      params.require(:session).permit(:mail, :password)
    end

end

一つずつ解説します。

1.newでログイン画面を表示

これは特に何もしてないので飛ばします。

2.ログイン画面で入力された値をcreateアクションで検証

  before_action :set_user, only: [:create]

  def create
    if @user.authenticate(session_params[:password])
      sign_in(@user)
      redirect_to root_path
    else
      flash.now[:danger] = t('.flash.invalid_password')
      render 'new'
    end
  end

before_actionでメールアドレスからユーザの情報を取得し、 authenticateメソッドでパスワードの検証を行っています。 authenticateメソッドは、モデルでhas_secure_passwordを宣言していると自動的に使用できるようになり、 入力されたパスワードを暗号化し、DBに登録されているpassword_digestと一致するか検証します。 検証が通れば、application_controllerで実装しているsign_inメソッドを呼び出し、 remember_tokenを作成し、userモデルとcookieにセットし、ログイン後の画面に遷移します。 remember_tokenは、後々ログインしているかどうかの検証に使用します。 Userモデルにnew_remember_tokenを実装しておきます。 これでログインは完了です。

  def sign_in(user)
    remember_token = User.new_remember_token
    cookies.permanent[:user_remember_token] = remember_token
    user.update!(remember_token: User.encrypt(remember_token))
    @current_user = user
  end
    def self.new_remember_token
      SecureRandom.urlsafe_base64
    end

    def encrypt(token)
      Digest::SHA256.hexdigest(token.to_s)
    end

3.ログアウトの処理

  def destroy
    sign_out
    redirect_to login_path
  end

sign_outcookieの中身のremember_tokenを削除します。

  def sign_out
    cookies.delete(:user_remember_token)
  end

ログインしていなかったらログイン画面に遷移させる。

ログインを実装していても、これをしていないと意味がありません。 ログアウトアクションは、ログインをしていないと使用できないと思いますのでこれを実装します。

  skip_before_action :require_sign_in!, only: [:new, :create]

ログインしていなかったらログイン画面に遷移させるrequire_sign_in!を実装します。 ログイン前のnew, createアクションでは実行させないようにしときましょう。 require_sign_in!の中身はapplication_controllerに実装します。 長くなるので、最終的なapplication_controllerの中身を以下に記します。

class ApplicationController < ActionController::Base

  before_action :current_user
  before_action :require_sign_in!
  helper_method :signed_in?

  protect_from_forgery with: :exception

  def current_user
    remember_token = User.encrypt(cookies[:user_remember_token])
    @current_user ||= User.find_by(remember_token: remember_token)
  end

  def sign_in(user)
    remember_token = User.new_remember_token
    cookies.permanent[:user_remember_token] = remember_token
    user.update!(remember_token: User.encrypt(remember_token))
    @current_user = user
  end

  def sign_out
    @current_user = nil
    cookies.delete(:user_remember_token)
  end

  def signed_in?
    @current_user.present?
  end

  private

    def require_sign_in!
      redirect_to login_path unless signed_in?
    end

end

ログインしているかを判定するsigned_in?メソッドを別に切り出しておきます。 条件としては@current_userがセットされているか。 これをするために、before_action@current_userをセットするcurrent_userメソッドを定義します。

  def current_user
    remember_token = User.encrypt(cookies[:user_remember_token])
    @current_user ||= User.find_by(remember_token: remember_token)
  end

cookieからトークンを取得後暗号化し、cookieと同じトークンを持ったuserを取得します。 取得できなかった場合はログインしていない、と判断します。

最後に

メソッドの切り出しはケースバイケースなので、参考程度に、と考えて下さい。 重要なのは以下の点だと思います。

  • ログイン時はuserモデルにhas_secure_passwordを宣言することで使用できるauthenticate使って検証をかけること。
  • ログイン後はremember_tokenをcookieに、暗号化したremember_tokenをDBにセットしておいて、DBとcookieのremember_tokenが一致しているかでログインしているかを判定する。

rspec内でCSFR対策を有効にする

railsでpost送信を行う際に、画面を経由しないリクエストには422が返却される。 これはrailsCSRF対策である、ActionController::Base.allow_forgery_protectionがtrueになり、ApplicationControllerの protect_from_forgery with: :exceptionで、CSRFチェックでNGだった場合に例外を発生させる設定になっているため。

rspecで使われるtest環境ではallow_forgery_protectionがfalseになっているが、rspec時にCSFR対策を有効にするには ActionController::Base.allow_forgery_protection = trueとする。

describe 'createの確認' do
  let(:params) { FactoryGirl.build(:post).attributes }

  before { ActionController::Base.allow_forgery_protection = true }
  after  { ActionController::Base.allow_forgery_protection = false }

  it 'レスポンスに422が返されること', autodoc: true do
    post '/api/v1/posts/create', params
    expect(response.status).to eq(422)
    expect(response).not_to be_success
  end
end

direnvを使ってディレクトリごとの環境変数設定を行う

direnvを使って、そのディレクトリ下でのみ有効な環境変数を設定する。 前提mac

$ brew install direnv

インストール後、.bashrcまたは.bash_profileに以下の1行を追加

# bashの場合
eval "$(direnv hook bash)"

あとは環境変数を設定したいディレクトリに以下のコマンドで設定ファイルを作成する。

$ direnv edit .

そうすると、.envrcファイルが出来上がるので、中身を編集する。

# 一例
export HOST_URL='http://localhost:3000'

エラーが発生する場合はdirenv allowコマンドを実行する。

注意点として.envrc内にgit管理されるとまずい情報が載っている場合は.gitignoreに忘れずに含めること。

railsのモデルに、特定の条件下で動くvalidationを追加

ifを使用した条件分岐

例えば、bという項目がtrueの場合のみaの値は必須にしたい、とかあると思う。
with_optionsメソッドを使用して以下のように実装することができる。

class Post < ActiveRecord::Base
  # ifオプションで条件に合致する場合のみvalidatesを実行(published?はメソッド名またはmodelのboolean項目)
  validates :name, presence: true if: published?

  # 複数valitatesをまとめたい場合はwith_optionsを使用
  with_options if: :published? do
    validates :name,  presence: true
    validates :category,  presence: true
  end

  # unlessで条件に合致しない場合のみvalidatesを実行
  # lambdaを使うことで複数条件を指定できる
  with_options unless: -> { :hoge? || :foo? } do
    validates :name,  presence: true
  end
end

ただし、この場合、with_optionsブロック内部で更にif: を使用してしまうと、
内部のif条件の場合にしかvalidatesが実行されなくなる。

  # hogeの時しかwith_options内部が実行されない
  with_options if: :published? do
    validates :name,  presence: true
    validates :category,  presence: true, if: hoge
  end

  # with_optionsで定義したものと逆のもの(この場合はunless)を使用すれば問題ない
  with_options if: :published? do
    validates :name,  presence: true
    validates :category,  presence: true, unless: 'hoge.blank?'
  end

ネストしたvalidationを記載するなら、メソッドを一つ作って指定した方が良さそう。

validates :name,  presence: true, if: :require_validation?

def require_validation?
  return true if aaa? && bbb?
  false
end

onを使用した条件分岐

例えば新規登録(create)の場合のみバリデーションをかけたい時は、onオプションを使用する。

class Post < ActiveRecord::Base
  # createの時のみ実行
  validates :name, presence: true on: :create

  # if同様with_optionsを使用できる
  with_options on: :create? do
    validates :name,  presence: true
    validates :category,  presence: true
  end

  # こうすることで、pattern_hogeコンテキストが渡された時のみvalidatesが実行される
  validates :hoge, presence: true, length: { maximum: 3000 }, on: :pattern_hoge
end
class PostsController < ApplicationController
  def hoge
    # pattern_hogeコンテキストを指定
    post.save(context: :hoge.save(context: :pattern_validation))
  end
end

railsでenumを使う

modelに記述する。

class Post < ActiveRecord::Base
  enum status: { created: 0, drafted: 1, canceled: 2 }
end

こうすることで、Post.statusesとして、各Enumにアクセスすることができる。

pry(main)> Post.statuses            # => {"created"=>0, "drafted"=>1, "canceled"=>2}
pry(main)> Post.statuses[:drafted]  # => 1
pry(main)> @post = Post.new
pry(main)> @post.status = Post.statuses[:drafted]

また、インスタンスに対して、enumの各要素をメソッドとして使用することができる。

pry(main)> @post = Post.new
pry(main)> @post.status = Post.statuses[:drafted]
pry(main)> @post.drafted?     # => true
pry(main)> @post.canceled!
pry(main)> @post.canceled?    # => true
pry(main)> @post.drafted?     # => false

modelとの関連付けはせず、Enumとしてだけ使用したい場合、
locales下のymlファイルに記述する方法もあり。

ja:
  Enum:
    status:
      created: 0
      drafted: 1
      canceled: 2
pry(main)> Enum.codes(:status)
=> ["created", "drafted", "canceled"]

選択肢が限られているセレクトボックスの検証とかで、inclusionと合わせて使えそう。

シェルでディレクトリ内のファイルの数を調べる

例えばシェルでディレクトリ内に何ファイル存在するか調べるには以下の方法でチェックをする。

ls -FU1 '対象のパス' | grep -v / | wc -l 

ざっと解説すると、lsの結果をgrepに渡し、grep -vで不要な情報を削除し、
wcコマンドにわたし、数をカウントしている。lsオプションは以下の通り。

  • -F:ディレクトリが存在する場合は/を付与して表示する。
  • -1:lsの結果を1行ずつ表示
  • -U:lsの結果をソートしない(速度向上)

上記の例だとディレクトリを省いてファイルの数を調べている。
また、wcコマンドは、渡されたファイルのバイト数も表示するため、
-lオプションは、渡されたファイルの個数のみを表示させている。

ブックマークレットでフォームに自動的に入力するスクリプトを作りたい

テストとかで画面に値を入力する場合、 繰り返しテストをしていると、
毎回フォームに値を入力しないといけないのがダルい。
ブックマークレットで1クリックだけでフォームに値をセットできるスクリプトを作りたい。
とりあえずフォームの要素に値をセットする方法。

javascript:(function(){ document.getElementById("srchtxt").value = "てすと";}())

ついでに画面内のID全て取得するブックマークレット

javascript:(function() {var elements = document.getElementsByTagName("input");var i = 0; var ids = "";for (i = 0 ; i < elements.length ; i++){ids += elements[i].id + "\r\n";}alert(ids)}());

ブックマークレットスクリプトで改行すると美味く解釈されないので、基本的には改行なし。