はじめに:そんな検索方法で大丈夫か?
たとえばRailsアプリケーションで以下のようなメソッドがあったとします。
def welcome_user(email)
user = User.find_by(email: email)
if user
"おかえりなさい"
else
"どうもはじめまして"
end
end
このメソッドは次のような仕様を想定しています。
- 引数で指定されたメールアドレスに合致するUserが存在するなら「おかえりなさい」を戻り値として返す
- それ以外は「どうもはじめまして」を返す
とてもシンプルなメソッドで特に問題はなさそうですね。
.
.
.
本当に?
思いがけない結果を引き起こすケース
実は、引数のemailがユーザーの入力値だったりすると、場合によっては予想外の動きをすることがあります。
たとえば、以下のようなケースです。
DBに保存されているemailの値はjnchito@example.comだが、
画面から入力されたemailはJnchito@example.comだった
この場合、welcome_userメソッドは「どうもはじめまして」を返します。
なぜなら、Jnchito@example.comでデータベースを検索しても合致するemailのUserは見つからないからです。(小文字のjと大文字のJは別物ですから!)
ログインユーザーをメールアドレスで識別するwebアプリケーションの場合、わざわざjnchito@example.comとJnchito@example.comを別々のユーザーとして扱いたいと思うケースは少ないはずです。
というわけで、大文字小文字を無視してメールアドレスを検索するようにしましょう!
解決策1: 小文字に変換してから検索する
大文字と小文字を無視して扱うのであれば、先ほどのメソッドは次のように修正する必要があります。
def welcome_user(email)
- user = User.find_by(email: email)
+ user = User.where('LOWER(email) = ?', email.downcase).first
if user
"おかえりなさい"
else
"どうもはじめまして"
end
end
こうすれば画面から入力されたemailがJnchito@example.comやJNCHITO@EXAMPLE.COMだった場合でも「おかえりなさい」が表示されます。
ただし、LOWER関数を使っているので、emailカラムに設定したindexは使われない可能性が高い点に注意してください。
(usersテーブルのレコードが何万、何十万とある場合は要注意)
解決策2: Deviseを使っているならfind_for_authenticationを使う
Railsアプリケーションの場合、認証用のgemとしてDeviseを使っていることも多いと思います。
その場合は次のようにfind_for_authenticationを使ってください。
def welcome_user(email)
- user = User.find_by(email: email)
+ user = User.find_for_authentication(email: email)
if user
"おかえりなさい"
else
"どうもはじめまして"
end
end
この場合、入力値のemailがJnchito@example.comやJnChItO@ExAmPlE.cOmのように大文字小文字混じりだったとしても、SQL実行時は以下のように小文字に変換されます。
SELECT "users".*
FROM "users"
WHERE "users"."email" = 'jnchito@example.com'
ORDER BY "users"."id" ASC
LIMIT 1
Deviseはemailを小文字に変換してから保存している
上のSQLを見て「あれ、"users"."email"にはLOWER関数を使わないの?」と思った人もいるかもしれません。
ですが、これで問題ありません。
なぜなら、Deviseはemailをすべて小文字に変換してからDBに保存(INSERT/UPDATE)するからです。
よって、usersテーブルに保存されるemailはすべて小文字になります。
なお、Deviseが大文字小文字を無視するデフォルトの検索キーはemailですが、これはconfig/initializers/devise.rbのcase_insensitive_keysで変更することができます。
# Configure which authentication keys should be case-insensitive.
# These keys will be downcased upon creating or modifying a user and when used
# to authenticate or find a user. Default is :email.
config.case_insensitive_keys = [:email]
ふーん、でもこれって何か問題があるの?→あります!
ここでは問題をわかりやすく説明するために、welcome_userというごく単純なメソッドを使いました。
これだと別に深刻な問題を引き起こすように見えないので、「へえ、そうなんだ」で終わってしまうかもしれません。
ですが、実務だとセキュリティ上の問題を引き起こす可能性があります。
たとえば、次のようにDeviseのログイン処理をカスタマイズするために以下のようなコードを書いていたとします。
class Users::SessionsController < Devise::SessionsController
def create
# ⚠️ BANされたUserを検索する...?
banned_user = User.banned.find_by(email: resource_params[:email])
if banned_user
alert = "あなたはBANされています"
redirect_to new_session_path(resource_name), alert: alert
else
# BANされていないので、デフォルトのログイン処理を実行する
super
end
end
end
上のコード例では、BANされたUserをログインさせたくないので、find_by(email: resource_params[:email])を使ってUserを検索しています。
ですが、これだと先ほど説明したように、利用者がわざと大文字小文字を混在させたメールアドレスを入力してきた場合、banned_userがnilになり、ログインに成功してしまいます(superで呼び出されるDeviseのログイン処理は、emailの大文字小文字を無視するため)。
そこで find_for_authentication
こういう場合はfind_for_authenticationメソッドを使って、大文字小文字を無視してemailを検索するようにしましょう。
-banned_user = User.banned.find_by(email: resource_params[:email])
+banned_user = User.banned.find_for_authentication(email: resource_params[:email])
こうすれば大文字小文字を無視してUserを検索するので、BANされたUserはログインできなくなります!
おまけ:怪しいコードがないか調べてみよう
以下のgit grepコマンドを実行すると、プロジェクト内にUser.find_by(email: email)やUser.some_scope.find_by email: emailのようなコードがないか検索することができます。
git grep -P 'find_by[\s(]\s*email:'
なお、このgit grepコマンドでは正規表現を使っています。
正規表現が苦手な人は、僕が以前書いた以下の記事を読んでもらえれば意味がわかるようになるはずです!
まとめ
というわけで、この記事ではUserをメールアドレスで検索するときは find_by を使っちゃダメですよ、という話を書いてみました。
Userを直接メールアドレスで検索するケースはそこまで多くないかもしれませんが、たまにそういう機会がやってきたときに「大文字小文字を無視するかどうか」まで意識が回らず、ついUser.find_by(email: email)のようなコードを書いてしまいがちなんですよねえ。。(経験者談)
ドキッとした人はこのドキュメントをチーム内で共有して、これまでにこういうコードを書いてこなかったか、チェックしてみてください!
あわせて読みたい
記事内で使っているメールアドレスのドメインをexample.comにしている理由は、こちらの記事に載っています↓