Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/admin-auth/module.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
use Marko\AdminAuth\Repository\AdminUserRepositoryInterface;
use Marko\AdminAuth\Repository\PermissionRepository;
use Marko\AdminAuth\Repository\PermissionRepositoryInterface;
use Marko\AdminAuth\Repository\RememberTokenRepository;
use Marko\AdminAuth\Repository\RoleRepository;
use Marko\AdminAuth\Repository\RoleRepositoryInterface;
use Marko\Authentication\Contracts\RememberTokenStorageInterface;

return [
// No global UserProviderInterface binding: the admin guard gets AdminUserProvider
Expand All @@ -24,6 +26,8 @@
AdminUserRepositoryInterface::class => AdminUserRepository::class,
RoleRepositoryInterface::class => RoleRepository::class,
PermissionRepositoryInterface::class => PermissionRepository::class,
// Per-device remember-me tokens (remember_tokens table) for every session guard, admin and frontend.
RememberTokenStorageInterface::class => RememberTokenRepository::class,
],
'singletons' => [
// Shared: permissions registered through one injected registry must be visible to every consumer.
Expand Down
59 changes: 59 additions & 0 deletions packages/admin-auth/src/Entity/RememberToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace Marko\AdminAuth\Entity;

use DateTimeImmutable;
use Marko\Authentication\Token\RememberTokenRecord;
use Marko\Database\Attributes\Column;
use Marko\Database\Attributes\Index;
use Marko\Database\Attributes\Table;
use Marko\Database\Entity\Entity;

/**
* One device's remember-me token. db:migrate creates the remember_tokens table from this entity;
* RememberTokenRepository reads and writes it for every session guard (the guard column keeps them apart).
*
* Only the SHA-256 hash of the validator is stored; the selector is the lookup key.
*/
#[Table('remember_tokens')]
#[Index('idx_remember_tokens_expires_at', ['expires_at'])]
class RememberToken extends Entity
{
#[Column(primaryKey: true, autoIncrement: true)]
public ?int $id = null;

#[Column(length: 100)]
public string $guard;

#[Column(length: 100)]
public string $userId;

#[Column(length: 64, unique: true)]
public string $selector;

#[Column(length: 64)]
public string $validatorHash;

#[Column(type: 'datetime')]
public DateTimeImmutable $expiresAt;

#[Column]
public ?string $userAgent = null;

#[Column(type: 'datetime')]
public ?DateTimeImmutable $createdAt = null;

public function toRecord(): RememberTokenRecord
{
return new RememberTokenRecord(
guard: $this->guard,
userId: $this->userId,
selector: $this->selector,
validatorHash: $this->validatorHash,
expiresAt: $this->expiresAt,
userAgent: $this->userAgent,
);
}
}
117 changes: 117 additions & 0 deletions packages/admin-auth/src/Repository/RememberTokenRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

declare(strict_types=1);

namespace Marko\AdminAuth\Repository;

use Marko\AdminAuth\Entity\RememberToken;
use Marko\Authentication\Contracts\RememberTokenStorageInterface;
use Marko\Authentication\Token\RememberTokenRecord;
use Marko\Database\Exceptions\EntityException;
use Marko\Database\Exceptions\RepositoryException;
use Marko\Database\Repository\Repository;

/**
* Per-device remember-me tokens in the remember_tokens table.
*
* Bound to RememberTokenStorageInterface, so every session guard (the admin guard and the app's own) issues one
* token per device: a remember-me login on one device no longer rotates another device's token, and logging out
* deletes only that device's row. `marko auth:clear-tokens` purges expired rows through clearExpiredTokens().
*
* @extends Repository<RememberToken>
*/
class RememberTokenRepository extends Repository implements RememberTokenStorageInterface
{
protected const string ENTITY_CLASS = RememberToken::class;

/**
* @throws RepositoryException
*/
public function store(
RememberTokenRecord $token,
): void {
$entity = new RememberToken();
$entity->guard = $token->guard;
$entity->userId = (string) $token->userId;
$entity->selector = $token->selector;
$entity->validatorHash = $token->validatorHash;
$entity->expiresAt = $token->expiresAt;
$entity->userAgent = $token->userAgent;
$entity->createdAt = $this->now();

$this->save($entity);
}

/**
* @throws EntityException
*/
public function findBySelector(
string $guard,
string $selector,
): ?RememberTokenRecord {
return $this->findOneBy(['guard' => $guard, 'selector' => $selector])?->toRecord();
}

/**
* A compare-and-swap on the validator hash: of two requests rotating the same token at once, the second
* matches no row and gets false.
*/
public function rotateValidator(
string $guard,
string $selector,
string $currentValidatorHash,
string $newValidatorHash,
): bool {
$sql = sprintf(
'UPDATE %s SET %s = ? WHERE %s = ? AND %s = ? AND %s = ?',
$this->table(),
$this->column('validator_hash'),
$this->column('guard'),
$this->column('selector'),
$this->column('validator_hash'),
);

return $this->connection->execute($sql, [$newValidatorHash, $guard, $selector, $currentValidatorHash]) === 1;
}

public function deleteBySelector(
string $guard,
string $selector,
): void {
$this->connection->execute(
sprintf(
'DELETE FROM %s WHERE %s = ? AND %s = ?',
$this->table(),
$this->column('guard'),
$this->column('selector'),
),
[$guard, $selector],
);
}

public function clearExpiredTokens(): int
{
$now = $this->hydrator->toDatabaseValue($this->now(), $this->metadata->properties['expiresAt']);

return $this->connection->execute(
sprintf('DELETE FROM %s WHERE %s <= ?', $this->table(), $this->column('expires_at')),
[$now],
);
}

public function clearAllTokens(): int
{
return $this->connection->execute(sprintf('DELETE FROM %s', $this->table()));
}

private function table(): string
{
return $this->connection->quoteIdentifier('remember_tokens');
}

private function column(
string $name,
): string {
return $this->connection->quoteIdentifier($name);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace Marko\AdminAuth\Tests\Fixtures;

use Marko\Database\Connection\ConnectionInterface;
use Marko\Database\Connection\StatementInterface;
use PDO;
use RuntimeException;

/**
* An in-memory SQLite connection with the remember_tokens table, so the RememberTokenRepository unit tests run
* their real SQL. The selector is unique, as on MySQL and PostgreSQL.
*/
class SqliteRememberTokenConnection implements ConnectionInterface
{
private PDO $pdo;

public function __construct()
{
$this->pdo = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->pdo->exec(
'CREATE TABLE remember_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, guard TEXT NOT NULL, '
. 'user_id TEXT NOT NULL, selector TEXT NOT NULL UNIQUE, validator_hash TEXT NOT NULL, '
. 'expires_at TEXT NOT NULL, user_agent TEXT NULL, created_at TEXT NULL)',
);
}

/**
* @return list<array<string, mixed>> Every stored row, ordered by id
*/
public function rows(): array
{
return $this->pdo->query('SELECT * FROM remember_tokens ORDER BY id')->fetchAll();
}

public function connect(): void {}

public function disconnect(): void {}

public function isConnected(): bool
{
return true;
}

public function query(
string $sql,
array $bindings = [],
): array {
$statement = $this->pdo->prepare($sql);
$statement->execute($bindings);

return $statement->fetchAll();
}

public function execute(
string $sql,
array $bindings = [],
): int {
$statement = $this->pdo->prepare($sql);
$statement->execute($bindings);

return $statement->rowCount();
}

public function prepare(
string $sql,
): StatementInterface {
throw new RuntimeException('Not implemented');
}

public function lastInsertId(): int
{
return (int) $this->pdo->lastInsertId();
}

public function driverName(): string
{
return 'sqlite';
}

public function supportsReturning(): bool
{
return false;
}

public function quoteIdentifier(
string $identifier,
): string {
return '"' . str_replace('"', '""', $identifier) . '"';
}
}
13 changes: 11 additions & 2 deletions packages/admin-auth/tests/Integration/AdminAuthSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Marko\AdminAuth\Entity\AdminUser;
use Marko\AdminAuth\Entity\AdminUserRole;
use Marko\AdminAuth\Entity\Permission;
use Marko\AdminAuth\Entity\RememberToken;
use Marko\AdminAuth\Entity\Role;
use Marko\AdminAuth\Entity\RolePermission;
use Marko\AdminAuth\Repository\AdminUserRepository;
Expand Down Expand Up @@ -44,14 +45,21 @@
* Builds the admin-auth tables on a real server for the PostgreSQL and MySQL/MariaDB integration tests, and the
* repositories that use them.
*
* All five tables are created from their entities, the only source of the admin-auth schema: create() runs the
* All six tables are created from their entities, the only source of the admin-auth schema: create() runs the
* driver's generator over each entity in foreign-key order, and migrate() runs the real db:migrate command in a
* temporary project that has marko/admin-auth installed, as a fresh install would.
*/
class AdminAuthSchema
{
/** @var list<string> Tables in reverse dependency order, the order they are dropped in */
public const array TABLES = ['admin_user_roles', 'role_permissions', 'admin_users', 'roles', 'permissions'];
public const array TABLES = [
'remember_tokens',
'admin_user_roles',
'role_permissions',
'admin_users',
'roles',
'permissions',
];

/** @var list<class-string> Entities in foreign-key order, the order they are created in */
private const array ENTITIES = [
Expand All @@ -60,6 +68,7 @@ class AdminAuthSchema
RolePermission::class,
AdminUser::class,
AdminUserRole::class,
RememberToken::class,
];

public static function drop(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
)?->columns;

expect($result['exitCode'])->toBe(0)
->and($result['output'])->toContain('Applied 5 schema migration(s).')
->and($result['output'])->toContain('Applied 6 schema migration(s).')
->and($missing)->toBe([])
->and($uniqueIndexColumns('role_permissions', 'idx_role_permissions_unique'))
->toBe(['role_id', 'permission_id'])
Expand Down Expand Up @@ -122,7 +122,7 @@
));

expect($result['exitCode'])->toBe(0)
->and($result['output'])->toContain('Applied 5 schema migration(s).')
->and($result['output'])->toContain('Applied 6 schema migration(s).')
->and($pivotKey('role_permissions'))->toBe(['id'])
->and($pivotKey('admin_user_roles'))->toBe(['id'])
->and($this->connection->query('SELECT id, role_id, permission_id FROM role_permissions'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
)?->columns;

expect($result['exitCode'])->toBe(0)
->and($result['output'])->toContain('Applied 5 schema migration(s).')
->and($result['output'])->toContain('Applied 6 schema migration(s).')
->and($missing)->toBe([])
->and($uniqueIndexColumns('role_permissions', 'idx_role_permissions_unique'))
->toBe(['role_id', 'permission_id'])
Expand Down
Loading
Loading