Open14

laravelでauth0を試す

You-sakuYou-saku

リポジトリ

https://github.com/You-saku/laravel-auth0

前提条件

1. laravelはdocker環境ではなくローカル
1-1. php => 8.1.7
1-2. composer => 2.3.7
1-3. laravel => 9
1-4. auth0/login => ^7.1

2. dbのみdockerで作成する
2-1. postgresql => latest

ひとまず環境を作ってmigration

修正箇所

config/database.php
    'default' => env('DB_CONNECTION', 'pgsql'),
.env
DB_CONNECTION=pgsql 
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=develop
DB_USERNAME=user
DB_PASSWORD=password

postgresqlのdocker

docker-compose.yml
version: "3.7"
services:
  postgres:
    image: postgres:latest
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: develop
      TZ: "Asia/Tokyo"
    ports:
      - 5432:5432
    volumes:
      - postgres:/var/lib/postgresql/data
volumes:
  postgres:
You-sakuYou-saku

ファイルの変更点はこんな感じ(公式ドキュメント参考)

configを編集

config/auth.php
    'defaults' => [
        'guard' => 'auth0',
    ],

    'guards' => [
        'auth0' => [
            'driver' => 'auth0',
            'provider' => 'auth0',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],

        'auth0' => [
            'driver' => 'auth0',
            'repository' => App\Auth\CustomUserRepository::class // 自作のRepositoryを使いたい
        ],

自作のReposirtoryを作ることができるので作る(defaultのままというというのは少し辛い)
今回はauth0から手に入れたjwtに含まれてるsubをもつUserのみ認証が通るようにする

app/Auth/CustomUserRepository.php
<?php

declare(strict_types=1);

namespace App\Auth;

use Auth0\Laravel\Contract\Auth\User\Repository as Repository;
use App\Models\User;

class CustomUserRepository implements Repository
{
    /**
     * @inheritdoc
     *
     * @phpcsSuppress SlevomatCodingStandard.Functions.UnusedParameter
     */
    public function fromSession(
        array $user
    ): ?\Illuminate\Contracts\Auth\Authenticatable {
        // Unused in this quickstart example.
        return null;
    }

    /**
     * @inheritdoc
     */
    public function fromAccessToken(
        array $user
    ): ?\Illuminate\Contracts\Auth\Authenticatable {

        $loginUser = User::where('auth0_id', $user['sub'])->first();
        return $loginUser;
    }
}

【注意】Userモデルを返したい場合はUserモデルにあるクラスをimplementsしなければいけない
1.ステートフルcookie)時 => Auth0\Laravel\Contract\Model\Stateful\User
2. ステートレス(jwt)時 => Auth0\Laravel\Contract\Model\Stateless\User

今回はjwtの時を採用する

app/Models/User.php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Auth0\Laravel\Contract\Model\Stateless\User as Stateless; // jwtの時はimplements
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable implements Stateless
{
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'auth0_id', // auth0のuuid
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * The attributes that should be cast.
     *
     * @var array<string, string>
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];
}

You-sakuYou-saku

ルーティング追加

routes/api.php
/**
 * - auth0.authorize
 *   Requires a valid bearer token to access the route.
 */
Route::get('/api/private', function () {
    return response()->json([
        'authorized' => true,
        // 'user' => json_decode(json_encode((array) Auth::user(), JSON_THROW_ON_ERROR), true),
        'user' => Auth::user(),
    ], 200, [], JSON_PRETTY_PRINT);
})->middleware(['auth0.authorize']);
You-sakuYou-saku

auth0ってmiddlewareに酒類がある。

種類

  1. auth0.authorize
  2. auth0.authorize:scope
  3. auth0.authorize.optional

それぞれ何?

  1. は普通の認証
  2. はAuth0のスコープという機能に関係があるらしい... 後で調べたい
  3. は認証チェックをするけど、認証できなかった場合でもリクエストは止まらない?らしい 「ゲストユーザーとして扱いたい場合に使う」と公式には書いてあった。
You-sakuYou-saku

トークン手に入れてpostman叩く

auth0はjwtを手に入れるcurlコマンドをすぐ手に入れられる

Testをスクロールするとcurlコマンドが手に入るのでそれを使おう

You-sakuYou-saku

jwt.ioでtokenの中身が見れる
jwtのpayload部分にあるsubをdbのユーザーが持つようにmigrationファイルやUserFactoryファイル, Seederファイルを修正していく

php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('auth0_id');
            $table->string('name');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('users');
    }
};
database/factories/UserFactory.php
<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
 */
class UserFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition()
    {
        return [
            'name' => fake()->name(),
        ];
    }

    /**
     * Indicate that the model's email address should be unverified.
     *
     * @return static
     */
    public function unverified()
    {
        return $this->state(function (array $attributes) {
            return [
                'email_verified_at' => null,
            ];
        });
    }
}
database/seeders/DatabaseSeeder.php
<?php

namespace Database\Seeders;

// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        \App\Models\User::factory()->create([
            'auth0_id' => {ここにsub}, // 直接は良くないので環境変数で宣言してconfigファイルから呼び出すのが良さそう
        ]);
    }
}

FacrotyとSeederはお好みです。別に使い捨てのコードなので笑
tinkerで直接入れるのもありです

You-sakuYou-saku

postman叩く
取れるね。あとjwtが間違ってると403が返る

You-sakuYou-saku

※ laravel9でやるとapi.phpのファイルのルーティングはprefixで"api"がつくからroutingに"api"をつける必要はないのかも

routes/api.php
Route::get('/private', function () {
    return response()->json([
        'authorized' => true,
        'user' => Auth::user(),
    ], 200, [], JSON_PRETTY_PRINT);
})->middleware(['auth0.authorize']);
You-sakuYou-saku

Q. そもそもなんで403を返してるのか?
A. コードにそう書いてあるからです(暴論)

自分なりの予想を書いてみる(合っているかはわからない)

vendor/auth0/login/src/ServiceProvider.php
<?php

declare(strict_types=1);

namespace Auth0\Laravel;

final class ServiceProvider extends \Spatie\LaravelPackageTools\PackageServiceProvider implements \Auth0\Laravel\Contract\ServiceProvider
{
    /**
     * @inheritdoc
     */
    public function configurePackage(
        \Spatie\LaravelPackageTools\Package $package
    ): void {
        $package
            ->name('auth0')
            ->hasConfigFile();
    }

    /**
     * @inheritdoc
     */
    public function registeringPackage(): void
    {
        app()->singleton(Auth0::class, static fn (): \Auth0\Laravel\Auth0 => new Auth0());

        app()->singleton('auth0', static fn (): \Auth0\Laravel\Auth0 => app()->make(Auth0::class));

        app()->singleton(StateInstance::class, static fn (): \Auth0\Laravel\StateInstance => new StateInstance());

        app()->singleton(\Auth0\Laravel\Auth\User\Repository::class, static fn (): \Auth0\Laravel\Auth\User\Repository => new \Auth0\Laravel\Auth\User\Repository());
    }

    /**
     * @inheritdoc
     */
    public function bootingPackage(): void
    {
        auth()->provider('auth0', static fn ($app, array $config): \Auth0\Laravel\Auth\User\Provider => new \Auth0\Laravel\Auth\User\Provider(app()->make($config['repository'])));

        auth()->extend('auth0', static fn ($app, $name, array $config): \Auth0\Laravel\Auth\Guard => new \Auth0\Laravel\Auth\Guard(auth()->createUserProvider($config['provider']), $app->make('request')));

        $router = app()->make(\Illuminate\Routing\Router::class);
        $router->aliasMiddleware('auth0.authenticate', \Auth0\Laravel\Http\Middleware\Stateful\Authenticate::class);
        $router->aliasMiddleware('auth0.authenticate.optional', \Auth0\Laravel\Http\Middleware\Stateful\AuthenticateOptional::class);
        $router->aliasMiddleware('auth0.authorize', \Auth0\Laravel\Http\Middleware\Stateless\Authorize::class);
        $router->aliasMiddleware('auth0.authorize.optional', \Auth0\Laravel\Http\Middleware\Stateless\AuthorizeOptional::class);
    }
}

middlewareを定義されてるみたい。今回はauth0.authorizeを使うので...

vendor/auth0/login/src/Http/Middleware/Stateless/Authorize.php
<?php

declare(strict_types=1);

namespace Auth0\Laravel\Http\Middleware\Stateless;

/**
 * This middleware will configure the authenticated user using an available access token.
 * If a token is not available, it will raise an exception.
 *
 * @package Auth0\Laravel\Http\Middleware
 */
final class Authorize implements \Auth0\Laravel\Contract\Http\Middleware\Stateless\Authorize
{
    /**
     * @inheritdoc
     */
    public function handle(
        \Illuminate\Http\Request $request,
        \Closure $next,
        string $scope = ''
    ) {
        $user = auth()->guard('auth0')->user();

        if ($user !== null && $user instanceof \Auth0\Laravel\Contract\Model\Stateless\User) {
            if (strlen($scope) >= 1 && auth()->guard('auth0')->hasScope($scope) === false) {
                return abort(403, 'Unauthorized');
            }

            auth()->login($user);
            return $next($request);
        }

        return abort(403, 'Unauthorized');
    }
}

確かに403だね。abortで返してるね

You-sakuYou-saku

見よう見まねでMiddlewareを作って401を返そう

php artisan make:middleware Auth0Authorize
app/Http/Middleware/Auth0Authorize.php
<?php

namespace App\Http\Middleware;

use Auth0\Laravel\Contract\Http\Middleware\Stateless\Authorize;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Auth\AuthenticationException;

class Auth0Authorize implements Authorize
{
    /**
     * @inheritdoc
     */
    public function handle(
        Request $request,
        Closure $next,
        string $scope = ''
    ) {
        $user = auth()->guard('auth0')->user();

        if ($user !== null && $user instanceof User) {
            if (strlen($scope) >= 1 && auth()->guard('auth0')->hasScope($scope) === false) {
                throw new AuthenticationException('Invalid Token.');
            }

            auth()->login($user);
            return $next($request);
        }

        throw new AuthenticationException('Invalid Token.');
    }
}

Middlewareに登録

app/Http/Kernel.php
    protected $routeMiddleware = [
        'auth' => \App\Http\Middleware\Authenticate::class,
        'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
        'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
        'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
        'can' => \Illuminate\Auth\Middleware\Authorize::class,
        'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
        'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
        'signed' => \App\Http\Middleware\ValidateSignature::class,
        'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
        'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,

        'auth.authorize' => \App\Http\Middleware\Auth0Authorize::class,
    ];

ルーティングもしとく

routes/api.php
Route::get('/private/self-made', function () {
    return response()->json([
        'authorized' => true,
        'user' => Auth::user(),
    ], 200, [], JSON_PRETTY_PRINT);
})->middleware(['auth.authorize']);
You-sakuYou-saku

postmanを叩く
※HeadersのAccept application/json設定を忘れずに