Authentication¶
Note
Not part of core. Install it separately:
composer require kinetis/auth
Bearer/opaque-token authentication: a PSR-15 route middleware that
validates an Authorization: Bearer <token> header and registers the
resolved user on the current request as CurrentUserInterface, plus a
token generator. Storage is entirely up to you — the package has no
opinion on where tokens live.
use Kinetis\Http\Attributes\Get;
use Kinetis\Http\Attributes\Middleware;
use Kinetis\Http\CurrentUserInterface;
use Kinetis\Auth\BearerAuthMiddleware;
#[Middleware(BearerAuthMiddleware::class)]
final readonly class OrderController
{
public function __construct(
private CurrentUserInterface $user,
) {}
#[Get('/orders')]
public function index(): array
{
return ['userId' => $this->user->id()];
}
}
UserProviderInterface¶
The one thing your app implements — resolving a raw token to a user, or
null if it doesn’t match anything:
use Kinetis\Auth\UserProviderInterface;
use Kinetis\Http\CurrentUserInterface;
final readonly class DatabaseUserProvider implements UserProviderInterface
{
public function __construct(
private MysqlLink $db,
) {}
public function findByToken(string $token): ?CurrentUserInterface
{
$hash = hash('sha256', $token);
$row = new Query($this->db)
->table('users')
->where('token_hash', '=', $hash)
->first(UserRow::class);
return $row;
}
}
Register it once, against the interface:
$app->instance(UserProviderInterface::class, new DatabaseUserProvider($db));
Tip
Store hash('sha256', $token), not the raw token, and look up by that
same hash. Don’t use password_hash()/bcrypt here — a bearer token is
already high-entropy random data, not a low-entropy human password, so a
slow KDF only adds latency to every request’s lookup with no security
benefit.
BearerAuthMiddleware is route middleware only¶
Register it with #[Middleware(BearerAuthMiddleware::class)] on the
controllers or methods that need it — never globally. A health check, a
login endpoint, or /openapi.json needs to stay reachable without a
token, and route middleware only runs after a route has already matched,
so there’s no way for it to block an unmatched request the way global
middleware could.
On a missing, malformed, or unrecognized token it returns 401 directly,
with a WWW-Authenticate: Bearer header, before your controller ever
runs:
{"error": "Unauthenticated."}
On success it does the same thing a hand-written auth middleware would
(see Middleware’s “Registering a value the controller reads later”
section) — $scope->instance(CurrentUserInterface::class, $user) — so any
controller constructor-injecting CurrentUserInterface receives it.
TokenGenerator¶
use Kinetis\Auth\TokenGenerator;
$token = TokenGenerator::generate(); // 64 hex characters, 32 bytes of entropy
A thin wrapper over random_bytes(), hex-encoded so the result is safe to
place directly in an Authorization header with no escaping. Generation
only — issuing a token to a user (verifying a password, calling this,
storing the hash) is your own login endpoint’s job.
PasswordHasher¶
use Kinetis\Auth\PasswordHasher;
$hash = PasswordHasher::hash($request->password); // at registration
if (!PasswordHasher::verify($request->password, $user->passwordHash)) {
return ErrorResponse::create(401, 'Invalid credentials.');
}
if (PasswordHasher::needsRehash($user->passwordHash)) {
$this->users->updatePasswordHash($user->id, PasswordHasher::hash($request->password));
}
hash()/verify()/needsRehash() wrap PHP’s own
password_hash()/password_verify()/password_needs_rehash(), always
with PASSWORD_DEFAULT — so a hash produced under an older PHP version
still verifies correctly, and needsRehash() tells you when it’s worth
upgrading to whatever PHP now recommends. Storage — where the hash lives,
when to call needsRehash() — is your own concern; this covers only the
three primitives.
Preventing brute-force login attempts¶
Kinetis\Security\AttemptThrottle locks an identifier out after too many
failures, backed by Psr\SimpleCache\CacheInterface:
use Kinetis\Security\AttemptThrottle;
use Kinetis\Http\Responses\ErrorResponse;
final readonly class LoginController
{
public function __construct(
private AttemptThrottle $throttle,
private UserProviderInterface $users,
) {}
#[Post('/login')]
public function attempt(#[Body] LoginRequest $data): ResponseInterface|array
{
if ($this->throttle->tooManyAttempts($data->email)) {
return ErrorResponse::create(429, 'Too many attempts.', headers: [
'Retry-After' => (string) $this->throttle->availableInSeconds($data->email),
]);
}
$user = $this->users->verify($data->email, $data->password);
if ($user === null) {
$this->throttle->recordFailure($data->email);
return ErrorResponse::create(401, 'Invalid credentials.');
}
$this->throttle->clear($data->email);
return ['token' => TokenGenerator::generate()];
}
}
The default is 5 failures within a rolling 15-minute window, adjustable through the constructor:
new AttemptThrottle($cache, maxAttempts: 3, decaySeconds: 600);
Each failure resets the window to a fresh decaySeconds from that
failure, so repeated attempts keep extending the lockout; clear() on a
successful attempt removes it immediately. Identifiers aren’t limited to
emails — anything failure-prone and identifier-keyed works the same way,
a 2FA code or an invite redemption included.
Note
The cache must count atomically, and construction enforces it.
AttemptThrottle requires the given cache to implement
Kinetis\SimpleCache\AtomicCounterInterface — RedisSimpleCache and
ClusteredRedisSimpleCache do, see Middleware’s rate-limiting
section for the REDIS_URL/REDIS_HOST configuration they read — and
throws Exception\AttemptThrottleUnavailableException at construction
for any cache that doesn’t, NullSimpleCache included.
Without it, failures arriving together cannot be counted — every attempt reads the same value before any of them writes, so they register as one and the lockout never arms. That is the normal shape of the attack this class exists to stop: someone working through a password list sends attempts in parallel by default. Measured against a real Redis, 40 parallel wrong passwords recorded a single failure without this guard.
See also¶
Middleware —
CurrentUserInterface, the global-vs-route middleware distinction, andRequestScopeself-injection.Persistence —
Query/TransactionGuardfor a database-backedUserProviderInterface.JWT Authentication — stateless JWT verification instead, with no token storage at all.