Authentication
Opt-in Session CoreとApplication-owned Identityの責任境界を確認する。
この章はRepository main PreviewでSession Starterの生成とHTTP境界を確認する手順です。AuthenticationはStable 1.1.0には含まれません。Repository main Previewを完了したProject Rootから実行し、問題はTroubleshootingで確認します。
Application-owned Starterを生成する
FrameworkはSession CoreとAuthentication Middlewareの境界だけを提供します。User、Password Hash、Registration Policy、Session Transport、Cookie/CSRFはApplicationの責任です。
docker compose build app http
docker compose up -d postgres
docker compose run --rm app composer require --no-update doctrine/dbal:^4.4 doctrine/migrations:^3.9
docker compose run --rm app composer install --no-interaction
docker compose run --rm app php blackops make:auth
make:authは二回目以降に既存Starterを検出して上書きしません。生成物を確認してからApplicationの実装を決めます。
主要な生成物(抜粋):
app/Domain/Identity/User.php
app/Infrastructure/Identity/DoctrineUserRepository.php
app/Feature/Identity/Register/Register.php
app/Feature/Identity/Login/Login.php
app/Feature/Identity/Logout/Logout.php
app/AuthServiceProvider.php
config/auth.php
migrations/Version20260722000000.php
migrations/Version20260722000100.php
Generatorは動作するApplication-owned Starterを生成します。生成済み実装をReviewし、ProductionのPassword/Registration Policyに合わせて置換または拡張します。
// app/Domain/Identity/User.php
final readonly class User
{
public function __construct(
public string $id,
public string $email,
public string $displayName,
public string $passwordHash,
) {}
}
// app/Domain/Identity/PasswordHasher.php
final readonly class PasswordHasher
{
private string $dummyHash;
public function __construct()
{
$this->dummyHash = $this->hash(base64_encode(random_bytes(32)));
}
public function hash(#[SensitiveParameter] string $password): string
{
$hash = password_hash($password, PASSWORD_DEFAULT);
if (!is_string($hash)) {
throw new RuntimeException('Password hashing failed.');
}
return $hash;
}
public function verifyCredential(#[SensitiveParameter] string $password, ?string $knownHash): bool
{
$verified = password_verify($password, $knownHash ?? $this->dummyHash);
return $knownHash !== null && $verified;
}
}
// app/Feature/Identity/Register/Register.php
#[Route(method: 'POST', path: '/auth/register')]
#[OperationType('auth.register')]
readonly class Register implements Operation
{
public function __construct(
private IdentityService $identity,
private SessionManager $sessions,
) {}
#[Transactional]
public function handle(RegisterValue $value): RegistrationCompleted
{
try {
$user = $this->identity->register($value->email, $value->displayName, $value->password);
} catch (DuplicateEmail) {
throw OperationRejectedException::conflict('auth.email_unavailable');
} catch (RegistrationDisabled) {
throw OperationRejectedException::forbidden('auth.registration_disabled');
}
return $this->outcome($this->sessions->issue($user->id));
}
private function outcome(IssuedSession $session): RegistrationCompleted
{
return new RegistrationCompleted(
$session->token()->reveal(),
$session->issuedAt()->format(DATE_ATOM),
$session->expiresAt()->format(DATE_ATOM),
);
}
}
config/auth.phpのPreview既定はSessionServiceProvider::bearer(ApplicationSessionIdentityProvider::class, ...)です。AUTH_REGISTRATION_ENABLED、AUTH_SESSION_TTL_SECONDS(既定28800)、AUTH_SESSION_TOUCH_INTERVAL_SECONDS(既定300)を決め、全ClientでToken Channelを統一します。
環境変数の責務とCookie/Bearerの選択はHTTP Authenticationの境界を参照してください。既存Starterを意図的に更新する場合はphp blackops make:auth --forceを使います。生成済みDomain/Operation/Migrationは上書きしません。
return static fn(Environment $env): array => [
'generator_version' => 1,
'services' => [
new AuthServiceProvider($env->bool('AUTH_REGISTRATION_ENABLED', true)),
SessionServiceProvider::bearer(
ApplicationSessionIdentityProvider::class,
new SessionConfiguration(
ttlSeconds: $env->positiveInt('AUTH_SESSION_TTL_SECONDS', 28_800),
touchIntervalSeconds: $env->positiveInt('AUTH_SESSION_TOUCH_INTERVAL_SECONDS', 300),
),
),
],
];
config/middleware.phpへAuthenticationMiddleware::classがすでに登録されていることを確認し、重複登録しません。/welcomeはmake:authを実行しても自動ではBearer保護されません。検証には生成されたAuth Operationか、次の節で作成するProtected Operationを使います。
Protected GET /meを追加する
生成物へ依存しない最小のProtected OperationをApplicationへ追加します。ActorContextにはCredentialではなく認証済みActorのID/Typeだけが入ります。
<?php
declare(strict_types=1);
namespace App\Feature\Identity\Me;
use App\Security\SampleUserAuthorizationPolicy;
use BlackOps\Core\Attribute\Authorize;
use BlackOps\Core\Attribute\OperationType;
use BlackOps\Core\ExecutionContext;
use BlackOps\Core\Operation;
use BlackOps\Core\OperationValue;
use BlackOps\Core\Outcome;
use BlackOps\Http\Attribute\Route;
use RuntimeException;
final readonly class MeValue implements OperationValue {}
final readonly class MeShown implements Outcome
{
public function __construct(public string $actorId) {}
}
#[Route(method: 'GET', path: '/me')]
#[OperationType('identity.me')]
#[Authorize(SampleUserAuthorizationPolicy::class)]
final readonly class ShowMe implements Operation
{
public function handle(MeValue $value, ExecutionContext $context): MeShown
{
$actor = $context->actorContext()?->authorization();
if ($actor === null) {
throw new RuntimeException('Authenticated actor is required.');
}
return new MeShown(actorId: $actor->id());
}
}
Autoload、Migration、Build、HTTP
ApplicationのBindingとPolicyを実装したら、Artifactを再生成してHTTPを再起動します。
docker compose run --rm app composer dump-autoload --classmap-authoritative
docker compose run --rm app php blackops database:migrate
docker compose run --rm app php blackops build:compile
docker compose run --rm app php blackops frontend:generate
docker compose run --rm app php blackops frontend:check
docker compose up -d http
Frontendを持たないBackend-only Applicationは最後の二つを省略できます。Worker Modeへ新しいArtifactを読ませるためHTTPはBuild後に再起動します。
Register、Login、Logoutの期待結果
Tokenは現在のShellだけで保持し、Report、Journal、Generated Client、Repositoryへ保存しません。
curl -i -X POST http://127.0.0.1:8080/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","displayName":"Ada","password":"correct horse battery staple"}'
# 200、43文字のOpaque Token
curl -i -X POST http://127.0.0.1:8080/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","displayName":"Ada","password":"correct horse battery staple"}'
# 409、code: auth.email_unavailable
curl -i -X POST http://127.0.0.1:8080/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"correct horse battery staple"}'
# 200、新しい43文字のToken
curl -i -X POST http://127.0.0.1:8080/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"wrong horse battery staple"}'
# 401、code: auth.invalid_credentials
curl -i -X POST http://127.0.0.1:8080/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"short"}'
# 422、code: validation.failed(violation: validation.length)
curl -i -X POST http://127.0.0.1:8080/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com"}'
# 422、code: validation.failed(violation: binding.required)
401はValidationを通過した形式上妥当なPasswordが一致しない場合だけです。短いPasswordは宣言的Validation、欠落したPasswordはBindingで422になります。層の違いはValue and Validationで確認できます。
Bearer PreviewでProtected Operationを呼び、同じTokenをLogout JSONへ渡します。
curl -i http://127.0.0.1:8080/me \
-H 'Authorization: Bearer <token-from-register-or-login>'
# 200、{"actorId":"<registered-user-id>"}(生成されたUser UUIDv7)
curl -i http://127.0.0.1:8080/me
# 401、code: authorization.authentication_required
curl -i -X POST http://127.0.0.1:8080/auth/logout \
-H 'Content-Type: application/json' \
-d '{"token":"<token-from-register-or-login>"}'
# 200、{}
curl -i -X POST http://127.0.0.1:8080/auth/logout \
-H 'Content-Type: application/json' \
-d '{"token":"<same-token>"}'
# 200、{}(冪等)
curl -i http://127.0.0.1:8080/me \
-H 'Authorization: Bearer <same-token>'
# 401、code: authentication.invalid_session
有効TokenのGET /meは200、TokenなしまたはLogout済みは401になります。/welcomeはAuthenticationを明示的に付けない限り匿名のままです。Raw Password、Session Token、Cookie、CSRF Token、Authorization HeaderはOperation Value、Outcome、Journal、Log、Task Reportへ出力しません。Cookie方式を選ぶ場合はCookieSessionAuthenticatorとSecurityの責務表を確認してください。