laravel12 starter kitのworkos連携機能について
workosに関しては
ここでは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
であるが主には
@@ -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
が追加されている
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
@@ -35,4 +35,10 @@
],
],
+ 'workos' => [
+ 'client_id' => env('WORKOS_CLIENT_ID'),
+ 'secret' => env('WORKOS_API_KEY'),
+ 'redirect_url' => env('WORKOS_REDIRECT_URL'),
+ ],
+
];
WORKOS周りが3点追加されているさらに
@@ -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周り
マイグレーション
@@ -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_id
とavatar
が追加されている。avatar
に関してはworkosで認証完了した時にworkosのavatar URLを投入しているだけのようである。
factory
@@ -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モデル
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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の変更)
@@ -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 (プロフィール設定)
@@ -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(退会処理)
@@ -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での退会となるので、ここでももちろん処理が変更されている。
コントローラーの変更
@@ -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
@@ -1,5 +1,3 @@
<?php
-return [
- App\Providers\AppServiceProvider::class,
-];
+return [];
テスト
基本的にprofileだけ
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