がるの健忘録

エンジニアでゲーマーで講師で占い師なおいちゃんのブログです。

複数ドメインでのセッション管理のポイント

ワードとしては「複数ドメイン間でのセッションの共有」とか「サブドメイン間でセッションの共有」とか「別ドメインでログイン(認可)情報を共有」とかそんな感じになりますかね。
ケースとしては比較的レアかと思うのですが。

今回の場合
・「複数のゲーム」をひとまとめにしたサイトを作りたい
・各ゲームは個々に実装。ディレクトリじゃなくてドメインで切り分ける想定
・ログインだけ「1カ所に集約」して、ユーザ登録は1回で片付けられるようにしたい
って感じです(プチ宣伝込み)。

あんまり「よくあるニーズ」ではなさそうなんですが。
まぁその分「必要な人には必要だろう」って建前と、ぶっちゃけ「書いておかないと自分が忘れるがな」っていうこのblog本来の目的のために、書いておきます(笑

まず事前準備。

# とりあえず基底のディレクトリ作る
mkdir session_test; cd $_

# 2つのドメイン用のディレクトリをそれぞれ作る
mkdir hoge
mkdir foo

# DNS設定(略
# nginx設定(略

nginxの設定の一部だけ書いておくとこんな感じです。

server {
    listen       80;
    server_name  hoge.example.com;
    root /home/example/session_test/hoge/;
    (略)
}
server {
    listen       80;
    server_name  foo.example.com;
    root /home/example/session_test/foo/;
    (略)
}

この辺はいわゆるインフラ系のお仕事。

ドメイン
hoge.example.com
foo.example.com
の2つを用意しました。

実際には
www.example.com
ゲーム1.example.com
ゲーム2.example.com
って感じのを用意して、wwwのほうに「ユーザ登録、ログイン機能」「ゲームの一覧」あたりを実装、各ゲームはゲーム1とかゲーム2とかのドメインに実装予定です。
(その時用の共通化についてもいろいろ考えているので、ある程度実装したら今度、記事にする予定です)。

用意したコードは以下の通り。
まず session_test に、共通の「セッション用のコード」を用意しています。

<?php  // common.php

declare(strict_types=1);

//
ob_start();

// ざっくりセッション設定
session_set_cookie_params([
    'lifetime' => 0,
    'samesite' => 'Strict',
    // 'secure' => true, // テスト環境なのでhttpsになってないからコメントアウト
    'httponly' => true,
    'domain' => 'example.com', // ここは、テストによって有効にしたりコメントアウトしたり
]);
//
ini_set('session.use_strict_mode', 1);

// セッション開始
session_start();

次に、hogeとfooに以下を用意しました。

hoge/index.php
foo/index.php

<?php  // hoge/index.php, foo/index.php
// 設定確認用
echo 'ok';

hoge/read.php
foo/read.php

<?php  // hoge/read.php, foo/read.php

declare(strict_types=1);

require __DIR__ . '/../common.php';

// セッション情報の表示
var_dump($_SESSION);
var_dump(session_id());

hoge/regenerate.php
foo/regenerate.php

<?php  // hoge/regenerate.php, foo/regenerate.php

declare(strict_types=1);

require __DIR__ . '/../common.php';

// セッションID差し替え
session_regenerate_id(true);

echo 'ok';


hoge/write.php

<?php  // hoge/write.php

declare(strict_types=1);

require __DIR__ . '/../common.php';

// セッションへの書き込み
$_SESSION['hoge'] = 'hoge value';

//
echo 'ok';

foo/write.php

<?php  // foo/write.php

declare(strict_types=1);

require __DIR__ . '/../common.php';

// セッションへの書き込み
$_SESSION['foo'] = 'foo value';

//
echo 'ok';

準備完了。
まずは

    'domain' => 'gjmj.net', // ここは、テストによって有効にしたりコメントアウトしたり

コメントアウトした状態で、「だよね~」の確認。

http://hoge.example.com/read.php
array(0) { }
string(32) "19c0589b071e4d2d5e995028cb9081aa"
http://foo.example.com/read.php
array(0) { }
string(32) "0187f6525a7d24e03b662e564471747f"


http://hoge.example.com/write.php
http://foo.example.com/write.php


http://hoge.example.com/read.php
array(1) { ["hoge"]=> string(10) "hoge value" }
string(32) "19c0589b071e4d2d5e995028cb9081aa"
http://foo.example.com/read.php
array(1) { ["foo"]=> string(9) "foo value" }
string(32) "0187f6525a7d24e03b662e564471747f"

当然ではありますが「違うドメイン」なので、違うセッションIDだし違う情報を書いてるし読んでるから、交わることなし。


次。今回の眼目。domainの所を有効にして実験。

http://hoge.example.com/read.php
array(0) { }
string(32) "8b5f581950014979cb679dee1c07d449"
http://foo.example.com/read.php
array(0) { }
string(32) "8b5f581950014979cb679dee1c07d449"


http://hoge.example.com/write.php
http://foo.example.com/write.php


http://hoge.example.com/read.php
array(2) { ["hoge"]=> string(10) "hoge value" ["foo"]=> string(9) "foo value" }
string(32) "8b5f581950014979cb679dee1c07d449"
http://foo.example.com/read.php
array(2) { ["hoge"]=> string(10) "hoge value" ["foo"]=> string(9) "foo value" }
string(32) "8b5f581950014979cb679dee1c07d449"


http://hoge.example.com/regenerate.php
http://foo.example.com/regenerate.php


http://hoge.example.com/read.php
array(2) { ["hoge"]=> string(10) "hoge value" ["foo"]=> string(9) "foo value" }
string(32) "f4a57c3d4dc2e0fe4f4fb4b167d4dcd2"
http://foo.example.com/read.php
array(2) { ["hoge"]=> string(10) "hoge value" ["foo"]=> string(9) "foo value" }
string(32) "f4a57c3d4dc2e0fe4f4fb4b167d4dcd2"

予想通り。
session_regenerate_id() も含めて、ちゃんと意図的に動いてくれる感じ。
まぁこれだと「ゲーム本体の情報」を下手にセッションにぶち込んだ時に割と死ねるんだけど。
その辺は最悪「(PHPのセッションは)認可と共通情報専用」にして「別途、個別ゲーム情報用のセッション」は組み直してもいいしなぁ……あとは「セッション名のスイッチング(この辺も、面白そうだったら今度blogに書きます)」とか。

とりあえず「できるといいなぁできないと気合いで自力実装だなぁ」と思っているところが割とさくっと解決したので、めでたいの思いを込めて一筆。

「そうだパスワードの持ち方、変えよう」

色々あっていろいろあったのに触発されました(笑
いや昔実際に実務で実装したこともあったんで、その辺を踏まえて。

大前提として
・ログインロジックに介入できる
・ユーザの「最終ログイン時間」が捕捉できる(ケースによる)
が必要になりますんでご注意を。
最終ログイン時間については「今からそのロジック追加」で間に合います。

次。
用語整理軽く。

Salt(ソルト)
hash時の味付け。詳しくは「salt hash」あたりでググってください。
この記事では勝手に「ユーザ個別のもの」をsaltって言っておきます(いちいち書くのが面倒なので)。
「共通のsalt」は、「共通salt」と明記しておきます。

Pepper(SecretSalt)(ペッパー、シークレットソルト)
これも「ペッパー hash」でググっていただくのが早いかと。

もういっちょ。
ログインのロジックの、以下に介入する前提です。だいたいイメージで察してください。
ベースになるコードイメージです。
端的には「idに対応するレコードがなければfalse、入力パスワードと保存パスワード比較して不一致ならfalse。idがokでパスワードもokならtrue」って感じ。
「そっくりそのまんま」とまでは言わんにしても、まぁ近似値な処理がきっとある、はず!!(笑

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashed_pwが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    // hashの突合せ  
    if (hash値の比較($raw_password, $hashed_pw) === false) {  
        // 駄目ぽ  
        return false;  
    }  

    // OKだったぽいので認証通る  
    return true;  
}  

ほんでもって次。
パスワードを「どのように保存しているか」は、たぶん、こんなグラデーションなんではないかなぁ、と。
(ペッパーの有無はいったん省きます。リストが2倍になるの面倒なんで)

  • 平文
  • md5
  • 共通salt付きmd5(ストレッチあったりなかったり
  • 個別salt付き適当なhash(ストレッチあったりなかったり
  • Modular Crypt Formatで適切なやつ(古くなったhashアルゴリズム)
  • Modular Crypt Formatで適切なやつ(良い感じのhashアルゴリズム)

さて一通り出そろいましたので、入れ替えを見ていきましょう。

MCFなんだけどアルゴリズム古し

今が「Modular Crypt Formatで適切なやつ(良い感じのhashアルゴリズム)」なら別段なにもせんでえぇので。
1ランク下がりました「Modular Crypt Formatで適切なやつ(古くなったhashアルゴリズム)」である場合を考えてみましょう。

古いんで新しいのに差し替えたいです。
おおざっぱには

  • (古いのだったら)新しいアルゴリズムのに差し替える
  • 適当に時間がたったら「パスワードhashのアルゴリズム」確認して、差し替え前アルゴリズムなら、一度パスワードのレコードを空文字とかにする

ってな感じになります。

実装。
ちょいと丁寧にすると、だいたいまぁこんな感じか、と。
password_hash()使ってる前提です。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashed_pwが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    // hashの突合せ  
    if (password_verify($raw_password, $hashed_pw) === false) {  
        // 駄目ぽ  
        return false;  
    }  

    /* OKだったぽいので */  
    // hashのアルゴリズムが古かったら、新しいのに差し替える  
    $password_info = password_get_info($hashed_pw);  
    if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
        update ユーザテーブル set password = password_hash($raw_password, 新しいアルゴリズムかDEFAULTか適当に, 必要ならオプション) WHERE userを特定する情報;  
    }  

    // 認証通る  
    return true;  
}  

こんな感じ。
これで「ログイン通過してhashが古い」人は、新しいhashに書き換わります。

……そこから幾星霜が経過しました(長い)。

そろそろ「古いhash、本格的に撲滅しねぇ?」って感じの頃合いでございます。それだけ時間がたったんだってば。
こんなbatchを組んでみましょう。

$users = userテーブルの全レコード;  
foreach ($users as $u) {  
    $hashed_pw = $uのpasswordhash取得();  
    // hashのアルゴリズムが古かったら、パスワードいったん消す  
    $password_info = password_get_info($hashed_pw);  
    if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
        update ユーザテーブル set password = '' WHERE userを特定する情報($uにあるはず);  
        なんかログに残したり表示したり  
    }  
}  

これで「古いhashは撲滅」できます。
後は、ログのuser_idでもいいし、改めてDBからselectしてもいいし、のターゲットに対して
「しばらくログインがなかったからパスワード無効にしたのでパスワードリマインダで設定してから再ログインしてね」
とかって連絡をすれば、一段落。

MCFじゃなかったんだけどまぁそこまでクリティカルにマズい持ち方してない

次。
「個別salt付き適当なhash(ストレッチあったりなかったり」を想定してみましょう。
他よりはましなのですが、MCFではないので色々と面倒が付きまといます。
サクっと本体実装を見ていきましょう。
大まかには

  • MCFで認証通ったらそのまま
  • 古いhash方式で認証通ったら「MCFに置き換えつつ」認証OK
  • そうじゃなきゃNG

って感じですね。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列と個別のsalt」を取得。ストレッチ回数は「ここに入れてるけど多分どこかglobalな領域にあるんじゃね?」を想定  
    $hashed[hadhed_password, 個別のsalt, ストレッチ回数] = XXXX();  
    if ($hashedが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    /* MCFでのhashの突合せ */  
    if (password_verify($raw_password, $hashed['hadhed_password']) === true) {  
        // 新しい方式で認証通ったのでOK  
        return true;  
    }  

    /* 古い方式での認証突合せ */  
    // raw_passwordをhash化する  
    $in_hashed_password = salt付けてストレッチしてハッシュするメソッド($raw_password, $hashed[個別のsalt], $hashed[ストレッチ回数]);  
    // hashされた値の比較  
    if (hash_equals($in_hashed_password, $hashed['hadhed_password']) === true) {  
        // hashのアルゴリズムが古かったら、新しいのに差し替える  
        $password_info = password_get_info($hashed_pw);  
        if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
            // 別に個別saltは空にしなくてもいいんだけど、一応  
            update ユーザテーブル set password = password_hash($raw_password, 新しいアルゴリズムかDEFAULTか適当に, 必要ならオプション), 個別salt='' WHERE userを特定する情報;  
        }  
        // 認証通る  
        return true;  
    }  

    // 認証NG  
    return false;  
}  

これでまたしばらくしたら「古い方式を無効にする」ので。
「幾星霜」よりもうちょっと短めのほうがいいかも、ではあるけど(早めにロジック掃除したいじゃん?)まぁ任意。
同じバッチを組めばOK。

$users = userテーブルの全レコード;  
foreach ($users as $u) {  
    $hashed_pw = $uのpasswordhash取得();  
    // hashのアルゴリズムが古かったら、パスワードいったん消す  
    $password_info = password_get_info($hashed_pw);  
    if ($password_info['algoName'] !== 意図する新しいhashアルゴリズム) {  
        update ユーザテーブル set password = '' WHERE userを特定する情報;  
        なんかログに残したり表示したり  
    }  
}  

$password_info['algoName']、たしか「unknown」だかなんだか、になったはず(うろ覚え)。
あと、このタイミングで「古いhashでの認証処理ロジックを削除」「個別saltのカラムを削除」「ストレッチ回数の情報の破棄」とかやってもよいですね。

MCFじゃなかったしクリティカルにマズい持ち方してる

さて、残るは

・平文
md5
・共通salt付きmd5(ストレッチあったりなかったり

というゴツ目なあたり。
いや身もふたもない「即時に片付く」方法が一応ありまして。

・ログインのロジック(とか周辺あちこち)を「MCFのパスワード」に置き換える(ステージングくらいまでで確認)
・メンテナンスに入る
・プログラムをデプロイ
・DBのパスワードカラムを全部「空文字」でupdateする
・メンテナンス終わり
・全ユーザに「ごめんパスワードリマインダからパスワード設定しなおして」の連絡をする

と、あら不思議「脆弱なパスワードがなくなります」(笑
いやマジなところ、上3つならコレやったほうがいいような気がするんですけどねぇ。
一度、マジで提案してみましょう。通ったらお互いに「少しだけ」幸せになれるwww

でも「ど~しても難しい」場合、以下の手順を踏むとまぁ「ギリ」かなぁ、と。
「明日、クラックされた」を前提にする分、ちょいと内部的には手間でございます。

「以下のコードに修正する」と「DBに細工をする」を同じタイミングで流す(メンテナンスのin-outで対応しましょう)

DB小細工。これによって「とりあえずパスワードを「MCFフォーマット(なので最悪漏れても時間稼ぎが出来る状態)」に置き換える

$users = userテーブルの全レコード;  
foreach ($users as $u) {  
    update ユーザテーブル set password = password_hash($u->password, DEFAULTなりお好きなアルゴリズムなり, オプションもお好みで) WHERE $u->user_id;  
}  

次、認証側のコード。
入っているパスワードが「平文」の場合、ある意味気楽で。
以下のコードでいけます。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashed_pwが取れなかった(idが存在しない)) {  
        return false;  
    }  

    // hashの突合せ  
    if (password_verify($raw_password, $hashed_pw) === false) {  
        // 駄目ぽ  
        return false;  
    }  

    // OKだったぽいので認証通る  
    return true;  
}  

md5だったり「共通salt付きmd5」だったりするときは、こんなふうにしましょう。

public function login認証処理メイン($id, $raw_password): bool  
{  
    // DBとかから「idに対応する、hash化されている(はずの)パスワード文字列」を取得  
    $hashed_pw = XXXX();  
    if ($hashedが取れなかった(idが存在しない)) {  
        // 駄目ぽ  
        return false;  
    }  

    // MCFでのhashの突合せ  
    if (password_verify($raw_password, $hashed_pw) === true) {  
        // 新しい方式で認証通ったのでOK  
        return true;  
    }  

    /* 古い方式での認証突合せ */  
    // raw_passwordを(以前のやり方で)hash化する(共通saltとストレッチ回数があるパターン。ない時は引数調整して)  
    $old_hashed_password = salt付けてストレッチしてハッシュするメソッド($raw_password, 共通salt, ストレッチ回数);  

    // 「古いやり方でhash化した」パスワードを「生パスワード」と仮定して、値の比較  
    if (password_verify($old_hashed_password, $hashed_pw) === true) {  
        // 本来の正しいのに置き換える  
        update ユーザテーブル set password = password_hash($raw_password, 新しいアルゴリズムかDEFAULTか適当に, 必要ならオプション) WHERE userを特定する情報;  
          
        // 認証通る  
        return true;  
    }  

    // 認証NG  
    return false;  
}  

これで多分そのうちなんとか。
最悪漏れてもこれなら「普通にちゃんとセキュリティ担保したパスワードとして有効な保持の仕方」なので、どうにかなるでしょ。

で……この方式のみ「切り替わってるんだか切り替わってないんだかわからない」ので。
もし「古いロジック残しておいても面倒だから適当なタイミングで"更新していないユーザのパスワード"リセットしたい」のであれば、最終ログイン時間を使って「上にあるバッチ」で「最終ログイン時間が一定以上過去の人のパスワードを空文字にする」でやればOK。

なんていう方法がありますよ、というおおざっぱな提案でした。
……事故が減るといいなぁ。
& 変な所とか怪しいところとか危ない所があったら、突っ込んでもらえれば速攻で直します!!

phinxを使ってみた(マイグレーション変)

とりあえずinstall

composer require robmorgan/phinx

initで初期化をするらしい。

vendor/bin/phinx init

phinx.php が生えた。

<?php

return
[
    'paths' => [
        'migrations' => '%%PHINX_CONFIG_DIR%%/db/migrations',
        'seeds' => '%%PHINX_CONFIG_DIR%%/db/seeds'
    ],
    'environments' => [
        'default_migration_table' => 'phinxlog',
        'default_environment' => 'development',
        'production' => [
            'adapter' => 'mysql',
            'host' => 'localhost',
            'name' => 'production_db',
            'user' => 'root',
            'pass' => '',
            'port' => '3306',
            'charset' => 'utf8',
        ],
        'development' => [
            'adapter' => 'mysql',
            'host' => 'localhost',
            'name' => 'development_db',
            'user' => 'root',
            'pass' => '',
            'port' => '3306',
            'charset' => 'utf8',
        ],
        'testing' => [
            'adapter' => 'mysql',
            'host' => 'localhost',
            'name' => 'testing_db',
            'user' => 'root',
            'pass' => '',
            'port' => '3306',
            'charset' => 'utf8',
        ]
    ],
    'version_order' => 'creation'
];

なるほど。
このフォーマットなら「他のFW(おいちゃんの想定はSlim)使ってて、そっちで(も)DBの接続設定がある」なんてケースでもちゃんとDRYに出来るねぇ!!
1点気になってたのが一瞬で氷塊したので、まぁ普通に「使う」前提でよいのではないかしらん???

見るに
・いったんのデフォはdevelopment
っぽいので、そのままテスト用のDBの設定をdevelopmentに書いて、追加で実験してみませう。

お次。
必要なディレクトリを作るぽ。

mkdir -p db/migrations db/seeds

これはまぁ実際には「このディレクトリもgitに登録しておく」とかで良さそうざます。
&
configの設定の「%%PHINX_CONFIG_DIR%%」がちょい気になるが、まぁなんかあったら後で出てくるでせう。
次。

眼目になるマイグレーションファイル作成。
本家サイトとか、アッパーキャメルなのがちょいと気になるなぁ……いったんヘビにしてみよう。

php vendor/bin/phinx create table_name

The migration class name "table_name" is invalid. Please use CamelCase format.

わをwww
キャメル固定なのかwww

では改めて。

php vendor/bin/phinx create TableName

Phinx by CakePHP - https://phinx.org.

using config file phinx.php
using config parser php
using migration paths
- /home/furu/PhinxTest/db/migrations
using seed paths
- /home/furu/PhinxTest/db/seeds
using migration base class Phinx\Migration\AbstractMigration
using default template
created db/migrations/20230819124347_table_name.php

ほむ……seedとmigrationsにファイルが出来てる? 確認。

seedには……ない。
なんじゃろ???
(後で分かった。ここ。seederを作る所なんだ)

migrationsにはファイルが出来てる。

<?php
declare(strict_types=1);

use Phinx\Migration\AbstractMigration;

final class TableName extends AbstractMigration
{
    /**
     * Change Method.
     *
     * Write your reversible migrations using this method.
     *
     * More information on writing migrations is available here:
     * https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
     *
     * Remember to call "create()" or "update()" and NOT "save()" when working
     * with the Table class.
     */
    public function change(): void
    {

    }
}

簡素だなをいw
んで、指定したのは純粋に「クラス名」なのか。それ以外の処理、なんもないw

(RDB準備中...RDB準備中...)

軽く書いていこう。
書く場所は
・change() に書くと、(ある程度までは)マイグレーションを巻き戻す時、勝手に良い感じに巻き戻してくれる
・でなきゃ、おとなしく up() と donw() を書け
らしい。

で……ちょっと気になる記述も見かけたので、その辺の確認込みで、いくつか書いてみる。

    public function change(): void
    {
        $table = $this->table('test');
        $table->addColumn('name', 'string')
            ->addColumn('str', 'varchar', ['limit' => 128])
            ->addColumn('str2', 'varbinary', ['limit' => 128])
            ->addColumn('num', 'int', ['comment' => 'コメント'])
            ->addColumn('num2', 'bigint')
            ->addColumn('created_at', 'datetime')
            ->create();

    }

ドライランがあるとの事で、早速やってみる。
あと、 -e での環境指定を「あえて」省略してみる(configにデフォ値あったし)。

php vendor/bin/phinx migrate --dry-run

InvalidArgumentException: An invalid column type "varchar" was specified for column "str".

あそ。
他いくつか修正。

    public function change(): void
    {
        $table = $this->table('test');
        $table->addColumn('name', 'string')
            ->addColumn('str', 'string', ['limit' => 128])
            ->addColumn('str2', 'varbinary', ['limit' => 128])
            ->addColumn('num', 'integer', ['comment' => 'コメント'])
            ->addColumn('num2', 'biginteger')
            ->addColumn('created_at', 'datetime')
            ->create();

    }

CREATE TABLE `test` (
`id` INT(11) unsigned NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NULL,
`str` VARCHAR(128) NULL,
`str2` VARBINARY(128) NULL,
`num` INT(11) NULL COMMENT 'コメント',
`num2` BIGINT(20) NULL,
`created_at` DATETIME NULL,
PRIMARY KEY (`id`)
) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

あら?
うん前情報通り「勝手にID入れてくる」のは気になるが、VARBINARYに対応しているのはおいちゃん的に大分と高ポイントざますよ?
ほむ……ちょいと本気を出してみよう(実験用なんでテーブルレイアウトはきにすんな)。

    public function change(): void
    {
        $table = $this->table('test', [
            'id' => false,
            'primary_key' => 'test_id',
            'comment' => 'テーブルコメント',
        ]);
        $table
            ->addColumn('test_id', 'biginteger', ['null' => false, 'signed' => false, 'identity' => true,])
            ->addColumn('name', 'string')
            ->addColumn('str', 'string', ['limit' => 128, 'null' => false])
            ->addColumn('str2', 'varbinary', ['limit' => 128])
            ->addColumn('num', 'integer', ['comment' => 'コメント'])
            ->addColumn('num2', 'biginteger')
            ->addColumn('created_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP'])
            ->create();

        //
        $table = $this->table('test_2', [
            'id' => false,
            'primary_key' => ['test_id', 'str'],
            'comment' => 'テーブルコメント',
        ]);
        $table
            ->addColumn('test_id', 'biginteger', ['null' => false, 'signed' => false, 'identity' => true,])
            ->addColumn('str', 'string', ['limit' => 128, 'null' => false])
            ->create();
    }

CREATE TABLE `test` (
`test_id` BIGINT(20) unsigned NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NULL,
`str` VARCHAR(128) NOT NULL,
`str2` VARBINARY(128) NULL,
`num` INT(11) NULL COMMENT 'コメント',
`num2` BIGINT(20) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`test_id`)
) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='テーブルコメント';

CREATE TABLE `test_2` (
`test_id` BIGINT(20) unsigned NOT NULL AUTO_INCREMENT,
`str` VARCHAR(128) NOT NULL,
PRIMARY KEY (`test_id`,`str`)
) ENGINE = InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='テーブルコメント';

よし、よし。
ちょっと調べるのに時間がかかったけど、大体いけるっぽいなぁ。
よしちゃんと実行してみやう。

php vendor/bin/phinx migrate
mysql> show tables;
+----------------------+
| Tables_in_phinx_test |
+----------------------+
| phinxlog             |
| test                 |
| test_2               |
+----------------------+
3 rows in set (0.00 sec)

mysql> show create table test \G
*************************** 1. row ***************************
       Table: test
Create Table: CREATE TABLE `test` (
  `test_id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `str` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,
  `str2` varbinary(128) DEFAULT NULL,
  `num` int DEFAULT NULL COMMENT 'コメント',
  `num2` bigint DEFAULT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`test_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='テーブルコメント'
1 row in set (0.00 sec)

mysql> show create table test_2 \G
*************************** 1. row ***************************
       Table: test_2
Create Table: CREATE TABLE `test_2` (
  `test_id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `str` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,
  PRIMARY KEY (`test_id`,`str`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='テーブルコメント'
1 row in set (0.00 sec)

んむんむ、よきよき。
一応、rollback

php vendor/bin/phinx rollback
mysql> show tables;
+----------------------+
| Tables_in_phinx_test |
+----------------------+
| phinxlog             |
+----------------------+
1 row in set (0.01 sec)

よきよき。

一応メモ程度に残しておくと。

php vendor/bin/phinx migrate -e 環境名
php vendor/bin/phinx rollback -e 環境名

ってのが本来っぽい感じではあるので、一応。
ただまぁこれだと

・ステージングまでは普通に「コードupしてからマイグレーションする」
・本番は「先にマイグレーションしてからコードupする」

とかもできそうだなぁ……ちょいと運用も考えてみやう。

大雑把に
・「テーブル作る」とか「カラム追加する」くらいなら、change() に実装すればよさげ
・一応「SQLダイレクト発行」もある、みたいだけど、現状だとあんまりいらなさそうな雰囲気がある
・まぁ必要になったら「up()とdown()で実装しつつSQL書く」でよいんじゃなかろうか
って感じなので。
「便利な機能はあるけど簡単にoffれるから足かせにならないので気楽」って感じだなぁ。
うん、ちょいと使い込んでみてもよいかも。ちょっと癖があるから色々調べなきゃいかんけどw

次回はseederを確認してみませう。

「始めて」の○○の学び方 おいちゃん変

チラと拝見したツイート群……多分、この辺が発端かなぁ???

https://twitter.com/tokuhirom/status/1682531879990497280

周りのエンジニアに一歩差をつける方法として、フレームワークミドルウェアやライブラリのドキュメントを最初から最後までちゃんと読む、というのがあって、これはマジでコスパ抜群です。

概ね同意なんだけど、ちょろっと前提もあるので、その辺を書いていこうかなぁ、と。
つぶやこうかとも思ったんだけど、残しておきたかったので、こちらで書きます。
元は、学校で「ドラッカーの書籍とか紹介する時」に話をしてる内容なのですが、大体そのまま当てはまるので、まとめて(笑

初めは「初心者本」の系譜を読むといいです。
なんでかというと「簡単だから」(笑

「初心者に向けている」ので、多分「読みやすさ、わかりやすさ」には一定の配慮がある、はず、なので。
まずはその辺で「楽に」スタートを切るとよいです。

で、おいちゃんがオススメするのは「異なる著者で、できたら2~5冊」。
1冊だとどうしても偏りが出る可能性が否定できないので、数冊「異なる著者で」読むと、その辺がある程度、なだらかになる可能性が期待できます。

読み終わると「とりあえずなんとなく全体像のふわっとしたところが一部分くらいは脳内に残っている」事が期待されるので。
そうしたら「本丸」を責めます。
ドラッカー本なら「ドラッカー本人が書いた書籍」、フレームワークとかなら「公式のドキュメント」とか「公式のチュートリアル」。

そうすると「あぁ読んだ」から「え? 書いてなかった」とか「ん? 書いてあった事と違う気がするぞ?」まで、色々な知識が得られるんじゃなかろうか、と思います。
+
フレームワークだと、その後に「フレームワークソースコード」とか、いくつか読んでみるのもオススメですね。

あと、Twitterでちょいちょい拝見していたのが「読むよりまず実装」系のご意見で。
それはそれで「あり」なんですが、どこかで「一度は基礎を一通りおさらい」しないと「欠けている事に気づかない」とか、ちょいちょい拝見するので。
「初手」ではなくてもよいのですが、どこかで一度「一通り、公式に目を通す」はやっておいたほうがいいです。

ネタ的に興味深くて参戦してみたかったので、書いてみました。

紅茶 ディンブラ

こちらも ヒュッゲ(Hygge) さんのところの。
ブランド名的には「FIKADAGS(フィーカダグス)」で、よいのかしらん?
なんかこのディンブラって「買うところ気をつけないと結構難しい」って見かけてたんだけど、これはなんていうか「サラっとしててとても飲みやすい」感じ。
なにげに、がぶ飲みの大本命の1つになるかも? くらいに気に入ったですます。

紅茶 ウバ

またしてもウバなんだけど、今回は ヒュッゲ(Hygge) さんのところの。
ブランド名的には「FIKADAGS(フィーカダグス)」で、よいのかしらん?
落ち着いた好みのお味で、お値段も廉価なので「がぶ飲み」しやすいwwww

「オリエンタル霊異譚 幽冥鬼使」の確率計算

数学苦手なんで(……とも言ってられないんだが)、馬力で計算してみたw

厳密には「3以上の時は技能値(等)が足される」ので、その辺で少し加減されるんだけど。
いったん「ダイス目だけ」で、陰徳値無考慮で純粋に「ダイスの確率」で確認。
プログラムでざっくり書いてるんで端数誤差あるんだけど、その辺は気にせずに。

0 27.777778%
3 4.320988%
4 4.783951%
5 8.641975%
6 9.567901%
7 12.962963%
8 10.030864%
9 8.641975%
10 6.17284%
11 4.320988%
12 2.314815%
20 0.462963%

「n値以上」だと、こんな感じ。

0以上 100%
3以上 72.222223%
4以上 67.901235%
5以上 63.117284%
6以上 54.475309%
7以上 44.907408%
8以上 31.944445%
9以上 21.913581%
10以上 13.271606%
11以上 7.098766%
12以上 2.777778%
20以上 0.462963%

ここの判定と比較的近似と思われる、2d6の分布は大体こんな感じ。

2 2.78%
3 5.56%
4 8.33%
5 11.11%
6 13.89%
7 16.67%
8 13.89%
9 11.11%
10 8.33%
11 5.56%
12 2.78%

2 100.00%
3 97.22%
4 91.67%
5 83.33%
6 72.22%
7 58.33%
8 41.67%
9 27.78%
10 16.67%
11 8.33%
12 2.78%

比較っぽい表にすると、こんな感じ。

0 - 100%
2 100.00% -
3 97.22% 72.222223%
4 91.67% 67.901235%
5 83.33% 63.117284%
6 72.22% 54.475309%
7 58.33% 44.907408%
8 41.67% 31.944445%
9 27.78% 21.913581%
10 16.67% 13.271606%
11 8.33% 7.098766%
12 2.78% 2.777778%
20 - 0.462963%

一概には言いにくいんだけど、中程度の難易度を想定するときは「平目で、2d6の時より1くらい値を下げておく」をベースにすると、割と、近似するんじゃなかろうか? と。

なお、コード(デバッグ出力があちこちあんのは気にすんな)。

<?php

// 元ネタ
$base = [];
for($i = 1; $i <= 6; ++$i) {
    for($j = 1; $j <= 6; ++$j) {
        for($k = 1; $k <= 6; ++$k) {
            for($l = 1; $l <= 6; ++$l) {
                $base["{$i},{$j},{$k},{$l}"] = [$i, $j, $k, $l];
            }
        }
    }
}
// var_dump($base);

// 確認
$data = [];
foreach($base as $k => $v) {
    // いったん、サイコロの目をsort
    sort($v);
    
    // 先にクリティカル確認
    if ( ($v[0] === $v[1])&&($v[1] === $v[2])&&($v[2] === $v[3]) ) {
        echo "クリット: {$k}\n";
        $data[20] ??= 0;
        $data[20] ++;
        continue;
    }
    // 失敗を算出
    if ( ($v[0] != $v[1])&&($v[1] !== $v[2])&&($v[2] !== $v[3]) ) {
        echo "ファンブル: {$k}\n";
        $data[0] ??= 0;
        $data[0] ++;
        continue;
    }
    // 通常成功を算出(面倒だから馬力)
    if ( ($v[0] === $v[1]) ) {
        $key = $v[2] + $v[3];
        echo "{$k} => {$v[2]} + {$v[3]} = {$key} \n";
    } elseif (($v[1] === $v[2])) {
        $key = $v[0] + $v[3];
        echo "{$k} => {$v[0]} + {$v[3]} = {$key} \n";
    } elseif (($v[2] === $v[3])) {
        $key = $v[0] + $v[1];
        echo "{$k} => {$v[0]} + {$v[1]} = {$key} \n";
    }
    //
    $data[$key] ??= 0;
    $data[$key] ++;
}
//var_dump($data);

// sortして
ksort($data);
// 合計値出して
$total = array_sum($data);
// 各値の確率を出力
foreach($data as $k => $v) {
    printf("%d: %f%% \n", $k, $v/$total*100);
}