定制 skunkbad/php-universal-authentication 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

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_mysql or pdo_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 returns true (credentials were valid), but the application is responsible for calling Core::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 parameter
  • token — the plain-text token; store this in the reset URL as a query parameter
  • user — 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_id from auth_sessions after resetPassword() returns true.

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() or navigator.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 a beforeSessionEstablishedCallback
  • It does not send any network requests or communicate with authenticator devices
  • It does not implement attestation verification beyond what lbuchs/webauthn provides
  • 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() or navigator.credentials.get() on any origin other than localhost unless the page is served over HTTPS.
  • Challenges are single-use. The library deletes the challenge record in a finally block, 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 the banned flag on the user record after a successful cryptographic verification and returns null if 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

Repository

统计信息

  • 总下载量: 66
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 2
  • 点击次数: 0
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: BSD-3-Clause
  • 更新时间: 2018-08-21

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固