🔄

Laravel のセッション ID 生成の仕組みを理解する

2023/09/15に公開

最近、自分が属する開発チームで徳丸本の勉強会を行っている。この本の中でセッション ID の固定化攻撃について紹介されており、認証後にセッション ID を変更することが対策として紹介されている。

Laravel ではスターターキットを使用すると認証時にセッション ID が再生成されるようなアプリケーションが構築される。本記事では、セッション ID の再生成がどのように実現されているのか、 Laravel のソースコードを読んでいく。

環境

  • PHP 8.2
  • Laravel 10.20.0

前提

公式ドキュメントの Session のページの内容を前提知識とする。

https://laravel.com/docs/10.x/session

Laravel のソースコードを読む

今回は公式ドキュメントに従って Laravel Sail の環境を構築し、 Laravel Breeze を使って認証機能の土台を追加した。

セッションのライフサイクル

まずセッションがどのように作成され、セッションドライバとして指定したストアに保存されるのかを見ていく。

デフォルトの設定では、 Laravel はリクエストを受け付けると \Illuminate\Session\Middleware\StartSession ミドルウェアの処理を通る。このミドルウェアの handle() メソッドは次のようになっている。

<?php

namespace Illuminate\Session\Middleware;

// ...(中略)...

class StartSession
{
    // ...(中略)...

    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        if (! $this->sessionConfigured()) {
            return $next($request);
        }

        $session = $this->getSession($request);

        if ($this->manager->shouldBlock() ||
            ($request->route() instanceof Route && $request->route()->locksFor())) {
            return $this->handleRequestWhileBlocking($request, $session, $next);
        }

        return $this->handleStatefulRequest($request, $session, $next);
    }

    // ...(中略)...
}

if (! $this->sessionConfigured()) ではセッション機能を利用していない場合のガード節であり、今回は利用している前提なので捕まらない。 $this->getSession($request) では Cookie から取得したセッション ID を元にセッションオブジェクトを構築している。このオブジェクトは \Illuminate\Contracts\Session\Session インターフェースに準じており、 Laravel のデフォルトの設定では \Illuminate\Session\Store のインスタンスになっている。このクラスはセッション ID や指定されたセッションドライバに対応するハンドラをプロパティとして持っている。基本的にセッション ID の取得・更新はこのプロパティに対して行われ、実際にセッション情報の保存先ストアとやり取りするのはこのクラスの start()save() などのメソッドを呼び出したタイミングだけである。

その次のセッションブロッキングに関する処理は本筋から逸れるのでスキップし、 $this->handleStatefulRequest($request, $session, $next) の部分を見ていく。

/**
 * Handle the given request within session state.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Illuminate\Contracts\Session\Session  $session
 * @param  \Closure  $next
 * @return mixed
 */
protected function handleStatefulRequest(Request $request, $session, Closure $next)
{
    // If a session driver has been configured, we will need to start the session here
    // so that the data is ready for an application. Note that the Laravel sessions
    // do not make use of PHP "native" sessions in any way since they are crappy.
    $request->setLaravelSession(
        $this->startSession($request, $session)
    );

    $this->collectGarbage($session);

    $response = $next($request);

    $this->storeCurrentUrl($request, $session);

    $this->addCookieToResponse($response, $session);

    // Again, if the session has been configured we will need to close out the session
    // so that the attributes may be persisted to some storage medium. We will also
    // add the session identifier cookie to the application response headers now.
    $this->saveSession($request);

    return $response;
}

大まかな処理の流れは次のようになっている。

  1. ストアからセッション情報を取得 ($request->setLaravelSession($this->startSession($request, $session)))
  2. レスポンス生成 ($response = $next($request))
  3. レスポンスヘッダに Set-Cookie を付与 ($this->addCookieToResponse($response, $session))
  4. セッション情報を保存 ($this->saveSession($request))

このように、セッションのライフサイクルは StartSession ミドルウェアを通して定義されている。

セッション ID の再生成

それでは、認証が関わる部分でセッション ID がどのように生成されるのか見ていく。なお \Illuminate\Contracts\Session\Session を型に持つ変数が出てきた場合、 \Illuminate\Session\Store のインスタンスが代入されているものとして読み進める。

ユーザー登録

まずはユーザー登録の処理を見ていく。

<?php

namespace App\Http\Controllers\Auth;

// ...(中略)...

class RegisteredUserController extends Controller
{
    // ...(中略)...

    /**
     * Handle an incoming registration request.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:'.User::class],
            'password' => ['required', 'confirmed', Rules\Password::defaults()],
        ]);

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);

        event(new Registered($user));

        Auth::login($user);

        return redirect(RouteServiceProvider::HOME);
    }
}

バリデーション、ユーザーのレコード追加、イベントの発火と処理が続き、 Auth::login($user) でログイン処理に入る。過程は省略するが、 Auth::login() を呼び出すと \Illuminate\Auth\SessionGuard::login() が呼び出される。

SessionGuard::login() は次のような処理になっている。

<?php

namespace Illuminate\Auth;

// ...(中略)...

class SessionGuard implements StatefulGuard, SupportsBasicAuth
{
    // ...(中略)...

    /**
     * Log a user into the application.
     *
     * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
     * @param  bool  $remember
     * @return void
     */
    public function login(AuthenticatableContract $user, $remember = false)
    {
        $this->updateSession($user->getAuthIdentifier());

        // If the user should be permanently "remembered" by the application we will
        // queue a permanent cookie that contains the encrypted copy of the user
        // identifier. We will then decrypt this later to retrieve the users.
        if ($remember) {
            $this->ensureRememberTokenIsSet($user);

            $this->queueRecallerCookie($user);
        }

        // If we have an event dispatcher instance set we will fire an event so that
        // any listeners will hook into the authentication events and run actions
        // based on the login and logout events fired from the guard instances.
        $this->fireLoginEvent($user, $remember);

        $this->setUser($user);
    }

    // ...(中略)...
}

ここで updateSession() に着目する。

/**
 * Update the session with the given ID.
 *
 * @param  string  $id
 * @return void
 */
protected function updateSession($id)
{
    $this->session->put($this->getName(), $id);

    $this->session->migrate(true);
}

続けて session->migrate() に着目する。

<?php

namespace Illuminate\Session;

class Store implements Session
{
    // ...(中略)...

    /**
     * Generate a new session ID for the session.
     *
     * @param  bool  $destroy
     * @return bool
     */
    public function migrate($destroy = false)
    {
        if ($destroy) {
            $this->handler->destroy($this->getId());
        }

        $this->setExists(false);

        $this->setId($this->generateSessionId());

        return true;
    }

    // ...(中略)...
}

まず migrate() の引数には true が渡されるので、まず最初の if ブロックの中の $this->handler->destroy($this->getId()) が実行され、対象のセッションが破棄される [1]。そして $this->setId($this->generateSessionId()) で、40文字の英数字がランダム生成されて新たなセッション ID として設定される。前述したようにこの時点ではセッション ID は Store クラスのプロパティに代入されるだけであり、 Set-Cookie ヘッダの設定やストアへの保存は StartSession ミドルウェアの処理内で行われる。

ログイン

ログイン処理についても見ていく。

<?php

namespace App\Http\Controllers\Auth;

// ...(中略)...

class AuthenticatedSessionController extends Controller
{
    // ...(中略)...

    /**
     * Handle an incoming authentication request.
     */
    public function store(LoginRequest $request): RedirectResponse
    {
        $request->authenticate();

        $request->session()->regenerate();

        return redirect()->intended(RouteServiceProvider::HOME);
    }

    // ...(中略)...
}

まず $request->authenticate() に着目する。

<?php

namespace App\Http\Requests\Auth;

// ...(中略)...

class LoginRequest extends FormRequest
{
    // ...(中略)...

    /**
     * Attempt to authenticate the request's credentials.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function authenticate(): void
    {
        $this->ensureIsNotRateLimited();

        if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
            RateLimiter::hit($this->throttleKey());

            throw ValidationException::withMessages([
                'email' => trans('auth.failed'),
            ]);
        }

        RateLimiter::clear($this->throttleKey());
    }

    // ...(中略)...
}

ここで Auth::attempt() に着目する。登録処理と同様に \Illuminate\Auth\SessionGuard のメソッドが呼び出される。

<?php

namespace Illuminate\Auth;

// ...(中略)...

class SessionGuard implements StatefulGuard, SupportsBasicAuth
{
    // ...(中略)...

    public function attempt(array $credentials = [], $remember = false)
    {
        $this->fireAttemptEvent($credentials, $remember);

        $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);

        // If an implementation of UserInterface was returned, we'll ask the provider
        // to validate the user against the given credentials, and if they are in
        // fact valid we'll log the users into the application and return true.
        if ($this->hasValidCredentials($user, $credentials)) {
            $this->login($user, $remember);

            return true;
        }

        // If the authentication attempt fails we will fire an event so that the user
        // may be notified of any suspicious attempts to access their account from
        // an unrecognized user. A developer may listen to this event as needed.
        $this->fireFailedEvent($user, $credentials);

        return false;
    }

    // ...(中略)...
}

メソッドの中盤で $this->login() を呼び出しているので、登録処理で見たような形でセッション ID が再生成されることが分かる。ちなみに if ($this->hasValidCredentials($user, $credentials)) の部分ではリクエスト時に渡された認証情報を照合しており、認証情報が正しければこの if ブロック内を通るようになっている。

一般的な Web アプリケーションの例に漏れず、 Laravel で発行されたセッション ID は Cookie に保存される。デフォルトの設定では laravel_session という key で保存される [2]

基本的に Cookie に保存されるデータは \App\Http\Middleware\EncryptCookies ミドルウェアを通して暗号化されるようになっている。元の値が同じであっても暗号化後の結果はリクエストごとに異なるため、セッション ID の値の変化を検証する場合は EncryptCookies$except プロパティにセッション ID を追加するなどして暗号化を無効にしておく必要がある (無論、検証が終わったら暗号化を有効にすること!)。

<?php

namespace App\Http\Middleware;

use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;

class EncryptCookies extends Middleware
{
    /**
     * The names of the cookies that should not be encrypted.
     *
     * @var array<int, string>
     */
    protected $except = [
        'laravel_session',
    ];
}

まとめ

サマリーすると次のようになる。

  • セッションのライフサイクルは \Illuminate\Session\Middleware\StartSession ミドルウェアによって定義されている
  • セッション ID は \Illuminate\Contracts\Session\Session インターフェースに準ずるクラスのインスタンスのプロパティとして一時的に管理される
  • 認証が関わる処理では Auth::login($user)Auth::attempt() などのメソッドが呼ばれ、 Session::migrate() でセッション ID が再生成される。

おまけ: Laravel UI の場合

現在は Laravel Breeze や Laravel Jetstream の利用が推奨されているが、 Laravel UI というツールキットを利用してアプリケーションの土台を構築することもできる。

https://github.com/laravel/ui

古い Laravel アプリケーションではこちらが利用されている場合もあるので、この場合のセッション ID 再生成処理についても読んでみる。なお、セッションのライフサイクル管理や Cookie の暗号化については本文と同様である。

ユーザー登録

登録処理では \App\Http\Controllers\Auth\RegisterController::register() メソッドが呼び出される。メソッドの実装は \Illuminate\Foundation\Auth\RegistersUsers トレイトに定義されている。

<?php

namespace Illuminate\Foundation\Auth;

// ...(中略)...

trait RegistersUsers
{
    // ...(中略)...

    /**
     * Handle a registration request for the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
     */
    public function register(Request $request)
    {
        $this->validator($request->all())->validate();

        event(new Registered($user = $this->create($request->all())));

        $this->guard()->login($user);

        if ($response = $this->registered($request, $user)) {
            return $response;
        }

        return $request->wantsJson()
                    ? new JsonResponse([], 201)
                    : redirect($this->redirectPath());
    }

    /**
     * Get the guard to be used during registration.
     *
     * @return \Illuminate\Contracts\Auth\StatefulGuard
     */
    protected function guard()
    {
        return Auth::guard();
    }

    // ...(中略)...
}

ここで $this->guard()->login($user) に着目する。過程は省略するが、 Auth::guard() の返り値の型は \Illuminate\Contracts\Auth\StatefulGuard になっており、実体は \Illuminate\Auth\SessionGuard() である。したがってここから先は Laravel Breeze の場合と同じ処理を通じてセッション ID が再生成される。

ログイン

ログイン処理では \App\Http\Controllers\Auth\LoginController::login() メソッドが呼び出される。メソッドの実装は \Illuminate\Foundation\Auth\AuthenticatesUsers トレイトに定義されている。

<?php

namespace Illuminate\Foundation\Auth;

// ...(中略)...

trait AuthenticatesUsers
{
    // ...(中略)...

    /**
     * Handle a login request to the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function login(Request $request)
    {
        $this->validateLogin($request);

        // If the class is using the ThrottlesLogins trait, we can automatically throttle
        // the login attempts for this application. We'll key this by the username and
        // the IP address of the client making these requests into this application.
        if (method_exists($this, 'hasTooManyLoginAttempts') &&
            $this->hasTooManyLoginAttempts($request)) {
            $this->fireLockoutEvent($request);

            return $this->sendLockoutResponse($request);
        }

        if ($this->attemptLogin($request)) {
            if ($request->hasSession()) {
                $request->session()->put('auth.password_confirmed_at', time());
            }

            return $this->sendLoginResponse($request);
        }

        // If the login attempt was unsuccessful we will increment the number of attempts
        // to login and redirect the user back to the login form. Of course, when this
        // user surpasses their maximum number of attempts they will get locked out.
        $this->incrementLoginAttempts($request);

        return $this->sendFailedLoginResponse($request);
    }

    // ...(中略)...

    /**
     * Attempt to log the user into the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    protected function attemptLogin(Request $request)
    {
        return $this->guard()->attempt(
            $this->credentials($request), $request->boolean('remember')
        );
    }

    // ...(中略)...

    /**
     * Get the guard to be used during registration.
     *
     * @return \Illuminate\Contracts\Auth\StatefulGuard
     */
    protected function guard()
    {
        return Auth::guard();
    }
}

ここで $this->attemptLogin($request) に着目する。メソッド内で $this->guard()->attempt() を呼び出しており、先程と同様に \Illuminate\Auth\SessionGuard のメソッドが呼び出される。したがってここから先は Laravel Breeze の場合と同じ処理を通じてセッション ID が再生成される。

脚注
  1. https://www.php.net/manual/en/sessionhandler.destroy.php ↩︎

  2. config/session.phpcookie で key の生成方法を変更できる。 ↩︎

Discussion