skunkbad/php-universal-authentication
最新稳定版本:1.0.0
Composer 安装命令:
composer require skunkbad/php-universal-authentication
包简介
Universal Authentication for PHP
关键字:
README 文档
README
A small, framework-agnostic authentication library for PHP. It provides a solid foundation for login, session tracking, brute-force protection, remember-me tokens, role/group checks, password reset, passkey (WebAuthn) enrollment and authentication, and pre/post-credential hooks — without dictating how your application is structured.
Status: Version 1.0.0 tagged June 9, 2026.
Requirements
- PHP: >= 8.4
- PDO with the appropriate driver (
pdo_mysqlorpdo_sqlite) - lbuchs/webauthn
^2.1(pulled in automatically via Composer; only required if you use the passkey feature)
Installation
composer require skunkbad/php-universal-authentication
Database setup
MySQL / MariaDB
mysql -u <user> -p -e "CREATE DATABASE IF NOT EXISTS php_auth CHARACTER SET utf8 COLLATE utf8_general_ci;"
mysql -u <user> -p php_auth < vendor/skunkbad/php-universal-authentication/src/Database/Mysql/install.mysql.sql
Or use the interactive init script, which also creates a test user:
php vendor/skunkbad/php-universal-authentication/scripts/mysql-db-init.php
php vendor/skunkbad/php-universal-authentication/scripts/mysql-db-init.php --force # drop and recreate
SQLite
mkdir -p var/db
sqlite3 var/db/auth.sqlite < vendor/skunkbad/php-universal-authentication/src/Database/Sqlite/install.sqlite.sql
Or use the interactive init script:
php vendor/skunkbad/php-universal-authentication/scripts/sqlite-db-init.php
php vendor/skunkbad/php-universal-authentication/scripts/sqlite-db-init.php --force # drop and recreate
The schema for both drivers includes the passkeys and passkey_challenges tables. No separate migration step is required.
Configuration
The package ships with two default config files:
config/main-auth.php— authentication settings (session lifetime, password rules, remember-me duration, password reset duration, passkey settings, callbacks, etc.)config/database.php— database connection settings
Copy either file into your application and point the loader at it, or implement the relevant interface to supply config from wherever your application keeps it.
Supplying a custom database config
use Universal\Auth\Config\DatabaseConfig;
use Universal\Auth\Database\Mysql\Db;
$db = new Db(new DatabaseConfig('/path/to/your/custom/database.php'));
$db->connect();
DatabaseConfig must return a plain PHP array:
return [
'hostname' => 'localhost',
'username' => 'myuser',
'password' => 'secret',
'database' => 'myapp',
];
Supplying a custom auth config
use Universal\Auth\Config\AuthConfig;
$config = new AuthConfig('/path/to/your/custom/main-auth.php');
You can also override individual config values at runtime using property assignment:
$config->preAuthenticationCallback = fn() => MyApp\Csrf::validate($_POST['csrf_token'] ?? '');
Wiring it up
MySQL
use Universal\Auth\{
AuthConfig,
Input,
Password,
Session,
Access,
RememberMe,
Core,
PasswordReset
};
use Universal\Auth\Database\Mysql\{
Db,
Model
};
$config = new AuthConfig();
$input = new Input($config);
$db = new Db();
$db->connect();
$model = new Model($db, $input, $config);
$access = new Access($config, $model);
$rememberMe = new RememberMe($config, $model);
$passwordReset = new PasswordReset($model, new Password(), $config);
$core = new Core($input, $model, new Password(), new Session(), $access, $rememberMe, $config);
SQLite
use Universal\Auth\{
AuthConfig,
Input,
Password,
Session,
Access,
RememberMe,
Core,
PasswordReset
};
use Universal\Auth\Database\Sqlite\{
Db,
Model
};
$config = new AuthConfig();
$input = new Input($config);
$db = new Db();
$db->connect();
$model = new Model($db, $input, $config);
$access = new Access($config, $model);
$rememberMe = new RememberMe($config, $model);
$passwordReset = new PasswordReset($model, new Password(), $config);
$core = new Core($input, $model, new Password(), new Session(), $access, $rememberMe, $config);
Custom database adapter
If you want to manage the database connection yourself, implement Universal\Auth\Interfaces\DatabaseInterface:
use Universal\Auth\Interfaces\DatabaseInterface;
class AppDatabase implements DatabaseInterface
{
private ?\PDO $pdo = null;
public function connect(): void
{
$this->pdo = new \PDO('mysql:host=127.0.0.1;dbname=myapp', 'user', 'pass');
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
}
public function close(): void
{
$this->pdo = null;
}
public function getPdo(): ?\PDO
{
return $this->pdo;
}
}
Core usage
Login
if ($core->attemptLogin()) {
header('Location: /dashboard');
} else {
// Failed — show error
}
attemptLogin() accepts an optional requirements argument (roles or groups) and an optional $rememberMe boolean:
$core->attemptLogin('admin', rememberMe: true);
$core->attemptLogin(['roles' => ['admin', 'editor']]);
Checking login status
if ($core->checkIfLoggedIn()) {
$user = $core->getUser();
}
// Require a specific role
if ($core->checkIfLoggedIn('admin')) {
// ...
}
If no active session is found, checkIfLoggedIn() automatically falls back to checking for a valid remember-me cookie. On a successful cookie validation the token is rotated — the existing database row is updated with a new hash and the cookie is rewritten, so the old token becomes immediately invalid. If a valid selector is presented with a mismatched token (a strong signal of theft), all remember-me tokens for that user are deleted at once.
Logout
$core->logOut();
Pre-authentication
The package does not implement CSRF token generation — that belongs to your application. It does provide a hook that fires before any credential checking occurs, which is the right place for CSRF validation, captcha checks, IP allowlisting, or any other gate that should run first.
Register a preAuthenticationCallback in your config or at runtime:
$config->preAuthenticationCallback = function(): bool {
return hash_equals(
$_SESSION['csrf_token'] ?? '',
$_POST['csrf_token'] ?? ''
);
};
If the callback returns false, the login is rejected immediately with error code Core::PRE_AUTH_FAILURE (10) and the attempt is counted toward the brute-force hold threshold. If no callback is configured the check is skipped and behaviour is unchanged.
Static methods are also supported:
$config->preAuthenticationCallback = [MyApp\Csrf::class, 'validate'];
Between credentials and session
The package provides a hook that fires after credentials are fully verified but before the session is created. This is the right place for two-factor authentication, step-up auth, terms-of-service acceptance, or anything else that must happen in that gap.
Register a beforeSessionEstablishedCallback in your config or at runtime. The callback receives the authenticated user object, the rememberMe flag, and the session instance, and returns a bool:
- Return
true— Core establishes the session immediately. Same behaviour as having no callback. - Return
false— Core does not establish the session.attemptLogin()still returnstrue(credentials were valid), but the application is responsible for callingCore::establishSession($user, $rememberMe)when its own intermediary step is satisfied.
// TOTP example — defer session if user has a secret configured
$config->beforeSessionEstablishedCallback = function(object $user, bool $rememberMe, $session): bool {
if (empty($user->totp_secret)) {
return true; // No 2FA configured — proceed immediately
}
// Store what we need and redirect to the TOTP form
$session->set('pending_2fa_user', $user);
$session->set('pending_2fa_remember_me', $rememberMe);
return false;
};
On the TOTP form submission, verify the code with your library and then call establishSession() directly:
$tfa = new RobThree\Auth\TwoFactorAuth(/* ... */);
$user = $session->get('pending_2fa_user');
if ($tfa->verifyCode($user->totp_secret, $_POST['code'])) {
$rememberMe = $session->get('pending_2fa_remember_me') ?? false;
$core->establishSession($user, $rememberMe);
$session->delete('pending_2fa_user');
$session->delete('pending_2fa_remember_me');
header('Location: /dashboard');
exit;
}
// Wrong code — track attempts and show error
If no beforeSessionEstablishedCallback is configured, attemptLogin() establishes the session immediately as usual.
Password reset
The library handles the token lifecycle for password reset. Sending the reset email is the application's responsibility — the library returns the plain-text token so the app can include it in a reset URL and deliver it however it chooses.
Requesting a reset token
use Universal\Auth\PasswordReset;
$passwordReset = new PasswordReset($model, new Password(), $config);
$result = $passwordReset->createToken($usernameOrEmail);
if ($result === null) {
// No matching user found
} else {
$resetUrl = 'https://example.com/reset-password'
. '?s=' . $result['selector']
. '&t=' . $result['token'];
// Deliver $resetUrl to $result['user']->email — the library does not send email
}
createToken() returns an array with three keys on success, or null if no matching user was found:
selector— store this in the reset URL as a query parametertoken— the plain-text token; store this in the reset URL as a query parameteruser— the full user object, so the app has access to the email address and any other fields needed to compose the email
Only one reset token per user is permitted at any time. If the user requests a second token before using the first, the old token is replaced.
Completing the reset
$selector = $_GET['s'] ?? '';
$plainToken = $_GET['t'] ?? '';
$newPassword = $_POST['new_password'] ?? '';
if ($passwordReset->resetPassword($selector, $plainToken, $newPassword)) {
// Password updated — token is now deleted and cannot be reused
header('Location: /login');
} else {
// Token invalid, expired, or password failed length requirements
}
The token is consumed on a successful reset and cannot be reused. The default expiry is one hour, configurable via passwordResetTokenDuration in your auth config.
Security considerations
- Reset tokens follow the same split-token pattern as remember-me tokens. The selector is stored in plain text for lookup; the token itself is stored as a SHA-256 hash. A compromised database cannot be used to construct valid reset links.
- The token is validated and the password updated in a single atomic operation, so there is no intermediate "token is valid" state that an attacker could probe without consuming the token.
- Consider invalidating all active sessions for the user on a successful password reset by deleting all records for that
user_idfromauth_sessionsafterresetPassword()returnstrue.
Passkeys (WebAuthn)
The library provides two classes for passkey support: PasskeyRegistration handles enrolling a new passkey credential, and PasskeyAuthentication handles verifying a passkey login. Both follow a two-step challenge/response pattern that maps directly to the browser's WebAuthn API.
Cryptographic verification is delegated to lbuchs/webauthn. This library does not reimplement WebAuthn — it handles the credential lifecycle (challenge generation, challenge storage and expiry, credential persistence, sign count tracking) and leaves the protocol verification to the dependency.
What this library does
- Generates and stores short-lived challenges for both registration and authentication ceremonies
- Persists enrolled credentials (credential ID, public key, sign count, optional device name) per user
- Retrieves the correct stored credential during authentication by matching the credential ID from the browser response
- Validates the sign count after each authentication to detect cloned authenticators
- Cleans up expired challenges automatically
- Returns the authenticated user object on success, consistent with the rest of the library's API
What this library does not do
- It does not call the browser's
navigator.credentials.create()ornavigator.credentials.get()APIs — that JavaScript lives in your application - It does not manage sessions after a successful passkey authentication — call
$core->establishSession($user)yourself, just as you would after abeforeSessionEstablishedCallback - It does not send any network requests or communicate with authenticator devices
- It does not implement attestation verification beyond what
lbuchs/webauthnprovides - It does not provide a UI for managing enrolled passkeys (listing, renaming, or revoking) — those are application-level concerns, though the underlying model methods are available
Configuration
use Universal\Auth\Config\PasskeyConfig;
$config = new PasskeyConfig('/path/to/your/custom/passkeys.php');
Wiring up the passkey classes
The lbuchs\WebAuthn\WebAuthn instance is injected rather than constructed internally, which keeps the classes testable and the dependency explicit:
use Universal\Auth\{PasskeyRegistration, PasskeyAuthentication, PasskeyConfig};
use lbuchs\WebAuthn\WebAuthn;
$config = new PasskeyConfig;
$webAuthn = new WebAuthn(
$config->passkeyRelyingPartyName,
$config->passkeyRelyingPartyId,
$config->passkeyAllowedOrigins
);
$passkeyRegistration = new PasskeyRegistration($model, $config, $webAuthn);
$passkeyAuthentication = new PasskeyAuthentication($model, $config, $webAuthn);
Enrolling a passkey
Registration is a two-step process. In the first request, generate a challenge and return it to the browser:
// Step 1 — GET /passkey/register
// User must already be authenticated at this point
$user = $core->getUser();
$userId = $user->user_id;
$username = $user->username;
$options = $passkeyRegistration->createChallenge($userId, $username);
header('Content-Type: application/json');
echo json_encode($options);
The browser calls navigator.credentials.create() with those options, then POSTs the result back. In the second request, verify and store the credential:
// Step 2 — POST /passkey/register
$clientResponse = json_decode(file_get_contents('php://input'), true);
$deviceName = $clientResponse['deviceName'] ?? ''; // optional label from your UI
if ($passkeyRegistration->verifyRegistration($userId, $clientResponse, $deviceName)) {
// Credential enrolled successfully
} else {
// Verification failed — challenge expired, bad attestation, etc.
}
Authenticating with a passkey
Authentication is also a two-step process. There are two flows depending on whether you ask for a username first.
Usernameless flow (the browser presents stored credentials itself):
// Step 1 — GET /passkey/login
$options = $passkeyAuthentication->createChallenge(); // null = usernameless
header('Content-Type: application/json');
echo json_encode($options);
Username-first flow (scope the challenge to a specific user's credentials):
// Step 1 — GET /passkey/login
$userId = $model->getUser($_POST['username'])->user_id;
$options = $passkeyAuthentication->createChallenge($userId);
header('Content-Type: application/json');
echo json_encode($options);
In both cases the second step is the same — the browser POSTs its assertion and you verify it:
// Step 2 — POST /passkey/login
$clientResponse = json_decode(file_get_contents('php://input'), true);
$user = $passkeyAuthentication->verifyAuthentication($clientResponse);
if ($user !== null) {
$core->establishSession($user);
// Respond with a redirect URL or success payload
} else {
// Verification failed — invalid signature, expired challenge, sign count anomaly, etc.
}
Sign count and cloned authenticators
After each successful authentication the sign count stored for the credential is updated to match the value returned by the authenticator. Clone detection is handled internally by lbuchs/webauthn — when the stored count is passed to processGet(), the library throws a WebAuthnException if the authenticator returns a count that has not advanced, and verifyAuthentication() returns null.
Some authenticators (including software-based ones like Bitwarden and iCloud Keychain) do not implement a counter and return null. This is valid per the WebAuthn specification. The library stores 0 in that case and skips clone detection for that credential.
Managing enrolled passkeys
The following Model methods are available if your application needs a passkey management UI:
// List all passkeys for a user (for a "your devices" page)
$passkeys = $model->getPasskeysByUserId($userId);
// Revoke a specific passkey by its credential ID
$model->deletePasskey($credentialId);
Each passkey row includes device_name, last_used_at, and created_at, which are useful for displaying to the user.
Security considerations
- WebAuthn requires HTTPS. The browser will refuse to call
navigator.credentials.create()ornavigator.credentials.get()on any origin other thanlocalhostunless the page is served over HTTPS. - Challenges are single-use. The library deletes the challenge record in a
finallyblock, so it is consumed regardless of whether verification succeeds or fails. A failed attempt cannot be retried with the same challenge. - Passkeys are additive. The library does not remove or replace password-based login when passkeys are enrolled. Users can have both, and which paths your application exposes is entirely up to you.
- Banned users are blocked.
verifyAuthentication()checks thebannedflag on the user record after a successful cryptographic verification and returnsnullif the user is banned. A user who enrolled a passkey before being banned cannot use it to sign in. - Passkeys stored in a password manager (Bitwarden, 1Password, iCloud Keychain, Google Password Manager) sync across devices. From the server's perspective, a synced passkey is indistinguishable from a device-bound one — the protocol is identical. The sign count on synced credentials may always be
null, which is handled correctly as noted above.
Brute-force protection
The package tracks failed login attempts per user string and IP address in auth_errors. Once the configured threshold is reached a hold is placed and the user is locked out.
Managing holds
use Universal\Auth\Utilities\HoldManager;
$holdManager = new HoldManager($model);
// Check — accepts username, email, or IP address
$holdManager->isHeld('user@example.com');
$holdManager->isHeld('192.168.1.100');
// Release a specific user or IP
$holdManager->releaseHold('user@example.com');
// Clear all holds (e.g. after a false-positive attack)
$holdManager->releaseAllHolds();
UUID generation
User IDs are RFC 4122 version 4 UUIDs generated using random_bytes(16) from the OS CSPRNG. If your application needs to generate a UUID for a new user:
use Universal\Auth\Uuid;
$userId = Uuid::v4();
Running the test suite
The test suite requires a MySQL database and a SQLite database to already exist with the schema installed and test users created. Run the provided scripts first:
php scripts/mysql-create-test-users.php
php scripts/sqlite-create-test-users.php
Then run PHPUnit:
./vendor/bin/phpunit
The MySQL tests will skip gracefully if the database is unreachable. The SQLite tests will skip if var/db/auth.sqlite does not exist.
License
BSD-3-Clause
统计信息
- 总下载量: 66
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 2
- 点击次数: 0
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: BSD-3-Clause
- 更新时间: 2018-08-21