はじめに
あるとき、いわゆる凍結ユーザーのログイン禁止をする「垢BAN機能」を実装しているときに、ふとログイン処理をしっかり読むことがあり、予想以上にいろんな事しているな、と思った。
認証周りはWEBアプリケーションには必ずといっていいほど実装される機能で、セキュリティも関わるここで一度認証周りをおさえておきたいと思ったのでコードリーディングをした。
その時のメモ。
今回はログイン処理を読み込んでいく。
※前提はlaravel/uiによる認証機能のコードリーディングです。
参考
↓参考記事
https://reffect.co.jp/laravel/laravel-authentication-understand
↓公式ドキュメント
https://readouble.com/laravel/8.x/ja/authentication.html
↓プロジェクトの作成、laravel/uiのインストール
https://qiita.com/daisu_yamazaki/items/a914a16ca1640334d7a5
↓特にログインの処理のコードリーディング
https://codelikes.com/laravel-customize-lockout/
前提
PHP 8.0.2
Laravel 8.0.1
内容
確認したいこと
おなじみの上記画面(http://<プロジェクト名>/login)で「Login」を押した際の挙動
処理の流れ
どのクラスを通り、ログイン処理がされていくか見ていく
「Login」押下
↓
↓
↓
ServiceProvider
<?php
namespace App\Providers;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
// 'App\Models\Model' => 'App\Policies\ModelPolicy',
];
public function boot()
{
$this->registerPolicies();
//
}
}
- registerPoliciesの処理が走り、Gate::policy($key, $value)を実行
- ログイン時はdumpしてみたが特に認可関係の処理をしていなさそう
class AuthServiceProvider extends ServiceProvider
{
protected $policies = [];
public function registerPolicies()
{
foreach ($this->policies() as $key => $value) {
Gate::policy($key, $value);
}
}
public function policies()
{
return $this->policies;
}
}
- 実際、Gate::policyの定義場所が見当たらず、policyはinterfaceだったので、認可関係の処理をしたい場合はAuthServiceProviderに処理を書くのかな?と推測
↓
↓
↓
Middleware
デフォルトは認証関係のMiddlewareはない。
ただ、認証しているか否かを確認するvendor\laravel\framework\src\Illuminate\Session\Middleware\AuthenticateSession.phpというミドルウェアはlaravel/iuインストール時に追加される
↓
↓
↓
Route
public function auth()
{
return function ($options = []) {
$namespace = class_exists($this->prependGroupNamespace('Auth\LoginController')) ? null : 'App\Http\Controllers';
$this->group(['namespace' => $namespace], function() use($options) {
// Login Routes...
if ($options['login'] ?? true) {
$this->get('login', 'Auth\LoginController@showLoginForm')->name('login');
$this->post('login', 'Auth\LoginController@login');
}
...
- $namespaceは
'App\Http\Controllers'
になり、認証関係の名前空間は全てこれになる - $optionについてはよくわからなかった
- 基本$optionは常に空配列だった
- ただ、if文内にdumpを置くと常に実行されるため、特定の条件下のみルートが開放されている、というわけではない
- ログイン画面は
Auth\LoginController@showLoginForm
を通り、ログイン入力画面を表示 - 「Login」ボタン押下時は
Auth\LoginController@login
へリクエストを渡す
↓
↓
↓
Controller
大きく分けて以下のことをしている
1, RedirectIfAuthenticatedで認証済みかチェック
2, ログイン情報(email, password)のバリデーションチェック
3, 複数回ログインを失敗している場合は、ログインできないようにする
4, ログイン成功時はログイン処理をする
5, ログイン失敗時はログイントライ回数のインクリメントとバリデーションエラーを発生させ、再度ログイン画面を表示
ここはかなり処理が多いので、詳細は別でまとめる。
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
class LoginController extends Controller
{
use AuthenticatesUsers;
protected $redirectTo = RouteServiceProvider::HOME;
public function __construct()
{
$this->middleware('guest')->except('logout');
}
}
まずはapp\Http\Controllers\Auth\LoginController.phpに処理が渡されるが、ここではmiddleware('guest')の処理しかしていない
実際のログイン処理はAuthenticatesUsersトレイトで実行している
trait AuthenticatesUsers
{
use RedirectsUsers, ThrottlesLogins;
public function showLoginForm()
{
return view('auth.login');
}
public function login(Request $request)
{
$this->validateLogin($request);
if (method_exists($this, 'hasTooManyLoginAttempts') &&
$this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
return $this->sendLockoutResponse($request);
}
if ($this->attemptLogin($request)) {
return $this->sendLoginResponse($request);
}
$this->incrementLoginAttempts($request);
return $this->sendFailedLoginResponse($request);
}
- showLoginFormはログイン画面の表示だけなので割愛
- validateLoginでログイン情報のバリデーションチェック
- hasTooManyLoginAttempts状態であればログインを締め出す
- sendLoginResponseでログイン成功時のレスポンスを返す
- ログイン失敗時はincrementLoginAttemptsでログイントライ回数を増やし、sendFailedLoginResponseでログイン失敗時のレスポンスを返す
↓
↓
↓
viewメソッドでbladeが返却され、おなじみのログイン完了画面表示
Controllerの処理詳細
1, RedirectIfAuthenticatedで認証済みかチェック
app\Http\Controllers\Auth\LoginController.phpのmiddleware('guest')でapp\Http\Middleware\RedirectIfAuthenticated.phpを呼び出しており、ファイル名の通り既に認証が済んでいれば、認証後のhome画面へリダイレクトするミドルウェア
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated
{
public function handle(Request $request, Closure $next, ...$guards)
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}
既に認証していたらログイン画面表示する必要ないですもんね。
2, ログイン情報(email, password)のバリデーションチェック
$this->validateLogin($request);
...
...
...
protected function validateLogin(Request $request)
{
$request->validate([
$this->username() => 'required|string',
'password' => 'required|string',
]);
}
...
...
public function username()
{
return 'email';
}
ここでバリデーションをしている
username()はnameを見ているかと思いきや、emailを見ているだけ
- フォームリクエストを使っていないのはなぜか?
→フォームリクエストの機能が入る前からある機能だから、古いままなのか
- わざわざなぜemailをusernameメソッドというわかりにくいメソッド名にしているのか?
という疑問がわいた
3, 複数回ログインを失敗している場合は、ログインできないようにする
if (method_exists($this, 'hasTooManyLoginAttempts') && $this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
return $this->sendLockoutResponse($request);
}
- デフォルトは5回ログインミスしたら
$this->hasTooManyLoginAttempts($request)
がtrueになるので、ロックアウトされる- vendor\laravel\ui\auth-backend\ThrottlesLogins.phpで$this→maxAttemptsを定義すれば回数を変えれる
- カウントの記録はlimiter経由で実行するLaravelのキャッシュインスタンスに記録される
- fireLockoutEventでLockoutイベントを実行する
- ただ、実行後にどんな処理がされているか、が見つからず自分でカスタマイズできるのかな?と推測
- app\Providers\EventServiceProvider.phpとかで処理を書いて、管理者にメールで通告(notifyする)とか
protected function fireLockoutEvent(Request $request)
{
event(new Lockout($request));
}
- 実際のLockout処理はsendLockoutResponseメソッドで実行
- ValidationExceptionを投げ、表示するメッセージはauth.throttleの内容
- キャッシュにある情報からavailableInで$secondsを定義
- デフォルトで何秒なのか把握できなかった。。。
protected function sendLockoutResponse(Request $request)
{
$seconds = $this->limiter()->availableIn(
$this->throttleKey($request)
);
throw ValidationException::withMessages([
$this->username() => [trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
])],
])->status(Response::HTTP_TOO_MANY_REQUESTS);
}
4, ログイン成功時はログイン処理をする
if ($this->attemptLogin($request)) {
return $this->sendLoginResponse($request);
}
- 途中で成功時処理が書いてあるという違和感。。。
- 成功時の実際の処理はsendLoginResponseで実行
protected function sendLoginResponse(Request $request)
{
$request->session()->regenerate();
$this->clearLoginAttempts($request);
if ($response = $this->authenticated($request, $this->guard()->user())) {
return $response;
}
return $request->wantsJson()
? new JsonResponse([], 204)
: redirect()->intended($this->redirectPath());
}
- ここでやっているのは以下
- csrfトークンを書き変えて
- ログイントライ回数を0にし、
- 認証済みであれば何もせずに
- jsonなら成功用jsonレスポンス、通常リクエストであれば/homeへ遷移させる
これで無事、ログイン完了画面が表示される
5, ログイン失敗時はログイントライ回数のインクリメントとバリデーションエラーを発生させ、再度ログイン画面を表示
$this->incrementLoginAttempts($request);
return $this->sendFailedLoginResponse($request);
- incrementLoginAttemptsで、今までと同じようにlimiter経由でログイントライ数を1個増やす
protected function incrementLoginAttempts(Request $request)
{
$this->limiter()->hit(
$this->throttleKey($request), $this->decayMinutes() * 60
);
}
- sendFailedLoginResponseでValidationExceptionを投げてresources\lang\en\auth.phpのfailedの文字列を返す
protected function sendFailedLoginResponse(Request $request)
{
throw ValidationException::withMessages([
$this->username() => [trans('auth.failed')],
]);
}
おわりに
こうやって見るとlimiterで制御しているIlluminate\Cacheが結構キーになっていたのはポイントかと。
キャッシュというとブラウザキャッシュを想定してしまったので、Laravelの中で使っているキャッシュがあるのか、と知れたのは大きい
**Illuminate\Cache**配下には他にも’Event’や’Comand’とかのキーワードがファイル名やクラス内にあったので、、無意識に使っている箇所がありそう、ということは覚えておこうと思う