Railsでgemなしでログイン機能を実装
はじめに
Railsでは便利なログイン機能を実装してくれるdeviseやsorceryといったgemが存在します。
導入するだけでsign_in
やsign_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_out
でcookieの中身の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を取得します。 取得できなかった場合はログインしていない、と判断します。
最後に
メソッドの切り出しはケースバイケースなので、参考程度に、と考えて下さい。 重要なのは以下の点だと思います。
rspec内でCSFR対策を有効にする
railsでpost送信を行う際に、画面を経由しないリクエストには422が返却される。
これはrailsのCSRF対策である、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)}());