どんなシステムでも、やることとしては以下の通りだと思います。これらを実装していきます。
- パスワードのハッシュ化方式を置き換える
- ID、パスワードの入っているテーブルや列名を変更する
- ID、パスワード以外の列を参照したい場合の作りこみ
バージョンは以下の通りです。
- Laravel 7.21.0
- php:7.4-apache-buster イメージ(Docker)
先に Laravel の認証について軽く触れておきます。auth.php を見ていただきたいのですが、
'guards' => [
'api-front' => [
'driver' => 'jwt',
'provider' => 'front-users',
],
],
このようになっていると思います。api-front という認証方法(guard)を定義していて、driver には jwt というものを指定しています。ドライバーはログイン・ログアウト・ログイン中か確認するなど、認証の制御をする中心部分です。Laravel 標準では session と token の2種類が入っています。
provider は front-users というものを指定しています。これが今回作りこむものです。ユーザーのID・パスワードなどのデータを参照し、入力された情報と比較して正しいか検証する部分です。パスワードのハッシュ化もここで行います。
JWT の導入
画面を作りこむと話が複雑になりそうなので、今回は API を作る想定で進めます。その際に JWT ドライバーを使った方が都合がよかったので導入します。session の場合はCookie を使うのでテストが面倒、 token だとユーザーテーブルに api_token 列の追加が必要なので。
まずはパッケージを導入します。ドキュメントはこちらを参考にしました。
composer require tymon/jwt-auth
次に設定ファイルを作成します。config/jwt.php
が生成されます。
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
最後に JWT 作成用の鍵を作成します。.env に JWT_SECRET が追加されます。JWT を署名する際に使用されます。
php artisan jwt:secret
auth.php の書き換え
先に少し書いてしまいましたが、以下の通り設定を書きます。
'guards' => [
'api-front' => [
'driver' => 'jwt',
'provider' => 'front-users',
],
],
'providers' => [
'front-users' => [
'driver' => 'custom',
'model' => App\Models\FrontUser::class,
],
],
custom は今回作る認証プロバイダー本体の名前です。Auth::provider を使用して自作プロバイダを追加できます。先にモデルを作成してから解説します。
モデルの作成
JWT 用にいくつかメソッドを作らなければなりません。また、ユーザーパスワード以外に追加で項目取得が必要な場合はメソッドを定義しておきます。今回は getSalt を追加します。
<?php
namespace App\Models;
use App\Services\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Auth\Authenticatable;
use Illuminate\Notifications\Notifiable;
use Tymon\JWTAuth\Contracts\JWTSubject;
class FrontUser extends Model implements AuthenticatableContract, JWTSubject
{
use Authenticatable, Notifiable;
protected $table = 'front_user';
public function getSalt()
{
return $this->salt;
}
public function getJWTIdentifier()
{
return $this->getAuthIdentifier();
}
public function getJWTCustomClaims()
{
return [];
}
/**
* password 以外の列名にしたい場合は実装する。
* 変更しない場合は Authenticatable トレイトにいるので実装不要。
*/
public function getAuthPassword()
{
return $this->passphrase;
}
}
インターフェースも拡張します。
<?php
namespace App\Services\Auth;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
interface Authenticatable extends UserContract
{
public function getSalt();
}
custom 認証プロバイダーを登録
AuthServiceProvider の boot メソッドを以下のように追記します。
use App\Services\Auth\CustomEloquentUserProvider;
use App\Services\Auth\PasswordHasher;
// ...
public function boot()
{
$this->registerPolicies();
Auth::provider('custom', function ($app, array $config) {
$hasher = $app->makeWith(PasswordHasher::class, [
'options' => $app['config']['hashing.custom'] ?? []
]);
return new CustomEloquentUserProvider(
$hasher,
$config['model']
);
});
}
ここで、PasswordHasher と CustomEloquentUserProvider が新しく出てきました。前者はハッシュ処理、後者はユーザーのデータ取得をする処理です。順に説明します。
設定ファイルを使用するので、 config/hashing.php に以下のように記述してください。
<?php
return [
'custom' => [
'algo' => env('CUSTOM_HASHER_ALGO', 'SHA256'),
],
];
ハッシュ化処理の作成
既存システムが hash_hmac 関数を使用していた、という想定で作成します。
<?php
namespace App\Services\Auth;
use Illuminate\Contracts\Hashing\Hasher as HasherContract;
use InvalidArgumentException;
class PasswordHasher implements HasherContract
{
protected $algo = '';
public function __construct(array $options = [])
{
$this->algo = $options['algo'] ?? $this->algo;
}
/**
* 指定された値をハッシュ化します。
*
* @throws \RuntimeException
*/
public function make($value, array $options = [])
{
$salt = $options['salt'];
if (strlen($salt) === 0) {
throw new InvalidArgumentException('salt が指定されていません。');
}
$res = hash_hmac($this->algo, $value, $salt);
return $res;
}
/**
* ハッシュ値から情報を取得します。
*/
public function info($hashedValue)
{
return [];
}
/**
* 平文のパスワードとハッシュ化したパスワードが一致するか確認します。
*/
public function check($value, $hashedValue, array $options = [])
{
if (strlen($hashedValue) === 0) {
return false;
}
$hash = $this->make($value, $options);
if ($hash === $hashedValue) {
return true;
}
return false;
}
/**
* ハッシュ値が指定されたオプションによってハッシュ化されているか確認します。
*/
public function needsRehash($hashedValue, array $options = [])
{
return false;
}
}
info メソッドや needsRehash メソッドは php の password_hash 関数を使う想定で用意しているようです。今回は独自のハッシュ関数を使う想定なので実装はしません(例外投げた方がいいかも)。
認証プロバイダーを作成
今回は Eloquent モデルを使用するので、EloquentUserProvider をベースに作っています。クエリビルダを使用したい場合は DatabaseUserProvider を参考に作ってみてください。
<?php
namespace App\Services\Auth;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
use InvalidArgumentException;
class CustomEloquentUserProvider extends EloquentUserProvider
{
/**
* 取得したユーザーと認証情報が一致するか検証します。
*/
public function validateCredentials(UserContract $user, array $credentials)
{
if (!($user instanceof Authenticatable)) {
throw new InvalidArgumentException('想定していない model です');
}
$plain = $credentials['password'];
return $this->hasher->check($plain, $user->getAuthPassword(), [
'salt' => $user->getSalt(),
]);
}
}
validateCredentials メソッドは、Auth::attempt を呼んだ際、要するにログイン処理の際に呼ばれるメソッドです。ユーザー情報を取得した後に呼ばれますが、取得処理の詳細は後述します。
コントローラーの作成
ここまでで認証処理は完成しているのですが、動作確認用にコントローラーを作成します。
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\Controller;
class AuthController extends Controller
{
/**
* ID、パスワードを使用してログインします。成功した場合は JWT を返却します。
*
* @return \Illuminate\Http\JsonResponse
*/
public function login()
{
// password 以外のパラメーターを使用して DB を検索する
$credentials = request(['username', 'password']);
if (! $token = Auth::guard('api-front')->attempt($credentials)) {
return response()->json(['error' => 'Unauthorized'], 401);
}
return $this->respondWithToken($token);
}
/**
* 認証したユーザー情報を返却します。
*
* @return \Illuminate\Http\JsonResponse
*/
public function me()
{
// モデルに hidden を設定しておかないと、ハッシュ化されたパスワードも出るので注意
return response()->json(Auth::guard('api-front')->user());
}
/**
* ログアウトします。(無効化リストにトークンを登録します)
*
* @return \Illuminate\Http\JsonResponse
*/
public function logout()
{
Auth::guard('api-front')->logout();
return response()->json(['message' => 'Successfully logged out']);
}
/**
* レスポンス用の連想配列を生成します。
*
* @return \Illuminate\Http\JsonResponse
*/
protected function respondWithToken($token)
{
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => Auth::guard('api-front')->factory()->getTTL() * 60
]);
}
}
リクエストパラメーターから username と password を取得し、Auth::attempt() に渡しています。ここでユーザーを探し、パスワードが一致するか調べています。ユーザーを探す方法ですが、attempt に渡した password 以外のパラメーターをそのまま検索条件として使用し、すべて一致するユーザーを検索しています。 where username = ‘入力値’ といった感じです。詳細はこの辺です。
テーブルのユーザー名の列名を変えたい場合はこの username を、パスワードの列名を変えたい場合はモデルの getAuthPassword メソッドを変更します。
routes/api.php
には以下のように記述します。
Route::group([
'middleware' => 'api',
'prefix' => 'auth'
], function ($router) {
Route::post('login', 'AuthController@login');
Route::group([
'middleware' => 'auth:api-front',
], function ($router) {
Route::post('logout', 'AuthController@logout');
Route::post('me', 'AuthController@me');
});
});
呼んでみる
以下のようなリクエストを投げます。(Rest Client という VS Code 拡張が便利です)
POST http://localhost:8080/api/auth/login
Content-Type: application/json
{
"username": "name",
"password": "strong-password"
}
成功するとこんな感じで返ってきます。JWT の sub には、モデルの getJWTIdentifier メソッドで返却した列の値が入ります。標準だと id 列の値です。
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
"token_type": "bearer",
"expires_in": 3600
}
トークンを指定する場合は Authorization ヘッダに記載します。
POST http://localhost:8080/api/auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
課題
- Eloquent は単一主キー前提なので、複合主キーだと面倒かもしれないです。
- ログインしていない状態だとログインページにリダイレクトされます。API として使うならば json で返したいですね。
- 実装例なので、本来やるべきことを色々省略しています。
- Vary とか Cache-Control とか CORS とかの設定
- パスワード再発行の実装
認証の実装を複数作る場合
管理画面用のアカウントと、フロント画面用のアカウントを別々のテーブルで管理している場合も多いと思います。その場合、 auth.php の api-front と同じように guard と provider の設定を追加すれば、複数の認証方法を設定できます。Auth::guard() に新しい guard の名前を指定して利用します。
ただ、その場合は名前空間の衝突がないかを確認した方がよいです。セッションを保存する場所が切り分けできていなくて、フロント側の admin ユーザーでログインしたら管理画面の admin ユーザーでもログインできたことになっていた・・・、とかにはならないと思いますが一応。
JWT は「prv」 claim で Eloquent モデルのクラス名のハッシュ値を持っているので、それでどの guard が生成した JWT か識別しているようです。
HTTP ヘッダは Authorization を共有するので、それを変えたい場合は guard ドライバーを新しく定義する必要があります。この辺とこの辺が参考になりそうです。AuthHeaders の setHeaderName メソッドで設定できそうです。
最後に
実は、最後に書いた認証を複数作れるかを検証するのが今回の目的だったのですが、問題なくできそうですね。次はポリシーとかロールとかの認可(Authorization)の部分も認証(Authentication)ごとに作りこめるか、また Passport も上記プロバイダーを利用可能か調べていこうと思います。