🐥

laravel12 starter kitのworkos連携機能について

に公開

workosに関しては

https://zenn.dev/catatsumuri/articles/cc1406b89a6aa1

ここではreact

diffを見る

ここではシンプルにセットアップしたのとの違いを見てみる

基本的には

laravel new --react --pest --npm <パッケージ名>
laravel new --react --workos --pest --npm <パッケージ名>

の違い(つまり --workos )

        modified:   .env.example
        modified:   app/Http/Controllers/Settings/ProfileController.php
        modified:   app/Models/User.php
        modified:   bootstrap/providers.php
        modified:   composer.json
        modified:   composer.lock
        modified:   config/auth.php
        modified:   config/services.php
        modified:   database/factories/UserFactory.php
        modified:   database/migrations/0001_01_01_000000_create_users_table.php
        modified:   resources/js/components/delete-user.tsx
        modified:   resources/js/layouts/settings/layout.tsx
        modified:   resources/js/pages/settings/profile.tsx
        modified:   resources/js/pages/welcome.tsx
        modified:   routes/auth.php
        modified:   routes/settings.php
        modified:   routes/web.php
        modified:   tests/Feature/Settings/ProfileUpdateTest.php

まあまあの差分が出ているので1つずつ見ていこう

phpライブラリーの追加

  • composer.json
  • composer.lock

であるが主には

composer.json
@@ -13,6 +13,7 @@
         "inertiajs/inertia-laravel": "^2.0",
         "laravel/framework": "^12.0",
         "laravel/tinker": "^2.10.1",
+        "laravel/workos": "^0.1.0",
         "tightenco/ziggy": "^2.4"
     },
     "require-dev": {

laravel/workosが追加されている

https://packagist.org/packages/laravel/workos

starter kitと同時リリースされた非常に若いパッケージだ

設定

@@ -56,6 +56,10 @@ MAIL_PASSWORD=null
 MAIL_FROM_ADDRESS="hello@example.com"
 MAIL_FROM_NAME="${APP_NAME}"

+WORKOS_CLIENT_ID=
+WORKOS_API_KEY=
+WORKOS_REDIRECT_URL="http://localhost:8000/authenticate"
+
 AWS_ACCESS_KEY_ID=
 AWS_SECRET_ACCESS_KEY=
 AWS_DEFAULT_REGION=us-east-1
config/services.php
@@ -35,4 +35,10 @@
         ],
     ],

+    'workos' => [
+        'client_id' => env('WORKOS_CLIENT_ID'),
+        'secret' => env('WORKOS_API_KEY'),
+        'redirect_url' => env('WORKOS_REDIRECT_URL'),
+    ],
+
 ];

WORKOS周りが3点追加されているさらに

config/auth.php
@@ -71,45 +71,4 @@
         // ],
     ],

-    /*
-    |--------------------------------------------------------------------------
-    | Resetting Passwords
-    |--------------------------------------------------------------------------
-    |
-    | These configuration options specify the behavior of Laravel's password
-    | reset functionality, including the table utilized for token storage
-    | and the user provider that is invoked to actually retrieve users.
-    |
-    | The expiry time is the number of minutes that each reset token will be
-    | considered valid. This security feature keeps tokens short-lived so
-    | they have less time to be guessed. You may change this as needed.
-    |
-    | The throttle setting is the number of seconds a user must wait before
-    | generating more password reset tokens. This prevents the user from
-    | quickly generating a very large amount of password reset tokens.
-    |
-    */
-
-    'passwords' => [
-        'users' => [
-            'provider' => 'users',
-            'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
-            'expire' => 60,
-            'throttle' => 60,
-        ],
-    ],
-
-    /*
-    |--------------------------------------------------------------------------
-    | Password Confirmation Timeout
-    |--------------------------------------------------------------------------
-    |
-    | Here you may define the amount of seconds before a password confirmation
-    | window expires and users are asked to re-enter their password via the
-    | confirmation screen. By default, the timeout lasts for three hours.
-    |
-    */
-
-    'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
-
 ];

このようにパスワードの再設定周りが消去されている。まあこれは確かに内部パスワードを使わなければ必要ないものではあるがworkos一本に統合するとまあまあ運用は面倒なんじゃないかという気もしなくもない。

DB周り

マイグレーション

database/migrations/0001_01_01_000000_create_users_table.php
@@ -16,17 +16,12 @@ public function up(): void
             $table->string('name');
             $table->string('email')->unique();
             $table->timestamp('email_verified_at')->nullable();
-            $table->string('password');
+            $table->string('workos_id')->unique();
             $table->rememberToken();
+            $table->text('avatar');
             $table->timestamps();
         });

-        Schema::create('password_reset_tokens', function (Blueprint $table) {
-            $table->string('email')->primary();
-            $table->string('token');
-            $table->timestamp('created_at')->nullable();
-        });
-
         Schema::create('sessions', function (Blueprint $table) {
             $table->string('id')->primary();
             $table->foreignId('user_id')->nullable()->index();
@@ -43,7 +38,6 @@ public function up(): void
     public function down(): void
     {
         Schema::dropIfExists('users');
-        Schema::dropIfExists('password_reset_tokens');
         Schema::dropIfExists('sessions');
     }
 };

パスワードそのものと、リセットトークン周りが削除されている。それに加えてworkos_idavatarが追加されている。avatarに関してはworkosで認証完了した時にworkosのavatar URLを投入しているだけのようである。

factory

database/factories/UserFactory.php
@@ -3,7 +3,6 @@
 namespace Database\Factories;

 use Illuminate\Database\Eloquent\Factories\Factory;
-use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;

 /**
@@ -11,11 +10,6 @@
  */
 class UserFactory extends Factory
 {
-    /**
-     * The current password being used by the factory.
-     */
-    protected static ?string $password;
-
     /**
      * Define the model's default state.
      *
@@ -27,8 +21,9 @@ public function definition(): array
             'name' => fake()->name(),
             'email' => fake()->unique()->safeEmail(),
             'email_verified_at' => now(),
-            'password' => static::$password ??= Hash::make('password'),
+            'workos_id' => 'fake-'.Str::random(10),
             'remember_token' => Str::random(10),
+            'avatar' => '',
         ];
     }

passwordフィールドが消滅した対応と、workos_idではfake IDを挿入している

Userモデル

app/Models/User.php
@@ -20,7 +20,8 @@ class User extends Authenticatable
     protected $fillable = [
         'name',
         'email',
-        'password',
+        'workos_id',
+        'avatar',
     ];

     /**
@@ -29,7 +30,7 @@ class User extends Authenticatable
      * @var list<string>
      */
     protected $hidden = [
-        'password',
+        'workos_id',
         'remember_token',
     ];

これも今まで見てきたのに準じた対応でありパスワードの消滅とworkos, avatarの追加

ルート周り

routes/web.php

routes/web.php
@@ -2,12 +2,16 @@

 use Illuminate\Support\Facades\Route;
 use Inertia\Inertia;
+use Laravel\WorkOS\Http\Middleware\ValidateSessionWithWorkOS;

 Route::get('/', function () {
     return Inertia::render('welcome');
 })->name('home');

-Route::middleware(['auth', 'verified'])->group(function () {
+Route::middleware([
+    'auth',
+    ValidateSessionWithWorkOS::class,
+])->group(function () {
     Route::get('dashboard', function () {
         return Inertia::render('dashboard');
     })->name('dashboard');

これはそこまで変化してるわけではない。ValidateSessionWithWorkOSはWorkOSで正しく認証されたかどうかをチェックしているMiddleWareでこれを通過しないと入れないようにしたというだけ。

routes/settings.php

routes/settings.php
@@ -1,20 +1,20 @@
 <?php

-use App\Http\Controllers\Settings\PasswordController;
 use App\Http\Controllers\Settings\ProfileController;
 use Illuminate\Support\Facades\Route;
 use Inertia\Inertia;
+use Laravel\WorkOS\Http\Middleware\ValidateSessionWithWorkOS;

-Route::middleware('auth')->group(function () {
+Route::middleware([
+    'auth',
+    ValidateSessionWithWorkOS::class,
+])->group(function () {
     Route::redirect('settings', 'settings/profile');

     Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
     Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
     Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');

-    Route::get('settings/password', [PasswordController::class, 'edit'])->name('password.edit');
-    Route::put('settings/password', [PasswordController::class, 'update'])->name('password.update');
-
     Route::get('settings/appearance', function () {
         return Inertia::render('settings/appearance');
     })->name('appearance');

ここは先のミドルウェアの追加対応とパスワード系の消滅

routes/auth.php

routes/auth.php
@@ -1,56 +1,18 @@
 <?php

-use App\Http\Controllers\Auth\AuthenticatedSessionController;
-use App\Http\Controllers\Auth\ConfirmablePasswordController;
-use App\Http\Controllers\Auth\EmailVerificationNotificationController;
-use App\Http\Controllers\Auth\EmailVerificationPromptController;
-use App\Http\Controllers\Auth\NewPasswordController;
-use App\Http\Controllers\Auth\PasswordResetLinkController;
-use App\Http\Controllers\Auth\RegisteredUserController;
-use App\Http\Controllers\Auth\VerifyEmailController;
 use Illuminate\Support\Facades\Route;
+use Laravel\WorkOS\Http\Requests\AuthKitAuthenticationRequest;
+use Laravel\WorkOS\Http\Requests\AuthKitLoginRequest;
+use Laravel\WorkOS\Http\Requests\AuthKitLogoutRequest;

-Route::middleware('guest')->group(function () {
-    Route::get('register', [RegisteredUserController::class, 'create'])
-        ->name('register');
+Route::get('login', function (AuthKitLoginRequest $request) {
+    return $request->redirect();
+})->middleware(['guest'])->name('login');

-    Route::post('register', [RegisteredUserController::class, 'store']);
+Route::get('authenticate', function (AuthKitAuthenticationRequest $request) {
+    return tap(to_route('dashboard'), fn () => $request->authenticate());
+})->middleware(['guest']);

-    Route::get('login', [AuthenticatedSessionController::class, 'create'])
-        ->name('login');
-
-    Route::post('login', [AuthenticatedSessionController::class, 'store']);
-
-    Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
-        ->name('password.request');
-
-    Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
-        ->name('password.email');
-
-    Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
-        ->name('password.reset');
-
-    Route::post('reset-password', [NewPasswordController::class, 'store'])
-        ->name('password.store');
-});
-
-Route::middleware('auth')->group(function () {
-    Route::get('verify-email', EmailVerificationPromptController::class)
-        ->name('verification.notice');
-
-    Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
-        ->middleware(['signed', 'throttle:6,1'])
-        ->name('verification.verify');
-
-    Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
-        ->middleware('throttle:6,1')
-        ->name('verification.send');
-
-    Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
-        ->name('password.confirm');
-
-    Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
-
-    Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
-        ->name('logout');
-});
+Route::post('logout', function (AuthKitLogoutRequest $request) {
+    return $request->logout();
+})->middleware(['auth'])->name('logout');

消滅した分は従来の認証のものなのでどうでもいいとして需要なのは

+use Laravel\WorkOS\Http\Requests\AuthKitAuthenticationRequest;
+use Laravel\WorkOS\Http\Requests\AuthKitLoginRequest;
+use Laravel\WorkOS\Http\Requests\AuthKitLogoutRequest;

+Route::get('login', function (AuthKitLoginRequest $request) {
+    return $request->redirect();
+})->middleware(['guest'])->name('login');


+Route::get('authenticate', function (AuthKitAuthenticationRequest $request) {
+    return tap(to_route('dashboard'), fn () => $request->authenticate());
+})->middleware(['guest']);

+Route::post('logout', function (AuthKitLogoutRequest $request) {
+    return $request->logout();
+})->middleware(['auth'])->name('logout');

という追加部分になるはずだ

view(react, tsx)

resources/js/pages/welcome.tsx

resources/js/pages/welcome.tsx
@@ -28,12 +28,6 @@ export default function Welcome() {
                 >
                   Log in
                 </Link>
-                <Link
-                  href={route('register')}
-                  className="inline-block rounded-sm border border-[#19140035] px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#1915014a] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:border-[#62605b]"
-                >
-                  Register
-                </Link>
               </>
             )}
           </nav>

単純にregisterのリンクが剥がれてworkosのログイン一択になっただけ

resources/js/layouts/settings/layout.tsx (設定tabの変更)

resources/js/layouts/settings/layout.tsx
@@ -12,11 +12,6 @@ const sidebarNavItems: NavItem[] = [
     href: '/settings/profile',
     icon: null,
   },
-  {
-    title: 'Password',
-    href: '/settings/password',
-    icon: null,
-  },
   {
     title: 'Appearance',
     href: '/settings/appearance',

パスワードの設定をとりはらっている。パスワードの変更はemailを使った場合に関しても全てworkos上でやる必要がある。

resources/js/pages/settings/profile.tsx (プロフィール設定)

resources/js/pages/settings/profile.tsx
@@ -1,6 +1,6 @@
 import { type BreadcrumbItem, type SharedData } from '@/types';
 import { Transition } from '@headlessui/react';
-import { Head, Link, useForm, usePage } from '@inertiajs/react';
+import { Head, useForm, usePage } from '@inertiajs/react';
 import { FormEventHandler } from 'react';

 import DeleteUser from '@/components/delete-user';
@@ -24,7 +24,7 @@ type ProfileForm = {
   email: string;
 };

-export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail: boolean; status?: string }) {
+export default function Profile() {
   const { auth } = usePage<SharedData>().props;

   const { data, setData, patch, errors, processing, recentlySuccessful } = useForm<Required<ProfileForm>>({
@@ -68,40 +68,11 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
             <div className="grid gap-2">
               <Label htmlFor="email">Email address</Label>

-              <Input
-                id="email"
-                type="email"
-                className="mt-1 block w-full"
-                value={data.email}
-                onChange={(e) => setData('email', e.target.value)}
-                required
-                autoComplete="username"
-                placeholder="Email address"
-              />
+              <Input id="email" type="email" className="mt-1 block w-full" value={data.email} required autoComplete="username" disabled />

               <InputError className="mt-2" message={errors.email} />
             </div>

-            {mustVerifyEmail && auth.user.email_verified_at === null && (
-              <div>
-                <p className="text-muted-foreground -mt-4 text-sm">
-                  Your email address is unverified.{' '}
-                  <Link
-                    href={route('verification.send')}
-                    method="post"
-                    as="button"
-                    className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
-                  >
-                    Click here to resend the verification email.
-                  </Link>
-                </p>
-
-                {status === 'verification-link-sent' && (
-                  <div className="mt-2 text-sm font-medium text-green-600">A new verification link has been sent to your email address.</div>
-                )}
-              </div>
-            )}
-
             <div className="flex items-center gap-4">
               <Button disabled={processing}>Save</Button>

emailの変更が無効化されたのとemailの検証をlaravelシステムで行うのをやめている

resources/js/components/delete-user.tsx(退会処理)

resources/js/components/delete-user.tsx
@@ -1,18 +1,14 @@
 import { useForm } from '@inertiajs/react';
-import { FormEventHandler, useRef } from 'react';
+import { FormEventHandler } from 'react';

-import InputError from '@/components/input-error';
 import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';

 import HeadingSmall from '@/components/heading-small';

 import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog';

 export default function DeleteUser() {
-  const passwordInput = useRef<HTMLInputElement>(null);
-  const { data, setData, delete: destroy, processing, reset, errors, clearErrors } = useForm<Required<{ password: string }>>({ password: '' });
+  const { delete: destroy, processing, reset, clearErrors } = useForm();

   const deleteUser: FormEventHandler = (e) => {
     e.preventDefault();
@@ -20,7 +16,6 @@ export default function DeleteUser() {
     destroy(route('profile.destroy'), {
       preserveScroll: true,
       onSuccess: () => closeModal(),
-      onError: () => passwordInput.current?.focus(),
       onFinish: () => reset(),
     });
   };
@@ -46,29 +41,10 @@ export default function DeleteUser() {
           <DialogContent>
             <DialogTitle>Are you sure you want to delete your account?</DialogTitle>
             <DialogDescription>
-              Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you
-              would like to permanently delete your account.
+              Once your account is deleted, all of its resources and data will also be permanently deleted. Please confirm you would like to
+              permanently delete your account.
             </DialogDescription>
             <form className="space-y-6" onSubmit={deleteUser}>
-              <div className="grid gap-2">
-                <Label htmlFor="password" className="sr-only">
-                  Password
-                </Label>
-
-                <Input
-                  id="password"
-                  type="password"
-                  name="password"
-                  ref={passwordInput}
-                  value={data.password}
-                  onChange={(e) => setData('password', e.target.value)}
-                  placeholder="Password"
-                  autoComplete="current-password"
-                />
-
-                <InputError message={errors.password} />
-              </div>
-
               <DialogFooter className="gap-2">
                 <DialogClose asChild>
                   <Button variant="secondary" onClick={closeModal}>

workosでの退会となるので、ここでももちろん処理が変更されている。

コントローラーの変更

app/Http/Controllers/Settings/ProfileController.php
@@ -3,13 +3,12 @@
 namespace App\Http\Controllers\Settings;

 use App\Http\Controllers\Controller;
-use App\Http\Requests\Settings\ProfileUpdateRequest;
-use Illuminate\Contracts\Auth\MustVerifyEmail;
+use App\Models\User;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Auth;
 use Inertia\Inertia;
 use Inertia\Response;
+use Laravel\WorkOS\Http\Requests\AuthKitAccountDeletionRequest;

 class ProfileController extends Controller
 {
@@ -19,7 +18,6 @@ class ProfileController extends Controller
     public function edit(Request $request): Response
     {
         return Inertia::render('settings/profile', [
-            'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
             'status' => $request->session()->get('status'),
         ]);
     }
@@ -27,15 +25,13 @@ public function edit(Request $request): Response
     /**
      * Update the user's profile settings.
      */
-    public function update(ProfileUpdateRequest $request): RedirectResponse
+    public function update(Request $request): RedirectResponse
     {
-        $request->user()->fill($request->validated());
-
-        if ($request->user()->isDirty('email')) {
-            $request->user()->email_verified_at = null;
-        }
+        $request->validate([
+            'name' => ['required', 'string', 'max:255'],
+        ]);

-        $request->user()->save();
+        $request->user()->update(['name' => $request->name]);

         return to_route('profile.edit');
     }
@@ -43,21 +39,10 @@ public function update(ProfileUpdateRequest $request): RedirectResponse
     /**
      * Delete the user's account.
      */
-    public function destroy(Request $request): RedirectResponse
+    public function destroy(AuthKitAccountDeletionRequest $request): RedirectResponse
     {
-        $request->validate([
-            'password' => ['required', 'current_password'],
-        ]);
-
-        $user = $request->user();
-
-        Auth::logout();
-
-        $user->delete();
-
-        $request->session()->invalidate();
-        $request->session()->regenerateToken();
-
-        return redirect('/');
+        return $request->delete(
+            using: fn (User $user) => $user->delete()
+        );
     }
 }

ユーザープロフィールの更新処理の変更および、退会の変更。

bootstrap/providers.php

bootstrap/providers.php
@@ -1,5 +1,3 @@
 <?php

-return [
-    App\Providers\AppServiceProvider::class,
-];
+return [];

テスト

基本的にprofileだけ

tests/Feature/Settings/ProfileUpdateTest.php

tests/Feature/Settings/ProfileUpdateTest.php
@@ -20,8 +20,7 @@
     $response = $this
         ->actingAs($user)
         ->patch('/settings/profile', [
-            'name' => 'Test User',
-            'email' => 'test@example.com',
+            'name' => 'Updated Name',
         ]);

     $response
@@ -30,26 +29,7 @@

     $user->refresh();

-    expect($user->name)->toBe('Test User');
-    expect($user->email)->toBe('test@example.com');
-    expect($user->email_verified_at)->toBeNull();
-});
-
-test('email verification status is unchanged when the email address is unchanged', function () {
-    $user = User::factory()->create();
-
-    $response = $this
-        ->actingAs($user)
-        ->patch('/settings/profile', [
-            'name' => 'Test User',
-            'email' => $user->email,
-        ]);
-
-    $response
-        ->assertSessionHasNoErrors()
-        ->assertRedirect('/settings/profile');
-
-    expect($user->refresh()->email_verified_at)->not->toBeNull();
+    expect($user->name)->toBe('Updated Name');
 });

 test('user can delete their account', function () {
@@ -68,20 +48,3 @@
     $this->assertGuest();
     expect($user->fresh())->toBeNull();
 });
-
-test('correct password must be provided to delete account', function () {
-    $user = User::factory()->create();
-
-    $response = $this
-        ->actingAs($user)
-        ->from('/settings/profile')
-        ->delete('/settings/profile', [
-            'password' => 'wrong-password',
-        ]);
-
-    $response
-        ->assertSessionHasErrors('password')
-        ->assertRedirect('/settings/profile');
-
-    expect($user->fresh())->not->toBeNull();
-});

パスワードの検証などがごっそり抜けた

まとめ

このように、とりあえずworkosで動作するくらいのノリで組まれているため、あまり深い運用に関しては考えられていない。ロールだのパーミッションだのを考えない運用であればこれでも十分機能するとは思うが実際の業務でそこまで水平なシステムというのはなかなか存在しないものなので、基本的にはプロトタイプを作るのが必須である。それを行うにしても何も考えなければ基本的にworkosを経由しないとログインできなくなるため、どうやって開発ユーザーを作成するのかという問題も発生する(まあ、今あるoauthで使える認証+email認証しか基本的には無いのではあるが、ディレクトリ同期は一般的に難易度が高いでしょうし)

パスワードの変更に関してとか退会処理とか実際の業務や案件で使うプロダクトに持っていく場合は最低限必ず確認すること

Discussion