-
Notifications
You must be signed in to change notification settings - Fork 0
Custom Adapters
Anything that implements AdapterInterface can
sit behind a Segment. Extending
AbstractAdapter is the quickest
path — it ships a sensible default collective() that iterates
set(), so you only have to implement the operations that matter.
<?php
declare(strict_types=1);
namespace App\Auth;
use InitPHP\Auth\AbstractAdapter;
use InitPHP\Auth\AdapterInterface;
final class ArrayAdapter extends AbstractAdapter
{
/** @var array<string, mixed> */
private array $store = [];
public function get(string $key, $default = null)
{
return $this->store[$key] ?? $default;
}
public function set(string $key, $value): AdapterInterface
{
$this->store[$key] = $value;
return $this;
}
public function has(string $key): bool
{
return \array_key_exists($key, $this->store);
}
public function remove(string ...$key): AdapterInterface
{
foreach ($key as $name) {
unset($this->store[$name]);
}
return $this;
}
public function destroy(): bool
{
$this->store = [];
return true;
}
}collective() is not implemented — the parent class provides a default
that calls set() per pair. Override it when your backing store can
commit atomically and the per-key write would be wasteful (the cookie
adapter does this so a bulk write emits one Set-Cookie header
instead of N).
use InitPHP\Auth\Segment;
$auth = Segment::custom('auth', App\Auth\ArrayAdapter::class);
$auth->set('user_id', 42);
$auth->get('user_id'); // 42The custom factory requires the class to extend AbstractAdapter.
Passing a class that does not raises InvalidArgumentException — see
Exceptions.
AdapterInterface deliberately does not include a constructor.
Different backing stores need different dependencies: a salt, a PDO
handle, a Redis client. Sign your constructor however the store needs.
The Segment::custom() factory will, however, invoke your constructor
as new YourClass($name, $options), so the convention if you want it
to be Segment-compatible is to accept those two arguments:
final class DatabaseAdapter extends AbstractAdapter
{
private \PDO $pdo;
private string $segment;
/**
* @param array{pdo: \PDO} $options
*/
public function __construct(string $name, array $options)
{
if (!isset($options['pdo']) || !$options['pdo'] instanceof \PDO) {
throw new \InvalidArgumentException('A PDO handle is required.');
}
$this->segment = $name;
$this->pdo = $options['pdo'];
}
// ... get/set/has/remove/destroy ...
}
$auth = Segment::custom('auth', App\Auth\DatabaseAdapter::class, [
'pdo' => $pdo,
]);If your adapter needs a constructor signature that does not fit
(string $name, array $options), build it by hand:
$adapter = new App\Auth\WeirdAdapter($somethingElse, $anotherDependency);
// Then consume $adapter directly — Segment does not currently accept a
// pre-built adapter through its public surface.The v1 README shipped a BasicAuthAdapter sample that concatenated
user input into SQL strings and used md5() to compare passwords.
This is the rewritten version: prepared statements, password_verify(),
and a column-name whitelist.
<?php
declare(strict_types=1);
namespace App\Auth;
use InitPHP\Auth\AbstractAdapter;
use InitPHP\Auth\AdapterInterface;
use PDO;
/**
* Looks up a user by HTTP Basic credentials, then exposes the row as
* an in-memory auth segment. Mutations are written back to the `users`
* table with prepared statements.
*
* @phpstan-type UserRow array{
* id: int,
* username: string,
* password_hash: string,
* role?: string|null,
* }
*/
final class BasicAuthAdapter extends AbstractAdapter
{
private const ALLOWED_COLUMNS = ['role'];
private PDO $pdo;
/** @var UserRow */
private array $user;
/**
* @param array{dsn: string, username: string, password: string} $options
*/
public function __construct(string $name, array $options)
{
$this->pdo = new PDO($options['dsn'], $options['username'], $options['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->user = $this->authenticate();
}
public function get(string $key, $default = null)
{
return $this->user[$key] ?? $default;
}
public function set(string $key, $value): AdapterInterface
{
$this->guardWritableColumn($key);
// Column name is whitelisted above; the value is bound.
$stmt = $this->pdo->prepare(
\sprintf('UPDATE users SET %s = :value WHERE id = :id', $key)
);
$stmt->execute([
':value' => $value,
':id' => $this->user['id'],
]);
$this->user[$key] = $value;
return $this;
}
public function has(string $key): bool
{
return \array_key_exists($key, $this->user);
}
public function remove(string ...$key): AdapterInterface
{
foreach ($key as $column) {
$this->guardWritableColumn($column);
$stmt = $this->pdo->prepare(
\sprintf('UPDATE users SET %s = NULL WHERE id = :id', $column)
);
$stmt->execute([':id' => $this->user['id']]);
unset($this->user[$column]);
}
return $this;
}
public function destroy(): bool
{
$this->user = ['id' => 0, 'username' => '', 'password_hash' => ''];
return true;
}
/**
* @return UserRow
*/
private function authenticate(): array
{
$username = $_SERVER['PHP_AUTH_USER'] ?? '';
$password = $_SERVER['PHP_AUTH_PW'] ?? '';
$stmt = $this->pdo->prepare(
'SELECT id, username, password_hash, role FROM users WHERE username = :username LIMIT 1'
);
$stmt->execute([':username' => $username]);
/** @var UserRow|false $row */
$row = $stmt->fetch();
if ($row === false || !\password_verify($password, $row['password_hash'])) {
\header('WWW-Authenticate: Basic realm="Private Area"');
\header('HTTP/1.1 401 Unauthorized');
exit('Authentication required.');
}
return $row;
}
private function guardWritableColumn(string $column): void
{
if (!\in_array($column, self::ALLOWED_COLUMNS, true)) {
throw new \InvalidArgumentException(\sprintf(
'Column "%s" is not writable through %s.',
$column,
self::class
));
}
}
}| Concern | v1 README | This page |
|---|---|---|
| User input in SQL | Concatenated | Bound via prepared statement |
| Column names in SQL | Concatenated | Whitelisted in ALLOWED_COLUMNS
|
| Password comparison | md5() |
password_verify() against password_hash() output |
| PDO error mode | Default (silent) |
ERRMODE_EXCEPTION + EMULATE_PREPARES=false
|
| Fetch mode | Default |
FETCH_ASSOC (typed array shape) |
The full request-lifecycle example (with Permission gating) lives in
the Basic Auth Recipe.
The default implementation iterates set(). Override when you can do
better:
public function collective(array $data): AdapterInterface
{
$this->pdo->beginTransaction();
try {
foreach ($data as $key => $value) {
$this->set((string) $key, $value);
}
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
return $this;
}(The above is illustrative; production code would also coalesce the
per-row UPDATE statements into a single batched query.)
Surface bad configuration immediately, before any I/O:
public function __construct(string $name, array $options)
{
if (!isset($options['pdo']) || !$options['pdo'] instanceof \PDO) {
throw new \InvalidArgumentException('A PDO handle is required.');
}
// ...
}This matches the pattern CookieAdapter uses for the salt option,
and it keeps confusing "method called on null" errors out of your
production logs.
-
Implementing the interface directly without
AbstractAdapter. You can — but you lose the defaultcollective()and have to implement six methods instead of five. -
Throwing from
destroy()on an already-destroyed store.destroy()should be idempotent. Returnfalseonce the store is clean and let subsequent reads/writes hit the existing guard that throws. - Forwarding raw user input as SQL column names. Always whitelist. Prepared statements bind values, not identifiers.
-
Hashing passwords with
md5()/sha1(). Usepassword_hash()at registration time andpassword_verify()at login. The cost parameter automatically tracks PHP defaults. - Storing the PDO connection on a long-lived adapter instance. If the adapter outlives the request (queue worker, daemon), the underlying TCP connection will eventually be dropped by the server. Reconstruct the adapter per job.
- Adapter Interface — the contract you implement.
- Segment — how the facade hands work off to your adapter.
-
Recipe → HTTP Basic Auth — full request
lifecycle around
BasicAuthAdapter. - Testing — how to test a custom adapter without a real database.
initphp/auth · MIT License · part of the InitPHP family
Source · Issues · Discussions · Packagist · Contributing · Security Policy
Getting Started
Core Types
Adapters
Reference
Recipes
Migration & Help