tohokuaikiのチラシの裏

技術的ネタとか。

SchemaSpyを使ってDockerのデータベース情報をHTMLで出力

前提

  • WSLのcliでdockerを導入
  • dockerにMariaDBなんかでDBを作っている。
  • MariaDBコンテナはややこしいアクセス制限とかしてない。

Docker

compose.yaml はこんな感じでMariaDBコンテナを起動している。このデータベーステーブルのSchemaを出力したい。

name: schemaspy_sample
services: 
  mariadb:
    container_name: schemaspy_sample_mariadb
    build:
      dockerfile: ./mariadb/Dockerfile
      context: .
      tags: 
       - "docker-mariadb:latest"
    image: "docker-mariadb:latest"
    environment:
      MARIADB_ROOT_PASSWORD: passwd
      MARIADB_USER: docker
      MARIADB_PASSWORD: passwd
      MARIADB_DATABASE: schemaspy_sample
    volumes: 
      - type: volume
        source: mariadb
        target: /var/lib/mysql
      - type: bind
        source: ./mariadb
        target:  /docker-entrypoint-initdb.d

./mariadb/Dockerfile はこんな感じ

FROM --platform=linux/amd64 mariadb:latest

SchemaSpyコンテナを作って実行

その前に出力ディレクトリを作っておく。

mkdir ./output
chmod 777 output

で、こんな感じでdocker run

docker run --rm \
  -v "$(pwd)/output":/output \
  --network schemaspy_sample_default \
  schemaspy/schemaspy:7.0.2 \
  -t mysql \
  -host schemaspy_sample_mariadb \
  -db schemaspy_sample \
  -u docker -p passwd -s schemaspy_sample

networkは docker network ls で調べる。-db はデータベース、と -sスキーマMySQL/MariaDBの場合は同じものになる。

出力フォルダは schemaspyコンテナの/output に出力されるので、-v "$(pwd)":/output でボリュームマウントしておく。Dockerからこのディレクトリに書き込みできるように777指定を忘れずに。

/output に出されるのは

$ docker inspect schemaspy/schemaspy:7.0.2|grep output
                "SCHEMASPY_OUTPUT=/output"

というようにschemaspyイメージで指定されているから。変えたければ -o /foo とかで変えられるらしい。

Laravel+Inertia.js(+Breeze)でセキュリティ的な意識からRouteをそれぞれに分けた話

buildしてから気づいたんだけど、思い違いがあれば是非指摘して欲しい。以下の記事では「問題」って書いてるけど、別にこれは問題じゃなくて適正な仕様だと思っているので別にInertia.jsが悪いわけではない。それを意識して使えない方が悪い。…が、初心者とかが何も考えずに「うわー、便利!」って使うとそりゃそうなるよね…っていう気はする。

Laravel+Inertia.jsのセキュリティ上の問題点

RoutingやPageのJavaScriptが露出してしまうこと。Build時にHashが付けられるので、PageのJavaScriptは当てずっぽうではURLを突き止めにくいけど、

Routing見えちゃってるよ問題

Breezeでお手軽に作れるLoginページでChrome DevToolsのコンソールから

Object.entries(Ziggy.routes).forEach((r)=> console.log(r[1].uri))

ってやると、

sanctum/csrf-cookie
/
logout
dashboard
register
login
forgot-password
reset-password/{token}
reset-password
verify-email
verify-email/{id}/{hash}
email/verification-notification
confirm-password
password
storage/{path}

と出る。ということは、あなたのLaravel+Inertia.jsアプリケーションはトップページでこのコンソールコマンドを打たれたら全てのURIが出てしまうということになる。

対応策

ZiggyにRouting情報を渡しているのは、Viewのルートになるapp.blade.phpになる。この@routes ディレクティブには引数を渡せる。例えば、

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title inertia>{{ config('app.name', 'Laravel') }}</title>

        <!-- Scripts -->
        @routes('admin')

みたいに。

で、この引数は、config/ziggy.php に紐づけられる。ここで、グループ設定をしてやることで表出するRoutingを制限できる。

<?php
return [
    'groups' => 
        'admin' => ['admin.*'],
];

色々なやり方ができるので、ZiggyのREADMEを読んで対応する。

resources/js/Pages 配下がアクセスできちゃってるよ問題

StaticなJavaScriptなんてアクセスできてもDBの内容はAPIで保護しとるし問題ないやろ!とか思うかもしれないが、使い方とか微妙にセンシティブな記述があるとあまり気持ちのよろしいものではない。そういう態度が(略

これはどこで出てしまうかというと、build後のHTMLに出てしまっている。ソースコードを見ると

            document.head.append(fragment)
        })

        loadNext(JSON.parse('[{\u0022rel\u0022:\u0022prefetch\u0022,\u0022href\u0022:\u0022https:\\\/\\\/laravel.inertiajs.example.com\\\/build\\\/assets\\\/DeleteModal-sGdPz8-i.js\u0022,\u0022fetchpriority\u0022:\u0022low\u0022},{\u0022rel\u0022:\u0022prefetch\u0022,\u0022href\u0022:\u0022https:\\\/\\\/laravel.inertiajs.example.com\\\/build\\\/assets\\\/InputError-D8SSzIW6.js\u0022,\u0022fetchpriority\u0022:\u0022low\u0022},{\u0022rel\u0022:\u0022prefetch\u0022,\u0022href\u0022:\u0022https:\\\/\\\/laravel.inertiajs.example.com\\\/build\\\/assets\\\/CAlert-CTPOVJDO.js\u0022,\u0022fe

というJavaScriptのPrefetch部分である。これはnpm run devではhmrであるのでbuild後じゃないと見えない。

ではこれはどこで制御するのかというと、viteの入り口である resources/ts/admin.tsx になる。先ほどのbladeファイルのすぐ下の部分で指定されている。

        @routes('admin')
        @viteReactRefresh
        @vite(['resources/ts/admin.tsx', "resources/ts/Pages/{$page['component']}.tsx"])
        @inertiaHead

対応策

いくつかの面から対応する必要がある。

PageのJavaScriptを収めるファイルを分割しておく

そもそも、アクセスされても仕方がないというスコープを設計段階で決めておき、それらをPagesの下階層のフォルダに分割しておく。 こんな感じ。

resources/ts/Pages/
├── Admin(管理者ページ)
├── App(ログイン後のユーザー用ページ)
├── Auth(Breezeの作ったページ)
└── Guest(ゲストユーザー用のページ)

分割したファイルに合ったようにそれぞれのPathを設定する

これが結構な影響がある。以下のような流れで考える。
route => bladeファイル =>Reactの起点&デフォルトページのtsx => LaravelのInerrtia::render()が返すPageのPath

この全てで対応が必要。

Inertia.jsのrootViewを変更してrootになるbladeファイルを変更する。

app/Http/Middleware/HandleInertiaRequests.php がrootViewを決定するのでこんな感じ。

<?php
    public function rootView(Request $request)
    {
        if ($rootView = $this->getRootViewName($request)) {
            return $rootView;
        }
        return parent::rootView($request);
    }

    private function getRootViewName(Request $request): string | null
    {
        $routeName = $request->route()->getName();
        if (strpos($routeName, 'admin.') === 0) {
            return 'admin';
        }
        if ($routeName === 'home' || strpos($routeName, 'guest.') === 0) {
            return 'guest';
        }
        if (
            in_array($routeName, ['login', 'logout', 'register',]) ||
            strpos($routeName, 'password.') === 0 ||
            strpos($routeName, 'verification.') === 0
        ) {
            return 'auth';
        }

        return null;
    }
}

ここで、routeによって4つのbladeに振り分けることができる。admin.blade.php app.blade.php auth.blade.php guest.blade.php の4つを作っておく。

bladeファイルの記述とLaravel Controllerのメソッドの返り値

先ほどまでで変更してきたものの、 @vite ディレクティブの引数の配列第二要素はアクセスしたURLに対応するデフォルトのPageになる。つまり、あるRouteに対してLaravel Controllerで以下のように返していた場合、

<?php
namespace App\Http\Controllers;
use Inertia\Inertia;

class AdminController extends Controller
{
    // 
    public function topPage(string $page = ''): \Inertia\Response
    {
        return  Inertia::render('Admin/Index');
    }
}

bladeの {$page['component']} には、Admin/Index が入る。Controllerの全てのメソッドの返り値に逐一 Admin/を付けてもいいのだが、それは面倒なので

        <!-- Scripts -->
        @routes('admin')
        @viteReactRefresh
        @vite(['resources/ts/admin.tsx', "resources/ts/Pages/Admin/{$page['component']}.tsx"])
        @inertiaHead

としておく。ただし、ControllerではPathが1階層ずれることを忘れないように。

Reactの起点である'resources/ts/admin.tsx' の変更

ここまで来てようやくPrefetchのための記述を変更することができる。 resolvePageComponentの引数2つに /Admin を入れることができる。

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) =>
        resolvePageComponent(
            `./Pages/Admin/${name}.tsx`,
            import.meta.glob('./Pages/Admin/**/*.tsx'),
        ),

ここまでやってなんだけど

こんなのみんな普通に意識できてるんかな…

LaravelのControllerでValidationを行う時

メモ

Controller

validateメソッドの - 第一引数がRule - 第二引数がエラーメッセージ - 第三引数がAttribute名

<?php
use App\Rules\UserPassword;

class PasswordController extends Controller
{
    /**
     * Update the user's password.
     */
    public function update(Request $request): RedirectResponse
    {
        $validated = $request->validate(
            [
                'current_password' => ['required', 'current_password'],
                'password' => ['confirmed', new UserPassword()],
            ],
            [
                'password.confirmed' => ':attribute の入力が確認用と一致しません。',
            ],
            [
                'current_password' => '現在のパスワード',
                'password' => '新しいパスワード',
            ]
        );

        $request->user()->update([
            'password' => $validated['password'],
        ]);

        return back();
    }
}

独自Rule Validator

ここでもAttributeのPlaceholderは使える。

<?php

class UserPassword implements ValidationRule
{

    private $min_length = 8;

    /**
     * Run the validation rule.
     *
     * @param  \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString  $fail
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $rules = [
            'required',
            'string',
            Password::min($this->min_length)
                ->mixedCase()
                ->numbers()
                ->symbols(),
        ];
        $messages = [
            ':attribute は必須です。',
            ':attribute は文字列で入力してください。',
            ':attribute は' . $this->min_length . '文字以上で、大文字・小文字・数字・記号を含める必要があります。',
        ];
        foreach ($rules as $key => $rule) {
            $validator = Validator::make(
                [$attribute => $value],
                [$attribute => [$rule]],
            );
            if ($validator->fails()) {
                $fail($messages[$key] ?? self::class . " validation error");
            }
        }
    }
}

$failでエラーメッセージを配列に積んでいける。ただし、Inertia.jsではエラーメッセージ配列の最初の1つ目だけを送るのでちょっと工夫が必要。

ということでInteria.jsでの工夫

ちょっと工夫はRequestクラスに行う。これを継承していけばOK

<?php

class InertiaRequest extends FormRequest
{
   /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
           return false;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
        ];
    }


    /**
     * Inertia.jsはValidationエラーを最初の1つしか投げないので、改行でJOINしたものにする。
     * 
     */
    protected function failedValidation(Validator $validator)
    {
        $errors = collect($validator->errors()->toArray())
            ->map(fn($messages) => implode("\n", $messages))
            ->toArray();

        // Inertiaにキャッチさせず、ここでResponseExceptionを投げて同一ページ更新
        throw new HttpResponseException(
            back()->withErrors($errors)->withInput()
        );
    }
}

何をしているかというと、改行コードでJOINしているだけ。受け取ったInertia.jsではそれをsplitして複数に見せれれば大丈夫。

Laravel12+Inertia.js+Reactの足場をBreezeを利用して整える(2025年11月)

なんか、まぁ、いっつも似たことやってるけど、ホント1年前と同じ事やってもダメなんよね。

ということで、メモ。

Docker

いつもbitnami/laravelのイメージ使ってたんだけど、無くなってた(笑)。なんか、有料プランになるらしくって、とりあえず FROM bitnamilegacy/laravel:12.3.1 は置いておいてくれるんだけど、これもいつまで使えるかなーって。

で、npmも最新のにしておきたいのでDockerfileはこんな感じ。 *1

FROM bitnamilegacy/laravel:12.3.1
USER root
RUN npm install -g npm@latest

これもいつまで使えるかわからんから、時間ある時にLaravel用のPHP環境そろえたDocker作らないとダメですね。

dockerコンテナに入ってインストール

Breezeは便利なんだけど、アップデートのタイミングが遅すぎるのでどうもReactとかちょっと古いんですよね。

先にReactなんかをインストール

先にこのあたりの最新版をインストールしておく。後でもいいのかもしれない。

npm i -D react@latest react-dom@latest @types/react-dom @types/react @types/node@^24.6.0 axios@latest @types/axios vite @vitejs/plugin-react @tailwindcss/vite @inertiajs/react @vitejs/plugin-react typescript sass

Reactは結局viteでコンパイルするから-Dオプション付きでいいんじゃないかな。知らんけど。

Inertia.jsとBreezeをインストール

こんな感じ。

composer require inertiajs/inertia-laravel
php artisan inertia:middleware
composer require laravel/breeze --dev
php artisan breeze:install

で、breeze:install の時に、さっきのpackageを古いので上書きするよってWarningが出ます。

なので、さっきの npm i -D react@latest ... なんかを再度実行する。 これでもpackage.jsonには古いのいっぱいあるんじゃないかな、いや絶対あるやろ…って気になるが、npm audit でエラーが出なければまぁいいか。

*1:実際にはmariadbとmailpitも使ってるからcompose.yamlだけど。

2025年10月にNext.jsをcreate-next-appして静的HTMLをbuild

Next.jsは1年前に取り組んだのが最後で、それからずっとReactやってたのでもう一度やり直し。1年も経てばNext.jsのことだからゼロから始めた方が良いと思います。

参考にしたのは公式サイト

https://nextjs.org/docs/app/getting-started/installation

create-next-app

npm ではなく、 pnpm が推奨らしい。pnpm = performant npm(高性能な npm)ということ。

npm install -g pnpm

で、

pnpm create next-app@latest foo

質問はこんな感じ。

? Which linter would you like to use? ? ESLint
? Would you like to use Tailwind CSS? … No (Tailwind使わないので)
? Would you like your code inside a `src/` directory? … Yes
? Would you like to use App Router? (recommended) … Yes
? Would you like to use Turbopack? (recommended) … Yes
? Would you like to customize the import alias (`@/*` by default)? … No

こんな Warning が出た。

Ignored build scripts: sharp, unrs-resolver.                                            
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts. 

pnpm approve-builds ってやったらこの2つをインストールした。

pnpm dev コマンドで http://localhost:3000/ にアクセスするとDevサイトが表示されている。

buildを試してみる。

とりあえず、コードには触らずにbuildしてStaticファイルが出るかを試す。

pnpm i

してnode_modules以下に依存性ファイル設置(pnpmの場合はシンボリックリンク

react-hooks が足りない?

pnpm build

すると

 Linting and checking validity of types  .. × ESLint: Failed to load plugin 'react-hooks' declared in ' ≫ eslint-config-next/core-web-vitals ≫ /home/t-ito/projects/foo/src/node_modules/.pnpm/eslint-config-next@15.5.5_eslint@9.37.0_typescript@5.9.3/node_modules/eslint-config-next/index.js': Cannot find module 'eslint-plugin-react-hooks' Require stack: - /home/t-ito/projects/foo/src/__placeholder__.js Referenced from: /home/t-ito/projects/foo/src/node_modules/.pnpm/eslint-config-next@15.5.5_eslint@9.37.0_typescript@5.9.3/node_modules/eslint-config-next/index.js

というエラーが出た。エラーの通りに pnpm i react-hooks してから再度buildしたら通った。

静的ファイルが無い

普通に静的ファイルが無い。 /.next/server/ にあるのはSSR用のファイルだと分かる。

で、pnpm next export したら

 ×
    `next export` has been removed in favor of 'output: export' in next.config.js.
Learn more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports

と言われてしまった。

で、当該のURLを読んで、

const nextConfig: NextConfig = {
  /* config options here */
  output: "export",
  distDir: 'dist',
};

としてから、pnpm build したら、dist というフォルダができているのでこれが静的HTML
distDir: 'dist', を指定しない場合は out になる。

LaravelのSanctumでBearer Tokenを取得して使用したり期限を設定したり

使っているLaravelは11なので、デフォルトでSanctumがインストールできるのですがそのあたりは公式サイトにて割愛。

laravel.com

やりたいこと

ログイン時にBearer Tokenで使えるTokenを発行して、そのTokenの管理をしたり有効期限を設定したりする。

Tokenの発行

ログイン処理をするところにひっかけておく。要はログインした $user が取れればよい。Auth::login()で行う場合は

<?php
Auth::login($user, true);

とかしてる付近の $user を使う。

Inertiajsを使ったセッションコントローラなら

<?php
    public function store(LoginRequest $request): HttpResponse
    {
        /// 省略
        LoginSuccess::dispatch($request->user());

あたりの $request->user() とか。

ModelにTraitでTokenを発行するメソッドを追加

<?php
    use HasApiTokens;

Tokenの発行はcreateToken()メソッド

<?php
$token = $user->createToken('jwt-token')->plainTextToken

引数は何でもいいんだけど、とりあえずjwt-tokenにしておいた。

Tokenの情報は、personal_access_tokens テーブルに保存される。

こんな感じ。

> select * from personal_access_tokens \G;
*************************** 1. row ***************************
            id: 5
tokenable_type: App\Models\User
  tokenable_id: 93
          name: jwt-token
         token: 7cd13a56f215a93d0f0050c04f32590d79cce58cee668dce9037202a9126f40d
     abilities: ["*"]
  last_used_at: NULL
    expires_at: NULL
    created_at: 2025-08-18 13:53:50
    updated_at: 2025-08-18 13:53:50
1 row in set (0.003 sec)

このDBテーブルに入っているTokenはハッシュ化されたものであり、生トークンではないのでこれだけあっても認証には使えない。認証には先ほどのcreateTokenメソッドで取得した $token 変数が必要。

で、このTokenをどうやってログインしたユーザーに知らせるか

生TokenはDBに保存とかしたくない。メールで通知するとかしてもいいかもしれない。とりあえず面倒だったので自分はログイン後のリダイレクトリンクにHTTPヘッダとして仕込んだ。

<?php
return redirect(config('app.admin_path'), 302, [
       'X-JWT-TOKEN' => $user->createToken('jwt-token')->plainTextToken
]);

で、ユーザーにはChromeのDevToolsとかでヘッダ探してなという不親切設計。

有効期限とartisanコマンドの sanctum:prune-expired

artisanコマンドには、sanctum:prune-expired というのがある。これを使うのがよさそう。

データベースの expires_at という幻惑フィールドと、config/sanctum.php について

この expires_at は、ユーザーが自由に使うフィールドで結局のところLaravelは何の面倒も見ない

sanctum:prune-expired コマンドの実行時には以下のようになる。

  1. config('sanctum.expiration') がnullなら何もしない(つまり、Tokenは無期限有効)
  2. nullでないなら、personal_access_tokens テーブルのcreated_at + config('sanctum.expiration') 分 が現在より小さいものを削除する

PHPのSlim Frameworkでセッションを使う

別に普通にsession_start()とかしてもよかったんだろうけど、なんとなくMiddlewareとか使ってみた。

使ってみたのはいいけど、どうもSlim FrameworkのMiddlewareは基本的に手出しができないものらしくって、そのMiddleware内部のObjectに手を出そうとしたらContainerを扱わなきゃなくなってしまって、とても面倒だった。

とりあえず、SESSIONをスタートさせたい。

Middlewareに登録するセッションライブラリのインストール

ChatGPTに聞いたら、これが良いみたいな話だったので GitHub - odan/session: A middleware oriented session handler for PHP and Slim 4+ を使う。

composer install odan/session

Middlewareに登録する。

Slim Frameworkのエントリポイントのadd()で登録する。

<?php
use Odan\Session\Middleware\SessionStartMiddleware;
use Odan\Session\PhpSession;

$app = AppFactory::create();
$app->add(new SessionStartMiddleware(new PhpSession([
    'path' => '/',
    'name' => 'your-session-name'
])));

とりあえずこんな感じでページを開くと自動的にセッションが始まる。

セッションの読み書き

普通にグローバル変数 $_SESSION を使う。

セッションの読み書きにグローバル変数$_SESSIONはいかがなものか?

何となくカッコ悪いというか、今更グローバル変数$_SESSIONもどうよって感じでもにょっとするのでカッコよくコンテナ経由で操作したい。要は、 new PhpSessionのObjectにアクセスしたいんですわ。

Middleware登録時にコンテナに結び付ける。

DIのライブラリ php-di/slim-bridge をインストール

composer require php-di/slim-bridge

以下のポイントは2点

  • createする前にContainerをセットする
  • Containerに session という名称でObjectをセットしておく
<?php
use Odan\Session\Middleware\SessionStartMiddleware;
use Odan\Session\PhpSession;
use DI\Container;

$container = new Container();
AppFactory::setContainer($container);
$app = AppFactory::create();
$app->add(new SessionStartMiddleware($session = new PhpSession([
    'path' => '/',
    'name' => $_ENV['APP_NAME']
])));
$container->set('session', $session);

$sessionを使う

$appさえ使えるならどこでも引っ張り出せます。

<?php
$session = $app->getContainer()->get("session");
$oldValue = $session->get('foo');
$session->set('foo', $newValue);

みたいな。

Slim Frameworkは情報が少ないのか、ChatGPTやGeminiに聞いても嘘ばっかで全く役に立たなかったよ。逆に言うと、AIがここまで使えなかったホンの数年前は面倒くさかったんだよなぁ。もう忘れてしまったけど。

今更ながらORIGINの違う(≒クロスドメイン)のAPIをブラウザからのJavaScriptで叩くときにCORSとかいろいろとありがとうございました。

JavaScriptドメインを越えるにはCORS設定をしなければならない。ドメインというか、正確にはORIGINだけど。

ORIGINってこの3つから成り立ってる。

項目
スキーム http / https
ホスト名 example.com / localhost
ポート 80, 443, 3000, etc.

以下、ブラウザが表示しているWEBページを http://localhost:8080 (送信元ORIGIN)として、そのWEBページからXHRで叩きたいAPIhttp://localhost:8000API ORIGIN)とする。

API側は、CORSヘッダを適切にブラウザに返さなければならない。

送ってきたリクエストに対してCORS(Cross-Origin Resource Sharing)が成立するためには以下のヘッダを適切に送らなければならない。適切にというのは、イケナイ送信元ORIGINだったらこれらを送らないということです。サンプルはPHPのSlim Frameworkだが、まあだいたいわかるだろう。

<?php
// $originは(送信元ORIGIN)であること。
$response =  $response->withHeader('Access-Control-Allow-Origin', $origin)
                        ->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
                        ->withHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
return $response;

CORSエラーの場合にJavaScriptではどうするか。

axiosを使っている場合だが、CORS エラーが発生した場合、サーバーからのレスポンスがJavaScriptまで届かないようにブラウザがブロックしてしまう。なのでこんな感じでCORSエラーを処理する。

try {
    await axios.post(url, data);
} catch (e) {
    if (axios.isAxiosError(e)) {
        if (e.response === undefined && e.request !== undefined){
             alert('ネットワークエラー');
        }
        setErrors(e.response?.data.errors);
    }
}

COOKIEを使いたい場合

更にCOOOKIEについてもCORSが成立するために、はヘッダーが必要である。これはクライアントとサーバーの両方で必要になる。

クライアント側(JavaScript

// fetch
fetch('https://api.example.com/endpoint', {
  method: 'POST',
  credentials: 'include', // ← これが必須
});

// axios
axios.post(url.confirm, data, {
   withCredentials: true
});

サーバ側(PHPのSlim Framework)

<?php
$response =   $response->withHeader('Access-Control-Allow-Origin', $origin)
                        ->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
                        ->withHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
                        ->withHeader('Access-Control-Allow-Credentials', 'true'); // trueではなく文字列の"true"

すげぇ面倒です。

サーバ側(追加)

POSTの前にはPreflightがOPTIONメソッドで飛ぶので、全てのPOSTルーティングに対してOPTIONルートも同様のCORS対応ヘッダを送るようにしないといけないよ。

PHPのcomposer、built-inサーバ、メール確認用のmailpitのついたdocker compose

PHP書いてて、お問い合わせフォームとかのちょっとしたものを書きたい場合に使う。

.
├── docker
│   ├── app
│   │   ├── Dockerfile
│   │   └── public
│   │       └── index.php
│   └── compose.yaml
└── src
    └── public
        └── index.php
name: sample
services: 
  php: 
    container_name: sample_php
    build: 
      dockerfile: ./app/Dockerfile
      context: .
      tags: 
       - "php:8.3-cli"
    image: "php:8.3-cli"
    working_dir: /app
    volumes: 
      - type: bind
        source: ../src
        target: /app
    ports:
      - "8000:8000"
    command: php -S 0.0.0.0:8000 -t public
  mailpit:
    container_name: sample_mailpit
    image: axllent/mailpit
    ports:
      - "1025:1025"
      - "8025:8025"
# Dockerfile
FROM php:8.3-cli

# Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
<?php
header("Access-Control-Allow-Origin: *");
header("Content-Type: application/json");
echo json_encode([
    'access' => true,
]);

Laravel11にAzure Entra IDのOpenIDのサインインを使う

なんか色々と試行錯誤したのでメモしておく。

Azure Entra管理センターでの設定

アプリの登録

「この組織ディレクトリのみに含まれるアカウント」のシングルテナントにする。
リダイレクトURLはWEBを選択して後でURLも自分のLaravelアプリケーションのURLを設定する。これは後で「概要」から変更できるし、テスト用に2つ目を追加できたりもする。

作成後に出てくる「アプリケーション (クライアント) ID」と「ディレクトリ (テナント) ID」をメモしておく。(このアプリケーションは記事作成後に消しています)

証明書とシークレットを作る。

作成したら、「値」の方をメモしておく。この値は作成直後しか表示されないので注意。(このシークレットは当然(略…)

APIのアクセス制限とかそのまま

APIのアクセス制限でデフォルトでUser.Readがあるけど、どうもこれは要らないみたい。あってもいいのかもしれない。そのままにしておく。

Laravel11側の設定

ライブラリのインストール

composer require  laravel/socialite socialiteproviders/microsoft-azure

Service Providerに登録

Event Facadeで登録しておく。

<?php

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
            $event->extendSocialite('azure', \SocialiteProviders\Azure\Provider::class);
        });
    }
}
<?php
return [
    SocialiteProviders\Manager\ServiceProvider::class,
];

Configureを追加

Azure用の設定値

<?php
return [
    'azure' => [
        'client_id' => env('AZURE_CLIENT_ID'),
        'client_secret' => env('AZURE_CLIENT_SECRET'),
        'redirect' => env('AZURE_REDIRECT_URI'),
        'tenant' => env('AZURE_TENANT_ID'),
    ],
];

.envに先ほどのAzureで登録した値を追加。

AZURE_CLIENT_ID=「アプリケーション (クライアント) ID」
AZURE_CLIENT_SECRET=「シークレットの値」
AZURE_REDIRECT_URI=「リダイレクトURL」
AZURE_TENANT_ID=「ディレクトリ (テナント) ID」

Routingを追加

最初のログインアクセスURLを登録。 http://laravel.example.com/azure/login にアクセスするとMicrosoftのログイン画面にリダイレクトされる。

<?php
Route::get('/azure/login', function () {
    return Socialite::driver('azure')->redirect();
})->name('microsoft.login');

認証を経て返ってきたデータを処理する。以下はAzureから返されるユーザー情報を表示するだけだが、実際はLoginしたり、初めてのログインならユーザー登録をしたりする。

うまくいかなかった時や、callbackされたURLそのままでリロードした時のエラー処理などもしておくとよい。

<?php
Route::get('/azure/callback', function () {
    try {
        $user = Socialite::driver('azure')->user();
        // とりあえずAzureから返されるユーザー情報を表示するだけ
        dd($user);
        exit;
        // 認証成功時の処理
    } catch (ClientException $e) {
        // Guzzleのレスポンス本文を全文取得して表示
        $response = $e->getResponse();
        $body = $response ? $response->getBody()->getContents() : 'No response body';

        \Log::error('Azure callback Guzzle error: ' . $body);
        return response("Guzzleエラー: <pre>$body</pre>", 500);
    } catch (\Exception $e) {
        // その他のエラー
        \Log::error('Callback error: ' . $e->getMessage());
        return response("例外: {$e->getMessage()}", 500);
    }    
    // Auth::login();
    return redirect(config('app.admin_path'));
})->name('microsoft.callback');