diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ec75da3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,176 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ["8.2", "8.3", "8.4", "8.5"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Validate Composer metadata + run: composer validate --strict + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run tests + run: vendor/bin/phpunit test + + oauth-real-providers: + # Validate integration using the actual optional League provider classes + # without sending requests to Google/Facebook or requiring live secrets. + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + coverage: none + + - name: Install test dependencies + run: composer install --no-interaction --prefer-dist + + - name: Install optional OAuth provider libraries for integration tests + run: | + composer require --dev --no-interaction --prefer-dist --no-progress \ + 'league/oauth2-google:^4.0' \ + 'league/oauth2-facebook:^2.0' \ + 'guzzlehttp/guzzle:^7.0' + + - name: Assert real OAuth providers are available + run: | + php -r 'require "vendor/autoload.php"; exit( + class_exists("League\\OAuth2\\Client\\Provider\\Google") && + class_exists("League\\OAuth2\\Client\\Provider\\Facebook") ? 0 : 1 + );' + + - name: Run real-provider OAuth integration tests + run: vendor/bin/phpunit test/RealOAuthProviderTest.php + + migrations-mysql: + name: MySQL ${{ matrix.mysql-version }} migrations and DBAuth + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + mysql-version: ["8.0", "8.4"] + + services: + mysql: + image: mysql:${{ matrix.mysql-version }} + env: + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: auth_test + MYSQL_USER: auth_user + MYSQL_PASSWORD: auth_password + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --host=127.0.0.1 --user=auth_user --password=auth_password --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: 3306 + MYSQL_DATABASE: auth_test + MYSQL_USER: auth_user + MYSQL_PASSWORD: auth_password + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: pdo_mysql + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run bundled Phinx migrations on MySQL + run: vendor/bin/phinx migrate --configuration test/phinx.php --environment mysql_testing + + - name: Verify MySQL migration status + run: vendor/bin/phinx status --configuration test/phinx.php --environment mysql_testing + + - name: Verify MySQL schema, indexes and foreign keys + run: php test/verify_mysql_migrations.php + + - name: Verify MySQL DBAuth authentication workflows + run: php test/verify_mysql_auth.php + + migrations-postgresql: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: auth_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGDATABASE: auth_test + PGUSER: postgres + PGPASSWORD: postgres + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + extensions: pdo_pgsql + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run bundled Phinx migrations + run: vendor/bin/phinx migrate --configuration test/phinx.php --environment testing + + - name: Verify migration status + run: vendor/bin/phinx status --configuration test/phinx.php --environment testing + + - name: Verify migrated schema + run: php test/verify_migrations.php + + - name: Verify portable role lookup + run: php test/verify_postgresql_role_lookup.php diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c732139 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,109 @@ +name: Release verification + +on: + push: + branches: + - master + pull_request: + branches: + - master + workflow_dispatch: + +jobs: + test: + # The regular CI workflow already tests the full PHP matrix on PRs. + # This release workflow runs the matrix after merge or when dispatched. + if: github.event_name != 'pull_request' + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - '8.2' + - '8.3' + - '8.4' + - '8.5' + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Validate Composer metadata + run: composer validate --strict + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: vendor/bin/phpunit test + + production-install: + name: Production install + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + tools: composer:v2 + + - name: Validate Composer metadata + run: composer validate --strict + + - name: Verify production dependencies + run: | + composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader + composer check-platform-reqs --no-dev + + - name: Smoke-test production autoload and authentication + run: php test/verify_production_install.php vendor/autoload.php + + - name: Install as a separate production consumer + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/auth-consumer" + # This prerelease is not yet published on Packagist. Use a copied + # Composer path package (not a symlink) pinned to the candidate + # version, as a real consumer would install it into vendor/. + php -r ' + $manifest = [ + "name" => "objectiveweb/auth-production-consumer", + "description" => "Isolated no-dev install verification", + "require" => [ + "php" => ">=8.2", + "objectiveweb/auth" => "^0.2", + ], + "repositories" => [[ + "type" => "path", + "url" => getenv("GITHUB_WORKSPACE"), + "options" => [ + "symlink" => false, + "versions" => ["objectiveweb/auth" => "0.2.0"], + ], + ]], + ]; + file_put_contents( + getenv("RUNNER_TEMP") . "/auth-consumer/composer.json", + json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" + ); + ' + cd "$RUNNER_TEMP/auth-consumer" + composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader + composer check-platform-reqs --no-dev + test -f vendor/objectiveweb/auth/src/Auth.php + php "$GITHUB_WORKSPACE/test/verify_production_install.php" "$PWD/vendor/autoload.php" + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9a7fc08..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: php - -sudo: false - -php: - - 5.3 - - 5.4 - - 5.5 - - 5.6 - - 7.0 - - hhvm - -services: mysql - -before_script: - - mysql -e 'create database if not exists objectiveweb;' - - composer install - -script: vendor/bin/phpunit test/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..51b3de0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +All notable changes to `objectiveweb/auth` are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) conventions and [Semantic Versioning](https://semver.org/); releases prior to 1.0 may include breaking changes in minor versions. + +## [0.2.0] — Unreleased + +**Breaking release.** This is a rewrite of the 0.1.x public API and storage model, not a drop-in upgrade. Read the [0.2.0 upgrade guide](docs/UPGRADING-0.2.0.md) before updating an existing application. + +### Added + +- Abstract `Objectiveweb\Auth` provider contract, `Objectiveweb\Auth\DBAuth` backed by `objectiveweb/db ^0.8`, and an in-memory `BasicAuth` provider. +- Separate user and `(provider, uid)` credential records, provider-specific profiles, UUIDs, configurable login/recovery providers, credential lifecycle (create, rename, delete) and per-credential verification status. +- Bundled Phinx migrations for `user`, `user_credentials`, `role`, `user_roles`, and `delegations`, including credential verification/expiry columns. +- Role-based authorization, `RequireRole` middleware, relational permissions/delegations, configurable eager loading, and `user_context_callback` for refreshing application-owned context in sessions. +- `Controller\AuthController`, `Controller\OAuthController`, and admin-only `Controller\UserController` with paginated, filterable `Collection` results, credential management, invitations, password resets, lifecycle management and CSRF-protected writes. +- Optional OAuth client login with Google/Facebook provider support, PKCE S256, provider/session-bound single-use state, and explicit, CSRF-protected account linking. +- A **first-time OAuth account-choice flow**: an unknown provider identity remains pending for up to ten minutes. The user explicitly chooses to create a new account or sign into an existing password account to link; the provider callback creates no account by itself. Includes an example frontend. +- Configurable callbacks for registration, token delivery, invitation/reset, audit events, deletion guards and application context. +- PHP 8.2–8.5 tests, SQLite unit tests, PostgreSQL 16 migration/integration checks, MySQL 8.0/8.4 schema and authentication integration checks, real Google/Facebook provider-class tests with mocked HTTP, and pre-merge production/consumer Composer installation tests. + +### Changed + +- **Minimum PHP version raised from 5.3 to 8.2.** Runtime dependency: `objectiveweb/db ^0.8.0`. Router/Phinx/PHPUnit packages are development dependencies; applications using router middleware or OAuth providers must install the relevant integration packages themselves. +- Database access moved from direct PDO/MySQL-specific SQL in `Objectiveweb\Auth` to the portable `DBAuth` abstraction. Queries now return `Objectiveweb\DB\Collection`, rather than the 0.1.x HAL-style `_embedded`/pagination array. +- Local identifiers are represented as credentials (`provider = local`, `uid`); the user table no longer contains the legacy `username` identity column by default. +- Password login failures return the same `AuthException` HTTP 401 for unknown identifiers, bad passwords and suspended accounts. +- Password reset tokens are cryptographically random, stored only as SHA-256 digests, expire and are consumed atomically; previously issued plaintext legacy tokens do **not** carry forward. +- Session IDs regenerate upon authentication; `user_can()` revalidates account status and roles on every call so deleted/suspended accounts and revoked roles cannot retain access through stale sessions. Public user payloads are sanitized. +- User-facing password changes require the existing password, a session-bound `X-CSRF-Token`, and a JSON request; public registration is restricted to local credentials and safe profile fields. +- OAuth callbacks require a **configured, trusted `redirectUri`** (HTTPS, or explicitly configured HTTP loopback for local development), no longer inferred from Host/proxy request headers. +- OAuth provider-resource-owner display names, emails and avatars are optional hints with safe fallbacks; matching email addresses do **not** link accounts. +- `BasicAuth::update_credential()` now preserves an existing provider profile when given `null`, matching `DBAuth` and preventing password login from erasing metadata. +- User-managed application associations (`managed_relations`) now belong to application services. Use `user_context_callback` to expose current assignments to the authenticated session. User deletion cleans only explicitly `auth_owned` authorization relation tables; application-owned relation tables are never implicitly modified. +- Project testing/release verification moved from Travis to GitHub Actions; Composer metadata and support URL updated. + +### Security fixes + +- Prevent account enumeration through password-login errors and anonymous password-recovery responses. +- Allow anonymous password recovery only through independently verified credential channels (including local), preventing reset delivery to unverified emails/phone numbers; preserve generic responses and trusted administrator invitation/reset workflows. +- Consume password reset and credential-verification tokens at most once; separate proof for each provider credential. +- Revoke outstanding password-reset tokens and expirations whenever a user password is changed, atomically with the password hash update on DBAuth. +- Prevent OAuth login CSRF, state replay, cross-provider state confusion, unconsented linking, email-based account takeover and cross-account credential reassignment. +- Restrict logout redirects to safe same-origin absolute paths and reject attacker-controlled OAuth callback URLs. +- Enforce management CSRF checks; check and serialize administrator removal, demotion and suspension inside DBAuth's existing update/delete transactions so concurrent operations cannot remove the final active admin (including direct DBAuth calls and lifecycle-field updates). Routine profile writes skip the lock; controllers retain self-protection and validation. Reject unknown DELETE subresources with 404 instead of accidentally deleting the user. +- Avoid returning passwords, reset tokens or other internal secrets in normal user/session/management payloads. + +### Removed / incompatible + +- Direct construction of `new Objectiveweb\Auth($pdo, ...)`; instantiate `DBAuth` or `BasicAuth` instead. +- `Objectiveweb\Auth\AuthController` (old class path), `PasswordMismatchException`, legacy `src/password.php`, and the 0.1.x MySQL-only query format. +- Implicit ownership of application-managed relations and implicit OAuth account linking based on email. +- Legacy plaintext password-reset token storage/consumption. + +### Deferred / not included + +- Built-in rate limiting is intentionally excluded. Deployments **must** configure throttling at the reverse proxy, gateway, WAF or application middleware. Planned non-SQL pluggable throttling: [#2](https://github.com/objectiveweb/auth/issues/2). +- Acting as an OAuth2/OIDC identity provider, JWT signing/JWKS: [#3](https://github.com/objectiveweb/auth/issues/3). +- WebAuthn/passkeys, TOTP and MFA: [#4](https://github.com/objectiveweb/auth/issues/4). +- Refresh-token rotation, device/session management and advanced audit sinks: [#5](https://github.com/objectiveweb/auth/issues/5). + +See [release notes](docs/RELEASE-0.2.0.md) for a concise announcement and the [upgrade guide](docs/UPGRADING-0.2.0.md) for required migration steps. This entry is **unreleased** until the PR is merged and a release tag is published. + +## 0.1.x — Legacy series + +The previous public API used a concrete, PDO-backed `Objectiveweb\Auth`, a single `ow_auth` user table with a `username` column, `Objectiveweb\Auth\AuthController` and PHP 5.3-era dependencies. This entry describes the legacy series for comparison; its individual release dates and tag history are not reconstructed here. diff --git a/README.md b/README.md index 967e978..52dad7b 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,725 @@ -# Auth [![Build Status](https://travis-ci.org/objectiveweb/auth.svg?branch=master)](https://travis-ci.org/objectiveweb/auth) +# objectiveweb/auth [![CI](https://github.com/objectiveweb/auth/actions/workflows/ci.yml/badge.svg)](https://github.com/objectiveweb/auth/actions/workflows/ci.yml) -Authentication Library +Authentication library with pluggable providers. -## Setup +> **v0.2.0 is a breaking upgrade from 0.1.x.** The old PDO-based `Auth` constructor, +> user table and controller paths are not compatible. Read the +> [0.2.0 upgrade guide](docs/UPGRADING-0.2.0.md) before upgrading a live app. + +**Release documentation:** [Changelog](CHANGELOG.md) · +[Upgrade guide (0.1.x → 0.2.0)](docs/UPGRADING-0.2.0.md) · +[0.2.0 release notes and release checklist](docs/RELEASE-0.2.0.md) + + +## Install + +```bash +composer require objectiveweb/auth +``` + +This package requires: +- PHP `>=8.2` +- `objectiveweb/db` + +### Pre-merge production installation check + +Every pull request targeting `master` runs the **Production install** job in +[`release.yml`](.github/workflows/release.yml), using PHP 8.2 (the minimum +supported version). This job verifies `composer validate --strict`, installs +the library's runtime dependencies using `composer install --no-dev`, checks +platform requirements, and executes +[`test/verify_production_install.php`](test/verify_production_install.php). +The smoke test confirms that the public Auth, BasicAuth, DBAuth, controller +and database classes autoload, and that registration, login and logout work +without PHPUnit or Phinx installed. + +It also installs the unreleased source into a **separate consumer project** +with a non-symlinked Composer path repository, then repeats the smoke checks +against that consumer's `vendor/autoload.php`. Until v0.2.0 is published, the +path package is explicitly assigned the candidate `0.2.0` version; this +checks real consumer autoloading and `require` dependencies without claiming +that Packagist already serves the new release. The standard PHP test matrix +continues running in `ci.yml`, while `release.yml` runs its additional +full matrix only after merging or on manual dispatch. + +For a local no-dev check: + +```sh +composer install --no-dev --prefer-dist --optimize-autoloader +composer check-platform-reqs --no-dev +php test/verify_production_install.php vendor/autoload.php +``` + +## Providers + +### `DBAuth` + +`DBAuth` uses `Objectiveweb\DB` and supports: +- local login with hashed password +- external credentials (`provider` + `uid`) +- password reset tokens +- immutable UUID generation on user creation (default field name: `uuid`) + +#### Example setup + +Apply the bundled Phinx migrations (or an equivalent schema) before using `DBAuth`. The example uses the default **logical** table names created by those migrations: `user`, `user_credentials`, `role`, `user_roles` and `delegations`. + +```php +use Objectiveweb\DB; +use Objectiveweb\Auth\DBAuth; + +$db = new DB('sqlite:/tmp/app.sqlite'); // MySQL and PostgreSQL also supported + +$auth = new DBAuth($db, [ + 'token' => 'token', // Enable password resets + 'disabled_at' => 'disabled_at', // Enable account suspension + 'invitation_callback' => function (array $user, array $credentials, string $token): void { + // Deliver invitations out of band; never return a token over HTTP. + }, + 'reset_callback' => function (array $user, array $credentials, string $token): void { + // Deliver admin-initiated resets out of band. + }, + 'token_callback' => function (array $credential): void { + // Anonymous forgot-password delivery; token is in $credential['token']. + }, +]); +``` + +The table names are defaults; they do **not** need to be repeated in the constructor. `DBAuth` initializes a `$db->table('user')` object for user CRUD and pagination. + +To use physical table prefixes, configure `DB` and your Phinx installation consistently, leaving `DBAuth` table names logical: + +```php +$db = new DB('sqlite:/tmp/app.sqlite', null, '', ['prefix' => 'app_']); +$auth = new DBAuth($db, ['token' => 'token', 'disabled_at' => 'disabled_at']); +// Logical table "user" resolves to physical table "app_user". +``` + +#### DBAuth parameters + +These options come from both the shared `Auth` contract and `DBAuth`. Set only the options your application needs. + +| Parameter | Default | Purpose | +| --- | --- | --- | +| `table` | `user` | Logical user table (DB applies any configured prefix) | +| `id` / `password` | `id` / `password` | User primary key and password-hash columns | +| `uuid` | `uuid` | Generates UUIDs on registration; `null` disables | +| `created` / `last_login` | `created` / `null` | User-table creation and optional last-login columns | +| `credentials_table` | `user_credentials` | Logical provider-credential table | +| `credentials_last_login` / `credentials_created` | `last_login` / `created` | Credential timestamp columns; `null` disables updates | +| `login_providers` | `['local', 'email']` | Ordered provider identities considered for password login | +| `recovery_providers` | `['local', 'email', 'phone']` | Ordered recovery channels; only credentials with `verified_at` are eligible | +| `token` | `null` | User-table reset-token field; use `'token'` to enable | +| `token_expires_field` / `token_ttl` | `token_expires_at` / `3600` | Reset-token expiry column and lifetime in seconds | +| `disabled_at` | `null` | Nullable suspension column; use `'disabled_at'` to enable lifecycle checks | +| `roles` | `roles` | Name of the calculated user roles array (not a database column) | +| `roles_table` / `user_roles_table` | `role` / `user_roles` | Global roles and memberships; both required or both `null` | +| `role_id` / `role_name` | `id` / `name` | Role table's ID/name fields | +| `user_roles_user_id` / `user_roles_role_id` | `user_id` / `role_id` | Membership foreign-key fields | +| `relations` | `[]` | Resource abilities and user-to-user delegations; see Relations | +| `user_context_callback` | `null` | Application callback `(sanitizedUser): array` to populate the authenticated session's `context` | +| `session_key` | `ow_auth` | Authenticated principal's session key | +| `register_scope` / `register_allow_grants` | `Auth::ANONYMOUS` / `false` | Registration access and whether callers may supply roles | +| `management_csrf` / `management_csrf_session_key` | `true` / `ow_auth_management_csrf` | Management write protection and CSRF session key | +| `register_callback` / `token_callback` | `null` / `null` | Registration callback / anonymous recovery delivery callback | +| `invitation_callback` / `reset_callback` | `null` / `null` | Delivery callbacks for invitation/admin reset: `(user, credentials, plaintextToken)` | +| `audit_callback` | `null` | Management events: `(actorId, targetId, action, metadata)` | +| `deletion_guard_callback` | `null` | Return `false` or a conflict string to reject user deletion | + +The anonymous `token_callback` receives a **single** credential array containing the one-time token. `Auth::invite()` instead invokes `invitation_callback` or `reset_callback` with **three** arguments; without dedicated callbacks it falls back to `register_callback`/`token_callback`, respectively. Prefer dedicated callbacks when supporting both flows. + +#### Database tables and migrations + +The bundled migrations in `db/` use the following **default logical names**: + +| Table | Fields and purpose | +| --- | --- | +| `user` | `id`, unique `uuid`, nullable `password`, `name`, `image`, `created`, nullable `token`, `token_expires_at` and `disabled_at` | +| `user_credentials` | Unique (`uid`, `provider`) credential identity, `user_id` FK, `profile`, `token`, `last_login`, `created` | +| `role` | `id` and unique role `name` | +| `user_roles` | (`user_id`, `role_id`) memberships with both foreign keys | +| `delegations` | Optional (`user_id`, `target_user_id`, `ability`) relation table | + +Use your application's Phinx configuration to run the package's migrations; Phinx is a development/migration dependency, not a runtime dependency of this package. The reset-token column is indexed in the bundled user migration and stores only a SHA-256 digest of the generated one-time token. + + +For a standalone SQLite setup, the following is an equivalent schema using **default table names**. Prefer the bundled migrations for a production application and adapt column types/constraints to your database: + +```sql +CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid VARCHAR(36) NOT NULL UNIQUE, + password VARCHAR(60), + name VARCHAR(255), + image VARCHAR(255), + token VARCHAR(255), + token_expires_at DATETIME, + disabled_at DATETIME, + created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX user_token_idx ON user(token); + +CREATE TABLE user_credentials ( + uid VARCHAR(255) NOT NULL, + provider VARCHAR(32) NOT NULL, + user_id INTEGER NOT NULL, + profile TEXT, + token VARCHAR(255), + token_expires_at DATETIME, + verified_at DATETIME, + last_login DATETIME, + created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (uid, provider), + FOREIGN KEY (user_id) REFERENCES user(id) +); + +CREATE TABLE role ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL UNIQUE +); +CREATE TABLE user_roles ( + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + PRIMARY KEY (user_id, role_id), + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (role_id) REFERENCES role(id) +); +CREATE INDEX user_roles_role_id_idx ON user_roles(role_id); + +-- Optional, used only when explicitly configured under "relations": +CREATE TABLE delegations ( + user_id INTEGER NOT NULL, + target_user_id INTEGER NOT NULL, + ability VARCHAR(64) NOT NULL, + PRIMARY KEY (user_id, target_user_id, ability) +); +CREATE INDEX delegations_target_idx ON delegations(target_user_id); +``` + +**Custom schemas:** Override `table`, `credentials_table`, `roles_table` and `user_roles_table` when integrating existing tables. For instance, the following maps to *logical* custom tables; these physical tables must already exist because the bundled migrations use the default names: + +```php +$auth = new DBAuth($db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'token' => 'token', + 'disabled_at' => 'disabled_at', +]); +``` + +Configure a prefix only on `DB` rather than manually including it in these table names. With the default configuration, **both** role tables are expected. To disable database roles, set **both** `roles_table` and `user_roles_table` to `null`; no role columns belong in the user table. + +#### Public registration + +`AuthController::postRegister()` accepts only `uid` (email), `password`, +and optional `name` and `image`. It always creates a `local` credential. +Role requests are ignored; provider names, OAuth profiles, IDs, suspension +state, tokens, and other fields are rejected with HTTP 400. + +This prevents clients from claiming external provider identities, but **does +not verify ownership of the submitted email address**. Applications must +complete email verification before treating an email as verified. Explicit +provider registration using `DBAuth::register()` is reserved for trusted +application code after authenticating the provider's identity. + +**OAuth subject matching:** The bundled MySQL 8 migration uses the case-sensitive, `NO PAD` `utf8mb4_0900_bin` collation for both `user_credentials.uid` and `provider`. OAuth subject IDs must match exactly, including case and trailing spaces. Custom schemas must enforce equivalent identity semantics. Editing this migration does **not** update tables already created by Phinx; if you've previously run it, migrate or recreate the credential table before deploying. + +#### Users + +`register()` creates the user and its initial credential transactionally. It returns a **sanitized** user. `get()` returns the provider's **raw** user record, including sensitive fields; sanitize it before exposing it to clients. + +```php +$user = $auth->register('alice@example.com', 'secret', ['name' => 'Alice']); +$userId = $user['id']; + +$rawUser = $auth->get($userId); +$publicUser = $auth->sanitize_user($rawUser); + +$auth->update($userId, ['name' => 'Alice Smith']); +``` + +`query()` returns `Objectiveweb\DB\Collection`, not HAL-formatted data: + +```php +$users = $auth->query([ + 'q' => 'alice', // Search name, numeric ID or credential UID + 'page' => 0, + 'size' => 20, + 'sort' => 'name ASC', + 'status' => 'active', +]); + +foreach ($users as $user) { + // Sanitized user, with roles and credentials +} +$total = $users->total(); +$range = $users->contentRange(); // e.g. "items 0-19/137" +``` + +For password-based login and session management: + +```php +use Objectiveweb\Auth\AuthException; + +try { + $current = $auth->login('alice@example.com', 'secret'); +} catch (AuthException $e) { + // 401 "Invalid credentials" for unknown users, wrong passwords, or + // suspended accounts. Do not reveal which case applies. +} + +if ($auth->check()) { + $current = $auth->user(); // Sanitized session principal +} +$auth->logout(); +``` + +`passwd($userId, $newPassword)` performs a **direct** password replacement and must only be called by trusted admin/service code. For an authenticated user's own change, the included `AuthController::postPassword()` verifies the old password and session CSRF token; see below. + +#### User credentials + +A user can have multiple identities, each uniquely identified by `(provider, uid)` and linked through `user_id`. The default `login_providers` are `local` and `email`; adding an OAuth/phone/email credential does not verify ownership automatically. + +```php +$auth->get_credential('local', 'alice@example.com'); // Raw provider credential or false +$credentials = $auth->get_credentials($userId); // Normalized public-safe list + +$auth->create_credential($userId, 'phone', '+5511999999999', ['country' => 'BR']); +$auth->rename_credential($userId, 'phone', '+5511999999999', 'phone', '+5511888888888'); +$auth->delete_credential($userId, 'phone', '+5511888888888'); +``` + +`create_credential()` rejects duplicates; `update_credential()` inserts or updates a provider identity and updates its last-login field when configured. Passing `null` as the profile **preserves existing provider metadata** (including during password login) in both `DBAuth` and `BasicAuth`; pass a non-null profile to replace it. `delete_credential()` refuses to remove the user's last credential. Treat raw provider profiles as potentially sensitive; send normalized/sanitized credentials to clients. + +##### Independent credential verification + +Each `(provider, uid)` credential has its own verification state: `token` +(SHA-256 digest only), `token_expires_at`, and `verified_at`. A new +credential is unverified until its specific identity has been proven. +The `user.token` field remains for **password reset/invitation**, and is +not interchangeable with credential verification tokens. + +For contact methods (`local`, `email`, `phone` by default), trusted +application code can issue an out-of-band token and deliver it **only** +through that credential's address or channel: + +```php +$auth->create_credential($userId, 'email', 'alice@example.com'); +$token = $auth->issue_credential_token($userId, 'email', 'alice@example.com'); +// Email the secret to alice@example.com; do not expose it via an API response. +// The caller redeems it with AuthController::postVerifyCredential(): +$result = $auth->verify_credential_token('email', 'alice@example.com', $token); +// true on first valid redemption; false for wrong, expired, or reused tokens. +``` + +Each token is independent; validating one credential does **not** validate +any other. Issuing a new token replaces an earlier pending one. Tokens +expire after `credential_token_ttl` seconds (default 3600; minimum 60), +and are consumed once. Credentials lists include `verified_at`, but never +return verification token digests or expirations. Renaming a credential's +provider or UID clears its prior verification state. + +An OAuth provider identity is **not** verified by a locally issued email or +SMS token. OAuth credentials are proven using the validated provider callback, +including code/token exchange and provider-specific user ID; the trusted +OAuth integration marks exactly that credential verified. OAuth sign-in never +links accounts by email equality and never automatically creates a separate +`email` credential. An existing unverified OAuth credential cannot authorize +an anonymous login; its owning user must authenticate separately and confirm +the provider link. External providers are deliberately excluded from +`credential_token_providers` unless explicitly configured by the application; +do not enable arbitrary OAuth provider names. + +The public `postVerifyCredential()` endpoint accepts `provider`, `uid`, and +`token`, marks the matching credential verified, and returns +`{"verified":true}`. It does not establish a login session. Applications +must rate-limit verification attempts and token issuance. Local password login +continues to prove knowledge of the password; it does not imply that the +credential's email address is verified. + +#### Roles + +**Global roles** are attached to users and stored in `role` and `user_roles`. `roles` in returned user data is a computed array, not a SQL column. They are independent of resource-specific relations. + +```php +// Trusted bootstrap/service code: +$admin = $auth->register('admin@example.com', 'secret', [ + 'name' => 'Admin', + 'roles' => ['admin'], +]); + +$auth->get_roles(); // ['admin', ...] +$auth->get_users_by_role('admin'); // Sanitized user arrays +$auth->update($admin['id'], ['roles' => ['admin', 'editor']]); + +// With the appropriate user already logged in: +$auth->user_can('admin'); // bool +``` + +`user_can()` **revalidates the logged-in principal on every permission check**, including role, resource and delegation checks. Role grants/revocations take effect immediately on the next call; suspended or deleted users are logged out and denied (`false`, or `[]` for a resource-list query). This requires a backend read for every call, so cache permission decisions only within a deliberately controlled authorization boundary, never across requests. + +Direct `DBAuth::register()` / `update()` can create role definitions when syncing names that do not exist. The **admin UserController API** is intentionally stricter and accepts only *existing* role names: seed the allowed roles before using its role-management endpoints. The public registration controller always ignores role grants, regardless of `register_allow_grants`. Both `roles_table` and `user_roles_table` must be disabled together if roles are not wanted. + +#### Relations and authorization + +`relations` provides **resource permissions and user-to-user delegations**, separate from global roles. The bundled `delegations` table is optional until you map it; any other permission tables are owned by your application and must be created in your migrations. + +```php +$auth = new DBAuth($db, [ + 'relations' => [ + 'user' => [ + 'table' => 'delegations', // Bundled table + 'subject_key' => 'user_id', + 'target_key' => 'target_user_id', + 'ability_key' => 'ability', + 'target_is_user' => true, // Also clean target-user rows when Auth owns this relation + 'auth_owned' => true, // Explicit opt-in: Auth may clean this table on user deletion + ], + 'item' => [ + 'table' => 'item_users', // Application-owned relation table + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'ability_key' => 'ability', + 'eager' => true, // Eagerly load matching rows with get()/query() + ], + ], +]); + +// With a user logged in: +$auth->user_can('delegate', 42); // "user" relation for target user 42 +$auth->user_can('manage', 'item', 10); // Ability on item 10 +$itemIds = $auth->user_can('manage', 'item'); // Accessible item IDs +``` + +Each permission relation requires `table`, `subject_key`, `target_key` and either `ability_key` or `role_key` together with `role_abilities` (a role-to-ability map). `eager` may be `true` or a `DB::select()` options array such as `['order' => 'item_id DESC']`. Eager relation rows remain scoped to the current subject user. + +**Deletion ownership is opt-in.** `DBAuth::delete()` removes rows from a configured relation table only when `auth_owned => true` is explicitly set (for example, the bundled `delegations` table). When enabled, it cleans rows with the deleted user as `subject_key`, plus `target_key` when `target_is_user => true`. Merely configuring a relation for authorization—even with `target_is_user => true`—does not transfer ownership of application tables to Auth. Never set `auth_owned` on application-owned association tables; use the application's own deletion logic, foreign-key cascades or `deletion_guard_callback` instead. + +#### Application-owned user context + +Use `user_context_callback` for application authorization context that must be available on the authenticated principal (for example, the venue IDs assigned to the current user). It receives a **sanitized** user and must return an array. Auth stores that array under `$auth->user()['context']` at login (including OAuth) and **rebuilds** it from the latest user record whenever `reload()` or `revalidate()` is called. -Setup the auth dependency on your project's composer.json, then run `composer update` +The application owns the underlying relationships, validation, and writes. Auth does not read or modify the application association table itself: + +```php +use Breakfastweekend\App\Service\UserService; + +$auth = new DBAuth($db, [ + 'user_context_callback' => function (array $user) use ($app): array { + $service = $app->create(UserService::class); + return [ + 'venues' => $service->getVenueIds($user['id']), + ]; + }, +]); + +$auth->login('alice@example.com', 'secret'); +$venueIds = $auth->user()['context']['venues']; // e.g. [12, 34] + +// Once the application updates venue assignments, refresh session context: +$auth->revalidate(); +$venueIds = $auth->user()['context']['venues']; +``` +`getVenueIds()` is an **application method to implement**; it is not supplied by Auth. Keep the existing application's `validateVenueIds()` in its own service, alongside an application-owned assignment method such as `syncVenueIds($userId, $ids)`. The latter should validate IDs and authorization before changing `venue_users` transactionally. For users whose assignments change in another session, call `revalidate()` on the next protected request or otherwise invalidate/refresh that session according to your application's needs. - "require": { - "objectiveweb/auth": "~0.1" - } +Session context is not implicitly added to `get()` or `query()` results; those remain user-record APIs. `AuthController::index()` returns the authenticated session principal, including its context. If the callback is omitted, Auth does not add a context field. -### Auth initialization +**Migration from `managed_relations` (breaking change for 0.2.0):** remove that parameter; move `get_managed_relations()` and `sync_managed_relations()` callers into your application's user service; expose venue assignments through application-specific endpoints rather than generic `UserController` create/update. The generic Auth controller now rejects `managed_relations` request fields instead of silently losing assignments, and it no longer includes them in user-detail responses. `DBAuth::delete()` cleans only explicitly `auth_owned` relations and never automatically cleans application association tables: arrange their cleanup using application transactions/foreign-key cascades and, where necessary, `deletion_guard_callback`. -Create a new $auth instance, passing the appropriate parameters +### `BasicAuth` - // Auth depends on PDO - $pdo = new PDO($dsn); +In-memory provider for tests/dev/small setups. - $auth = new Auth($pdo, [ - 'session_key' => 'ow_auth', - 'table' => 'ow_auth', - 'id' => 'id', - 'username' => 'username', - 'password' => 'password', - 'token' => NULL, - 'created' => NULL, - 'last_login' => NULL - ]); +`BasicAuth` fully implements the provider contract: +- `query`, `get`, `register`, `login` +- `passwd`, `passwd_reset` +- `update`, `delete` +- `update_token` +- `get_credential`, `update_credential` -#### Parameters -* 'session_key' +#### BasicAuth setup - Key used for $_SESSION storage, defaults to 'ow_auth' +```php +use Objectiveweb\Auth\BasicAuth; -* 'table' +$auth = new BasicAuth( + [ + 'admin@example.com' => 'secret', + ], + [ + 'token' => 'token', + ] +); +``` - Database table which stores users, defaults to 'ow_auth' +Passwords passed to the constructor/register are hashed internally unless already hashed. -* 'id' +## Authenticated password changes - Primary key of the user table, defaults to 'id' +`AuthController::postPassword()` requires proof of the existing password for +authenticated sessions. Retrieve the session-bound CSRF token from the +authenticated `AuthController::index()` response (`_csrf`) and send it as +`X-CSRF-Token` with `Content-Type: application/json`: -* 'username' +```http +POST /auth/password +Content-Type: application/json +X-CSRF-Token: - Username field on the user table, defaults to 'username' +{"current_password":"old-password","password":"new-password","confirm":"new-password"} +``` -* 'password' +The authenticated change is refused when the current password is missing or +incorrect, or when the user has no usable local password (for example, an +OAuth-only account). Passwordless users must complete an independently verified +password-reset or reauthentication flow instead; a session alone cannot set a +password. The CSRF token is required even for direct controller invocations. - Password field on the user table, defaults to 'password', +## Password reset flow -* 'token' +When `token` is configured: - Optional CHAR(32) field to store a random token +```php +$credential = $auth->get_credential('local', 'alice@example.com'); +$token = $auth->update_token($credential['user_id']); -* 'created' +// Send token, then later: +$auth->passwd_reset($token, 'new-password'); +``` - Optional DATETIME field to store the accounts creation date +Direct password changes through `passwd($userId, $newPassword)` (including authenticated `AuthController::postPassword()`) revoke any outstanding user password-reset token and its expiry. `DBAuth` changes these fields in one SQL `UPDATE` so a previously issued reset link cannot overwrite the new password. This is separate from credential-verification tokens. -* 'last_login' +`DBAuth` stores only a SHA-256 digest of the high-entropy reset token and looks it up through the indexed token column. The plaintext token is returned only once for delivery. - Optional DATETIME field to store the last successful login date +Anonymous password recovery uses **only independently verified** credentials (`verified_at` is set), including for the default `local`, `email` and `phone` providers. An unverified contact method cannot request an account-wide reset token; if multiple configured providers have the same UID, the first **verified** credential is selected. Password login alone does not verify control of the recovery address. Newly registered local credentials therefore cannot use anonymous recovery until their address is verified through `issue_credential_token()` / `postVerifyCredential()` or other trusted out-of-band proof. Legacy local credentials imported with `verified_at = NULL` likewise cannot recover anonymously; do not bulk-mark them verified without evidence of ownership. An admin-initiated `Auth::invite()` or reset remains a separate trusted operation and must be delivered only via an independently trusted channel. -## Usage +Anonymous password-recovery requests intentionally return the same empty success payload whether or not the supplied UID exists or is verified. Recovery callbacks are delivery-only: their return value is not exposed, and delivery failures do not change the anonymous response. **There is no built-in rate limiting in v0.2.0.** Applications must enforce external rate limits at their web server, reverse proxy, gateway, or middleware for login, public registration, recovery, reset-token redemption, and credential verification. Future non-database throttling is tracked in [issue #2](https://github.com/objectiveweb/auth/issues/2). - # register user - try { - $user = $auth->register('username', 'password'); - } - catch(\Exception $ex) { - printf('DB Error creating user: %s', $ex->getMessage()); - } +### Database CI coverage - # login user - try { - $user = $auth->login('username', 'password'); - } - catch(PasswordMismatchException $ex) { - printf("Password mismatch"); - } - catch(UserException $ex) { - printf("Error logging in: %s", $ex->getMessage()); - } +The `CI` workflow validates the bundled Phinx migrations and DBAuth +operations against **MySQL 8.0 and MySQL 8.4 (LTS)**, in addition to the +existing PostgreSQL 16 migration check and SQLite unit tests. MySQL CI checks +the migrated columns, indexes, foreign keys and InnoDB tables, then executes +registration, login, role assignment/queries, credential verification, +single-use password reset, pagination and deletion against MySQL. + +The MySQL-specific Phinx environment is `mysql_testing` in +[`test/phinx.php`](test/phinx.php). To run locally, create an empty MySQL +database and set `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_DATABASE`, +`MYSQL_USER` and `MYSQL_PASSWORD` with a user able to create tables and +indexes. Then run: - # check if the user is logged in - if($auth->check()) { - // user is logged in - } - else { - // user is not logged in - }; +```sh +vendor/bin/phinx migrate --configuration test/phinx.php --environment mysql_testing +vendor/bin/phinx status --configuration test/phinx.php --environment mysql_testing +php test/verify_mysql_migrations.php +php test/verify_mysql_auth.php +``` - # retrieve the current user from the session - try { - $user = $auth->user(); - } - catch(UserException $ex) { - printf("User not logged in"); - } +These test scripts **create and delete users/credentials** and require a +dedicated empty database; do not run them against an application database. - # logout user - $auth->logout(); +## OAuth controller -### Store additional data when registering a user +Configure OAuth providers by provider name. **Every provider must have an explicitly configured, trusted `redirectUri`** that exactly matches its callback URL registered at the provider: - $user = $auth->register('username', 'password', [ - 'email' => 'someone@somewhere', - 'displayName' => 'Test User' - ]); +```php +use Objectiveweb\Auth\Controller\OAuthController; -### Generating a password reset token +$oauth = new OAuthController($auth, [ + 'google' => [ + 'clientId' => getenv('GOOGLE_CLIENT_ID'), + 'clientSecret' => getenv('GOOGLE_CLIENT_SECRET'), + 'redirectUri' => 'https://app.example/auth/oauth/google', + ], +]); +``` -When the `token` parameter is enabled in initialization +`redirectUri` is **required**, including in development. Missing or malformed values fail immediately before OAuth authorization or token exchange begins. The controller never reads `HTTP_HOST`, `HTTP_X_FORWARDED_PROTO`, the current request scheme, or request paths to construct a callback URL. The configured URL is passed unchanged to the OAuth client. - $token = $auth->update_token($username); +Only absolute **HTTPS** callback URLs are accepted in production. **HTTP** is permitted solely for the loopback hosts `localhost`, `127.0.0.1`, and `[::1]` for local development; even local URLs must be explicitly configured. Relative URLs, credentials embedded in URLs, URL fragments, control characters, and other schemes are rejected. Static query parameters can be used if the provider has registered the exact callback URL. + +For example, a local development configuration may use: + +```php +$oauth = new OAuthController($auth, [ + 'google' => [ + 'clientId' => getenv('GOOGLE_CLIENT_ID'), + 'clientSecret' => getenv('GOOGLE_CLIENT_SECRET'), + 'redirectUri' => 'http://localhost:8080/auth/oauth/google', + ], +]); +``` - // send $token to user, then later - try { - $auth->passwd_reset($token, $new_password); - } - catch(UserException $ex) { - printf('Invalid token provided'); - } +**Breaking behavior change in v0.2.0:** applications that previously omitted `redirectUri` must supply one for every enabled OAuth provider. Do not derive it from user input or untrusted forwarding headers in application code. This policy applies to both ordinary OAuth login and explicit account linking. -### Updating user data +OAuth authorization includes a fresh **PKCE S256** challenge and stores a per-provider authorization flow in the PHP session (`oauth2flows`). The callback requires the matching provider's state, the original initiating session principal, and a state created within the past 10 minutes. The state is consumed before exchanging the code, and the matching PKCE verifier is supplied to the token endpoint. Providers must support PKCE S256. OAuth accounts are linked by the provider's stable subject identifier only; matching email addresses never establishes or links identities. - $auth->update($username, [ 'email' => 'new@email.com' ]); +### Real Google and Facebook provider integration tests +The optional `league/oauth2-google` and `league/oauth2-facebook` packages are +tested in a dedicated `oauth-real-providers` CI job. These tests instantiate +the **real League provider classes** and pass a Guzzle mock HTTP transport +instead of making network calls or using production OAuth secrets. They verify: + +- Provider-specific authorization URLs, redirect URIs, scopes, state, and + PKCE S256 challenges and token-exchange `code_verifier` parameters. +- Google OpenID Connect userinfo and Facebook Graph API profile parsing, + including avatar/email display hints from real resource-owner classes. +- Unknown OAuth identities remain pending until the user chooses to + create an account or authenticate an existing account for linking. +- Invalid state is rejected before network access; provider token errors + do not create sessions or pending identities. + +The provider packages remain optional for library users (the runtime +`require` dependencies are unchanged). The dedicated job temporarily +installs them via Composer: + +```sh +composer require --dev 'league/oauth2-google:^4.0' \ + 'league/oauth2-facebook:^2.0' 'guzzlehttp/guzzle:^7.0' +vendor/bin/phpunit test/RealOAuthProviderTest.php +``` + +These are deterministic interoperability tests against real provider +**implementations**, not live Google/Facebook service checks. Live validation +still requires developer-owned OAuth applications, registered callback URLs, +and test users; do not place provider secrets in CI or test fixtures. + +### Resource-owner profile compatibility + +The League OAuth2 `ResourceOwnerInterface` requires only `getId()` and +`toArray()`. `OAuthController` therefore never assumes provider-specific +`getName()`, `getAvatar()`, `getPictureUrl()` or `getEmail()` methods +exist. It first reads usable values from optional getters when available, then +uses well-known keys in `toArray()`: + +- **Name:** `name`, `display_name`/`displayName`, `full_name`, + `given_name` + `family_name`, `first_name` + `last_name`, and + finally username/login-style display hints. +- **Avatar:** `avatar_url`, `picture`, `picture_url`, `image`, + `image_url`, `profile_image_url`, or a nested `picture.data.url`; + only valid HTTP(S) URLs are accepted. +- **Email hint:** `email`, `email_address` or `mail`, when syntactically + valid. This is **only a display hint**, never proof of email ownership + and never grounds for automatic account linking. + +Optional methods returning empty/malformed values or throwing are ignored. +Missing fields remain `null`, and the ordinary local-account display-name +default applies when the user explicitly creates an account. The raw provider +profile remains associated only with its provider credential, not as proof of +unrelated local identities. + +### First-time OAuth account choice (no automatic duplicate accounts) + +After the OAuth authorization-code/PKCE exchange, if no `(provider, uid)` credential exists, the user is **not logged in** and no local user is created. A short-lived `oauth2pending` entry remains in their PHP session; it contains the provider's verified subject, profile, safe display hints, and creation timestamp, and expires after 10 minutes. The OAuth state is already consumed. + +The callback redirects to **`/?oauth=choose-account`** by default. The application must render an account-choice page at that URL (or configure a different same-origin path in the controller constructor): + +```php +$oauth = new OAuthController($auth, $providers, '/account/choose'); +``` + +A ready-to-adapt frontend is included as [`examples/oauth-account-choice.html`](examples/oauth-account-choice.html). The library does not manage application UI routes, so mount the page at the configured redirect path and map the following methods to your application router: + +| Example route | OAuthController method | Function | +| --- | --- | --- | +| `GET /auth/oauth/pending` | `getPendingAccount()` | Safe choice metadata, email/name hints and session `_csrf` | +| `POST /auth/oauth/pending/create` | `postCreateAccount(array $form)` | Explicitly create a new **OAuth-only** local account | +| `POST /auth/oauth/pending/existing` | `postExistingAccount(array $form)` | Authenticate existing `uid` and `password`, then link the provider identity | +| `POST /auth/oauth/pending/cancel` | `postCancelPendingAccount()` | Discard the pending identity | + +All three POST actions require JSON, the `X-CSRF-Token` from `getPendingAccount()`, and the same browser session. Creation permits only optional `name` and respects `register_scope`; arbitrary provider, user ID, role and profile fields are rejected. Existing-account linking requires the actual password for that existing account. Wrong passwords do not consume the pending proof, so users can retry before expiry. Neither option treats the provider email as an automatically verified local email credential. + +On successful creation, the library creates an external-provider credential and establishes a new local session. On successful existing-account login, it attaches **that same provider identity to the password-authenticated local user**, marks the provider credential verified, and keeps that existing account. Both actions consume the pending proof; it cannot be reused. Concurrent ownership conflicts return HTTP 409; expired/cancelled/consumed choices return HTTP 410. For security, do not store the pending proof in the browser or return OAuth tokens/provider subject IDs as client data. The application must maintain secure, HttpOnly, SameSite session cookies; enforce external rate limits (see issue #2); and avoid caching the pending-account API response. + +```js +const pending = await fetch('/auth/oauth/pending', { credentials: 'same-origin' }).then(r => r.json()); +// Render two user-selected actions: "Create new account" and "Use existing account". +const response = await fetch('/auth/oauth/pending/existing', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': pending._csrf }, + body: JSON.stringify({ uid: email, password }), +}); +// Do not navigate to the signed-in area unless response.ok. +``` + +If the OAuth credential already belongs to a verified local user, normal OAuth login still signs that user in immediately. If the local user is already signed in and wants another OAuth credential, use the explicit account-linking flow below instead. + +### Explicit OAuth account linking + +**OAuth login and account linking are separate operations.** A normal `OAuthController::get($provider, $query)` authorization/callback authenticates an already verified external identity. If an anonymous user's provider identity has no local credential yet, the callback **does not create an account**: it stores a ten-minute, session-bound pending identity and redirects to a configurable account-choice page. It never attaches a new provider credential to an existing authenticated account, even when email addresses match. A signed-in user attempting to sign in through an unassociated provider receives HTTP 409 and must explicitly link the account first. Existing unverified OAuth identities cannot sign in anonymously or be silently confirmed. + +To link a provider, the application must expose `OAuthController::postLink($provider)` on an authenticated **POST** route (for example, `POST /auth/oauth/link/google` using the application's router). The request requires `Content-Type: application/json` and a valid session-bound `X-CSRF-Token` from `Auth::management_csrf_token()` (also available in the logged-in `AuthController::index()` response as `_csrf`). Do not allow GET links or unprotected forms to initiate linking. + +The action returns JSON containing an `authorization_url`. **The frontend must navigate to that URL** rather than relying on an AJAX redirect: + +```js +const response = await fetch('/auth/oauth/link/google', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + body: '{}', +}); +if (!response.ok) throw new Error('Could not start account linking'); +const { authorization_url } = await response.json(); +window.location.assign(authorization_url); +``` + +The provider's usual OAuth callback still uses `get()`. The session flow records an explicit `link` intent, the initiating user ID, provider, PKCE verifier, expiring one-time state and login session. Only a valid callback for the same active user may create/verify that user's provider credential. Identities already associated with a different user are rejected with HTTP 409; unverified mappings owned by the current user require this explicit flow to be confirmed. State is consumed even when linking fails. + +Applications should make the action visible only after the user chooses **Link account**, and may require additional recent-authentication or step-up checks as local policy dictates. All existing authorization, CSRF, and provider-account security checks continue to apply. + +The explicit authenticated `postLink()` flow remains distinct from the *anonymous first-time* account choice above. Never interpret an OAuth callback, query-string `intent`, or matching email as permission to link accounts. + +## User-management API + +DBAuth enforces the final-active-administrator invariant directly in its +existing `update()` and `delete()` transactions. Operations that may +demote or suspend an administrator, and user deletions, serialize through +a lock on the `admin` role row before counting active administrators. +Ordinary profile updates and account activation do not acquire that lock. +This protection also applies to direct DBAuth calls; application code that +writes to role or user tables without DBAuth must enforce it separately. +The lock is implemented with a no-op UPDATE, which can activate role-table +UPDATE triggers. + + +Register `Objectiveweb\Auth\Controller\UserController` at an application-owned +prefix such as `/api/users`. It requires the `admin` role and supports: + +- searchable, filtered and paginated `GET /api/users` (`q`, `role`, + `status`, `page`, `size`, whitelisted `sort`), returned directly as an + `Objectiveweb\DB\Collection`; +- role and user detail reads; +- create/invite and profile/global-role updates; +- credential create, rename and delete; +- suspend, activate, invitation and password-reset actions; +- guarded deletion with relation cleanup. + +User detail and role-list responses include a session CSRF token. Send it as +`X-CSRF-Token` with `Content-Type: application/json` on every write. The user +list itself is a plain `Collection`; use `total()` and `contentRange()` for +pagination metadata. Setup and reset tokens are passed only to delivery +callbacks and are never serialized in HTTP responses. + +Role names accepted by the management API must already exist in the configured +roles table. Use migrations/seeds for role definitions. Application-owned assignments +(e.g. venues) belong in application-specific endpoints; `managed_relations` is not +accepted by the generic management API. + +## Controllers and middleware + +Included: +- `AuthController` +- `OAuthController` +- `UserController` +- `RequireRole` middleware diff --git a/composer.json b/composer.json index 9b9df84..410013a 100644 --- a/composer.json +++ b/composer.json @@ -8,20 +8,27 @@ } ], "support": { - "issues" : "https://github.com/bravado/objectiveweb/issues" + "issues" : "https://github.com/objectiveweb/auth/issues" }, "require": { - "php": ">=5.3.0" + "php": ">=8.2.0", + "objectiveweb/db": "^0.8.0" }, "require-dev": { - "phpunit/phpunit": "4.*" + "phpunit/phpunit": "10.*", + "robmorgan/phinx": "^0.16", + "objectiveweb/router": "^3.0" + }, + "suggest": { + "league/oauth2-google": "Google Login", + "league/oauth2-facebook": "Facebook Login" }, "autoload": { "psr-4": { - "Objectiveweb\\": "src" + "Objectiveweb\\Auth\\": "src/Auth/" }, "files": [ - "src/password.php" + "src/Auth.php" ] } } diff --git a/db/20250419210134_auth_migration.php b/db/20250419210134_auth_migration.php new file mode 100644 index 0000000..85e7b81 --- /dev/null +++ b/db/20250419210134_auth_migration.php @@ -0,0 +1,72 @@ +table('user', ['signed' => false]); + $user + ->addColumn('uuid', 'string', ['limit' => 36]) + ->addColumn('password', 'string', ['limit' => 60, 'null' => true]) + ->addColumn('name', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('image', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('token', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('token_expires_at', 'datetime', ['null' => true]) + ->addColumn('disabled_at', 'datetime', ['null' => true]) + ->addColumn('created', 'datetime', ['default' => Literal::from('now()')]) + ->addIndex(['uuid'], ['unique' => true]) + ->addIndex(['token']) + ->create(); + + $user_credentials = $this->table('user_credentials', [ + 'id' => false, + 'primary_key' => ['uid', 'provider'] + ]); + $user_credentials + // MySQL 8 needs exact, NO PAD comparisons for OAuth subject IDs. + // Phinx ignores this MySQL-specific option on SQLite/PostgreSQL. + ->addColumn('uid', 'string', [ + 'limit' => 255, + 'null' => false, + 'collation' => 'utf8mb4_0900_bin', + ]) + ->addColumn('provider', 'string', [ + 'limit' => 32, + 'null' => false, + 'collation' => 'utf8mb4_0900_bin', + ]) + ->addColumn('user_id', 'integer', ['signed' => false]) + ->addColumn('profile', 'text', ['null'=> true]) + ->addColumn('token', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('last_login', 'datetime', ['null' => true]) + ->addColumn('created', 'datetime', [ 'default' => Literal::from('now()')]) + ->addForeignKey('user_id', $user->getTable()) + ->create(); + } +} diff --git a/db/20260310224000_auth_dbauth_relations_migration.php b/db/20260310224000_auth_dbauth_relations_migration.php new file mode 100644 index 0000000..b10e588 --- /dev/null +++ b/db/20260310224000_auth_dbauth_relations_migration.php @@ -0,0 +1,36 @@ +table('role', ['signed' => false]) + ->addColumn('name', 'string', ['limit' => 255]) + ->addIndex(['name'], ['unique' => true]) + ->create(); + + $user_roles = $this->table('user_roles', [ + 'id' => false, + 'primary_key' => ['user_id', 'role_id'], + ]) + ->addColumn('user_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('role_id', 'integer', ['signed' => false, 'null' => false]) + ->addIndex(['role_id']) + ->addForeignKey('user_id', 'user', 'id') + ->addForeignKey('role_id', 'role', 'id') + ->create(); + + $this->table('delegations', ['id' => false, 'primary_key' => ['user_id', 'target_user_id', 'ability']]) + ->addColumn('user_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('target_user_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('ability', 'string', ['limit' => 64, 'null' => false]) + ->addIndex(['target_user_id']) + ->create(); + + } +} diff --git a/db/20261019210000_auth_credential_verification_migration.php b/db/20261019210000_auth_credential_verification_migration.php new file mode 100644 index 0000000..b435382 --- /dev/null +++ b/db/20261019210000_auth_credential_verification_migration.php @@ -0,0 +1,18 @@ +table('user_credentials') + ->addColumn('token_expires_at', 'datetime', ['null' => true]) + ->addColumn('verified_at', 'datetime', ['null' => true]) + ->update(); + } +} diff --git a/docs/RELEASE-0.2.0.md b/docs/RELEASE-0.2.0.md new file mode 100644 index 0000000..65b845e --- /dev/null +++ b/docs/RELEASE-0.2.0.md @@ -0,0 +1,74 @@ +# objectiveweb/auth v0.2.0 — Release notes + +**Status: release candidate — not published.** Prepared for [PR #1](https://github.com/objectiveweb/auth/pull/1). These notes may be used as the GitHub release description **after** the PR is merged, the release checks pass on the intended commit, and the `v0.2.0` tag is created. + +## Summary + +v0.2.0 is a **breaking rewrite** of `objectiveweb/auth`. It introduces a provider-based authentication core, a portable `objectiveweb/db` backend, a complete in-memory implementation, secure OAuth login/linking flows, role-based authorization, reusable management controllers and stronger session/token handling. + +**Do not upgrade an existing 0.1.x application in place without a migration plan.** The former PDO-backed class, tables and controller paths are incompatible with v0.2.0. + +Read the [upgrade guide](UPGRADING-0.2.0.md) and [full changelog](../CHANGELOG.md) before upgrading. + +## Highlights + +- **Provider architecture:** abstract `Objectiveweb\Auth`, database-backed `Objectiveweb\Auth\DBAuth` and `BasicAuth` for small/in-memory deployments. +- **Portable data model:** independent user and provider-credential records; UUID, roles, user-role memberships, optional delegations, session context callbacks and normalized `Collection` queries. +- **Password and credential protection:** generic invalid-login responses, sanitized public users, expiring SHA-256 reset/verification token digests, atomic single-use redemption, CSRF-protected authenticated password changes and account suspension checks. +- **OAuth hardening:** configured HTTPS callback URLs, PKCE S256, bound one-time state, safe provider-profile handling and **explicit account linking** without email-based account takeover. +- **First-time OAuth choice:** unknown OAuth users choose to create an account or sign in with an existing email/password before any local account is created or linked; example frontend included. +- **Admin API:** user lifecycle and credential management, role constraints, safe sorting/pagination and protection of the last active administrator. +- **Validation:** PHP 8.2–8.5; SQLite; PostgreSQL 16 migrations; MySQL 8.0/8.4 migrations and DBAuth scenarios; real League Google/Facebook provider implementations with mocked network; Composer `--no-dev` and isolated consumer installation. + +## Required changes for existing applications + +1. Upgrade to **PHP 8.2+** and `objectiveweb/db ^0.8.0`. The 0.1.x concrete `new Auth($pdo, ...)` API is removed; instantiate `DBAuth(new DB(...))` or `BasicAuth`. +2. Provision the **new user/credentials schema** and manually map legacy `ow_auth` users and their login IDs. There is **no automatic 0.1.x data migration**. +3. Replace legacy `Objectiveweb\Auth\AuthController` routes with `Objectiveweb\Auth\Controller\AuthController` and `UserController`; update role enforcement, JSON/CSRF requirements, and `Collection` response handling. +4. Invalidate legacy plaintext password-reset links and old sessions, then confirm new reset, registration and login flows in staging. +5. Explicitly configure an OAuth `redirectUri` for each provider and integrate the OAuth account-choice and consent-based linking routes if OAuth is enabled. +6. Configure **external authentication rate limiting** at the reverse proxy, WAF, gateway or middleware before making endpoints public. + +### New application installation + +```sh +composer require 'objectiveweb/auth:^0.2' +``` + +> The above command is for **after v0.2.0 is published**. The pre-release CI verifies the candidate through a separate Composer path-based consumer project without falsely assuming Packagist publication. + +```php + 'token', 'disabled_at' => 'disabled_at']); +``` + +Apply the bundled **Phinx migrations** or an equivalent checked schema before constructing an application with persistent users. Provider implementations and the router integration must be included by consuming applications that use those features. + +## Security and deployment notes + +- A matching OAuth email does **not** imply ownership of an existing account; use explicit, authenticated linking. The pending-account choice expires after ten minutes. +- Runtime PHP sessions and secure cookie settings are the consuming application's responsibility; use HttpOnly, Secure and SameSite where appropriate. +- Reset/verification tokens are one-time and should be delivered only out-of-band; provide the application's delivery callbacks. +- `managed_relations` is not part of the generic Auth-owned persistence model; application services own business-specific authorization assignments. +- `register_scope`, protected admin routes, CSRF, gateway throttling and deployed OAuth callback URLs must be reviewed per application. + +## Deferred to later versions + +Rate limiting ([#2](https://github.com/objectiveweb/auth/issues/2)), OAuth2/OIDC server and JWT/JWKS ([#3](https://github.com/objectiveweb/auth/issues/3)), passkeys/TOTP/MFA ([#4](https://github.com/objectiveweb/auth/issues/4)), and refresh-token/device-session management ([#5](https://github.com/objectiveweb/auth/issues/5)) are **not** delivered by v0.2.0. + +## Release operator checklist + +- [ ] Review and approve [PR #1](https://github.com/objectiveweb/auth/pull/1), including this changelog and upgrade guide. +- [ ] Confirm all mandatory CI and release-verification checks pass for the **final** PR head/merge commit: PHP 8.2–8.5, MySQL 8.0/8.4, PostgreSQL 16, OAuth and production installation. +- [ ] Verify package metadata and `composer validate --strict`, and check that the chosen tag points to the exact merged commit. +- [ ] Confirm production applications have migration, backup/rollback, session, callback, OAuth redirect and rate-limit plans. +- [ ] Merge the PR, create the `v0.2.0` tag and publish release notes **only after** those checks; do not imply this document itself performs a release. +- [ ] Confirm Composer/Packagist can resolve the newly published tag (if using Packagist), and validate a clean consumer installation. + +Full reference: [README](../README.md) · [Changelog](../CHANGELOG.md) · [Upgrade guide](UPGRADING-0.2.0.md). diff --git a/docs/UPGRADING-0.2.0.md b/docs/UPGRADING-0.2.0.md new file mode 100644 index 0000000..3ddcdbd --- /dev/null +++ b/docs/UPGRADING-0.2.0.md @@ -0,0 +1,146 @@ +# Upgrading objectiveweb/auth from 0.1.x to 0.2.0 + +> **Breaking rewrite — manual migration required.** There is no automatic schema/data converter or compatibility adapter from 0.1.x. Do not deploy v0.2.0 against the old `ow_auth` table and assume that existing users will work. Migrate a copy of your application first, and plan a coordinated code + schema rollout. + +## Before you start + +- Confirm the new runtime can run **PHP 8.2+** and install `objectiveweb/db ^0.8.0`. +- Review every caller of `Objectiveweb\Auth`, the old `Objectiveweb\Auth\AuthController`, the old HAL-style `query()` response and legacy exception classes. +- Inventory existing `ow_auth` columns, password hashes, primary IDs, external foreign keys, username/email semantics, sessions, outstanding password reset links and application permission tables. +- Prepare a current **restorable backup** of both the database and application configuration. Use a staging clone and run the migration with writes paused or with a proven incremental catch-up strategy. +- Decide which legacy user IDs must be retained to preserve external FK references. Verify there are no duplicate login identifiers in the new `(uid, provider)` namespace. +- Plan a rollback point. The old and new user models are not schema-compatible; rolling back code alone after new writes is unsafe. + +## 1. Change application code and dependencies + +| In 0.1.x | In 0.2.0 | Required action | +| --- | --- | --- | +| PHP >=5.3, concrete `Objectiveweb\Auth` | PHP >=8.2, abstract `Objectiveweb\Auth` | Update PHP/dependencies, instantiate an implementation | +| `new Auth($pdo, $params)` | `new DBAuth(new DB($dsn, ...), $params)` | Replace direct PDO construction with `objectiveweb/db` | +| One `ow_auth` table, `username` stored on user row | `user` + `user_credentials`, `(provider, uid)` identity | Create schema and import old identities | +| `Auth::get($username)` / `update($username, ...)` by inferred username | `DBAuth::get($id)`, `update($id, ...)` by user ID (or explicit alternate key where supported) | Resolve the credential first or use retained user IDs | +| `Auth::query()` returns `_embedded` + page arrays | Returns `Objectiveweb\DB\Collection` | Rewrite list controllers and pagination rendering | +| `Objectiveweb\Auth\AuthController` (old class) | `Objectiveweb\Auth\Controller\AuthController` and `UserController` | Update imports, routes, authorization and write payloads | +| `PasswordMismatchException` / distinct unknown-user response | `AuthException('Invalid credentials', 401)` for password login | Update exception handling without leaking which condition failed | +| 0.1.x plaintext password-reset tokens | Expiring, one-use SHA-256 token digests | Invalidate old links and issue new ones | +| Legacy session principal format | Sanitized principal with credentials, roles and optional context | Expire old sessions and require a fresh login | +| Implicit application assignments in Auth-managed relations | Application-owned relationships and `user_context_callback` | Move write/validation logic to application services | + +Minimal setup for a new application database: + +```php + 'token', + 'disabled_at' => 'disabled_at', +]); +``` + +Install/upgrade the package when the migrated schema is ready: + +```sh +composer require 'objectiveweb/auth:^0.2' +``` + +Until v0.2.0 is published, the package cannot be installed from a public release tag; use PR/test environments rather than changing production prematurely. If your application uses the packaged router controllers, add the compatible `objectiveweb/router` integration explicitly. The bundled Phinx package and OAuth provider implementations are optional; include them in the **application** deployment/build environment if used. + +## 2. Provision and inspect the new schema + +The bundled Phinx migrations in `db/` create the **default logical tables**: + +| Table | Meaning | +| --- | --- | +| `user` | User IDs, unique UUID, optional bcrypt password, name/image, password reset token digest/expiry, suspension timestamp, created timestamp | +| `user_credentials` | Unique `(uid, provider)`, `user_id` foreign key, provider profile, last login, token digest, `token_expires_at`, `verified_at` | +| `role` / `user_roles` | Global roles and membership | +| `delegations` | Optional application-configured authorization relationship | + +Apply all bundled migrations **in timestamp order**, including +`20261019210000_auth_credential_verification_migration.php`. The last migration adds credential-level `token_expires_at` and `verified_at`; it does **not** migrate legacy users. + +Point your application's Phinx configuration to the installed package's `db/` directory (or copy those migrations into your application's migration plan). The repository's `test/phinx.php` is for integration tests, **not** a production deployment configuration. + +If the application already has conflicting table names or uses prefixes, configure explicit table mappings in `DBAuth` and migrate into a compatible schema. Check foreign keys, collations and column sizes on your target engine; CI covers SQLite, PostgreSQL 16, MySQL 8.0 and MySQL 8.4 but cannot validate an arbitrary application's existing schema. + +## 3. Migrate legacy user data explicitly + +The 0.1.x default table was `ow_auth`, with fields such as `id`, `username`, `password`, `created`, `last_login` and optionally `token`, plus any application-specific fields. + +A **migration script that you write and test** should apply the following mapping. These are mapping rules, not an executable generic migration: actual schemas and data constraints vary. + +| Old value | New value | Rule | +| --- | --- | --- | +| `ow_auth.id` | `user.id` | Preserve the numeric ID if other tables reference it, otherwise build and persist an old-to-new ID map | +| `ow_auth.password` | `user.password` | Retain valid bcrypt hash **as-is**; do not double-hash it, decrypt it or replace it with a plaintext password | +| `ow_auth.username` | `user_credentials.uid` | Use `provider = 'local'`; do not assume that the username is necessarily an email address | +| Old username/password identity | `user_credentials.user_id` | Link to exactly one migrated user; enforce uniqueness of `(uid, provider)` | +| Existing display name/image | `user.name` / `user.image` | Map approved application-specific columns explicitly | +| `ow_auth.created` | `user.created` | Preserve if valid; otherwise select a documented import value | +| New UUID | `user.uuid` | Generate a **unique, valid UUID v4** for each migrated user; the old model did not have this column by default | +| `ow_auth.last_login` | `user_credentials.last_login` | Map only if it represents that credential's login; otherwise leave nullable | +| `ow_auth.token` | **do not migrate** | Revoke outstanding legacy password-reset links; set `user.token` and `token_expires_at` to `NULL` | +| `user_credentials.verified_at` | Initially `NULL` unless independently proven | Never mark legacy email/phone/OAuth ownership verified solely because a matching address was stored | +| Existing roles and relations | `role`, `user_roles` or application tables | Review grants and target IDs; do not silently grant `admin` to a legacy user | + +**Data migration order:** import user rows; verify ID/UUID uniqueness; import linked `local` credentials; migrate or remap application foreign keys; import the intended role memberships; migrate any Auth-owned delegations; then run validation queries. Do this in a controlled transaction where the database/schema permits it, and stop deployment on conflicts. + +**Recovery verification during migration:** Anonymous password recovery requires `user_credentials.verified_at` for every recovery provider, including `local`. Imported local credentials with `verified_at = NULL` can still use password login but cannot initiate anonymous recovery until the recovery address is independently verified. Preserve evidence of verified contacts during migration; do not assume that knowing an old password or storing an email address verifies ownership. Provide an out-of-band verification flow for new and legacy accounts, or use a trusted administrator-initiated invitation/reset delivered over an already trusted channel. + +Preserving an existing bcrypt hash allows a password user to sign in without knowing the plaintext. But a legacy username like `jdoe` will not automatically work with the new public registration endpoint, which requires an email-formatted UID. Choose and document your application's identity/registration strategy before launch. Password login itself checks configured `login_providers` (default `['local', 'email']`). + +If you preserve numeric `id` values, also reset the auto-increment/identity sequence for the migrated user table above the maximum imported ID before any new registration. Migrate and verify application-side FKs before enabling deletes, and confirm the DBAuth default table names or configured aliases resolve to the intended physical tables. + +## 4. Update requests, controllers and authorization + +- Replace old `Objectiveweb\Auth\AuthController` routes with `Objectiveweb\Auth\Controller\AuthController` for authentication and `UserController` for admin user management. +- Public registration accepts an email UID, an optional password and safe `name`/`image` profile fields, not arbitrary `provider` or role grants. +- Privileged management operations require the **admin** role. Seed valid roles before accepting role assignments; the management API rejects unknown roles. +- Management writes and authenticated password changes use **JSON**, the authenticated PHP session and `X-CSRF-Token` from `AuthController::index()` (`_csrf`). Authenticated password changes also require `current_password`. +- Replace HAL queries such as `$result['_embedded']['ow_auth']` with iteration over a `Collection` and `$collection->total()` / `$collection->contentRange()`. +- Replace old `PasswordMismatchException` handlers with `AuthException` and a generic 401 response for invalid password login. +- Update any direct password/credential/token calls to use **numeric user IDs** and the new `(provider, uid)` credential APIs. `passwd()` is a trusted service/admin operation; use `postPassword()` for end-user password changes. +- If using `managed_relations` in earlier development builds, migrate their reads/writes/validation to application services and use `user_context_callback` for refreshed session context. Auth does not automatically persist or remove application-owned association rows. A configured `relations` entry opts into deletion cleanup only when explicitly marked `auth_owned => true` (for genuinely Auth-owned tables such as bundled `delegations`); do not enable it for application tables. + +## 5. Deploy OAuth changes deliberately + +- Install each optional League OAuth client implementation you use, e.g. `league/oauth2-google` or `league/oauth2-facebook`, in the application; they are suggestions, not mandatory runtime dependencies. +- Configure an exact registered `redirectUri` for every provider. It must use HTTPS, except explicitly configured HTTP on `localhost` / loopback for development. Request-derived Host/proxy callback URLs are no longer accepted. +- OAuth uses PKCE S256 and expiring single-use provider/session-bound state. Maintain secure, HttpOnly, SameSite browser sessions. +- A provider identity that has never been seen before does **not** create a local account automatically. Your application must serve the pending-choice page and wire `getPendingAccount()`, `postCreateAccount()`, `postExistingAccount()`, `postCancelPendingAccount()`. See [account-choice example](../examples/oauth-account-choice.html). +- Existing signed-in users link OAuth identities only via an authenticated, CSRF-protected `postLink($provider)` POST; the frontend navigates to its returned `authorization_url`. Normal OAuth login never silently links by email. +- Provider display profile/email values are **hints**, never proof that two accounts belong together. + +## 6. Configure production security and callback delivery + +- **Enable external rate limiting** for login, registration, recovery, reset redemption and credential verification at the gateway/WAF/reverse proxy or middleware. There is intentionally no application-DB rate-limit counter in 0.2.0; see [#2](https://github.com/objectiveweb/auth/issues/2). +- Set `register_scope`, `token`, `token_ttl`, `token_callback`, `invitation_callback`, `reset_callback`, and `disabled_at` appropriately. The package cannot deliver email/SMS itself. +- Back up data, expire old auth sessions and clear legacy password-reset links during cutover. Require fresh login; the legacy session payload and credentials cannot be trusted as a v0.2.0 principal. +- Keep user secrets, credential profiles and tokens out of logs, analytics, public APIs and generic error pages. +- Validate application-specific roles, relationships and callbacks on staging before allowing privileged operations. + +## 7. Verify the rollout + +Before merging/upgrading, check: + +- [ ] PHP >=8.2 and correct Composer runtime/integration dependencies; `composer validate --strict` passes. +- [ ] A clean install with `--no-dev` and the standalone production smoke test passes; PR checks cover this. +- [ ] The target migration schema includes both credential verification columns; indexes and foreign keys validate. +- [ ] Old/new user counts match; preserved IDs and all application foreign keys resolve correctly. +- [ ] Every old password identity maps to exactly one `local` credential and bcrypt login works. +- [ ] Old password-reset tokens are invalid; new reset and credential-specific verification tokens are one-time and expire. +- [ ] Anonymous recovery works only through independently verified credentials; imported local accounts with unverified recovery addresses have a documented verification or trusted-admin recovery process. +- [ ] List/search pagination, role assignment and active-admin safeguards behave as expected. +- [ ] Existing sessions are invalidated; password changes require current password and CSRF. +- [ ] OAuth registered redirect URIs, first-time account choice, existing-account linking, returning-login and link-conflict cases work with **your** providers. +- [ ] External rate limits and secure session cookies are configured and tested. +- [ ] Backups, restore procedure, cutover maintenance window and rollback criteria are agreed. + +**Rollback:** Restore a consistent snapshot of the original 0.1.x database and code together, including application tables and session strategy. Do not simply point 0.1.x code at the 0.2.0 schema, and do not expect new accounts/credentials to survive a rollback unless you build a separate reverse-conversion process. + +For complete API documentation, see [README](../README.md); for the feature summary, see [CHANGELOG](../CHANGELOG.md). diff --git a/examples/oauth-account-choice.html b/examples/oauth-account-choice.html new file mode 100644 index 0000000..a0c7a2e --- /dev/null +++ b/examples/oauth-account-choice.html @@ -0,0 +1,128 @@ + + + + + + Choose how to continue + + + +
+

Continue with your account

+

Checking the authentication result…

+

+ +
+ + + diff --git a/src/Auth.php b/src/Auth.php index c619389..286e4de 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -2,54 +2,60 @@ namespace Objectiveweb; -use Objectiveweb\Auth\UserException; -use Objectiveweb\Auth\PasswordMismatchException; +use Objectiveweb\DB\Collection; -use PDO; - -class Auth +abstract class Auth { - private $params; - /** @var \PDO */ - private $pdo; + // Base access markers + const ANONYMOUS = ['anon']; + const AUTHENTICATED = ['auth']; + const ALL = ['anon', 'auth']; - const SELECT_ALL = "SELECT * FROM `%s` where `%s` = %s"; - const SELECT_SEARCH = "SELECT SQL_CALC_FOUND_ROWS * FROM `%s` WHERE %s"; - const REGISTER_QUERY = "INSERT INTO `%s` (%s) VALUES (%s)"; - const RESET_PASSWORD = 'UPDATE `%s` SET `%s` = %s, `%s` = NULL WHERE %s = %s'; - const UPDATE_QUERY = "UPDATE `%s` SET %s WHERE `%s` = %s"; - const UPDATE_VALUE = "UPDATE `%s` SET `%s` = %s WHERE `%s` = %s"; - const USER_BY_NAME = "SELECT `%s` FROM `%s` WHERE `%s` = %s"; - const DELETE_QUERY = "DELETE FROM `%s` WHERE `%s` = %s LIMIT 1"; + public array $params; - public static function hash($password = null) - { - if (!$password) { - // return a random token - return md5(microtime(true)); - } - - return password_hash($password, PASSWORD_BCRYPT); - } + // Fixed valid bcrypt hash for uniform verification of unknown accounts. + private const DUMMY_PASSWORD_HASH = '$2y$10$p7uZwkJNF9lGxXQ8ubwbEe.Ud1mjaN7gTrU59hKvMolizfH0cO0qa'; - public function __construct(\PDO $pdo, $params = array()) + function __construct(array $params) { - $defaults = array( + $defaults = [ 'session_key' => 'ow_auth', - 'table' => 'ow_auth', 'id' => 'id', - 'username' => 'username', 'password' => 'password', - 'token' => NULL, - 'created' => NULL, - 'last_login' => NULL - ); - - $this->pdo = $pdo; + 'roles' => 'roles', + 'login_providers' => ['local', 'email'], + 'token' => NULL, // Name of the field that should store user tokens + 'token_expires_field' => 'token_expires_at', + 'token_ttl' => 3600, + 'credential_token_ttl' => 3600, + 'credential_token_providers' => ['local', 'email', 'phone'], + 'register_scope' => Auth::ANONYMOUS, // who is allowed to use /register + 'register_allow_grants' => false, + 'recovery_providers' => ['local', 'email', 'phone'], + 'register_callback' => null, + 'token_callback' => null, + 'invitation_callback' => null, + 'reset_callback' => null, + 'audit_callback' => null, + 'deletion_guard_callback' => null, + 'disabled_at' => null, + 'user_context_callback' => null, + 'management_csrf' => true, + 'management_csrf_session_key' => 'ow_auth_management_csrf', + ]; $this->params = array_merge($defaults, $params); + } + + public static function hash($password = null) + { + if ($password === null) { + // return a random token + return bin2hex(random_bytes(16)); + } + return \password_hash($password, PASSWORD_BCRYPT); } /** @@ -60,234 +66,296 @@ public function check() return !empty($_SESSION[$this->params['session_key']]); } - /** - * Queries the Auth table - */ - public function query($params = array(), $operator = "OR") { - - $page = intval(@$params['page']); - $size = intval(@$params['size']); - $sort = @$params['sort']; - - unset($params['page']); - unset($params['size']); - unset($params['sort']); - - if(!$size) $size = 20; - - $cond = array(); - $bindings = array(); - - foreach($params as $key => $value) { - $cond[] = sprintf("`%s` %s :where_%s", - str_replace('`', '``', $key), - is_null($value) ? 'is' : (strpos($value, '%') !== FALSE ? 'LIKE' : '='), - $key); - $bindings[":where_$key"] = $value; - } - - $query = sprintf(self::SELECT_SEARCH, - $this->params['table'], - empty($cond) ? '1=1' : implode(" $operator ", $cond) - ); - - $query .= sprintf(" LIMIT %d, %d", $page * $size, $size); - - $stmt = $this->pdo->prepare($query); - - $stmt->execute($bindings); - - if(!$stmt) { - $error = $this->pdo->errorInfo(); - - throw new \Exception($error[2]); - } - - $data = $stmt->fetchAll(PDO::FETCH_ASSOC); - - $stmt = $this->pdo->query("SELECT FOUND_ROWS() as count"); - - $count = $stmt->fetch(PDO::FETCH_ASSOC); - - if(!$count) { - throw new \Exception("Error fetching count"); - } - - $count = intval($count['count']); - - return array( - '_embedded' => array( - $this->params['table'] => $data - ), - 'page' => array( - 'size' => $size, - 'number' => $page, - 'totalElements' => $count, - 'totalPages' => ceil($count/$size) - ) - ); - } - - /** - * Retrieves a user from the database - * @param $username username - * @return array user data - * @throws UserException - * @throws Exception - */ - public function get($username) { + public function is_active(array $user): bool + { + $field = $this->params['disabled_at']; + return !is_string($field) || $field === '' || empty($user[$field]); + } + + public function sanitize_user(array $user): array + { + foreach (['password', 'token', 'token_expires_field'] as $param) { + $field = $this->params[$param] ?? null; + if (is_string($field) && $field !== '') { + unset($user[$field]); + } + } + return $user; + } - if(is_numeric($username)) { - $key = 'id'; + public function establish_session(array $user): array + { + if (!$this->is_active($user)) { + throw new Auth\AuthException('Account suspended', 403); } - else { - $key = 'username'; + + $this->regenerateSessionId(); + $this->user($this->sanitize_user($user)); + + return $this->user(); + } + + protected function regenerateSessionId(): void + { + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + } + + /** + * Reload the session principal so role and lifecycle changes take effect on + * the very next protected request. + */ + public function revalidate(): bool + { + if (!$this->check()) { + return false; } - $query = sprintf(self::SELECT_ALL, - $this->params['table'], - $this->params[$key], - $this->pdo->quote($username)); - - $stmt = $this->pdo->query($query); - - if(!$stmt) { - $error = $this->pdo->errorInfo(); - - throw new \Exception($error[2]); - } - - if($user = $stmt->fetch(PDO::FETCH_ASSOC)) { - return $user; + try { + $user = $this->reload(); + } catch (\Throwable) { + $this->logout(); + return false; } - else { - throw new UserException('User not found', 404); + + if (!$this->is_active($user)) { + $this->logout(); + return false; } + + return true; } /** - * @param $username - * @param $password - * @throws UserException - * @throws PasswordMismatchException + * Verify an authenticated user's existing password using this provider's + * raw user record. Providers with a different password backend may override + * this method. + * + * Passwordless / OAuth-only accounts must use a verified reset or + * reauthentication flow; an active session alone cannot set a password. */ - public function &login($username, $password) + public function verify_password(mixed $userId, string $password): bool { + $user = $this->get($userId); + $field = (string) $this->params['password']; + $hash = $user[$field] ?? null; - $user = $this->get($username); + if (!is_string($hash) || $hash === '' || password_get_info($hash)['algoName'] === 'unknown') { + throw new Auth\AuthException('Password reset or reauthentication required', 403); + } - if(\password_verify($password, $user[$this->params['password']])) { + return password_verify($password, $hash); + } - unset($user[$this->params['password']]); + public function management_csrf_token(): string + { + $key = (string) $this->params['management_csrf_session_key']; + if (empty($_SESSION[$key]) || !is_string($_SESSION[$key])) { + $_SESSION[$key] = bin2hex(random_bytes(32)); + } - $_SESSION[$this->params['session_key']] = $user; + return $_SESSION[$key]; + } + + public function audit(string $action, int|string|null $targetUserId, array $metadata = []): void + { + $callback = $this->params['audit_callback']; + if (!is_callable($callback)) { + return; + } + + $actorId = null; + if ($this->check()) { + $actorId = $this->user()[$this->params['id']] ?? null; + } + call_user_func($callback, $actorId, $targetUserId, $action, $metadata); + } + + public function invite(int|string $userId, bool $reset = false): void + { + $user = $this->sanitize_user($this->get($userId)); + $token = $this->update_token($userId); + $credentials = $this->get_credentials($userId); + $callback = $this->params[$reset ? 'reset_callback' : 'invitation_callback'] + ?? ($reset ? $this->params['token_callback'] : $this->params['register_callback']); + + if (is_callable($callback)) { + call_user_func($callback, $user, $credentials, $token); + } + } + + public function user_can(string $ability, mixed ...$args): bool|array + { + if (count($args) === 0) { + // Permission decisions must not trust roles or account status cached + // at login. Revalidation also logs out deleted or suspended users. + if (!$this->revalidate()) { + return false; + } + + $user = $this->user(); + $roleField = $this->params['roles']; + $roles = is_array($user[$roleField] ?? null) ? $user[$roleField] : []; - if($this->params['last_login']) { - $query = sprintf(self::UPDATE_VALUE, - $this->params['table'], - $this->params['last_login'], - 'NOW()', - $this->params['id'], - $user[$this->params['id']]); + return in_array($ability, $roles, true); + } + + $mode = null; + if (count($args) === 1 && is_int($args[0])) { + $mode = 'user_relation'; + } elseif (count($args) === 1 && is_string($args[0])) { + $mode = 'resource_list'; + } elseif (count($args) === 2 && is_string($args[0]) && is_int($args[1])) { + $mode = 'resource_relation'; + } + + if ($mode === null) { + throw new \InvalidArgumentException('Invalid user_can() signature'); + } + + // Revalidate relation checks too: a stale session must not retain + // access to associations after the account is disabled or removed. + if (!$this->revalidate()) { + return $mode === 'resource_list' ? [] : false; + } - $this->pdo->query($query); + $subjectId = $this->currentUserId(); + if ($subjectId === null) { + if ($mode === 'resource_list') { + return []; } - return $_SESSION[$this->params['session_key']]; + return false; } - else { - throw new PasswordMismatchException(); + + if ($mode === 'user_relation') { + return $this->userCanRelation($subjectId, 'user', $args[0], $ability); + } + + if ($mode === 'resource_list') { + return $this->userCanRelationList($subjectId, $args[0], $ability); } + return $this->userCanRelation($subjectId, $args[0], $args[1], $ability); } - /** - * Logs out the current user (unsets the session key) - */ - public function logout() + private function currentUserId(): ?int { - unset($_SESSION[$this->params['session_key']]); + $idField = $this->params['id']; + $user = $this->user(); + $id = $user[$idField] ?? null; + if (is_int($id)) { + return $id; + } + + if (is_string($id) && ctype_digit($id)) { + return (int) $id; + } + + return null; } + protected function userCanRelation(int $subjectId, string $resourceType, int $resourceId, string $ability): bool + { + return false; + } + protected function userCanRelationList(int $subjectId, string $resourceType, string $ability): array + { + return []; + } /** - * @param string $username - * @param string $password - * @param array $data associative array of additional columns to store + * Validate local login credentials without changing the authenticated session + * or recording a login. OAuth account linking uses this before committing + * the external identity to the account. + * + * @return array{user: array, provider: string} */ - public function register($username, $password = null, $data = array()) + public function authenticate($uid, $password): array { - if(is_array($username)) { - $data = $username; - - $username = @$data[$this->params['username']]; - unset($data[$this->params['username']]); - - $password = @$data['password']; - unset($data['password']); - } - - $fields = array(); - - // escape fields - foreach($data as $k => $v) { - $fields[str_replace(array('\\',"\0" ,'`'), '', $k)] = $this->pdo->quote($v); - } - - if(empty($username) || empty($password)) { - throw new \Exception("Por favor informe usuário e senha"); - } - - $fields[$this->params['username']] = $this->pdo->quote($username); - $fields[$this->params['password']] = $this->pdo->quote(self::hash($password)); - if($this->params['token']) { - $fields[$this->params['token']] = $this->pdo->quote(self::hash()); + $credential = false; + $provider = null; + foreach ($this->params['login_providers'] as $candidateProvider) { + $candidateCredential = $this->get_credential($candidateProvider, $uid); + if (!$candidateCredential) { + continue; + } + $credential = $candidateCredential; + $provider = $candidateProvider; + break; } - if($this->params['created']) { - $fields[$this->params['created']] = 'NOW()'; + // Verify against a dummy hash for unknown or passwordless accounts. + // Invalid usernames, passwords and suspended users share one error. + $user = $credential ? $this->get($credential['user_id']) : null; + $stored = is_array($user) ? ($user[$this->params['password']] ?? null) : null; + $hash = is_string($stored) && $stored !== '' + ? $stored : self::DUMMY_PASSWORD_HASH; + $valid = \password_verify(is_string($password) ? $password : '', $hash); + if (!$credential || !$valid || !$this->is_active($user)) { + throw new Auth\AuthException('Invalid credentials', 401); } - $query = (sprintf(self::REGISTER_QUERY, - $this->params['table'], - implode(", ", array_keys($fields)), - implode(", ", array_values($fields)) - )); + return ['user' => $user, 'provider' => (string) $provider]; + } - if ($this->pdo->query($query)) { + public function &login($uid, $password) + { + $authenticated = $this->authenticate($uid, $password); + $user = $this->establish_session($authenticated['user']); + $this->update_credential( + $user[$this->params['id']], $authenticated['provider'], $uid, null + ); + return $user; + } - $fields[$this->params['id']] = $this->pdo->lastInsertId(); - unset($fields[$this->params['created']]); + /** + * Logs out the current user (unsets the session key) + */ + public function logout() + { + unset($_SESSION[$this->params['session_key']]); + } - return $fields; - } else { - $errorInfo = $this->pdo->errorInfo(); + /** + * Reloads current user from the backend + */ + public function reload() + { + $user = $this->user(); + $user = $this->get($user[$this->params['id']], $this->params['id']); - if($errorInfo[1] == 1062) { - throw new \Exception(sprintf("User %s already exists", $username), 409); - } - else { - throw new \Exception($errorInfo[2]); - } - } + return $this->user($user); } /** * Returns the current logged in user or sets the current login data * @param $user array - * @return array user data + * @return array user data, sets a new user on session if * @throws UserException if noone is logged in */ public function &user($user = null) { - if($user) { + if ($user) { + $user = $this->sanitize_user($user); + $callback = $this->params['user_context_callback']; + if (is_callable($callback)) { + // Context is derived by the application, never trusted from + // user-supplied fields or an earlier session snapshot. + unset($user['context']); + $context = call_user_func($callback, $user); + if (!is_array($context)) { + throw new \UnexpectedValueException('user_context_callback must return an array'); + } + $user['context'] = $context; + } $_SESSION[$this->params['session_key']] = $user; - } - else { - if(!$this->check()) { - throw new UserException('Not logged in', 403); + } else { + if (!$this->check()) { + throw new Auth\UserException('Not logged in', 403); } } @@ -295,176 +363,163 @@ public function &user($user = null) return $_SESSION[$this->params['session_key']]; } - /** - * - * Update a user's password - * - * @param $username - * @param $password - * @return bool TRUE on success - * @throws UserException if no rows were updated - */ - public function passwd($username, $password) + public function validate($user) { + $uid = trim((string) ($user['uid'] ?? '')); - if(is_numeric($username)) { - $key = 'id'; + if (empty($uid)) { + throw new \Exception("Missing uid", 400); } - else { - $key = 'username'; + + $user['uid'] = $uid; + + if (isset($user[$this->params['password']])) { + if (empty($user[$this->params['password']])) { + throw new \Exception("Missing password", 400); + } } - $query = sprintf(self::UPDATE_VALUE, - $this->params['table'], - $this->params['password'], - $this->pdo->quote(self::hash($password)), - $this->params[$key], - $this->pdo->quote($username)); + return $user; + } - $stmt = $this->pdo->query($query); + // - if($stmt === FALSE || $stmt->rowCount() !== 1) { - throw new UserException('User not found'); - } + abstract public function query($params = array(), $operator = "OR"): Collection; - return TRUE; + /** + * Retrieves a user from the database + * @param $user_id String user_id + * @param $key String which key to lookup (id, token) + * @return array user data + * @throws UserException + * @throws \Exception + */ + abstract public function get($user_id, $key = 'id'); - } + /** + * @param string $uid + * @param string $password + * @param array $data associative array of additional columns to store + */ + abstract public function register($uid, $password = null, $data = array()); /** * - * Reset a user's password + * Update a user's password * - * @param $token - * @param $password new password + * @param $user_id + * @param $password + * @param $key String * @return bool TRUE on success * @throws UserException if no rows were updated */ - public function passwd_reset($token, $password) - { - - $query = sprintf(self::RESET_PASSWORD, - $this->params['table'], - $this->params['password'], - $this->pdo->quote(self::hash($password)), - $this->params['token'], - $this->params['token'], - $this->pdo->quote($token)); - - - $stmt = $this->pdo->query($query); - - if($stmt === FALSE || $stmt->rowCount() !== 1) { - throw new UserException('Hash not found'); - } - - return TRUE; - - } + abstract public function passwd($user_id, $password, $key = 'id'); + /** + * + * Reset a user's password + * + * @param $token String + * @param $password String new password + * @return $user array user data on success + * @throws UserException if no rows were updated + */ + abstract public function passwd_reset($token, $password); /** * Update arbitrary user data - * @param $username + * @param $user_id * @param array $data associative array of data + * @param key String * @throws UserException if no rows were updated */ - public function update($username, array $data) { - $cond = array(); - unset($data[$this->params['id']]); - unset($data[$this->params['password']]); - - if($this->params['token']) { - unset($data[$this->params['token']]); - } - - if($this->params['created']) { - unset($data[$this->params['created']]); - } - - if($this->params['last_login']) { - unset($data[$this->params['last_login']]); - } - - - foreach($data as $k => $v) { - $cond[] = sprintf("`%s` = %s", str_replace(array('\\',"\0" ,'`'), '', $k), $this->pdo->quote($v)); - } - - - if(is_numeric($username)) { - $key = 'id'; - } - else { - $key = 'username'; - } + abstract public function update($user_id, array $data, $key = 'id'); - $query = sprintf(self::UPDATE_QUERY, - $this->params['table'], - implode(', ', $cond), - $this->params[$key], - $this->pdo->quote($username)); + abstract public function delete($user_id); - $stmt = $this->pdo->query($query); - - if($stmt === FALSE) { - throw new UserException(json_encode($this->pdo->errorInfo())); - } + /** + * Generates a new token for the user and update the database + * @param $user_id + * @return string new token + */ + abstract public function update_token($user_id); - } + /** + * Retrieves an account from credentials_table + * @param $provider String provider + * @param $userid String user_id + * @return mixed account data or null if account not found + * @throws \Exception + */ + abstract public function get_credential($provider, $accountid); - public function delete($username) { + /** + * List all credentials for a user. + * + * Returned rows are normalized to: + * - uid + * - provider + * - profile + * - last_login + * - created + */ + abstract public function get_credentials($user_id, $key = 'id'): array; - if(is_numeric($username)) { - $key = 'id'; - } - else { - $key = 'username'; - } + /** + * List all users that have the given role name. + * + * @param string $roleName + * @return array> + */ + abstract public function get_users_by_role($roleName): array; + /** + * List all available role names. + * + * @return array + */ + abstract public function get_roles(): array; - if($this->check()) { - $user = $this->user(); + /** + * Inserts a new account on $userid, or update the existing one + * + * @param $userid + * @param $provider + * @param $uid + * @param null $profile + * @return bool + * @throws \Exception + */ + abstract public function update_credential($userid, $provider, $uid, $profile = null); - if($user[$this->params[$key]] == $username) { - throw new \Exception("Cannot delete yourself!"); - } - } + abstract public function create_credential($userid, string $provider, string $uid, mixed $profile = null): array; - $query = sprintf(self::DELETE_QUERY, - $this->params['table'], - $this->params[$key], - $this->pdo->quote($username)); + abstract public function rename_credential( + $userid, + string $provider, + string $uid, + string $newProvider, + string $newUid + ): array; - $stmt = $this->pdo->query($query); + abstract public function delete_credential($userid, string $provider, string $uid): bool; - if($stmt === FALSE || $stmt->rowCount() !== 1) { - throw new \Exception(json_encode($this->pdo->errorInfo())); - } - } - /** - * Generates a new token for the user and update the database - * @param $username - * @return string new token + * Issue a one-time verification token for exactly one unverified credential. + * The returned secret must be delivered out of band by trusted application code. */ - public function update_token($username) { - $token = self::hash(); - - $query = sprintf(self::UPDATE_VALUE, - $this->params['table'], - $this->params['token'], - $this->pdo->quote($token), - $this->params['username'], - $this->pdo->quote($username)); + abstract public function issue_credential_token($userid, string $provider, string $uid): string; - $stmt = $this->pdo->query($query); + /** + * Redeem a token for one provider/UID; other credentials stay unchanged. + */ + abstract public function verify_credential_token(string $provider, string $uid, string $token): bool; - if($stmt === FALSE || $stmt->rowCount() !== 1) { - throw new UserException('Token not found'); - } + /** + * Trusted integration hook: use only after independent provider proof + * (for example, a validated OAuth callback for this provider and UID). + */ + abstract public function mark_credential_verified($userid, string $provider, string $uid): void; - return $token; - } -} \ No newline at end of file +} diff --git a/src/Auth/AuthController.php b/src/Auth/AuthController.php deleted file mode 100644 index 8357020..0000000 --- a/src/Auth/AuthController.php +++ /dev/null @@ -1,49 +0,0 @@ -auth = $auth; - } - - public function index() { - return $this->get(); - } - - public function get($params = array()) { - if(is_array($params)) { - return $this->auth->query($params); - } - else { - return $this->auth->get($params); - } - } - - public function post($data) { - - return $this->auth->register($data); - - } - - public function put($username, $data) { - - if(!empty($data['password'])) { - $this->auth->passwd($username, $data['password']); - } - - unset($data['password']); - - $this->auth->update($username, $data); - - return true; - } - - public function delete($username) { - $this->auth->delete($username); - } -} diff --git a/src/Auth/AuthException.php b/src/Auth/AuthException.php new file mode 100644 index 0000000..6ca2280 --- /dev/null +++ b/src/Auth/AuthException.php @@ -0,0 +1,7 @@ + 'basic_user', + ]; + parent::__construct(array_merge($defaults, $params)); + + foreach ($passwd as $uid => $password) { + if (is_array($password)) { + $row = $password; + $candidateUid = $row['uid'] ?? (is_string($uid) ? $uid : null); + if (empty($candidateUid)) { + continue; + } + $this->register($candidateUid, $row[$this->params['password']] ?? null, $row); + continue; + } + + if (!is_string($uid)) { + continue; + } + + $this->register($uid, (string) $password); + } + } + + public function query($params = array(), $operator = "OR"): Collection + { + $page = max(0, (int) ($params['page'] ?? 0)); + $size = max(1, (int) ($params['size'] ?? 20)); + $sort = (string) ($params['sort'] ?? ($this->params['id'] . ' ASC')); + $q = trim((string) ($params['q'] ?? '')); + $role = trim((string) ($params['role'] ?? '')); + $status = trim((string) ($params['status'] ?? '')); + unset($params['page'], $params['size'], $params['sort'], $params['q'], $params['role'], $params['status']); + + $matches = []; + foreach ($this->users as $row) { + if (!$this->matchesFilters($row, $params, $operator)) { + continue; + } + + $credentials = $this->get_credentials($row[$this->params['id']]); + if ($q !== '') { + $haystack = strtolower(implode(' ', array_merge( + [(string) ($row['name'] ?? ''), (string) ($row[$this->params['id']] ?? '')], + array_map(fn (array $credential): string => (string) $credential['uid'], $credentials) + ))); + if (!str_contains($haystack, strtolower($q))) { + continue; + } + } + $roles = is_array($row[$this->params['roles']] ?? null) ? $row[$this->params['roles']] : []; + if ($role === 'unassigned' && $roles !== []) { + continue; + } + if ($role !== '' && $role !== 'unassigned' && !in_array($role, $roles, true)) { + continue; + } + if ($status === 'active' && !$this->is_active($row)) { + continue; + } + if ($status === 'suspended' && $this->is_active($row)) { + continue; + } + $public = $this->publicUser($row); + $public['credentials'] = $credentials; + $matches[] = $public; + } + + [$sortField, $sortDirection] = array_pad(preg_split('/\s+/', trim($sort), 2), 2, 'ASC'); + usort($matches, static function (array $a, array $b) use ($sortField, $sortDirection): int { + $comparison = ($a[$sortField] ?? null) <=> ($b[$sortField] ?? null); + return strtoupper($sortDirection) === 'DESC' ? -$comparison : $comparison; + }); + + $total = count($matches); + $start = $page * $size; + $slice = array_slice($matches, $start, $size); + + return new Collection( + $slice, + $start, + $start + count($slice) - 1, + $total + ); + } + + public function get($user_id, $key = 'id') + { + $resolved = $this->resolveUserId($user_id, $key); + if ($resolved === null || !isset($this->users[$resolved])) { + throw new UserException("User `$user_id` not found", 404); + } + + return $this->users[$resolved]; + } + + public function &login($uid, $password) + { + return parent::login($uid, $password); + } + + public function register($uid, $password = null, $data = array()) + { + if (is_array($uid)) { + $data = array_merge($uid, $data); + $uid = $data['uid'] ?? null; + $password = $data[$this->params['password']] ?? null; + unset($data['uid'], $data[$this->params['password']]); + } + + if (empty($uid)) { + throw new UserException('Missing uid', 400); + } + + $provider = $data['provider'] ?? 'local'; + $profile = $data['profile'] ?? null; + unset($data['provider'], $data['profile']); + + if ($this->get_credential($provider, $uid)) { + throw new UserException("User `$uid` already exists", 409); + } + + $idField = $this->params['id']; + $userId = $data[$idField] ?? $this->nextId(); + + $record = $data; + $record[$idField] = $userId; + $record['uid'] = $uid; + if (is_string($this->params['disabled_at']) && $this->params['disabled_at'] !== '') { + $record[$this->params['disabled_at']] = $record[$this->params['disabled_at']] ?? null; + } + + if ($password !== null && $password !== '') { + $record[$this->params['password']] = $this->isPasswordHash($password) + ? $password + : self::hash($password); + } elseif (!array_key_exists($this->params['password'], $record)) { + $record[$this->params['password']] = null; + } + + if (!isset($record['name'])) { + $parts = explode('@', (string) $uid, 2); + $record['name'] = $parts[0]; + } + + $this->users[$userId] = $record; + $this->update_credential($userId, $provider, $uid, $profile); + $this->credentials[$provider][$uid]['last_login'] = null; + + return $this->publicUser($record); + } + + public function passwd($user_id, $password, $key = 'id') + { + $resolved = $this->resolveUserId($user_id, $key); + if ($resolved === null || !isset($this->users[$resolved])) { + throw new UserException('User not found', 404); + } + + $changes = [$this->params['password'] => self::hash($password)]; + $tokenField = $this->params['token']; + if (is_string($tokenField) && $tokenField !== '') { + $changes[$tokenField] = null; + $expiresField = $this->params['token_expires_field']; + if (is_string($expiresField) && $expiresField !== '') { + $changes[$expiresField] = null; + } + } + + // Apply password replacement and reset-token revocation together. + $this->users[$resolved] = array_replace($this->users[$resolved], $changes); + return true; + } + + public function passwd_reset($token, $password) + { + $tokenField = $this->params['token']; + if (empty($tokenField)) { + throw new UserException('Token support is disabled', 400); + } + + foreach ($this->users as $userId => $user) { + $hash = (string) ($user[$tokenField] ?? ''); + if ($hash === '' || !password_verify($token, $hash)) { + continue; + } + $expiresField = $this->params['token_expires_field']; + if ( + is_string($expiresField) + && $expiresField !== '' + && !empty($user[$expiresField]) + && strtotime((string) $user[$expiresField]) < time() + ) { + continue; + } + + $this->users[$userId][$this->params['password']] = self::hash($password); + $this->users[$userId][$tokenField] = null; + if (is_string($expiresField) && $expiresField !== '') { + $this->users[$userId][$expiresField] = null; + } + + return $this->publicUser($this->users[$userId]); + } + + throw new UserException('Hash not found', 404); + } + + public function update($user_id, array $data, $key = 'id') + { + $resolved = $this->resolveUserId($user_id, $key); + if ($resolved === null || !isset($this->users[$resolved])) { + throw new UserException('User not found', 404); + } + + unset($data[$this->params['id']], $data[$this->params['password']]); + if ($this->params['token']) { + unset($data[$this->params['token']]); + } + + foreach ($data as $field => $value) { + $this->users[$resolved][$field] = $value; + } + } + + public function delete($user_id) + { + $resolved = $this->resolveUserId($user_id, $this->params['id']); + if ($resolved === null || !isset($this->users[$resolved])) { + throw new UserException('User not found', 404); + } + + if ($this->check()) { + $current = $this->user(); + if (($current[$this->params['id']] ?? null) == $resolved) { + throw new \Exception('Cannot delete yourself!'); + } + } + + $guard = $this->params['deletion_guard_callback']; + if (is_callable($guard)) { + $result = call_user_func($guard, $resolved); + if ($result === false || is_string($result)) { + throw new UserException( + is_string($result) ? $result : 'User has application history', + 409 + ); + } + } + + $this->audit('user.deleted', $resolved); + unset($this->users[$resolved]); + foreach ($this->credentials as $provider => $records) { + foreach ($records as $uid => $credential) { + if (($credential['user_id'] ?? null) == $resolved) { + unset($this->credentials[$provider][$uid]); + } + } + if (empty($this->credentials[$provider])) { + unset($this->credentials[$provider]); + } + } + + return true; + } + + public function update_token($user_id) + { + $resolved = $this->resolveUserId($user_id, $this->params['id']); + if ($resolved === null || !isset($this->users[$resolved])) { + throw new UserException('Token not found', 404); + } + + $tokenField = $this->params['token']; + if (empty($tokenField)) { + throw new UserException('Token support is disabled', 400); + } + + $token = self::hash(); + $this->users[$resolved][$tokenField] = self::hash($token); + $expiresField = $this->params['token_expires_field']; + if (is_string($expiresField) && $expiresField !== '') { + $this->users[$resolved][$expiresField] = date( + 'Y-m-d H:i:s', + time() + max(60, (int) $this->params['token_ttl']) + ); + } + return $token; + } + + public function get_credential($provider, $accountid) + { + return $this->credentials[$provider][$accountid] ?? false; + } + + public function get_credentials($user_id, $key = 'id'): array + { + $user = $this->get($user_id, $key); + $resolvedUserId = $user[$this->params['id']] ?? null; + $result = []; + + foreach ($this->credentials as $provider => $records) { + foreach ($records as $uid => $credential) { + if (($credential['user_id'] ?? null) != $resolvedUserId) { + continue; + } + + $profile = $credential['profile'] ?? null; + if (is_string($profile) && $profile !== '') { + $decoded = json_decode($profile, true); + $profile = is_array($decoded) ? $decoded : $profile; + } + + if (is_array($profile) && isset($profile['_auth']) && is_array($profile['_auth'])) { + unset($profile['_auth']['token']); + } + + $result[] = [ + 'uid' => $uid, + 'provider' => $provider, + 'profile' => $profile, + 'verified_at' => $credential['verified_at'] ?? null, + 'last_login' => $credential['last_login'] ?? null, + 'created' => $credential['created'] ?? null, + ]; + } + } + + usort($result, function (array $a, array $b): int { + return [$a['provider'], $a['uid']] <=> [$b['provider'], $b['uid']]; + }); + + return $result; + } + + public function get_users_by_role($roleName): array + { + $roleField = $this->params['roles']; + $matches = []; + + foreach ($this->users as $user) { + $roles = $user[$roleField] ?? []; + if (!is_array($roles) || !in_array($roleName, $roles, true)) { + continue; + } + + $matches[] = $this->publicUser($user); + } + + return $matches; + } + + public function get_roles(): array + { + $roleField = $this->params['roles']; + $roles = []; + + foreach ($this->users as $user) { + $userRoles = $user[$roleField] ?? []; + if (!is_array($userRoles)) { + continue; + } + + foreach ($userRoles as $role) { + if (!is_string($role) || $role === '') { + continue; + } + $roles[$role] = true; + } + } + + $result = array_keys($roles); + sort($result, SORT_STRING); + return $result; + } + + public function update_credential($userid, $provider, $uid, $profile = null) + { + $existing = $this->get_credential($provider, $uid); + if ($existing && ($existing['user_id'] ?? null) != $userid) { + throw new UserException('Credential already registered', 409); + } + if (is_array($profile)) { + $profile = json_encode($profile); + } + + $created = $this->credentials[$provider][$uid]['created'] ?? date('Y-m-d H:i:s'); + $updates = [ + 'user_id' => $userid, + 'provider' => $provider, + 'uid' => $uid, + 'last_login' => date('Y-m-d H:i:s'), + 'created' => $created, + ]; + // A null profile means "leave unchanged", as in DBAuth. Password + // login passes null here and must not erase stored provider metadata. + if ($profile !== null) { + $updates['profile'] = $profile; + } + + $this->credentials[$provider][$uid] = array_merge([ + 'token' => null, + 'token_expires_at' => null, + 'verified_at' => null, + 'profile' => null, + ], $existing ?: [], $updates); + + return true; + } + + public function issue_credential_token($userid, string $provider, string $uid): string + { + if (!in_array($provider, (array) $this->params['credential_token_providers'], true)) { + throw new UserException('Provider does not support out-of-band verification', 400); + } + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + if (!empty($credential['verified_at'])) { + throw new UserException('Credential already verified', 409); + } + + $secret = bin2hex(random_bytes(32)); + $this->credentials[$provider][$uid]['token'] = hash('sha256', $secret); + $this->credentials[$provider][$uid]['token_expires_at'] = date( + 'Y-m-d H:i:s', + time() + max(60, (int) ($this->params['credential_token_ttl'] ?? 3600)) + ); + return $secret; + } + + public function verify_credential_token(string $provider, string $uid, string $token): bool + { + if (!in_array($provider, (array) $this->params['credential_token_providers'], true)) { + return false; + } + $credential = $this->get_credential($provider, $uid); + if ($token === '' || !$credential || !empty($credential['verified_at'])) { + return false; + } + $digest = $credential['token'] ?? null; + $expires = $credential['token_expires_at'] ?? null; + if (!is_string($digest) || !is_string($expires) + || $expires <= date('Y-m-d H:i:s') + || !hash_equals($digest, hash('sha256', $token))) { + return false; + } + + $this->credentials[$provider][$uid]['verified_at'] = date('Y-m-d H:i:s'); + $this->credentials[$provider][$uid]['token'] = null; + $this->credentials[$provider][$uid]['token_expires_at'] = null; + return true; + } + + public function mark_credential_verified($userid, string $provider, string $uid): void + { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + $this->credentials[$provider][$uid]['verified_at'] = ($credential['verified_at'] ?? null) ?: date('Y-m-d H:i:s'); + $this->credentials[$provider][$uid]['token'] = null; + $this->credentials[$provider][$uid]['token_expires_at'] = null; + } + + public function create_credential($userid, string $provider, string $uid, mixed $profile = null): array + { + $this->get($userid); + $provider = trim($provider); + $uid = trim($uid); + if ($provider === '' || $uid === '') { + throw new UserException('Provider and uid are required', 400); + } + if ($this->get_credential($provider, $uid)) { + throw new UserException('Credential already registered', 409); + } + $this->update_credential($userid, $provider, $uid, $profile); + $this->credentials[$provider][$uid]['last_login'] = null; + return $this->get_credential($provider, $uid); + } + + public function rename_credential( + $userid, + string $provider, + string $uid, + string $newProvider, + string $newUid + ): array { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + + $newProvider = trim($newProvider); + $newUid = trim($newUid); + if ($newProvider === '' || $newUid === '') { + throw new UserException('Provider and uid are required', 400); + } + + $duplicate = $this->get_credential($newProvider, $newUid); + if ( + ($provider !== $newProvider || $uid !== $newUid) + && $duplicate + ) { + throw new UserException('Credential already registered', 409); + } + + unset($this->credentials[$provider][$uid]); + if (isset($this->credentials[$provider]) && $this->credentials[$provider] === []) { + unset($this->credentials[$provider]); + } + + $credential['provider'] = $newProvider; + $credential['uid'] = $newUid; + if ($provider !== $newProvider || $uid !== $newUid) { + $credential['verified_at'] = null; + } + $credential['token'] = null; + $credential['token_expires_at'] = null; + $this->credentials[$newProvider][$newUid] = $credential; + + return $this->get_credential($newProvider, $newUid); + } + + public function delete_credential($userid, string $provider, string $uid): bool + { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + if (count($this->get_credentials($userid)) <= 1) { + throw new UserException('A user must have at least one credential', 409); + } + unset($this->credentials[$provider][$uid]); + return true; + } + + private function nextId(): int + { + if (empty($this->users)) { + return 1; + } + + $keys = array_keys($this->users); + return ((int) max($keys)) + 1; + } + + private function resolveUserId(mixed $value, string $key): int|string|null + { + if ($key === 'id' || $key === $this->params['id']) { + return $value; + } + + if ($key === 'uid') { + foreach ($this->params['login_providers'] as $provider) { + $credential = $this->get_credential($provider, $value); + if (!empty($credential['user_id'])) { + return $credential['user_id']; + } + } + + return null; + } + + foreach ($this->users as $id => $user) { + if (($user[$key] ?? null) == $value) { + return $id; + } + } + + return null; + } + + private function matchesFilters(array $row, array $filters, string $operator): bool + { + if (empty($filters)) { + return true; + } + + $matches = []; + foreach ($filters as $field => $value) { + $rowValue = $row[$field] ?? null; + if (is_string($value) && str_contains($value, '%')) { + $pattern = '/^' . str_replace('%', '.*', preg_quote($value, '/')) . '$/i'; + $matches[] = (bool) preg_match($pattern, (string) $rowValue); + continue; + } + $matches[] = ($rowValue == $value); + } + + if (strtoupper($operator) === 'AND') { + return !in_array(false, $matches, true); + } + + return in_array(true, $matches, true); + } + + private function publicUser(array $user): array + { + return $this->sanitize_user($user); + } + + private function isPasswordHash(string $value): bool + { + return password_get_info($value)['algo'] !== null; + } + +} diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php new file mode 100644 index 0000000..0b64627 --- /dev/null +++ b/src/Auth/Controller/AuthController.php @@ -0,0 +1,310 @@ +auth->check()) { + $user = $this->auth->user(); + $user['credentials'] = $this->auth->get_credentials($user[$this->auth->params['id']]); + $user['_csrf'] = $this->auth->management_csrf_token(); + return $user; + } + + return null; + } + + #[Middleware(RequireRole::class, Auth::AUTHENTICATED)] + function getLogout($params = []) + { + if (!$this->auth->check()) { + throw new AuthException('Forbidden', 401); + } + + $redirect = null; + if (array_key_exists('redirect', $params) && $params['redirect'] !== null && $params['redirect'] !== '') { + $redirect = $this->normalizeLogoutRedirect($params['redirect']); + } + + $this->auth->logout(); + + if ($redirect !== null) { + header('Location: ' . $redirect); + exit; + } + + return []; + } + + /** + * Logout redirects are intentionally limited to same-origin absolute paths. + * Scheme-relative URLs, backslashes and header control characters are + * rejected instead of being normalized by a browser into another origin. + */ + protected function normalizeLogoutRedirect(mixed $redirect): string + { + if (!is_string($redirect) || $redirect === '') { + throw new UserException('Invalid redirect', 400); + } + + if ( + $redirect[0] !== '/' + || str_starts_with($redirect, '//') + || str_contains($redirect, '\\') + || preg_match('/[\\x00-\\x1F\\x7F]/', $redirect) + ) { + throw new UserException('Invalid redirect', 400); + } + + return $redirect; + } + + /** + * Login a local user + */ + function post(array $user) + { + $uid = trim((string) ($user['uid'] ?? '')); + if ($uid === '') { + throw new UserException('Missing uid', 400); + } + unset($user['uid']); + $passwordField = $this->auth->params['password']; + $password = $user[$passwordField] ?? null; + if (!is_string($password) || $password === '') { + throw new UserException('Missing password', 400); + } + unset($user[$passwordField]); + + return $this->auth->login($uid, $password); + } + + /** + * Register a new user + * @param $user array + */ + function postRegister(array $user) + { + $this->assertCanRegister(); + + $uid = trim((string) ($user['uid'] ?? '')); + if ($uid === '') { + throw new UserException('Missing uid', 400); + } + unset($user['uid']); + $passwordField = $this->auth->params['password']; + $password = $user[$passwordField] ?? null; + unset($user[$passwordField]); + + if (!filter_var($uid, FILTER_VALIDATE_EMAIL)) { + throw new AuthException('Email inválido'); + } + + // Public registration cannot claim external identities or privileged + // account attributes. Roles are always managed by trusted code. + unset($user[$this->auth->params['roles']]); + foreach (array_keys($user) as $field) { + if (!in_array($field, ['name', 'image'], true)) { + throw new UserException("Invalid registration field `$field`", 400); + } + } + + $user['provider'] = 'local'; + $user = $this->auth->register($uid, $password, $user); + + $user['uid'] = $uid; + + if (!is_string($password) || $password === '') { + // A password-less registration is an invitation. The backend stores + // only a hash of the short-lived setup token and delivery happens + // through the application callback. + $this->auth->invite($user[$this->auth->params['id']]); + } elseif (is_callable($this->auth->params['register_callback'])) { + call_user_func($this->auth->params['register_callback'], $user); + } + + return $user; + } + + /** + * Redeem a credential-specific proof sent through an independent channel. + * Never authenticates a user or links another account on its own. + */ + #[Middleware(RequireRole::class, Auth::ALL)] + function postVerifyCredential(array $form): array + { + $provider = $form['provider'] ?? null; + $uid = $form['uid'] ?? null; + $token = $form['token'] ?? null; + if (!is_string($provider) || $provider === '' + || !is_string($uid) || $uid === '' + || !is_string($token) || $token === '') { + throw new UserException('Invalid verification request', 400); + } + if (!$this->auth->verify_credential_token($provider, $uid, $token)) { + throw new UserException('Invalid or expired verification token', 400); + } + return ['verified' => true]; + } + + #[Middleware(RequireRole::class, Auth::ALL)] + function postToken(array $form) + { + if (empty($form['token'])) { + throw new UserException('Invalid request', 400); + } + + $confirm = $form['confirm'] ?? null; + if (empty($form['password']) || $form['password'] != $confirm) { + throw new UserException('Passwords don\'t match', 400); + } + + return $this->auth->passwd_reset($form['token'], $form['password']); + } + + #[Middleware(RequireRole::class, Auth::ALL)] + function postPassword(array $form) + { + // The authenticated path is not a password-reset endpoint. Require + // session-bound CSRF and proof of the account's current password. + if ($this->auth->check()) { + $this->assertPasswordChangeCsrf(); + + $currentPassword = $form['current_password'] ?? null; + if (!is_string($currentPassword) || $currentPassword === '') { + throw new UserException('Current password is required', 400); + } + + $password = $form['password'] ?? null; + if (!is_string($password) || $password === '' || $password !== ($form['confirm'] ?? null)) { + throw new UserException('Passwords don\'t match', 400); + } + + $user = $this->auth->user(); + $userId = $user[$this->auth->params['id']]; + if (!$this->auth->verify_password($userId, $currentPassword)) { + throw new AuthException('Invalid credentials', 401); + } + + return $this->auth->passwd($userId, $password); + } // Forgot password + else { + if (empty($form['uid'])) { + throw new UserException('Invalid request', 400); + } + + // Anonymous recovery must prove ownership of the *recovery + // channel*, not just find a credential attached to the account. + // This includes 'local': knowing a local password does not prove + // control over the email/phone identifier used for recovery. + $credential = false; + $providers = $this->auth->params['recovery_providers']; + if (!is_array($providers)) { + $providers = []; + } + + foreach ($providers as $provider) { + $candidate = $this->auth->get_credential($provider, $form['uid']); + if (is_array($candidate) + && !empty($candidate['user_id']) + && !empty($candidate['verified_at'])) { + $credential = $candidate; + break; + } + } + + if (!empty($credential['user_id'])) { + $credential['token'] = $this->auth->update_token($credential['user_id']); + + if (is_callable($this->auth->params['token_callback'])) { + try { + call_user_func($this->auth->params['token_callback'], $credential); + } catch (\Throwable $exception) { + // Delivery exceptions can contain reset URLs, tokens + // and recipient details. Log only a safe error type + // and an opaque reference for operational correlation. + error_log(sprintf( + 'Password recovery delivery failed [ref=%s]: %s', + bin2hex(random_bytes(8)), + $exception::class + )); + } + } + } + + // Anonymous recovery requests deliberately return the same payload + // whether or not a credential exists. This prevents account + // enumeration through status codes or callback return values. + return []; + } + } + + /** + * Changing an authenticated password is a session-authenticated write. + * Always require the session token, even for direct controller calls. + * HTTP clients must also submit JSON to avoid simple cross-origin forms. + */ + private function assertPasswordChangeCsrf(): void + { + if (!empty($_SERVER['REQUEST_METHOD'])) { + if (strtoupper((string) $_SERVER['REQUEST_METHOD']) !== 'POST') { + throw new UserException('POST is required', 405); + } + + $contentType = strtolower(trim(explode(';', (string) ($_SERVER['CONTENT_TYPE'] ?? ''), 2)[0])); + if ($contentType !== 'application/json') { + throw new UserException('Content-Type application/json is required', 415); + } + } + + $provided = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; + $expected = $this->auth->management_csrf_token(); + if (!is_string($provided) || $provided === '' || !hash_equals($expected, $provided)) { + throw new UserException('Invalid CSRF token', 403); + } + } + + protected function assertCanRegister(): void + { + $required = $this->auth->params['register_scope']; + $required = is_array($required) ? $required : [$required]; + + if ($this->auth->check()) { + $available = Auth::AUTHENTICATED; + $user = $this->auth->user(); + $roleField = $this->auth->params['roles']; + $userRoles = $user[$roleField] ?? []; + if (is_array($userRoles)) { + $available = array_merge($available, $userRoles); + } + } else { + $available = Auth::ANONYMOUS; + } + + if (count(array_intersect($required, $available)) === 0) { + throw new AuthException('Forbidden', in_array('anon', $available, true) ? 401 : 403); + } + } +} diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php new file mode 100644 index 0000000..c0911db --- /dev/null +++ b/src/Auth/Controller/OAuthController.php @@ -0,0 +1,569 @@ +providers = $providers; + // Never allow an OAuth callback to redirect to an external origin. + $this->pendingAccountRedirect = $this->normalizeLogoutRedirect($pendingAccountRedirect); + } + + /** + * Start a normal OAuth sign-in or receive the provider callback. + * This route never authorizes linking an unassociated provider identity. + */ + #[Middleware(RequireRole::class, Auth::ALL)] + function get($id, $query) + { + $provider = $this->buildProvider($id); + + if (!empty($query['error'])) { + unset($_SESSION[self::FLOW_KEY][$id]); + throw new \Exception('OAuth provider rejected authorization', 401); + } + + if (empty($query['code'])) { + // Beginning a new login discards any unfinished account choice. + unset($_SESSION[self::PENDING_KEY]); + $this->redirect($this->startAuthorization($id, $provider, 'login')); + return; + } + + // The purpose of an authorization is fixed when it starts, not by + // callback query parameters. Consume it before any network request. + $flow = $_SESSION[self::FLOW_KEY][$id] ?? null; + unset($_SESSION[self::FLOW_KEY][$id]); + $state = $query['state'] ?? null; + if (!is_array($flow) + || !is_string($state) + || !isset($flow['state'], $flow['created_at'], $flow['code_verifier'], $flow['intent']) + || !is_string($flow['state']) + || !hash_equals($flow['state'], $state) + || !is_int($flow['created_at']) + || $flow['created_at'] > time() + || time() - $flow['created_at'] > 600 + || !is_string($flow['code_verifier']) + || $flow['code_verifier'] === '' + || !in_array($flow['intent'], ['login', 'link'], true)) { + throw new \Exception('Invalid or expired OAuth state', 406); + } + + // A flow started under a particular account cannot be completed by a + // different session (including a logout followed by another login). + $currentPrincipal = $this->auth->check() + ? (string) $this->auth->user()[$this->auth->params['id']] + : null; + if (($flow['principal'] ?? null) !== $currentPrincipal) { + throw new \Exception('OAuth session changed during authorization', 403); + } + if ($flow['intent'] === 'link' && !$this->auth->revalidate()) { + throw new \Objectiveweb\Auth\AuthException('Authentication required to link account', 401); + } + + $token = $provider->getAccessToken('authorization_code', [ + 'code' => $query['code'], + 'code_verifier' => $flow['code_verifier'], + ]); + $resourceOwner = $provider->getResourceOwner($token); + $result = $this->login($id, $resourceOwner, $flow['intent']); + if (($result['status'] ?? null) === 'pending') { + $this->redirect($this->pendingAccountRedirect); + return; + } + $this->redirect('/'); + } + + /** + * Explicitly request an account link. Only a CSRF-protected POST made by + * the already-authenticated user can authorize a link to this account. + * The OAuth callback itself remains GET, as required by OAuth providers. + */ + #[Middleware(RequireRole::class, Auth::AUTHENTICATED)] + function postLink($id, array $form = []): array + { + if (!$this->auth->revalidate()) { + throw new \Objectiveweb\Auth\AuthException('Authentication required to link account', 401); + } + $this->assertJsonCsrfPost(); + $provider = $this->buildProvider($id); + // Return the URL for the application's browser to navigate to. + // A JSON POST cannot reliably initiate cross-origin navigation by + // sending a 302 to fetch/XMLHttpRequest. + return ['authorization_url' => $this->startAuthorization($id, $provider, 'link')]; + } + + /** + * The application renders its account-choice page using this safe DTO. + * Nothing here constitutes a logged-in account or a verified email. + */ + #[Middleware(RequireRole::class, Auth::ALL)] + function getPendingAccount(): array + { + $pending = $this->requirePendingAccount(); + return [ + 'status' => 'account_choice_required', + 'provider' => $pending['provider'], + 'name' => $pending['name'], + 'email_hint' => $pending['email_hint'], + 'expires_at' => $pending['created_at'] + self::PENDING_TTL, + 'choices' => ['create', 'existing'], + '_csrf' => $this->auth->management_csrf_token(), + ]; + } + + /** + * Explicit user choice: create an OAuth-only account. + * This is NEVER called automatically by the OAuth callback. + */ + #[Middleware(RequireRole::class, Auth::ALL)] + function postCreateAccount(array $form = []): array + { + $this->assertJsonCsrfPost(); + $pending = $this->requirePendingAccount(); + $this->assertCanRegister(); + + foreach (array_keys($form) as $field) { + if ($field !== 'name') { + throw new UserException("Invalid account creation field `$field`", 400); + } + } + if (isset($form['name']) && (!is_string($form['name']) || trim($form['name']) === '')) { + throw new UserException('Invalid account name', 400); + } + + if ($this->auth->get_credential($pending['provider'], $pending['uid'])) { + unset($_SESSION[self::PENDING_KEY]); + throw new UserException('OAuth identity is already associated with an account', 409); + } + + $data = [ + 'uid' => $pending['uid'], + 'provider' => $pending['provider'], + 'profile' => $pending['profile'], + 'name' => isset($form['name']) ? trim($form['name']) : $pending['name'], + 'image' => $pending['image'], + ]; + + // Single-use, including in the event of a database failure. + unset($_SESSION[self::PENDING_KEY]); + $user = $this->auth->register($data); + $userId = $user[$this->auth->params['id']]; + $this->auth->mark_credential_verified($userId, $pending['provider'], $pending['uid']); + $this->auth->audit('oauth.account_created', $userId, ['provider' => $pending['provider']]); + return ['status' => 'created', 'user' => $this->auth->establish_session($this->auth->get($userId))]; + } + + /** + * Explicit user choice: prove ownership of an existing account using + * its email/password and attach the independently proven OAuth subject. + */ + #[Middleware(RequireRole::class, Auth::ALL)] + function postExistingAccount(array $form): array + { + $this->assertJsonCsrfPost(); + $pending = $this->requirePendingAccount(); + + foreach (array_keys($form) as $field) { + if (!in_array($field, ['uid', 'password'], true)) { + throw new UserException("Invalid existing-account field `$field`", 400); + } + } + $uid = $form['uid'] ?? null; + $password = $form['password'] ?? null; + if (!is_string($uid) || trim($uid) === '' + || !is_string($password) || $password === '') { + throw new UserException('Email and password are required', 400); + } + + $existing = $this->auth->get_credential($pending['provider'], $pending['uid']); + if ($existing) { + unset($_SESSION[self::PENDING_KEY]); + throw new UserException('OAuth identity is already associated with an account', 409); + } + + // Validate ownership without authenticating the browser. A failed + // database link or verification must not create a logged-in session. + $authenticated = $this->auth->authenticate(trim($uid), $password); + $user = $authenticated['user']; + $userId = $user[$this->auth->params['id']]; + + // Do not allow a competing request to attach this OAuth identity to + // a different account. Backend uniqueness constraints also apply. + $existing = $this->auth->get_credential($pending['provider'], $pending['uid']); + unset($_SESSION[self::PENDING_KEY]); + if ($existing) { + throw new UserException('OAuth identity is already associated with an account', 409); + } + $this->auth->update_credential( + $userId, $pending['provider'], $pending['uid'], $pending['profile'] + ); + $this->auth->mark_credential_verified($userId, $pending['provider'], $pending['uid']); + $this->auth->audit('credential.linked', $userId, ['provider' => $pending['provider']]); + return ['status' => 'linked', 'user' => $this->auth->establish_session($this->auth->get($userId))]; + } + + /** + * Explicitly cancel a pending first-time OAuth sign-in. + */ + #[Middleware(RequireRole::class, Auth::ALL)] + function postCancelPendingAccount(): array + { + $this->assertJsonCsrfPost(); + $this->requirePendingAccount(); + unset($_SESSION[self::PENDING_KEY]); + return ['status' => 'cancelled']; + } + + private function requirePendingAccount(): array + { + if ($this->auth->check()) { + throw new UserException('Finish or cancel the OAuth account choice before signing in', 409); + } + $pending = $_SESSION[self::PENDING_KEY] ?? null; + if (!is_array($pending) + || !isset($pending['provider'], $pending['uid'], $pending['created_at']) + || !is_string($pending['provider']) || $pending['provider'] === '' + || !is_string($pending['uid']) || $pending['uid'] === '' + || !is_int($pending['created_at']) + || $pending['created_at'] > time() + || time() - $pending['created_at'] > self::PENDING_TTL + || !array_key_exists($pending['provider'], $this->providers)) { + unset($_SESSION[self::PENDING_KEY]); + throw new UserException('No valid pending OAuth account choice', 410); + } + return $pending; + } + + private function assertJsonCsrfPost(): void + { + if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? '')) !== 'POST') { + throw new UserException('POST is required', 405); + } + $contentType = strtolower(trim(explode(';', (string) ($_SERVER['CONTENT_TYPE'] ?? ''), 2)[0])); + if ($contentType !== 'application/json') { + throw new UserException('Content-Type application/json is required', 415); + } + $provided = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; + $expected = $this->auth->management_csrf_token(); + if (!is_string($provided) || $provided === '' || !hash_equals($expected, $provided)) { + throw new UserException('Invalid CSRF token', 403); + } + } + + private function buildProvider($id): object + { + if (!isset($this->providers[$id])) { + throw new \Exception("Invalid provider $id", 406); + } + $config = $this->providers[$id]; + // Redirect URIs are part of the OAuth client registration. Never + // synthesize them from HTTP_HOST, forwarded headers, or request paths. + // This is enforced in all environments so production cannot silently + // fall back to attacker-controlled request metadata. + $config['redirectUri'] = $this->validateRedirectUri($config['redirectUri'] ?? null); + $classname = "\\League\\OAuth2\\Client\\Provider\\" . ucfirst($id); + + return $this->createProvider($classname, $config); + } + + /** + * Optional construction hook for integration tests and custom provider + * collaborators (e.g. an injected PSR HTTP transport). The callback URL + * has already been validated before this method is called. + */ + protected function createProvider(string $class, array $config): object + { + return new $class($config); + } + + private function startAuthorization($id, object $provider, string $intent): string + { + $verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + $authUrl = $provider->getAuthorizationUrl([ + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ]); + + $providerVerifier = method_exists($provider, 'getPkceCode') + ? $provider->getPkceCode() : null; + if (is_string($providerVerifier) && $providerVerifier !== '') { + $verifier = $providerVerifier; + } + $state = $provider->getState(); + if (!is_string($state) || $state === '') { + throw new \RuntimeException('OAuth provider did not generate state'); + } + + $principal = $this->auth->check() + ? (string) $this->auth->user()[$this->auth->params['id']] + : null; + $_SESSION[self::FLOW_KEY][$id] = [ + 'state' => $state, + 'created_at' => time(), + 'code_verifier' => $verifier, + 'principal' => $principal, + 'intent' => $intent, + ]; + return $authUrl; + } + + /** + * Require a trusted, pre-registered callback URL for every OAuth provider. + * Only HTTPS is accepted, except HTTP loopback URLs for local development. + * Static query parameters may be used if they are registered with the + * provider; fragments and embedded credentials are never valid callbacks. + */ + private function validateRedirectUri(mixed $uri): string + { + if (!is_string($uri) || $uri === '') { + throw new \InvalidArgumentException( + 'OAuth redirectUri must be explicitly configured for each provider' + ); + } + + if (preg_match('/[\x00-\x20\x7f\\\\]/', $uri) || filter_var($uri, FILTER_VALIDATE_URL) === false) { + throw new \InvalidArgumentException('Invalid OAuth redirectUri'); + } + + $parts = parse_url($uri); + if (!is_array($parts) + || empty($parts['host']) + || !isset($parts['scheme']) + || isset($parts['user']) + || isset($parts['pass']) + || isset($parts['fragment'])) { + throw new \InvalidArgumentException('Invalid OAuth redirectUri'); + } + + $scheme = strtolower($parts['scheme']); + $host = strtolower($parts['host']); + $loopback = in_array($host, ['localhost', '127.0.0.1', '[::1]'], true); + + if ($scheme !== 'https' && !($scheme === 'http' && $loopback)) { + throw new \InvalidArgumentException( + 'OAuth redirectUri must use HTTPS (HTTP is only allowed on loopback)' + ); + } + + return $uri; + } + + protected function redirect(string $location): void + { + header('Location: ' . $location); + exit; + } + + /** + * Complete OAuth sign-in or an explicitly initiated account-link flow. + * A normal sign-in is never permission to attach an external identity. + */ + private function login($provider, $resourceOwner, string $intent = 'login') + { + $subject = $resourceOwner->getId(); + // League OAuth2 resource owners may return a string or integer ID. + // Do not coerce arrays, objects or null into a credential UID. + if ((!is_string($subject) && !is_int($subject)) + || trim((string) $subject) === '') { + throw new \Objectiveweb\Auth\AuthException('Invalid provider identity', 401); + } + $uid = (string) $subject; + if (!in_array($intent, ['login', 'link'], true)) { + throw new UserException('Invalid OAuth intent', 400); + } + + $credential = $this->auth->get_credential($provider, $uid); + $sessionUser = $this->auth->check() ? $this->auth->user() : null; + $idField = $this->auth->params['id']; + $profile = $resourceOwner->toArray(); + + if ($intent === 'link') { + if ($sessionUser === null || !$this->auth->revalidate()) { + throw new \Objectiveweb\Auth\AuthException('Authentication required to link account', 401); + } + $sessionUser = $this->auth->user(); + $userId = $sessionUser[$idField]; + + if ($credential && $credential['user_id'] != $userId) { + throw new UserException('Credential belongs to another account', 409); + } + $this->auth->update_credential($userId, $provider, $uid, $profile); + $this->auth->mark_credential_verified($userId, $provider, $uid); + $this->auth->audit('credential.linked', $userId, ['provider' => $provider]); + return $this->auth->establish_session($this->auth->get($userId)); + } + + // This is sign-in only. Existing verified credentials authenticate + // their owner, but unknown or unverified identities cannot be attached + // to an unrelated session. + if ($credential) { + $userId = $credential['user_id']; + if ($sessionUser !== null && $sessionUser[$idField] != $userId) { + throw new UserException('Credential belongs to another account', 409); + } + if (empty($credential['verified_at'])) { + throw new \Objectiveweb\Auth\AuthException( + 'OAuth credential requires explicit account linking', 403 + ); + } + $user = $this->auth->get($userId); + $this->auth->update_credential($userId, $provider, $uid, $profile); + } else { + if ($sessionUser !== null) { + throw new UserException('Use explicit OAuth account linking', 409); + } + // Provider authentication proves an external identity, but the + // user must choose whether to create or link a local account. + // The OAuth callback must not create any account by itself. + // ResourceOwnerInterface guarantees only getId() and toArray(). + // Display fields are optional hints, never identity proofs. + $hints = $this->resourceOwnerHints($resourceOwner, $profile); + $_SESSION[self::PENDING_KEY] = [ + 'provider' => $provider, + 'uid' => $uid, + 'profile' => $profile, + 'name' => $hints['name'], + 'image' => $hints['image'], + 'email_hint' => $hints['email_hint'], + 'created_at' => time(), + ]; + return ['status' => 'pending']; + } + + return $this->auth->establish_session($user); + } + /** + * Optional profile fields vary by provider. League's ResourceOwnerInterface + * guarantees only getId() and toArray(); everything else is a hint. + * The caller has already authenticated the provider-specific subject. + * + * @return array{name: ?string, image: ?string, email_hint: ?string} + */ + private function resourceOwnerHints(object $owner, array $profile): array + { + $text = static fn (string $value): bool => true; + $name = $this->optionalProfileValue( + $owner, $profile, + ['getName', 'getDisplayName'], + ['name', 'display_name', 'displayName', 'full_name', 'fullName'], + $text + ); + + if ($name === null) { + $given = $this->optionalProfileValue( + $owner, $profile, ['getFirstName', 'getGivenName'], + ['given_name', 'first_name', 'firstName'], $text + ); + $family = $this->optionalProfileValue( + $owner, $profile, ['getLastName', 'getFamilyName'], + ['family_name', 'last_name', 'lastName'], $text + ); + $joined = trim(implode(' ', array_filter([$given, $family]))); + $name = $joined !== '' ? $joined : null; + } + + $name ??= $this->optionalProfileValue( + $owner, $profile, ['getNickname'], + ['preferred_username', 'login', 'username', 'nickname'], $text + ); + + // URLs are used for presentation only. Reject schemes such as + // javascript:, data: and file: even if supplied by an OAuth provider. + $image = $this->optionalProfileValue( + $owner, $profile, + ['getAvatar', 'getPictureUrl', 'getAvatarUrl', 'getPicture'], + ['avatar_url', 'avatarUrl', 'picture', 'picture_url', + 'image', 'image_url', 'profile_image_url', 'profile_image_url_https'], + static fn (string $url): bool => + filter_var($url, FILTER_VALIDATE_URL) !== false + && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true) + ); + + // Facebook-like providers may nest the picture URL in picture.data. + if ($image === null && is_array($profile['picture'] ?? null)) { + $url = $profile['picture']['data']['url'] ?? null; + if (is_string($url) && filter_var($url, FILTER_VALIDATE_URL) !== false + && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) { + $image = $url; + } + } + + $email = $this->optionalProfileValue( + $owner, $profile, ['getEmail'], + ['email', 'email_address', 'emailAddress', 'mail'], + static fn (string $value): bool => + filter_var($value, FILTER_VALIDATE_EMAIL) !== false + ); + + return ['name' => $name, 'image' => $image, 'email_hint' => $email]; + } + + /** + * Tolerate missing/throwing optional getters and unexpected value types. + * Do not invoke arbitrary profile methods or coerce objects into strings. + */ + private function optionalProfileValue( + object $owner, + array $profile, + array $methods, + array $fields, + callable $accept + ): ?string { + foreach ($methods as $method) { + if (!is_callable([$owner, $method])) { + continue; + } + try { + $value = $owner->{$method}(); + } catch (\Throwable) { + continue; + } + if (is_string($value)) { + $value = trim($value); + if ($value !== '' && $accept($value)) { + return $value; + } + } + } + + foreach ($fields as $field) { + $value = $profile[$field] ?? null; + if (is_string($value)) { + $value = trim($value); + if ($value !== '' && $accept($value)) { + return $value; + } + } + } + return null; + } + +} diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php new file mode 100644 index 0000000..2c735f4 --- /dev/null +++ b/src/Auth/Controller/UserController.php @@ -0,0 +1,421 @@ +allowedSortFields = array_values(array_unique(array_filter([ + $this->auth->params['id'], + 'name', + $this->auth->params['created'], + $this->auth->params['last_login'], + $this->auth->params['disabled_at'], + ]))); + } + + public function index(array $params = []): Collection + { + return $this->list($params); + } + + public function get(mixed $userOrParams = [], mixed $resource = null, array $params = []): array|Collection + { + if (is_array($userOrParams)) { + return $this->list($userOrParams); + } + if ($resource === 'credentials') { + return ['credentials' => $this->auth->get_credentials($userOrParams)]; + } + return $this->detail($userOrParams); + } + + public function getRoles(array $params = []): array + { + return [ + 'roles' => $this->auth->get_roles(), + '_csrf' => $this->auth->management_csrf_token(), + ]; + } + + public function post(mixed $userOrData, mixed $resource = null, ?array $data = null): array + { + $this->assertManagementWrite(); + if (is_array($userOrData)) { + return $this->create($userOrData); + } + $data ??= []; + return match ((string) $resource) { + 'credentials' => $this->createCredential($userOrData, $data), + 'suspend' => $this->setSuspended($userOrData, true), + 'activate' => $this->setSuspended($userOrData, false), + 'password-reset' => $this->sendToken($userOrData, true), + 'invite' => $this->sendToken($userOrData, false), + default => throw new UserException('Invalid user action', 404), + }; + } + + public function put(mixed $userId, mixed $resourceOrData, ?array $data = null): array + { + $this->assertManagementWrite(); + if (is_array($resourceOrData)) { + return $this->updateUser($userId, $resourceOrData); + } + if ($resourceOrData === 'credentials') { + return $this->renameCredential($userId, $data ?? []); + } + throw new UserException('Invalid user action', 404); + } + + public function delete( + mixed $userId, + mixed $resource = null, + mixed $provider = null, + mixed $uid = null, + array $params = [] + ): array { + $this->assertManagementWrite(); + if ($resource === 'credentials') { + $provider = rawurldecode((string) $provider); + $uid = rawurldecode((string) $uid); + $this->auth->delete_credential($userId, $provider, $uid); + $this->auth->audit('credential.deleted', $userId, ['provider' => $provider]); + return ['ok' => true]; + } + + // Only the base user endpoint may delete an account. Unknown or + // partially matched subresource paths must never fall through here. + if ($resource !== null || $provider !== null || $uid !== null) { + throw new UserException('Invalid user action', 404); + } + + $this->assertNotSelf($userId, 'delete'); + $target = $this->auth->get($userId); + $this->assertNotFinalActiveAdmin($target); + try { + // DBAuth enforces the final-admin invariant again inside its own + // transaction. Controller validation also supports BasicAuth. + $this->auth->delete($userId); + } catch (UserException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw new UserException('User has application history; suspend the account instead', 409, $exception); + } + return ['ok' => true]; + } + + private function list(array $params): Collection + { + $allowed = ['q', 'role', 'status', 'page', 'size', 'sort']; + foreach (array_keys($params) as $field) { + if (!in_array((string) $field, $allowed, true)) { + throw new UserException("Invalid filter `$field`", 400); + } + } + $params['page'] = max(0, (int) ($params['page'] ?? 0)); + $params['size'] = min(100, max(1, (int) ($params['size'] ?? 20))); + $params['sort'] = $this->sanitizeSort((string) ($params['sort'] ?? ($this->auth->params['id'] . ' ASC'))); + if (!in_array((string) ($params['status'] ?? ''), ['', 'active', 'suspended'], true)) { + throw new UserException('Invalid status filter', 400); + } + + return $this->auth->query($params, 'AND'); + } + + private function detail(mixed $userId): array + { + $user = $this->sanitizeUser($this->auth->get($userId)); + $user['credentials'] = $this->auth->get_credentials($userId); + $user['status'] = $this->auth->is_active($user) ? 'active' : 'suspended'; + $user['_csrf'] = $this->auth->management_csrf_token(); + return $user; + } + + private function create(array $data): array + { + $uid = trim((string) ($data['uid'] ?? '')); + if ($uid === '') { + throw new UserException('Missing uid', 400); + } + $this->assertNoApplicationRelations($data); + unset($data['uid']); + $passwordField = $this->auth->params['password']; + $password = $data[$passwordField] ?? null; + unset($data[$passwordField]); + $this->assertWriteFields($data, true); + $roleField = $this->auth->params['roles']; + if (array_key_exists($roleField, $data)) { + $this->assertRolePayload($data[$roleField]); + $this->assertKnownRoles($data[$roleField]); + } + + $user = $this->auth->register($uid, is_string($password) && $password !== '' ? $password : null, $data); + $id = $user[$this->auth->params['id']]; + if (!is_string($password) || $password === '') { + $this->auth->invite($id); + } + $this->auth->audit('user.created', $id, [ + 'roles' => $user[$this->auth->params['roles']] ?? [], + 'invited' => !is_string($password) || $password === '', + ]); + return $this->detail($id); + } + + private function updateUser(mixed $userId, array $data): array + { + $this->assertNoApplicationRelations($data); + unset($data['uid'], $data[$this->auth->params['password']]); + $this->assertWriteFields($data); + + $roleField = $this->auth->params['roles']; + if (array_key_exists($roleField, $data)) { + $this->assertRolePayload($data[$roleField]); + $this->assertKnownRoles($data[$roleField]); + } + + $target = $this->auth->get($userId); + if (array_key_exists($roleField, $data)) { + $this->assertOwnAdminRolePreserved($userId, $data[$roleField]); + } + + // Retain provider-independent validation. DBAuth rechecks against + // locked, current data in update() before applying a risky mutation. + $proposed = array_replace($target, $data); + if ( + in_array('admin', (array) ($target[$roleField] ?? []), true) + && $this->auth->is_active($target) + && ( + !in_array('admin', (array) ($proposed[$roleField] ?? []), true) + || !$this->auth->is_active($proposed) + ) + ) { + $this->assertNotFinalActiveAdmin($target); + } + + $this->auth->update($userId, $data); + $this->auth->audit('user.updated', $userId, ['fields' => array_keys($data)]); + return $this->detail($userId); + } + + private function createCredential(mixed $userId, array $data): array + { + $credential = $this->auth->create_credential( + $userId, + (string) ($data['provider'] ?? ''), + (string) ($data['uid'] ?? ''), + $data['profile'] ?? null + ); + $this->auth->audit('credential.created', $userId, ['provider' => $credential['provider'] ?? null]); + return ['credential' => $this->sanitizeCredential($credential)]; + } + + private function renameCredential(mixed $userId, array $data): array + { + $credential = $this->auth->rename_credential( + $userId, + (string) ($data['provider'] ?? ''), + (string) ($data['uid'] ?? ''), + (string) ($data['new_provider'] ?? $data['provider'] ?? ''), + (string) ($data['new_uid'] ?? '') + ); + $this->auth->audit('credential.renamed', $userId, ['provider' => $credential['provider'] ?? null]); + return ['credential' => $this->sanitizeCredential($credential)]; + } + + private function setSuspended(mixed $userId, bool $suspended): array + { + $field = $this->auth->params['disabled_at']; + if (!is_string($field) || $field === '') { + throw new UserException('Lifecycle state is disabled', 400); + } + if ($suspended) { + $this->assertNotSelf($userId, 'suspend'); + } + + if ($suspended) { + $this->assertNotFinalActiveAdmin($this->auth->get($userId)); + } + $this->auth->update($userId, [$field => $suspended ? date('Y-m-d H:i:s') : null]); + $this->auth->audit($suspended ? 'user.suspended' : 'user.activated', $userId); + return $this->detail($userId); + } + + private function sendToken(mixed $userId, bool $reset): array + { + $this->auth->invite($userId, $reset); + $this->auth->audit($reset ? 'password.reset_requested' : 'invitation.sent', $userId); + return ['ok' => true]; + } + + private function assertNoApplicationRelations(array $data): void + { + if (array_key_exists('managed_relations', $data)) { + throw new UserException( + 'Application relations must be managed by the application', + 400 + ); + } + } + + private function assertManagementWrite(): void + { + if (!$this->auth->params['management_csrf']) { + return; + } + // Direct service/controller calls in tests have no request method. HTTP + // requests must always be JSON and carry the session-bound token. + if (empty($_SERVER['REQUEST_METHOD'])) { + return; + } + $contentType = strtolower((string) ($_SERVER['CONTENT_TYPE'] ?? '')); + if (!str_contains($contentType, 'application/json')) { + throw new UserException('Content-Type application/json is required', 415); + } + $provided = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''); + $expected = $this->auth->management_csrf_token(); + if ($provided === '' || !hash_equals($expected, $provided)) { + throw new UserException('Invalid CSRF token', 403); + } + } + + private function assertRolePayload(mixed $roles): void + { + if (!is_array($roles)) { + throw new UserException('Invalid roles; expected an array', 400); + } + + foreach ($roles as $role) { + if (!is_string($role) || trim($role) === '') { + throw new UserException('Invalid role value', 400); + } + } + } + + private function assertKnownRoles(array $roles): void + { + $unknown = array_values(array_diff($roles, $this->auth->get_roles())); + if ($unknown !== []) { + throw new UserException('Unknown role `' . (string) $unknown[0] . '`', 400); + } + } + + private function assertNotSelf(mixed $userId, string $action): void + { + $current = $this->auth->user(); + if (($current[$this->auth->params['id']] ?? null) == $userId) { + throw new UserException("Cannot $action your own account", 409); + } + } + + private function assertOwnAdminRolePreserved(mixed $userId, array $roles): void + { + $current = $this->auth->user(); + if ( + ($current[$this->auth->params['id']] ?? null) == $userId + && in_array('admin', (array) ($current[$this->auth->params['roles']] ?? []), true) + && !in_array('admin', $roles, true) + ) { + throw new UserException('Cannot remove your own admin role', 409); + } + } + + private function assertNotFinalActiveAdmin(array $target): void + { + if ( + !in_array('admin', (array) ($target[$this->auth->params['roles']] ?? []), true) + || !$this->auth->is_active($target) + ) { + return; + } + $activeAdmins = array_filter( + $this->auth->get_users_by_role('admin'), + fn (array $user): bool => $this->auth->is_active($user) + ); + if (count($activeAdmins) <= 1) { + throw new UserException('Cannot remove or suspend the final active administrator', 409); + } + } + + private function assertWriteFields(array $data, bool $creating = false): void + { + $allowed = [ + 'name', + 'image', + $this->auth->params['roles'], + $this->auth->params['disabled_at'], + ]; + if ($creating) { + $allowed[] = 'provider'; + $allowed[] = 'profile'; + } + $allowed = array_values(array_unique(array_filter($allowed))); + foreach (array_keys($data) as $field) { + if (!in_array((string) $field, $allowed, true)) { + throw new UserException("Invalid field `$field`", 400); + } + } + } + + private function sanitizeSort(string $sort): string + { + [$field, $direction] = array_pad(preg_split('/\s+/', trim($sort), 2), 2, 'ASC'); + if (!in_array($field, $this->allowedSortFields, true)) { + throw new UserException("Invalid sort field `$field`", 400); + } + $direction = strtoupper($direction); + if (!in_array($direction, ['ASC', 'DESC'], true)) { + throw new UserException("Invalid sort direction `$direction`", 400); + } + return $field . ' ' . $direction; + } + + private function sanitizeUser(array $user): array + { + unset($user[$this->auth->params['password']]); + if ($this->auth->params['token']) { + unset($user[$this->auth->params['token']]); + } + $expires = $this->auth->params['token_expires_field']; + if (is_string($expires) && $expires !== '') { + unset($user[$expires]); + } + if (isset($user['credentials']) && is_array($user['credentials'])) { + $user['credentials'] = array_map([$this, 'sanitizeCredential'], $user['credentials']); + } + return $user; + } + + private function sanitizeCredential(array $credential): array + { + unset($credential['token'], $credential['user_id']); + if (isset($credential['profile']['_auth'])) { + unset($credential['profile']['_auth']['token']); + } + return $credential; + } +} diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php new file mode 100644 index 0000000..da675fa --- /dev/null +++ b/src/Auth/DBAuth.php @@ -0,0 +1,1367 @@ + 'user', + 'created' => 'created', + 'last_login' => null, + 'credentials_table' => 'user_credentials', + 'credentials_last_login' => 'last_login', + 'credentials_created' => 'created', + 'credential_token_ttl' => 3600, + 'uuid' => 'uuid', + 'roles_table' => 'role', + 'user_roles_table' => 'user_roles', + 'user_roles_user_id' => 'user_id', + 'user_roles_role_id' => 'role_id', + 'role_id' => 'id', + 'role_name' => 'name', + 'relations' => [], + ]; + + parent::__construct(array_merge($defaults, $params)); + $this->userTable = $this->db->table( + (string) $this->params['table'], + ['pk' => (string) $this->params['id']] + ); + $this->assertRoleConfig(); + } + + public function query($params = array(), $operator = "OR"): Collection + { + $page = max(0, (int) ($params['page'] ?? 0)); + $size = max(1, (int) ($params['size'] ?? 20)); + $sort = (string) ($params['sort'] ?? ($this->params['id'] . ' ASC')); + $q = trim((string) ($params['q'] ?? '')); + $role = trim((string) ($params['role'] ?? '')); + $status = trim((string) ($params['status'] ?? '')); + unset($params['page'], $params['size'], $params['sort'], $params['q'], $params['role'], $params['status']); + + $userTableName = (string) $this->params['table']; + $userIdField = (string) $this->params['id']; + + [$sortField, $sortDirection] = array_pad(preg_split('/\s+/', trim($sort), 2), 2, 'ASC'); + $sortDirection = strtoupper($sortDirection); + if (!in_array($sortDirection, ['ASC', 'DESC'], true)) { + throw new \InvalidArgumentException('Invalid sort direction'); + } + + $where = []; + $candidateIds = null; + $operator = strtoupper((string) $operator); + + if ($params !== []) { + if ($operator === 'AND') { + foreach ($params as $key => $value) { + if ($key === 'uid') { + $credential = $this->get_credential('local', $value); + $candidateIds = $this->intersectUserIds( + $candidateIds, + $credential && isset($credential['user_id']) ? [$credential['user_id']] : [] + ); + continue; + } + + $where[(string) $key] = $value; + } + } else { + $filterIds = []; + foreach ($params as $key => $value) { + if ($key === 'uid') { + $credential = $this->get_credential('local', $value); + if ($credential && isset($credential['user_id'])) { + $filterIds[] = $credential['user_id']; + } + continue; + } + + // Table::select() also issues a COUNT query. For OR candidate + // discovery we only need IDs, so keep this as a low-level scan. + $rows = $this->db->select( + $userTableName, + [(string) $key => $value], + ['fields' => [$userIdField]] + )->all(); + foreach ($rows as $row) { + if (array_key_exists($userIdField, $row)) { + $filterIds[] = $row[$userIdField]; + } + } + } + $candidateIds = $this->intersectUserIds($candidateIds, $filterIds); + } + } + + if ($q !== '') { + $searchIds = []; + // Same rationale as above: this is candidate-ID discovery, not the + // paginated user query itself. + $nameRows = $this->db->select( + $userTableName, + ['name' => '%' . $q . '%'], + ['fields' => [$userIdField]] + )->all(); + foreach ($nameRows as $row) { + if (array_key_exists($userIdField, $row)) { + $searchIds[] = $row[$userIdField]; + } + } + + if (ctype_digit($q)) { + $idRow = $this->db->select( + $userTableName, + [$userIdField => $q], + ['fields' => [$userIdField], 'limit' => 1] + )->fetch(); + if ($idRow && array_key_exists($userIdField, $idRow)) { + $searchIds[] = $idRow[$userIdField]; + } + } + + $credentialRows = $this->db->select( + (string) $this->params['credentials_table'], + ['uid' => '%' . $q . '%'], + ['fields' => ['user_id']] + )->all(); + foreach ($credentialRows as $row) { + if (array_key_exists('user_id', $row)) { + $searchIds[] = $row['user_id']; + } + } + + $candidateIds = $this->intersectUserIds($candidateIds, $searchIds); + } + + if ($role !== '') { + if (!$this->rolesEnabled()) { + if ($role !== 'unassigned') { + $candidateIds = $this->intersectUserIds($candidateIds, []); + } + } elseif ($role === 'unassigned') { + $assigned = $this->db->select( + (string) $this->params['user_roles_table'], + null, + ['fields' => [(string) $this->params['user_roles_user_id']]] + )->all(); + $assignedIds = array_values(array_unique(array_map( + fn (array $row): mixed => $row[(string) $this->params['user_roles_user_id']] ?? null, + $assigned + ))); + $assignedIds = array_values(array_filter($assignedIds, fn (mixed $id): bool => $id !== null)); + if ($assignedIds !== []) { + $where['!' . $userIdField] = $assignedIds; + } + } else { + $roleRow = $this->db->select( + (string) $this->params['roles_table'], + [(string) $this->params['role_name'] => $role], + ['fields' => [(string) $this->params['role_id']], 'limit' => 1] + )->fetch(); + + $roleUserIds = []; + if ($roleRow && array_key_exists((string) $this->params['role_id'], $roleRow)) { + $memberships = $this->db->select( + (string) $this->params['user_roles_table'], + [(string) $this->params['user_roles_role_id'] => $roleRow[(string) $this->params['role_id']]], + ['fields' => [(string) $this->params['user_roles_user_id']]] + )->all(); + foreach ($memberships as $membership) { + $id = $membership[(string) $this->params['user_roles_user_id']] ?? null; + if ($id !== null) { + $roleUserIds[] = $id; + } + } + } + $candidateIds = $this->intersectUserIds($candidateIds, $roleUserIds); + } + } + + if ($status !== '') { + $disabledField = $this->params['disabled_at']; + if (!is_string($disabledField) || $disabledField === '') { + if ($status === 'suspended') { + $candidateIds = $this->intersectUserIds($candidateIds, []); + } + } elseif ($status === 'active') { + $where[$disabledField] = null; + } elseif ($status === 'suspended') { + $where['!' . $disabledField] = null; + } + } + + if ($candidateIds !== null) { + if ($candidateIds === []) { + return new Collection([], $page * $size, ($page * $size) - 1, 0); + } + + if (array_key_exists($userIdField, $where)) { + $requestedIds = is_array($where[$userIdField]) + ? $where[$userIdField] + : [$where[$userIdField]]; + $candidateIds = $this->intersectUserIds($candidateIds, $requestedIds); + if ($candidateIds === []) { + return new Collection([], $page * $size, ($page * $size) - 1, 0); + } + } + $where[$userIdField] = $candidateIds; + } + + $start = $page * $size; + $collection = $this->userTable->select( + $where, + [ + 'sort' => [$sortField, $sortDirection], + 'range' => [$start, $start + $size - 1], + ] + ); + + foreach ($collection as &$row) { + $hydrated = $this->hydrateUserRow($row); + $hydrated['credentials'] = $this->get_credentials($hydrated[$userIdField]); + $row = $this->sanitize_user($hydrated); + } + unset($row); + + return $collection; + } + + public function get($user_id, $key = 'id') + { + $column = (string) ($this->params[$key] ?? $key); + + if ($column === (string) $this->params['id']) { + try { + $row = $this->userTable->get($user_id); + } catch (NotFoundException) { + throw new UserException('User not found', 404); + } + } else { + $rows = $this->userTable->select( + [$column => $user_id], + ['range' => [0, 0]] + ); + if (count($rows) === 0) { + throw new UserException('User not found', 404); + } + $row = $rows[0]; + } + + return $this->hydrateUserRow($row); + } + + public function register($uid, $password = null, $data = array()) + { + if (is_array($uid)) { + $data = array_merge($uid, $data); + $uid = $data['uid'] ?? null; + $password = $data[$this->params['password']] ?? null; + unset($data['uid'], $data[$this->params['password']]); + } + + if (empty($uid)) { + throw new UserException('Missing uid', 400); + } + + $provider = empty($data['provider']) ? 'local' : $data['provider']; + unset($data['provider']); + + $credential = $this->get_credential($provider, $uid); + if (!empty($credential)) { + $ex = new UserException('Credential already registered', 409); + $ex->setUser($this->sanitize_user($this->get($credential['user_id']))); + throw $ex; + } + + if (empty($data['name'])) { + [$name] = explode('@', (string) $uid, 2); + $data['name'] = $name; + } + + $profile = !empty($data['profile']) ? json_encode($data['profile']) : null; + unset($data['profile']); + + $roleField = $this->params['roles']; + $syncRoles = array_key_exists($roleField, $data); + $roles = ($syncRoles && is_array($data[$roleField])) ? $data[$roleField] : []; + unset($data[$roleField]); + if ($syncRoles && !$this->rolesEnabled()) { + throw new \InvalidArgumentException('Role sync requested but role tables are disabled'); + } + + $fields = []; + foreach ($data as $key => $value) { + $fields[$key] = is_array($value) ? json_encode($value) : $value; + } + + if (!empty($this->params['uuid'])) { + unset($fields[$this->params['uuid']]); + $fields[$this->params['uuid']] = $this->uuidV4(); + } + + if ($password) { + $fields[$this->params['password']] = self::hash($password); + } + + if ($this->params['token']) { + $fields[$this->params['token']] = null; + } + + if ($this->params['created']) { + $fields[$this->params['created']] = date('Y-m-d H:i:s'); + } + + return $this->db->transaction(function () use ($uid, $provider, $profile, $fields, $syncRoles, $roles): array { + if ($syncRoles) { + $this->resolveRoleIds($roles); + } + $inserted = $this->userTable->insert($fields); + $idField = (string) $this->params['id']; + $id = $inserted[$idField] ?? null; + if ($id === null || $id === '') { + throw new \Exception('Could not create user'); + } + + $userId = ctype_digit((string) $id) ? (int) $id : $id; + $fields[$idField] = $userId; + + $credentialPayload = [ + 'user_id' => $userId, + 'provider' => $provider, + 'uid' => $uid, + 'profile' => $profile, + ]; + + if (!empty($this->params['credentials_last_login'])) { + $credentialPayload[$this->params['credentials_last_login']] = null; + } + if (!empty($this->params['credentials_created'])) { + $credentialPayload[$this->params['credentials_created']] = date('Y-m-d H:i:s'); + } + + $this->db->insert($this->params['credentials_table'], $credentialPayload); + + if ($syncRoles) { + $this->syncUserRoles($userId, $roles); + } + + return $this->sanitize_user($this->get($userId)); + }); + } + + public function passwd($user_id, $password, $key = 'id') + { + $column = $this->params[$key] ?? $key; + $payload = [$this->params['password'] => self::hash($password)]; + + // Changing a password must also revoke outstanding reset links. + // Commit all fields in one UPDATE so a concurrent token redemption + // cannot later overwrite the newly chosen password. + $tokenField = $this->params['token']; + if (is_string($tokenField) && $tokenField !== '') { + $payload[$tokenField] = null; + $expiresField = $this->params['token_expires_field']; + if (is_string($expiresField) && $expiresField !== '') { + $payload[$expiresField] = null; + } + } + + $updated = $this->userTable->update( + [$column => $user_id], + $payload + )['updated']; + + if ($updated !== 1) { + throw new UserException('User not found', 404); + } + + return true; + } + + public function passwd_reset($token, $password) + { + if (empty($this->params['token'])) { + throw new UserException('Token support is disabled', 400); + } + + $user = $this->findUserByResetToken((string) $token); + if ($user === null) { + throw new UserException('Hash not found', 404); + } + + $payload = [ + $this->params['password'] => self::hash($password), + $this->params['token'] => null, + ]; + $tokenExpiresField = $this->params['token_expires_field']; + if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { + $payload[$tokenExpiresField] = null; + } + + // The password change and token consumption must be one atomic + // compare-and-swap. Checking the token with SELECT alone is not + // enough: two requests can both read the same valid token. + $where = [ + (string) $this->params['id'] => $user[$this->params['id']], + (string) $this->params['token'] => $this->resetTokenDigest((string) $token), + ]; + if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { + // Also reject a concurrently replaced or invalidated expiry. + $where[$tokenExpiresField] = $user[$tokenExpiresField]; + if ($user[$tokenExpiresField] <= date('Y-m-d H:i:s')) { + throw new UserException('Hash not found', 404); + } + } + + // DB::update() uses the configured table prefix and returns the + // affected row count. Once the first request clears the token, every + // competing request fails the token predicate without changing the + // new password. + $updated = $this->db->update( + (string) $this->params['table'], + $payload, + $where + ); + + if ($updated !== 1) { + throw new UserException('Hash not found', 404); + } + + return $this->sanitize_user($this->get($user[$this->params['id']])); + } + + public function update($user_id, array $data, $key = 'id') + { + $column = $this->params[$key] ?? $key; + + unset($data[$this->params['id']], $data[$this->params['password']]); + + if ($this->params['token']) { + unset($data[$this->params['token']]); + } + + if ($this->params['created']) { + unset($data[$this->params['created']]); + } + + if ($this->params['last_login']) { + unset($data[$this->params['last_login']]); + } + + if (!empty($this->params['uuid'])) { + unset($data[$this->params['uuid']]); + } + + $roleField = $this->params['roles']; + $syncRoles = array_key_exists($roleField, $data); + $roles = ($syncRoles && is_array($data[$roleField])) ? $data[$roleField] : []; + unset($data[$roleField]); + if ($syncRoles && !$this->rolesEnabled()) { + throw new \InvalidArgumentException('Role sync requested but role tables are disabled'); + } + + // Only updates that might remove an active administrator need the + // shared lock; routine profile edits, promotions and activation don't. + $disabledField = $this->params['disabled_at']; + $removesAdminRole = $syncRoles && !in_array('admin', $roles, true); + $suspendsAccount = is_string($disabledField) && $disabledField !== '' + && !empty($data[$disabledField]); + $needsAdminCheck = $this->rolesEnabled() && ($removesAdminRole || $suspendsAccount); + + // User fields, role definitions and memberships commit or roll back + // together, including the final-active-admin safeguard. + $this->db->transaction(function () use ( + $column, $user_id, $key, $data, $syncRoles, $roles, $needsAdminCheck + ): void { + if ($needsAdminCheck) { + // All admin-removing DBAuth writes lock the same stable row. + // A no-op UPDATE serializes them in MySQL/PostgreSQL, and + // obtains SQLite's write lock without dialect-specific SQL. + $roleName = (string) $this->params['role_name']; + $this->db->update( + (string) $this->params['roles_table'], + [$roleName => 'admin'], + [$roleName => 'admin'] + ); + + // Both the target and count must be loaded after the lock. + $target = $this->get($user_id, $key); + if (in_array('admin', (array) ($target[$this->params['roles']] ?? []), true) + && $this->is_active($target)) { + $activeAdmins = array_filter( + $this->get_users_by_role('admin'), + fn (array $user): bool => $this->is_active($user) + ); + if (count($activeAdmins) <= 1) { + throw new UserException('Cannot remove or suspend the final active administrator', 409); + } + } + } + + if (!empty($data)) { + foreach ($data as $k => $v) { + if (is_array($v)) { + $data[$k] = json_encode($v); + } + } + + $this->userTable->update([$column => $user_id], $data); + } + + if ($syncRoles) { + $target = $this->get($user_id, $key); + $this->resolveRoleIds($roles); + $this->syncUserRoles($target[$this->params['id']], $roles); + } + }); + } + + public function delete($user_id) + { + if ($this->check()) { + $user = $this->user(); + if (($user[$this->params['id']] ?? null) == $user_id) { + throw new \Exception('Cannot delete yourself!'); + } + } + + return $this->db->transaction(function () use ($user_id): bool { + if ($this->rolesEnabled()) { + // Serialize deletion with any demotion/suspension of an admin. + $roleName = (string) $this->params['role_name']; + $this->db->update( + (string) $this->params['roles_table'], + [$roleName => 'admin'], + [$roleName => 'admin'] + ); + + $target = $this->get($user_id); + if (in_array('admin', (array) ($target[$this->params['roles']] ?? []), true) + && $this->is_active($target)) { + $activeAdmins = array_filter( + $this->get_users_by_role('admin'), + fn (array $user): bool => $this->is_active($user) + ); + if (count($activeAdmins) <= 1) { + throw new UserException('Cannot remove or suspend the final active administrator', 409); + } + } + } + + $guard = $this->params['deletion_guard_callback']; + if (is_callable($guard)) { + $result = call_user_func($guard, $user_id); + if ($result === false || is_string($result)) { + throw new UserException( + is_string($result) ? $result : 'User has application history', + 409 + ); + } + } + $this->db->delete($this->params['credentials_table'], ['user_id' => $user_id]); + if ($this->rolesEnabled()) { + $this->db->delete( + $this->params['user_roles_table'], + [$this->params['user_roles_user_id'] => $user_id] + ); + } + foreach ((array) $this->params['relations'] as $relation) { + // A relation used for authorization is not necessarily owned by Auth. + // Only explicitly Auth-owned relation tables may be cleaned up here. + if (!is_array($relation) + || ($relation['auth_owned'] ?? false) !== true + || empty($relation['table'])) { + continue; + } + if (!empty($relation['subject_key'])) { + $this->db->delete((string) $relation['table'], [(string) $relation['subject_key'] => $user_id]); + } + if (!empty($relation['target_key']) && ($relation['target_is_user'] ?? false)) { + $this->db->delete((string) $relation['table'], [(string) $relation['target_key'] => $user_id]); + } + } + $this->audit('user.deleted', $user_id); + $deleted = $this->userTable->delete($user_id); + + if ($deleted !== 1) { + throw new UserException('User not found', 404); + } + + return true; + }); + } + + public function update_token($user_id) + { + if (empty($this->params['token'])) { + throw new UserException('Token support is disabled', 400); + } + + $token = self::hash(); + $payload = [$this->params['token'] => $this->resetTokenDigest($token)]; + $tokenExpiresField = $this->params['token_expires_field']; + if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { + $ttl = max(60, (int) $this->params['token_ttl']); + $payload[$tokenExpiresField] = date('Y-m-d H:i:s', time() + $ttl); + } + + $updated = $this->userTable->update($user_id, $payload)['updated']; + + if ($updated !== 1) { + throw new UserException('Token not found', 404); + } + + return $token; + } + + public function get_credential($provider, $accountid) + { + $account = $this->db->select( + $this->params['credentials_table'], + [ + 'provider' => $provider, + 'uid' => $accountid, + ], + ['limit' => 1] + )->fetch(); + + if (!$account) { + return false; + } + + if (!empty($account['profile'])) { + $decoded = json_decode($account['profile'], true); + $account['profile'] = is_array($decoded) ? $decoded : $account['profile']; + } + + return $account; + } + + public function update_credential($userid, $provider, $uid, $profile = null) + { + if (is_array($profile)) { + $profile = json_encode($profile); + } + + $data = []; + if ($profile !== null) { + $data['profile'] = $profile; + } + if (!empty($this->params['credentials_last_login'])) { + $data[$this->params['credentials_last_login']] = date('Y-m-d H:i:s'); + } + + $existing = $this->get_credential($provider, $uid); + if ($existing && ($existing['user_id'] ?? null) != $userid) { + throw new UserException('Credential already registered', 409); + } + if ($existing) { + if (empty($data)) { + return true; + } + + $this->db->update($this->params['credentials_table'], $data, [ + 'provider' => $provider, + 'uid' => $uid, + ]); + + return true; + } + + $data = array_merge($data, [ + 'user_id' => $userid, + 'provider' => $provider, + 'uid' => $uid, + ]); + if (!empty($this->params['credentials_created'])) { + $data[$this->params['credentials_created']] = date('Y-m-d H:i:s'); + } + + $this->db->insert($this->params['credentials_table'], $data); + return true; + } + + /** + * Tokens are per credential, never per user. Only trusted service code + * should issue them and deliver them through the credential's verified + * address/channel; never return this secret from a public HTTP endpoint. + */ + public function issue_credential_token($userid, string $provider, string $uid): string + { + if (!in_array($provider, (array) $this->params['credential_token_providers'], true)) { + throw new UserException('Provider does not support out-of-band verification', 400); + } + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + if (!empty($credential['verified_at'])) { + throw new UserException('Credential already verified', 409); + } + + $secret = bin2hex(random_bytes(32)); + $updated = $this->db->update($this->params['credentials_table'], [ + 'token' => hash('sha256', $secret), + 'token_expires_at' => date('Y-m-d H:i:s', time() + max(60, (int) $this->params['credential_token_ttl'])), + ], [ + 'user_id' => $userid, 'provider' => $provider, 'uid' => $uid, + ]); + if ($updated !== 1) { + throw new UserException('Credential not found', 404); + } + return $secret; + } + + public function verify_credential_token(string $provider, string $uid, string $token): bool + { + if (!in_array($provider, (array) $this->params['credential_token_providers'], true)) { + return false; + } + if ($token === '') { + return false; + } + $credential = $this->get_credential($provider, $uid); + if (!$credential || !empty($credential['verified_at'])) { + return false; + } + $digest = $credential['token'] ?? null; + $expires = $credential['token_expires_at'] ?? null; + if (!is_string($digest) || !is_string($expires) + || $expires <= date('Y-m-d H:i:s') + || !hash_equals($digest, hash('sha256', $token))) { + return false; + } + + // Match the expiry snapshot as well as the token, so concurrent + // rotation or expiry changes invalidate this attempt. Check the clock + // again after the conditional UPDATE and roll back if it expired + // between the initial read and the write. + $expiredDuringRedemption = new \RuntimeException('Credential token expired during redemption'); + try { + return $this->db->transaction(function (DB $db) use ( + $credential, $provider, $uid, $digest, $expires, $expiredDuringRedemption + ): bool { + $updated = $db->update($this->params['credentials_table'], [ + 'verified_at' => date('Y-m-d H:i:s'), + 'token' => null, + 'token_expires_at' => null, + ], [ + 'user_id' => $credential['user_id'], + 'provider' => $provider, + 'uid' => $uid, + 'token' => $digest, + 'token_expires_at' => $expires, + 'verified_at' => null, + ]); + if ($updated !== 1) { + return false; + } + if ($expires <= date('Y-m-d H:i:s')) { + throw $expiredDuringRedemption; + } + return true; + }); + } catch (\RuntimeException $exception) { + if ($exception === $expiredDuringRedemption) { + return false; + } + throw $exception; + } + } + + public function mark_credential_verified($userid, string $provider, string $uid): void + { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + $this->db->update($this->params['credentials_table'], [ + 'verified_at' => $credential['verified_at'] ?: date('Y-m-d H:i:s'), + 'token' => null, + 'token_expires_at' => null, + ], [ + 'user_id' => $userid, 'provider' => $provider, 'uid' => $uid, + ]); + } + + public function create_credential($userid, string $provider, string $uid, mixed $profile = null): array + { + $this->get($userid); + $provider = trim($provider); + $uid = trim($uid); + if ($provider === '' || $uid === '') { + throw new UserException('Provider and uid are required', 400); + } + if ($this->get_credential($provider, $uid)) { + throw new UserException('Credential already registered', 409); + } + $this->update_credential($userid, $provider, $uid, $profile); + if (!empty($this->params['credentials_last_login'])) { + $this->db->update($this->params['credentials_table'], [ + $this->params['credentials_last_login'] => null, + ], [ + 'user_id' => $userid, + 'provider' => $provider, + 'uid' => $uid, + ]); + } + return $this->get_credential($provider, $uid); + } + + public function rename_credential( + $userid, + string $provider, + string $uid, + string $newProvider, + string $newUid + ): array { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + $newProvider = trim($newProvider); + $newUid = trim($newUid); + if ($newProvider === '' || $newUid === '') { + throw new UserException('Provider and uid are required', 400); + } + $duplicate = $this->get_credential($newProvider, $newUid); + if ( + ($provider !== $newProvider || $uid !== $newUid) + && $duplicate + ) { + throw new UserException('Credential already registered', 409); + } + $this->db->update($this->params['credentials_table'], [ + 'provider' => $newProvider, + 'uid' => $newUid, + // Changing either identity component requires fresh proof. + 'verified_at' => ($provider === $newProvider && $uid === $newUid) + ? ($credential['verified_at'] ?? null) : null, + 'token' => null, + 'token_expires_at' => null, + ], [ + 'user_id' => $userid, + 'provider' => $provider, + 'uid' => $uid, + ]); + return $this->get_credential($newProvider, $newUid); + } + + public function delete_credential($userid, string $provider, string $uid): bool + { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + if (count($this->get_credentials($userid)) <= 1) { + throw new UserException('A user must have at least one credential', 409); + } + $this->db->delete($this->params['credentials_table'], [ + 'user_id' => $userid, + 'provider' => $provider, + 'uid' => $uid, + ]); + return true; + } + + public function get_credentials($user_id, $key = 'id'): array + { + $user = $this->get($user_id, $key); + $rows = $this->db->select( + $this->params['credentials_table'], + ['user_id' => $user[$this->params['id']]] + )->all(); + + $lastLoginField = $this->params['credentials_last_login']; + $createdField = $this->params['credentials_created']; + + $result = []; + foreach ($rows as $row) { + $profile = $row['profile'] ?? null; + if (is_string($profile) && $profile !== '') { + $decoded = json_decode($profile, true); + $profile = is_array($decoded) ? $decoded : $profile; + } + + if (is_array($profile) && isset($profile['_auth']) && is_array($profile['_auth'])) { + unset($profile['_auth']['token']); + } + + $result[] = [ + 'uid' => $row['uid'] ?? null, + 'provider' => $row['provider'] ?? null, + 'profile' => $profile, + 'verified_at' => $row['verified_at'] ?? null, + 'last_login' => ($lastLoginField && array_key_exists($lastLoginField, $row)) + ? $row[$lastLoginField] + : ($row['last_login'] ?? null), + 'created' => ($createdField && array_key_exists($createdField, $row)) + ? $row[$createdField] + : ($row['created'] ?? null), + ]; + } + + usort($result, function (array $a, array $b): int { + return [$a['provider'], $a['uid']] <=> [$b['provider'], $b['uid']]; + }); + + return $result; + } + + public function get_users_by_role($roleName): array + { + if (!$this->rolesEnabled()) { + return []; + } + + $rolesTable = (string) $this->params['roles_table']; + $userRolesTable = (string) $this->params['user_roles_table']; + $userRolesUserId = (string) $this->params['user_roles_user_id']; + $userRolesRoleId = (string) $this->params['user_roles_role_id']; + $roleIdField = (string) $this->params['role_id']; + $roleNameField = (string) $this->params['role_name']; + + $role = $this->db->select( + $rolesTable, + [$roleNameField => $roleName], + ['limit' => 1] + )->fetch(); + if (!$role || !array_key_exists($roleIdField, $role)) { + return []; + } + + $memberships = $this->db->select( + $userRolesTable, + [$userRolesRoleId => $role[$roleIdField]], + [ + 'fields' => [$userRolesUserId], + 'order' => $userRolesUserId, + ] + )->all(); + + $result = []; + $seen = []; + foreach ($memberships as $membership) { + $userId = $membership[$userRolesUserId] ?? null; + if ($userId === null) { + continue; + } + + $identity = (string) $userId; + if (isset($seen[$identity])) { + continue; + } + $seen[$identity] = true; + + try { + $user = $this->userTable->get($userId); + } catch (NotFoundException) { + continue; + } + + $user[$this->params['roles']] = $this->loadUserRoles($userId); + $result[] = $this->sanitize_user($user); + } + + return $result; + } + + public function get_roles(): array + { + if (!$this->rolesEnabled()) { + return []; + } + + $rolesTable = (string) $this->params['roles_table']; + $roleIdField = (string) $this->params['role_id']; + $roleNameField = (string) $this->params['role_name']; + + $rows = $this->db->select($rolesTable, [], ['order' => $roleNameField])->all(); + return array_values(array_map( + fn (array $row): string => (string) ($row[$roleNameField] ?? ''), + $rows + )); + } + + private function hydrateUserRow(array $row): array + { + $row = $this->hydrateEagerRelations($row); + + $roleField = $this->params['roles']; + $row[$roleField] = $this->loadUserRoles($row[$this->params['id']]); + + return $row; + } + + private function rolesEnabled(): bool + { + return !empty($this->params['roles_table']) && !empty($this->params['user_roles_table']); + } + + private function assertRoleConfig(): void + { + $rolesTable = $this->params['roles_table']; + $userRolesTable = $this->params['user_roles_table']; + if (($rolesTable && !$userRolesTable) || (!$rolesTable && $userRolesTable)) { + throw new \InvalidArgumentException('Both roles_table and user_roles_table must be configured together'); + } + } + + private function loadUserRoles(int|string $userId): array + { + if (!$this->rolesEnabled()) { + return []; + } + + $roleName = (string) $this->params['role_name']; + $roleId = (string) $this->params['role_id']; + $rolesTable = (string) $this->params['roles_table']; + $userRolesTable = (string) $this->params['user_roles_table']; + $userRolesUserId = (string) $this->params['user_roles_user_id']; + $userRolesRoleId = (string) $this->params['user_roles_role_id']; + + $rows = $this->db->select( + $userRolesTable, + [$userRolesTable . '.' . $userRolesUserId => $userId], + [ + 'fields' => ['role_name' => 'r.' . $roleName], + 'join' => [ + [ + 'type' => 'inner', + 'table' => $rolesTable, + 'alias' => 'r', + 'on' => $userRolesTable . '.' . $userRolesRoleId . ' = r.' . $roleId, + ], + ], + 'order' => 'r.' . $roleName, + ] + )->all(); + + return array_values(array_map( + fn (array $entry): string => (string) ($entry['role_name'] ?? ''), + $rows + )); + } + + private function syncUserRoles(int|string $userId, array $roles): void + { + if (!$this->rolesEnabled()) { + return; + } + + $userRolesTable = (string) $this->params['user_roles_table']; + $userRolesUserId = (string) $this->params['user_roles_user_id']; + $userRolesRoleId = (string) $this->params['user_roles_role_id']; + + $this->db->delete($userRolesTable, [$userRolesUserId => $userId]); + + foreach ($this->resolveRoleIds($roles) as $roleId) { + $this->db->insert($userRolesTable, [ + $userRolesUserId => $userId, + $userRolesRoleId => $roleId, + ]); + } + } + + private function resolveRoleIds(array $roles): array + { + if (!$this->rolesEnabled()) { + return []; + } + + $roleIds = []; + $rolesTable = (string) $this->params['roles_table']; + $roleIdField = (string) $this->params['role_id']; + $roleNameField = (string) $this->params['role_name']; + + foreach ($roles as $roleName) { + $existing = $this->db->select($rolesTable, [$roleNameField => $roleName], ['limit' => 1])->fetch(); + if ($existing && isset($existing[$roleIdField])) { + $roleIds[] = $existing[$roleIdField]; + continue; + } + + $inserted = $this->db->insert($rolesTable, [$roleNameField => $roleName]); + if ($inserted !== null) { + $roleIds[] = ctype_digit((string) $inserted) ? (int) $inserted : $inserted; + continue; + } + throw new UserException("Unknown role `$roleName`", 400); + } + + return array_values(array_unique($roleIds)); + } + + protected function userCanRelation(int $subjectId, string $resourceType, int $resourceId, string $ability): bool + { + $relation = $this->getRelationConfig($resourceType); + if ($relation === null) { + throw new \InvalidArgumentException("Unknown relation type `$resourceType`"); + } + + $rows = $this->db->select( + $relation['table'], + [ + $relation['subject_key'] => $subjectId, + $relation['target_key'] => $resourceId, + ] + )->all(); + + return $this->rowsAllowAbility($rows, $relation, $ability); + } + + protected function userCanRelationList(int $subjectId, string $resourceType, string $ability): array + { + $relation = $this->getRelationConfig($resourceType); + if ($relation === null) { + throw new \InvalidArgumentException("Unknown relation type `$resourceType`"); + } + + $rows = $this->db->select( + $relation['table'], + [$relation['subject_key'] => $subjectId] + )->all(); + + $ids = []; + foreach ($rows as $row) { + if (!$this->rowAllowsAbility($row, $relation, $ability)) { + continue; + } + + $targetId = $row[$relation['target_key']] ?? null; + if (is_int($targetId)) { + $ids[] = $targetId; + continue; + } + + if (is_string($targetId) && ctype_digit($targetId)) { + $ids[] = (int) $targetId; + } + } + + $ids = array_values(array_unique($ids)); + sort($ids); + return $ids; + } + + private function getRelationConfig(string $resourceType): ?array + { + $relations = $this->params['relations']; + $relation = $relations[$resourceType] ?? null; + if (!is_array($relation)) { + return null; + } + + $table = $relation['table'] ?? null; + $subjectKey = $relation['subject_key'] ?? null; + $targetKey = $relation['target_key'] ?? null; + if (!is_string($table) || !is_string($subjectKey) || !is_string($targetKey)) { + return null; + } + + if (!isset($relation['ability_key']) && !isset($relation['role_key'])) { + return null; + } + + return $relation; + } + + private function rowsAllowAbility(array $rows, array $relation, string $ability): bool + { + foreach ($rows as $row) { + if ($this->rowAllowsAbility($row, $relation, $ability)) { + return true; + } + } + + return false; + } + + private function rowAllowsAbility(array $row, array $relation, string $ability): bool + { + $abilityKey = $relation['ability_key'] ?? null; + if (is_string($abilityKey)) { + return (($row[$abilityKey] ?? null) === $ability); + } + + $roleKey = $relation['role_key'] ?? null; + if (!is_string($roleKey)) { + return false; + } + + $role = (string) ($row[$roleKey] ?? ''); + if ($role === '') { + return false; + } + + $roleAbilities = $relation['role_abilities'] ?? []; + if (!is_array($roleAbilities)) { + return false; + } + + $abilities = $roleAbilities[$role] ?? []; + if (!is_array($abilities)) { + return false; + } + + return in_array($ability, $abilities, true); + } + + private function hydrateEagerRelations(array $row): array + { + $relations = $this->params['relations']; + if (!is_array($relations)) { + return $row; + } + + $subjectId = $row[$this->params['id']] ?? null; + if (!is_int($subjectId)) { + if (is_string($subjectId) && ctype_digit($subjectId)) { + $subjectId = (int) $subjectId; + } else { + return $row; + } + } + + foreach ($relations as $relationName => $relation) { + if (!is_array($relation) || !array_key_exists('eager', $relation)) { + continue; + } + + $eager = $relation['eager']; + if ($eager === false || $eager === null) { + continue; + } + + if ($eager === true) { + $eagerParams = []; + } elseif (is_array($eager)) { + $eagerParams = $eager; + } else { + throw new \InvalidArgumentException("Invalid eager config for relation `$relationName`"); + } + + $targetIds = $this->relationTargetIdsForSubject($relation, $subjectId); + if ($targetIds === []) { + $row[(string) $relationName] = []; + continue; + } + + $eagerTable = $relation['table'] ?? null; + $subjectKey = $relation['subject_key'] ?? null; + $eagerKey = $relation['target_key'] ?? null; + if (!is_string($eagerTable) || !is_string($subjectKey) || !is_string($eagerKey)) { + throw new \InvalidArgumentException("Invalid relation table/key config for relation `$relationName`"); + } + + $row[(string) $relationName] = $this->db->select( + $eagerTable, + [ + $subjectKey => $subjectId, + $eagerKey => $targetIds, + ], + $eagerParams + )->all(); + } + + return $row; + } + + private function relationTargetIdsForSubject(array $relation, int $subjectId): array + { + $table = $relation['table'] ?? null; + $subjectKey = $relation['subject_key'] ?? null; + $targetKey = $relation['target_key'] ?? null; + if (!is_string($table) || !is_string($subjectKey) || !is_string($targetKey)) { + return []; + } + + $rows = $this->db->select($table, [$subjectKey => $subjectId])->all(); + $ids = []; + foreach ($rows as $entry) { + $targetId = $entry[$targetKey] ?? null; + if (is_int($targetId)) { + $ids[] = $targetId; + } elseif (is_string($targetId) && ctype_digit($targetId)) { + $ids[] = (int) $targetId; + } + } + + $ids = array_values(array_unique($ids)); + sort($ids); + return $ids; + } + + private function findUserByResetToken(string $token): ?array + { + $tokenField = (string) $this->params['token']; + $tokenExpiresField = $this->params['token_expires_field']; + + $rows = $this->userTable->select( + [$tokenField => $this->resetTokenDigest($token)], + ['range' => [0, 0]] + ); + if (count($rows) === 0) { + return null; + } + $row = $rows[0]; + + if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { + $expiresAt = $row[$tokenExpiresField] ?? null; + if (!is_string($expiresAt) || $expiresAt < date('Y-m-d H:i:s')) { + return null; + } + } + + return $row; + } + + private function resetTokenDigest(string $token): string + { + return hash('sha256', $token); + } + + private function intersectUserIds(?array $current, array $next): array + { + $normalize = static function (array $ids): array { + $normalized = []; + foreach ($ids as $id) { + if ($id === null) { + continue; + } + $normalized[(string) $id] = $id; + } + return $normalized; + }; + + $nextMap = $normalize($next); + if ($current === null) { + return array_values($nextMap); + } + + $currentMap = $normalize($current); + return array_values(array_intersect_key($currentMap, $nextMap)); + } + + private function uuidV4(): string + { + $data = random_bytes(16); + $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); + $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + +} diff --git a/src/Auth/Middleware/RequireRole.php b/src/Auth/Middleware/RequireRole.php new file mode 100644 index 0000000..3bfcd3a --- /dev/null +++ b/src/Auth/Middleware/RequireRole.php @@ -0,0 +1,52 @@ +roles = is_array($roles) ? $roles : [$roles]; + } + + public function after(string $method, string $fn, array $params, mixed $response): mixed + { + if ($this->auth->check() && is_array($response) && !isset($response['_user'])) { + $response['_user'] = $this->auth->user(); + } + + return $response; + } + + public function before(string $method, string $fn, array $params): array + { + if ($this->auth->check() && !$this->auth->revalidate()) { + throw new AuthException('Forbidden', 401); + } + + if ($this->auth->check()) { + $grants = \Objectiveweb\Auth::AUTHENTICATED; + $user = $this->auth->user(); + $roleField = $this->auth->params['roles']; + $roles = $user[$roleField] ?? []; + if (is_array($roles)) { + $grants = array_merge($grants, $roles); + } + } else { + $grants = \Objectiveweb\Auth::ANONYMOUS; + } + + if (count(array_intersect($this->roles, $grants)) === 0) { + $isAnonymous = count(array_intersect($grants, \Objectiveweb\Auth::ANONYMOUS)) > 0; + throw new AuthException('Forbidden', $isAnonymous ? 401 : 403); + } + + return $params; + } +} diff --git a/src/Auth/PasswordMismatchException.php b/src/Auth/PasswordMismatchException.php deleted file mode 100644 index 2c14d55..0000000 --- a/src/Auth/PasswordMismatchException.php +++ /dev/null @@ -1,7 +0,0 @@ -user = $user; + } + + function getUser() { + return $this->user; + } +} diff --git a/src/password.php b/src/password.php deleted file mode 100644 index 805caa5..0000000 --- a/src/password.php +++ /dev/null @@ -1,314 +0,0 @@ - - * @license http://www.opensource.org/licenses/mit-license.html MIT License - * @copyright 2012 The Authors - */ - -namespace { - - if (!defined('PASSWORD_BCRYPT')) { - /** - * PHPUnit Process isolation caches constants, but not function declarations. - * So we need to check if the constants are defined separately from - * the functions to enable supporting process isolation in userland - * code. - */ - define('PASSWORD_BCRYPT', 1); - define('PASSWORD_DEFAULT', PASSWORD_BCRYPT); - define('PASSWORD_BCRYPT_DEFAULT_COST', 10); - } - - if (!function_exists('password_hash')) { - - /** - * Hash the password using the specified algorithm - * - * @param string $password The password to hash - * @param int $algo The algorithm to use (Defined by PASSWORD_* constants) - * @param array $options The options for the algorithm to use - * - * @return string|false The hashed password, or false on error. - */ - function password_hash($password, $algo, array $options = array()) { - if (!function_exists('crypt')) { - trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING); - return null; - } - if (is_null($password) || is_int($password)) { - $password = (string) $password; - } - if (!is_string($password)) { - trigger_error("password_hash(): Password must be a string", E_USER_WARNING); - return null; - } - if (!is_int($algo)) { - trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING); - return null; - } - $resultLength = 0; - switch ($algo) { - case PASSWORD_BCRYPT: - $cost = PASSWORD_BCRYPT_DEFAULT_COST; - if (isset($options['cost'])) { - $cost = $options['cost']; - if ($cost < 4 || $cost > 31) { - trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING); - return null; - } - } - // The length of salt to generate - $raw_salt_len = 16; - // The length required in the final serialization - $required_salt_len = 22; - $hash_format = sprintf("$2y$%02d$", $cost); - // The expected length of the final crypt() output - $resultLength = 60; - break; - default: - trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING); - return null; - } - $salt_req_encoding = false; - if (isset($options['salt'])) { - switch (gettype($options['salt'])) { - case 'NULL': - case 'boolean': - case 'integer': - case 'double': - case 'string': - $salt = (string) $options['salt']; - break; - case 'object': - if (method_exists($options['salt'], '__tostring')) { - $salt = (string) $options['salt']; - break; - } - case 'array': - case 'resource': - default: - trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING); - return null; - } - if (PasswordCompat\binary\_strlen($salt) < $required_salt_len) { - trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", PasswordCompat\binary\_strlen($salt), $required_salt_len), E_USER_WARNING); - return null; - } elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) { - $salt_req_encoding = true; - } - } else { - $buffer = ''; - $buffer_valid = false; - if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) { - $buffer = mcrypt_create_iv($raw_salt_len, MCRYPT_DEV_URANDOM); - if ($buffer) { - $buffer_valid = true; - } - } - if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) { - $buffer = openssl_random_pseudo_bytes($raw_salt_len); - if ($buffer) { - $buffer_valid = true; - } - } - if (!$buffer_valid && @is_readable('/dev/urandom')) { - $file = fopen('/dev/urandom', 'r'); - $read = PasswordCompat\binary\_strlen($buffer); - while ($read < $raw_salt_len) { - $buffer .= fread($file, $raw_salt_len - $read); - $read = PasswordCompat\binary\_strlen($buffer); - } - fclose($file); - if ($read >= $raw_salt_len) { - $buffer_valid = true; - } - } - if (!$buffer_valid || PasswordCompat\binary\_strlen($buffer) < $raw_salt_len) { - $buffer_length = PasswordCompat\binary\_strlen($buffer); - for ($i = 0; $i < $raw_salt_len; $i++) { - if ($i < $buffer_length) { - $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255)); - } else { - $buffer .= chr(mt_rand(0, 255)); - } - } - } - $salt = $buffer; - $salt_req_encoding = true; - } - if ($salt_req_encoding) { - // encode string with the Base64 variant used by crypt - $base64_digits = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - $bcrypt64_digits = - './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - - $base64_string = base64_encode($salt); - $salt = strtr(rtrim($base64_string, '='), $base64_digits, $bcrypt64_digits); - } - $salt = PasswordCompat\binary\_substr($salt, 0, $required_salt_len); - - $hash = $hash_format . $salt; - - $ret = crypt($password, $hash); - - if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != $resultLength) { - return false; - } - - return $ret; - } - - /** - * Get information about the password hash. Returns an array of the information - * that was used to generate the password hash. - * - * array( - * 'algo' => 1, - * 'algoName' => 'bcrypt', - * 'options' => array( - * 'cost' => PASSWORD_BCRYPT_DEFAULT_COST, - * ), - * ) - * - * @param string $hash The password hash to extract info from - * - * @return array The array of information about the hash. - */ - function password_get_info($hash) { - $return = array( - 'algo' => 0, - 'algoName' => 'unknown', - 'options' => array(), - ); - if (PasswordCompat\binary\_substr($hash, 0, 4) == '$2y$' && PasswordCompat\binary\_strlen($hash) == 60) { - $return['algo'] = PASSWORD_BCRYPT; - $return['algoName'] = 'bcrypt'; - list($cost) = sscanf($hash, "$2y$%d$"); - $return['options']['cost'] = $cost; - } - return $return; - } - - /** - * Determine if the password hash needs to be rehashed according to the options provided - * - * If the answer is true, after validating the password using password_verify, rehash it. - * - * @param string $hash The hash to test - * @param int $algo The algorithm used for new password hashes - * @param array $options The options array passed to password_hash - * - * @return boolean True if the password needs to be rehashed. - */ - function password_needs_rehash($hash, $algo, array $options = array()) { - $info = password_get_info($hash); - if ($info['algo'] != $algo) { - return true; - } - switch ($algo) { - case PASSWORD_BCRYPT: - $cost = isset($options['cost']) ? $options['cost'] : PASSWORD_BCRYPT_DEFAULT_COST; - if ($cost != $info['options']['cost']) { - return true; - } - break; - } - return false; - } - - /** - * Verify a password against a hash using a timing attack resistant approach - * - * @param string $password The password to verify - * @param string $hash The hash to verify against - * - * @return boolean If the password matches the hash - */ - function password_verify($password, $hash) { - if (!function_exists('crypt')) { - trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING); - return false; - } - $ret = crypt($password, $hash); - if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != PasswordCompat\binary\_strlen($hash) || PasswordCompat\binary\_strlen($ret) <= 13) { - return false; - } - - $status = 0; - for ($i = 0; $i < PasswordCompat\binary\_strlen($ret); $i++) { - $status |= (ord($ret[$i]) ^ ord($hash[$i])); - } - - return $status === 0; - } - } - -} - -namespace PasswordCompat\binary { - - if (!function_exists('PasswordCompat\\binary\\_strlen')) { - - /** - * Count the number of bytes in a string - * - * We cannot simply use strlen() for this, because it might be overwritten by the mbstring extension. - * In this case, strlen() will count the number of *characters* based on the internal encoding. A - * sequence of bytes might be regarded as a single multibyte character. - * - * @param string $binary_string The input string - * - * @internal - * @return int The number of bytes - */ - function _strlen($binary_string) { - if (function_exists('mb_strlen')) { - return mb_strlen($binary_string, '8bit'); - } - return strlen($binary_string); - } - - /** - * Get a substring based on byte limits - * - * @see _strlen() - * - * @param string $binary_string The input string - * @param int $start - * @param int $length - * - * @internal - * @return string The substring - */ - function _substr($binary_string, $start, $length) { - if (function_exists('mb_substr')) { - return mb_substr($binary_string, $start, $length, '8bit'); - } - return substr($binary_string, $start, $length); - } - - /** - * Check if current PHP version is compatible with the library - * - * @return boolean the check result - */ - function check() { - static $pass = NULL; - - if (is_null($pass)) { - if (function_exists('crypt')) { - $hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG'; - $test = crypt("password", $hash); - $pass = $test == $hash; - } else { - $pass = false; - } - } - return $pass; - } - - } -} \ No newline at end of file diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index fb6a456..d9db4ac 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -1,105 +1,608 @@ originalServer = $_SERVER; + unset($_SERVER['REQUEST_METHOD'], $_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN']); + $_SESSION = []; + $this->auth = new BasicAuth([], ['token' => 'token']); + $this->controller = new AuthController($this->auth); + } + + protected function tearDown(): void + { + $_SERVER = $this->originalServer; + $_SESSION = []; + } + + public function testLogoutWithoutRedirectEndsSession(): void + { + $this->auth->register('logout@example.com', 'secret'); + $this->auth->login('logout@example.com', 'secret'); + + $this->assertSame([], $this->controller->getLogout()); + $this->assertFalse($this->auth->check()); + } + + public function testLogoutRejectsUnsafeRedirectsBeforeEndingSession(): void + { + $this->auth->register('logout@example.com', 'secret'); + $this->auth->login('logout@example.com', 'secret'); + + $invalid = [ + 'https://evil.example/', + 'http://evil.example/', + '//evil.example/', + '\\evil.example\\path', + '/\\evil.example/path', + 'javascript:alert(1)', + 'account', + "/safe\r\nLocation: https://evil.example/", + ]; + + foreach ($invalid as $redirect) { + try { + $this->controller->getLogout(['redirect' => $redirect]); + $this->fail("Unsafe redirect was accepted: {$redirect}"); + } catch (UserException $exception) { + $this->assertSame(400, $exception->getCode()); + $this->assertSame('Invalid redirect', $exception->getMessage()); + $this->assertTrue($this->auth->check()); + } + } + } + + public function testLogoutRedirectValidatorAcceptsSameOriginPaths(): void + { + $controller = new class($this->auth) extends AuthController { + public function validateRedirect(mixed $redirect): string + { + return $this->normalizeLogoutRedirect($redirect); + } + }; + + $this->assertSame('/', $controller->validateRedirect('/')); + $this->assertSame( + '/account/logout?done=1#top', + $controller->validateRedirect('/account/logout?done=1#top') + ); + } + + public function testPostMissingUid(): void + { + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->post(['password' => 'secret']); + } + + public function testPostMissingPassword(): void + { + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->post(['uid' => 'a@example.com']); + } + + public function testPostRegisterInvalidEmail(): void + { + $this->expectException(AuthException::class); + $this->controller->postRegister(['uid' => 'invalid', 'password' => 'secret']); + } + + public function testCredentialTokenRedemptionDoesNotAuthenticateAUser(): void + { + $user = $this->auth->register('verify@example.com', 'secret'); + $token = $this->auth->issue_credential_token($user['id'], 'local', 'verify@example.com'); + + $this->assertSame(['verified' => true], $this->controller->postVerifyCredential([ + 'provider' => 'local', + 'uid' => 'verify@example.com', + 'token' => $token, + ])); + $this->assertFalse($this->auth->check()); + $this->assertNotEmpty($this->auth->get_credential('local', 'verify@example.com')['verified_at']); + + try { + $this->controller->postVerifyCredential([ + 'provider' => 'local', + 'uid' => 'verify@example.com', + 'token' => $token, + ]); + $this->fail('Reused token was accepted'); + } catch (UserException $e) { + $this->assertSame(400, $e->getCode()); + } + } + + public function testCredentialTokenRedemptionRejectsMalformedAndMismatchedInputs(): void + { + $user = $this->auth->register('verify@example.com', 'secret'); + $token = $this->auth->issue_credential_token($user['id'], 'local', 'verify@example.com'); + + foreach ([ + ['provider' => 'local', 'uid' => 'verify@example.com', 'token' => 'wrong'], + ['provider' => 'email', 'uid' => 'verify@example.com', 'token' => $token], + ['provider' => 'local', 'uid' => 'else@example.com', 'token' => $token], + ['provider' => 'local', 'uid' => 'verify@example.com', 'token' => []], + ] as $form) { + try { + $this->controller->postVerifyCredential($form); + $this->fail('Invalid token request was accepted'); + } catch (UserException $e) { + $this->assertSame(400, $e->getCode()); + } + } + $this->assertNull($this->auth->get_credential('local', 'verify@example.com')['verified_at'] ?? null); + } + + public function testPostTokenPasswordMismatch(): void + { + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->postToken([ + 'token' => 'abc', + 'password' => 'a', + 'confirm' => 'b', + ]); + } + + public function testAuthenticatedPasswordChangeRequiresCsrfToken(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + + $this->expectException(UserException::class); + $this->expectExceptionCode(403); + $this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + } + + public function testAuthenticatedPasswordChangeRejectsInvalidCsrfToken(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['HTTP_X_CSRF_TOKEN'] = 'invalid'; + + $this->expectException(UserException::class); + $this->expectExceptionCode(403); + $this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + } + + public function testAuthenticatedPasswordChangeRejectsMissingCurrentPassword(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); + + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->postPassword([ + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + } + + public function testAuthenticatedPasswordChangeRejectsIncorrectCurrentPassword(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); + + try { + $this->controller->postPassword([ + 'current_password' => 'wrong-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + $this->fail('Incorrect current password was accepted'); + } catch (AuthException $exception) { + $this->assertSame(401, $exception->getCode()); + } + + $this->assertTrue(password_verify('old-secret', $this->auth->get(1)['password'])); + $this->assertFalse(password_verify('new-secret', $this->auth->get(1)['password'])); + } + + public function testAuthenticatedPasswordChangeRejectsMismatchedConfirmation(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); + + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'different', + ]); + } + + public function testAuthenticatedPasswordChangeSucceedsAndRequiresNewPasswordOnNextLogin(): void + { + $user = $this->auth->register('alice@example.com', 'old-secret'); + $resetToken = $this->auth->update_token($user['id']); + $this->auth->login('alice@example.com', 'old-secret'); + + $current = $this->controller->index(); + $this->assertIsString($current['_csrf']); + $_SERVER['HTTP_X_CSRF_TOKEN'] = $current['_csrf']; + + $this->assertTrue($this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ])); + + $changed = $this->auth->get($user['id']); + $this->assertNull($changed['token']); + $this->assertNull($changed['token_expires_at']); + try { + $this->auth->passwd_reset($resetToken, 'stale-reset-secret'); + $this->fail('Authenticated password change left a reset token active'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + $this->auth->logout(); + try { + $this->auth->login('alice@example.com', 'old-secret'); + $this->fail('Old password still works'); + } catch (AuthException) { + // Expected. + } + $this->assertSame(1, $this->auth->login('alice@example.com', 'new-secret')['id']); + } + + public function testPasswordlessOAuthAccountRequiresVerifiedResetFlow(): void + { + $user = $this->auth->register('oauth@example.com', null, ['provider' => 'google']); + $this->auth->establish_session($this->auth->get($user['id'])); + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + $this->controller->postPassword([ + 'current_password' => 'anything', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + } + + public function testAuthenticatedPasswordChangeRejectsFormEncodedHttpRequest(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); + + $this->expectException(UserException::class); + $this->expectExceptionCode(415); + $this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + } + + public function testAuthenticatedPasswordChangeAcceptsJsonHttpRequest(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json; charset=utf-8'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); - public static function setUpBeforeClass() + $this->assertTrue($this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ])); + $this->assertTrue(password_verify('new-secret', $this->auth->get(1)['password'])); + } + + public function testPostPasswordForgotFlowMissingUid(): void + { + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->postPassword(['password' => 'a', 'confirm' => 'a']); + } + + public function testPostPasswordForgotFlowDoesNotRevealUnknownCredential(): void + { + $callbackCalls = 0; + $auth = new BasicAuth([], [ + 'token' => 'token', + 'token_callback' => function () use (&$callbackCalls): void { + $callbackCalls++; + }, + ]); + $controller = new AuthController($auth); + + $result = $controller->postPassword(['uid' => 'missing@example.com']); + + $this->assertSame([], $result); + $this->assertSame(0, $callbackCalls); + } + + public function testPostPasswordForgotFlowIgnoresCallbackReturnValue(): void { + $auth = new BasicAuth([], [ + 'token' => 'token', + 'token_callback' => fn (): array => ['account_exists' => true], + ]); + $controller = new AuthController($auth); + $user = $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $auth->mark_credential_verified($user['id'], 'email', 'alice@example.com'); - $pdo = new PDO('mysql:dbname=objectiveweb;host=localhost', 'root'); - $pdo->query('drop table if exists ow_auth_test'); - $pdo->query('create table ow_auth_test - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `username` VARCHAR(255), - `displayName` VARCHAR(255), - `email` VARCHAR(255), - `created` DATETIME, - `last_login` DATETIME, - `password` CHAR(60), - `token` CHAR(32));'); + $result = $controller->postPassword(['uid' => 'alice@example.com']); - $auth = new Auth($pdo, array( - 'table' => 'ow_auth_test', - 'created' => 'created', + $this->assertSame([], $result); + } + + public function testPostPasswordForgotFlowHidesDeliveryFailure(): void + { + $auth = new BasicAuth([], [ 'token' => 'token', - 'last_login' => 'last_login' - )); - - self::$controller = new AuthController($auth); - } - - public function testPost() - { - - $user = self::$controller->post(array( - 'username' => 'user', - 'password' => 'test', - 'email' => 'vagrant@localhost', - 'displayName' => 'Test User')); - - $this->assertEquals(1, $user['id']); - - } - - /** - * @depends testPost - */ - public function testGet() { - $user = self::$controller->get('user'); - - $this->assertEquals(1, $user['id']); - } - - /** - * @depends testGet - */ - public function testQuery() { - $all = self::$controller->get(); - - $this->assertEquals(1, count($all['_embedded']['ow_auth_test'])); - - $this->assertEquals('Test User', $all['_embedded']['ow_auth_test'][0]['displayName']); - - $this->assertEquals(1, $all['page']['totalElements']); - $this->assertEquals(1, $all['page']['totalPages']); - $this->assertEquals(0, $all['page']['number']); - } - - /** - * @depends testQuery - */ - public function testPut() { - - self::$controller->put('user', array( 'displayName' => 'Updated name' )); - - $user = self::$controller->get('user'); - - $this->assertEquals('Updated name', $user['displayName']); - } - - /** - * @depends testPut - * @expectedException Objectiveweb\Auth\UserException - */ - public function testDelete() { - self::$controller->delete('user'); - - $user = self::$controller->get('user'); - } -} \ No newline at end of file + 'token_callback' => function (): void { + throw new \RuntimeException('mail transport unavailable'); + }, + ]); + $controller = new AuthController($auth); + $user = $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $auth->mark_credential_verified($user['id'], 'email', 'alice@example.com'); + + $result = $controller->postPassword(['uid' => 'alice@example.com']); + + $this->assertSame([], $result); + } + + public function testPasswordRecoveryDeliveryLogsNoSensitiveExceptionDetails(): void + { + $recoveryToken = null; + $auth = new BasicAuth([], [ + 'token' => 'token', + 'token_callback' => function (array $credential) use (&$recoveryToken): void { + $recoveryToken = $credential['token']; + throw new \RuntimeException( + 'Failed to deliver to private@example.com: ' + . 'https://app.example/reset?token=' . $recoveryToken + ); + }, + ]); + $user = $auth->register('private@example.com', 'secret', ['provider' => 'email']); + $auth->mark_credential_verified($user['id'], 'email', 'private@example.com'); + $controller = new AuthController($auth); + + $logFile = tempnam(sys_get_temp_dir(), 'ow-auth-recovery-'); + $this->assertNotFalse($logFile); + $previousLogFile = ini_get('error_log'); + try { + $this->assertNotFalse(ini_set('error_log', $logFile)); + $this->assertSame([], $controller->postPassword(['uid' => 'private@example.com'])); + + $logged = file_get_contents($logFile); + $this->assertIsString($logged); + $this->assertMatchesRegularExpression( + '/Password recovery delivery failed \[ref=[0-9a-f]{16}\]: RuntimeException/', + $logged + ); + $this->assertNotNull($recoveryToken); + $this->assertStringNotContainsString($recoveryToken, $logged); + $this->assertStringNotContainsString('private@example.com', $logged); + $this->assertStringNotContainsString('https://app.example/reset', $logged); + } finally { + ini_set('error_log', (string) $previousLogFile); + unlink($logFile); + } + } + + public function testPostPasswordForgotFlowUsesEmailCredential(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new AuthController($auth); + + $user = $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $auth->mark_credential_verified($user['id'], 'email', 'alice@example.com'); + $result = $controller->postPassword(['uid' => 'alice@example.com']); + + $this->assertSame([], $result); + } + + public function testPostPasswordForgotFlowUsesPhoneCredential(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new AuthController($auth); + + $user = $auth->register('+5511999999999', 'secret', ['provider' => 'phone']); + $auth->mark_credential_verified($user['id'], 'phone', '+5511999999999'); + $result = $controller->postPassword(['uid' => '+5511999999999']); + + $this->assertSame([], $result); + } + + public function testPostPasswordForgotFlowUsesLocalCredentialByDefault(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new AuthController($auth); + + $user = $auth->register('local@example.com', 'secret', ['provider' => 'local']); + $auth->mark_credential_verified($user['id'], 'local', 'local@example.com'); + $result = $controller->postPassword(['uid' => 'local@example.com']); + + $this->assertSame([], $result); + } + + public function testAnonymousRecoveryRejectsUnverifiedLocalWithoutRotatingResetToken(): void + { + $deliveries = []; + $auth = new BasicAuth([], [ + 'token' => 'token', + 'token_callback' => static function (array $credential) use (&$deliveries): void { + $deliveries[] = $credential; + }, + ]); + $user = $auth->register('legacy@example.com', 'secret'); + $controller = new AuthController($auth); + + // An admin-issued invitation/reset token must not be replaced by + // anonymous recovery to an unverified local identifier. + $existingToken = $auth->update_token($user['id']); + $existingDigest = $auth->get($user['id'])['token']; + + $this->assertSame([], $controller->postPassword(['uid' => 'legacy@example.com'])); + $this->assertSame([], $controller->postPassword(['uid' => 'missing@example.com'])); + $this->assertSame([], $deliveries); + $this->assertSame($existingDigest, $auth->get($user['id'])['token']); + + // Recovery becomes available only after independent channel proof. + $proof = $auth->issue_credential_token($user['id'], 'local', 'legacy@example.com'); + $this->assertTrue($auth->verify_credential_token('local', 'legacy@example.com', $proof)); + $this->assertSame([], $controller->postPassword(['uid' => 'legacy@example.com'])); + $this->assertCount(1, $deliveries); + $this->assertSame('local', $deliveries[0]['provider']); + $this->assertNotSame($existingToken, $deliveries[0]['token']); + $this->assertTrue(password_verify($deliveries[0]['token'], $auth->get($user['id'])['token'])); + } + + public function testAnonymousRecoverySkipsUnverifiedLocalInFavorOfVerifiedEmail(): void + { + $deliveries = []; + $auth = new BasicAuth([], [ + 'token' => 'token', + 'token_callback' => static function (array $credential) use (&$deliveries): void { + $deliveries[] = $credential; + }, + ]); + $user = $auth->register('shared@example.com', 'secret'); + $auth->create_credential($user['id'], 'email', 'shared@example.com'); + $proof = $auth->issue_credential_token($user['id'], 'email', 'shared@example.com'); + $this->assertTrue($auth->verify_credential_token('email', 'shared@example.com', $proof)); + $this->assertNull($auth->get_credential('local', 'shared@example.com')['verified_at']); + + $controller = new AuthController($auth); + $this->assertSame([], $controller->postPassword(['uid' => 'shared@example.com'])); + $this->assertCount(1, $deliveries); + $this->assertSame('email', $deliveries[0]['provider']); + } + + public function testPublicRegistrationCreatesOnlyLocalCredential(): void + { + $user = $this->controller->postRegister([ + 'uid' => 'new@example.com', + 'password' => 'secret', + 'name' => 'New user', + ]); + + $this->assertNotFalse($this->auth->get_credential('local', 'new@example.com')); + $this->assertFalse($this->auth->get_credential('email', 'new@example.com')); + $this->assertSame('New user', $user['name']); + } + + public function testPublicRegistrationRejectsProviderAndPrivilegedAttributes(): void + { + $payloads = [ + ['provider' => 'google'], + ['provider' => 'email'], + ['provider' => 'local'], + ['profile' => ['email_verified' => true]], + ['id' => 100], + ['uuid' => 'fake-uuid'], + ['disabled_at' => null], + ['token' => 'forged'], + ['context' => ['admin' => true]], + ]; + + foreach ($payloads as $i => $payload) { + $uid = "forbidden-{$i}@example.com"; + try { + $this->controller->postRegister(array_merge([ + 'uid' => $uid, + 'password' => 'secret', + ], $payload)); + $this->fail('Untrusted registration field was accepted'); + } catch (UserException $e) { + $this->assertSame(400, $e->getCode()); + } + $this->assertFalse($this->auth->get_credential('local', $uid)); + $this->assertFalse($this->auth->get_credential('email', $uid)); + $this->assertFalse($this->auth->get_credential('google', $uid)); + } + } + + public function testPublicRegistrationNeverAllowsRoleGrants(): void + { + $auth = new BasicAuth([], [ + 'token' => 'token', + 'register_allow_grants' => true, + ]); + $controller = new AuthController($auth); + $user = $controller->postRegister([ + 'uid' => 'no-admin@example.com', + 'password' => 'secret', + 'roles' => ['admin'], + ]); + $this->assertSame([], $user['roles'] ?? []); + } + + public function testPostRegisterStripsRolesByDefault(): void + { + $user = $this->controller->postRegister([ + 'uid' => 'safe@example.com', + 'password' => 'secret', + 'roles' => ['admin'], + ]); + + $this->assertSame([], $user['roles'] ?? []); + } + + public function testPostRegisterRejectsWhenRegisterScopeIsAuthenticated(): void + { + $controller = new AuthController(new BasicAuth([], [ + 'token' => 'token', + 'register_scope' => \Objectiveweb\Auth::AUTHENTICATED, + ])); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + $controller->postRegister([ + 'uid' => 'blocked@example.com', + 'password' => 'secret', + ]); + } + + public function testIndexReturnsUserWithCredentialsWhenLoggedIn(): void + { + $this->auth->register('alice@example.com', 'secret', ['provider' => 'local']); + $this->auth->update_credential(1, 'phone', '+5511999999999', ['country' => 'BR']); + $this->auth->login('alice@example.com', 'secret'); + + $result = $this->controller->index(); + + $this->assertSame(1, $result['id']); + $this->assertArrayHasKey('credentials', $result); + $this->assertCount(2, $result['credentials']); + } +} diff --git a/test/AuthTest.php b/test/AuthTest.php deleted file mode 100644 index cb71364..0000000 --- a/test/AuthTest.php +++ /dev/null @@ -1,162 +0,0 @@ -query('drop table if exists ow_auth_test'); - $pdo->query('create table ow_auth_test - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `username` VARCHAR(255), - `displayName` VARCHAR(255), - `email` VARCHAR(255), - `created` DATETIME, - `last_login` DATETIME, - `password` CHAR(60), - `token` CHAR(32));'); - - self::$auth = new Auth($pdo, array( - 'table' => 'ow_auth_test', - 'created' => 'created', - 'token' => 'token', - 'last_login' => 'last_login' - )); - } - - public function setUp() - { - $_SESSION = self::$shared_session; - } - - public function tearDown() - { - - self::$shared_session = $_SESSION; - } - - public function testRegistration() - { - - $user = AuthTest::$auth->register('user', 'test', array( - 'email' => 'vagrant@localhost', - 'displayName' => 'Test User' - )); - - $this->assertEquals(1, $user['id']); - - // test Login - $user = self::$auth->login('user', 'test'); - - $this->assertEquals(1, $user['id'], "Check user ID"); - $this->assertRegExp('/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}/', - $user['created'], - "Check creation date"); - $this->assertTrue(AuthTest::$auth->check()); - - // test Session - $user = self::$auth->user(); - - $this->assertEquals(1, $user['id']); - - // test Logout - self::$auth->logout(); - - $this->assertFalse(self::$auth->check()); - } - - /** - * @expectedException Objectiveweb\Auth\UserException - */ - public function testInvalidLogin() - { - self::$auth->login('nouser', 'pass'); - - } - - /** - * @expectedException Objectiveweb\Auth\PasswordMismatchException - */ - public function testInvalidPassword() - { - self::$auth->login('user', 'pass'); - - } - - public function testPasswd() - { - $t = self::$auth->passwd('user', "1234"); - - $this->assertTrue($t); - - $user = self::$auth->login('user', '1234'); - - $this->assertEquals(1, $user['id']); - - } - - /** - * @depends testRegistration - */ - public function testUpdate() { - - $user = self::$auth->get('user'); - - $this->assertEquals(1, $user['id']); - - self::$auth->update('user', array( 'displayName' => 'Updated name' )); - - $user = self::$auth->get('user'); - - $this->assertEquals('Updated name', $user['displayName']); - } - - /** - * @depends testUpdate - */ - public function testQuery() { - $all = self::$auth->query(); - - $this->assertEquals(1, count($all['_embedded']['ow_auth_test'])); - - $this->assertEquals('Updated name', $all['_embedded']['ow_auth_test'][0]['displayName']); - - $this->assertEquals(1, $all['page']['totalElements']); - $this->assertEquals(1, $all['page']['totalPages']); - $this->assertEquals(0, $all['page']['number']); - } - - public function testRequestToken() { - $token = self::$auth->update_token('user'); - - self::$auth->passwd_reset($token, 'test'); - - self::$auth->login('user', 'test'); - } - /** - * @expectedException Objectiveweb\Auth\UserException - */ - public function testDelete() { - self::$auth->logout(); - self::$auth->delete("user"); - self::$auth->login('user', 'pass'); - } - -} \ No newline at end of file diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php new file mode 100644 index 0000000..54e7fd0 --- /dev/null +++ b/test/BasicAuthTest.php @@ -0,0 +1,414 @@ +auth = new BasicAuth( + [ + 'alice@example.com' => 'secret', + ], + [ + 'token' => 'token', + ] + ); + } + + public function testLoginSuccessAndSessionData(): void + { + $user = $this->auth->login('alice@example.com', 'secret'); + + $this->assertSame(1, $user['id']); + $this->assertSame('alice@example.com', $user['uid']); + $this->assertTrue($this->auth->check()); + $this->assertArrayNotHasKey('password', $user); + } + + public function testPasswordLoginPreservesExistingCredentialProfile(): void + { + $profile = ['source' => 'import', 'attributes' => ['locale' => 'pt-BR']]; + $user = $this->auth->register('profile@example.com', 'secret', [ + 'profile' => $profile, + ]); + $original = $this->auth->get_credential('local', 'profile@example.com'); + $this->assertSame(json_encode($profile), $original['profile']); + $this->assertNull($original['last_login']); + + $this->auth->login('profile@example.com', 'secret'); + $afterLogin = $this->auth->get_credential('local', 'profile@example.com'); + $this->assertSame($original['profile'], $afterLogin['profile']); + $this->assertSame($original['created'], $afterLogin['created']); + $this->assertNotNull($afterLogin['last_login']); + + $this->auth->logout(); + $this->auth->login('profile@example.com', 'secret'); + $this->assertSame($original['profile'], $this->auth->get_credential('local', 'profile@example.com')['profile']); + + $listed = $this->auth->get_credentials($user['id']); + $this->assertCount(1, $listed); + $this->assertSame($profile, $listed[0]['profile']); + } + + public function testNullCredentialProfileDoesNotReplaceMetadataButNonNullDoes(): void + { + $user = $this->auth->register('update-profile@example.com', 'secret'); + $this->assertNull($this->auth->get_credential('local', 'update-profile@example.com')['profile']); + + $firstProfile = ['source' => 'google', 'avatar' => 'example.png']; + $this->auth->update_credential($user['id'], 'local', 'update-profile@example.com', $firstProfile); + $this->assertSame( + json_encode($firstProfile), + $this->auth->get_credential('local', 'update-profile@example.com')['profile'] + ); + + $this->auth->update_credential($user['id'], 'local', 'update-profile@example.com', null); + $this->assertSame( + json_encode($firstProfile), + $this->auth->get_credential('local', 'update-profile@example.com')['profile'] + ); + + $replacement = ['source' => 'manual']; + $this->auth->update_credential($user['id'], 'local', 'update-profile@example.com', $replacement); + $this->assertSame( + json_encode($replacement), + $this->auth->get_credential('local', 'update-profile@example.com')['profile'] + ); + $this->assertSame($replacement, $this->auth->get_credentials($user['id'])[0]['profile']); + } + + public function testLoginInvalidPassword(): void + { + $this->expectException(AuthException::class); + $this->auth->login('alice@example.com', 'wrong'); + } + + public function testPasswordLoginFailuresHaveIdenticalPublicErrors(): void + { + $auth = new BasicAuth([], ['disabled_at' => 'disabled_at']); + $auth->register('known@example.com', 'secret'); + $auth->register('disabled@example.com', 'secret', [ + 'disabled_at' => '2026-01-01 00:00:00', + ]); + $auth->register('passwordless@example.com', null); + + foreach ([ + ['absent@example.com', 'secret'], + ['known@example.com', 'wrong'], + ['disabled@example.com', 'secret'], + ['passwordless@example.com', 'anything'], + ] as [$uid, $password]) { + try { + $auth->login($uid, $password); + $this->fail('Invalid login unexpectedly succeeded'); + } catch (AuthException $e) { + $this->assertSame(401, $e->getCode()); + $this->assertSame('Invalid credentials', $e->getMessage()); + } + } + } + + public function testRegisterAndGetCredential(): void + { + $user = $this->auth->register('bob@example.com', 'pass123', ['name' => 'Bob']); + $credential = $this->auth->get_credential('local', 'bob@example.com'); + + $this->assertSame('Bob', $user['name']); + $this->assertSame($user['id'], $credential['user_id']); + } + + public function testPasswdInvalidatesOutstandingResetToken(): void + { + $credential = $this->auth->get_credential('local', 'alice@example.com'); + $userId = $credential['user_id']; + $token = $this->auth->update_token($userId); + $this->assertNotNull($this->auth->get($userId)['token']); + $this->assertNotNull($this->auth->get($userId)['token_expires_at']); + + $this->assertTrue($this->auth->passwd($userId, 'changed-secret')); + $updated = $this->auth->get($userId); + $this->assertTrue(password_verify('changed-secret', $updated['password'])); + $this->assertNull($updated['token']); + $this->assertNull($updated['token_expires_at']); + + try { + $this->auth->passwd_reset($token, 'stale-reset-secret'); + $this->fail('Password reset token survived a direct password change'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + $this->assertSame($userId, $this->auth->login('alice@example.com', 'changed-secret')['id']); + } + + public function testPasswordResetFlow(): void + { + $credential = $this->auth->get_credential('local', 'alice@example.com'); + $token = $this->auth->update_token($credential['user_id']); + + $this->auth->passwd_reset($token, 'new-secret'); + $user = $this->auth->login('alice@example.com', 'new-secret'); + + $this->assertSame('alice@example.com', $user['uid']); + } + + public function testDeleteUserRemovesCredential(): void + { + $credential = $this->auth->get_credential('local', 'alice@example.com'); + $this->auth->delete($credential['user_id']); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + $this->auth->login('alice@example.com', 'secret'); + } + + public function testLoginWithEmailProviderWhenConfiguredInLoginProviders(): void + { + $auth = new BasicAuth([], [ + 'token' => 'token', + 'login_providers' => ['email', 'local'], + ]); + + $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $user = $auth->login('alice@example.com', 'secret'); + + $this->assertSame('alice@example.com', $user['uid']); + } + + public function testGetCredentialsReturnsOnlyExpectedFields(): void + { + $user = $this->auth->register('bob@example.com', 'secret', ['provider' => 'email']); + $this->auth->update_credential($user['id'], 'phone', '+5511999999999', ['country' => 'BR']); + + $credentials = $this->auth->get_credentials($user['id']); + $this->assertCount(2, $credentials); + + foreach ($credentials as $credential) { + $this->assertSame( + ['uid', 'provider', 'profile', 'verified_at', 'last_login', 'created'], + array_keys($credential) + ); + $this->assertArrayHasKey('last_login', $credential); + $this->assertNotNull($credential['created']); + } + } + + public function testCredentialVerificationIsScopedSingleUseAndHashed(): void + { + $user = $this->auth->register('token-user@example.com', 'secret'); + $this->auth->create_credential($user['id'], 'email', 'token-user@example.com'); + $this->auth->create_credential($user['id'], 'phone', '+5511999999999'); + + $emailToken = $this->auth->issue_credential_token($user['id'], 'email', 'token-user@example.com'); + $phoneToken = $this->auth->issue_credential_token($user['id'], 'phone', '+5511999999999'); + + $credential = $this->auth->get_credential('email', 'token-user@example.com'); + $this->assertSame(hash('sha256', $emailToken), $credential['token']); + $this->assertNotSame($emailToken, $credential['token']); + $this->assertNotEmpty($credential['token_expires_at']); + $this->assertFalse($this->auth->verify_credential_token('phone', '+5511999999999', $emailToken)); + $this->assertFalse($this->auth->verify_credential_token('email', 'token-user@example.com', $phoneToken)); + $this->assertFalse($this->auth->verify_credential_token('email', 'token-user@example.com', 'bad-token')); + + $public = $this->auth->get_credentials($user['id']); + foreach ($public as $item) { + $this->assertArrayNotHasKey('token', $item); + $this->assertArrayNotHasKey('token_expires_at', $item); + } + + $this->assertTrue($this->auth->verify_credential_token('email', 'token-user@example.com', $emailToken)); + $this->assertFalse($this->auth->verify_credential_token('email', 'token-user@example.com', $emailToken)); + $this->assertNotEmpty($this->auth->get_credential('email', 'token-user@example.com')['verified_at']); + $this->assertNull($this->auth->get_credential('phone', '+5511999999999')['verified_at'] ?? null); + $this->assertTrue($this->auth->verify_credential_token('phone', '+5511999999999', $phoneToken)); + + // Metadata refresh keeps verification, but a changed UID requires proof again. + $this->auth->update_credential($user['id'], 'email', 'token-user@example.com', ['source' => 'profile']); + $this->assertNotEmpty($this->auth->get_credential('email', 'token-user@example.com')['verified_at']); + $renamed = $this->auth->rename_credential( + $user['id'], 'email', 'token-user@example.com', 'email', 'renamed@example.com' + ); + $this->assertNull($renamed['verified_at']); + } + + public function testExternalOAuthCredentialCannotBeVerifiedWithContactToken(): void + { + $this->auth->create_credential(1, 'google', 'google-id'); + $this->assertFalse($this->auth->verify_credential_token('google', 'google-id', 'anything')); + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->auth->issue_credential_token(1, 'google', 'google-id'); + } + + public function testRenameCredentialNormalizesTargetAndPreservesMetadata(): void + { + $user = $this->auth->register('rename@example.com', 'secret'); + $created = $this->auth->create_credential( + $user['id'], + 'phone', + '+5511999999999', + ['country' => 'BR'] + ); + + $renamed = $this->auth->rename_credential( + $user['id'], + 'phone', + '+5511999999999', + ' sms ', + ' +5511888888888 ' + ); + + $this->assertSame('sms', $renamed['provider']); + $this->assertSame('+5511888888888', $renamed['uid']); + $this->assertSame($created['created'], $renamed['created']); + $this->assertSame($created['last_login'], $renamed['last_login']); + $this->assertFalse($this->auth->get_credential('phone', '+5511999999999')); + } + + public function testRenameCredentialRejectsDuplicateAfterNormalization(): void + { + $user = $this->auth->register('duplicate-rename@example.com', 'secret'); + $this->auth->create_credential($user['id'], 'phone', '+5511999999999'); + $this->auth->create_credential($user['id'], 'sms', '+5511888888888'); + + $this->expectException(UserException::class); + $this->expectExceptionCode(409); + + $this->auth->rename_credential( + $user['id'], + 'phone', + '+5511999999999', + ' sms ', + ' +5511888888888 ' + ); + } + + public function testDeletionGuardCallbackPreventsDeletion(): void + { + $auth = new BasicAuth( + ['guarded@example.com' => 'secret'], + [ + 'token' => 'token', + 'deletion_guard_callback' => fn ($userId): string => 'User has application history', + ] + ); + $credential = $auth->get_credential('local', 'guarded@example.com'); + + try { + $auth->delete($credential['user_id']); + $this->fail('Expected deletion guard to reject deletion'); + } catch (UserException $exception) { + $this->assertSame(409, $exception->getCode()); + $this->assertSame('User has application history', $exception->getMessage()); + } + + $this->assertSame( + $credential['user_id'], + $auth->get($credential['user_id'])['id'] + ); + $this->assertNotFalse($auth->get_credential('local', 'guarded@example.com')); + } + + public function testSessionContextIsLoadedAndRefreshedFromApplication(): void + { + $assignments = [1 => [5, 8]]; + $calls = 0; + $auth = new BasicAuth([], [ + 'user_context_callback' => function (array $user) use (&$assignments, &$calls): array { + $calls++; + $this->assertArrayNotHasKey('password', $user); + return ['venues' => $assignments[$user['id']] ?? []]; + }, + ]); + $auth->register('context@example.com', 'secret'); + $session = $auth->login('context@example.com', 'secret'); + $this->assertSame(['venues' => [5, 8]], $session['context']); + $this->assertSame(1, $calls); + + $assignments[1] = [8, 13]; + $this->assertSame(['venues' => [5, 8]], $auth->user()['context']); + $this->assertTrue($auth->revalidate()); + $this->assertSame(['venues' => [8, 13]], $auth->user()['context']); + $this->assertSame(2, $calls); + + $auth->logout(); + $this->assertFalse($auth->check()); + } + + public function testContextCallbackReplacesUntrustedSessionContext(): void + { + $auth = new BasicAuth([], [ + 'user_context_callback' => function (array $user): array { + $this->assertArrayNotHasKey('context', $user); + return ['venues' => [7]]; + }, + ]); + $user = $auth->register('trusted@example.com', 'secret'); + $principal = $auth->get($user['id']); + $principal['context'] = ['venues' => [999]]; + + $session = $auth->establish_session($principal); + $this->assertSame(['venues' => [7]], $session['context']); + } + + public function testContextCallbackMustReturnArrayWithoutMutatingSession(): void + { + $auth = new BasicAuth([], [ + 'user_context_callback' => fn (array $user): string => 'invalid', + ]); + $auth->register('invalid-context@example.com', 'secret'); + + try { + $auth->login('invalid-context@example.com', 'secret'); + $this->fail('Expected callback return type validation'); + } catch (\UnexpectedValueException $exception) { + $this->assertSame('user_context_callback must return an array', $exception->getMessage()); + } + + $this->assertFalse($auth->check()); + } + + public function testQueryReturnsCollectionWithPaginationMetadata(): void + { + $this->auth->register('bob@example.com', 'secret', ['name' => 'Bob']); + $this->auth->register('carol@example.com', 'secret', ['name' => 'Carol']); + + $result = $this->auth->query([ + 'page' => 1, + 'size' => 2, + 'sort' => 'name ASC', + ]); + + $this->assertInstanceOf(\Objectiveweb\DB\Collection::class, $result); + $this->assertSame(3, $result->total()); + $this->assertSame('items 2-2/3', $result->contentRange()); + $this->assertCount(1, $result); + $this->assertSame('alice', $result[0]['name']); + } + + public function testGetUsersByRoleReturnsMatchingUsers(): void + { + $admin = $this->auth->register('admin@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + $this->auth->register('viewer@example.com', 'secret', [ + 'roles' => ['viewer'], + ]); + + $admins = $this->auth->get_users_by_role('admin'); + + $this->assertCount(1, $admins); + $this->assertSame($admin['id'], $admins[0]['id']); + $this->assertSame(['admin'], $admins[0]['roles']); + } +} diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php new file mode 100644 index 0000000..d66673d --- /dev/null +++ b/test/DBAuthSqliteTest.php @@ -0,0 +1,1610 @@ +beforeVerificationUpdate !== null) { + $callback = $this->beforeVerificationUpdate; + $this->beforeVerificationUpdate = null; + $callback(); + } + return parent::update($table, $data, $where); + } +} + +class DBAuthSqliteTest extends TestCase +{ + private static DB $db; + private static DBAuth $auth; + + public static function setUpBeforeClass(): void + { + self::$db = new DB('sqlite::memory:'); + + self::$db->query( + 'CREATE TABLE auth_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + name TEXT, + image TEXT, + created TEXT, + password TEXT, + token TEXT, + token_expires_at TEXT, + disabled_at TEXT + )' + )->exec(); + + self::$db->query('CREATE INDEX auth_user_token_idx ON auth_user(token)')->exec(); + + self::$db->query( + 'CREATE TABLE auth_credentials ( + uid TEXT NOT NULL, + provider TEXT NOT NULL, + user_id INTEGER NOT NULL, + profile TEXT NULL, + token TEXT NULL, + token_expires_at TEXT NULL, + verified_at TEXT NULL, + last_login TEXT NULL, + created TEXT NULL, + PRIMARY KEY(uid, provider) + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE auth_role ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE auth_user_role ( + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + PRIMARY KEY(user_id, role_id) + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE user_delegations ( + user_id INTEGER NOT NULL, + target_user_id INTEGER NOT NULL, + ability TEXT NOT NULL + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE item_users ( + user_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + ability TEXT NOT NULL + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE managed_items ( + user_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + PRIMARY KEY(user_id, item_id) + )' + )->exec(); + + self::$auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => 'created', + 'roles' => 'roles', + 'disabled_at' => 'disabled_at', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'user' => [ + 'table' => 'user_delegations', + 'subject_key' => 'user_id', + 'target_key' => 'target_user_id', + 'ability_key' => 'ability', + 'target_is_user' => true, + 'auth_owned' => true, + ], + 'item' => [ + 'table' => 'item_users', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'ability_key' => 'ability', + ], + ], + ]); + } + + protected function setUp(): void + { + $_SESSION = []; + self::$db->query('DELETE FROM auth_credentials')->exec(); + self::$db->query('DELETE FROM auth_user_role')->exec(); + self::$db->query('DELETE FROM auth_role')->exec(); + self::$db->query('DELETE FROM user_delegations')->exec(); + self::$db->query('DELETE FROM item_users')->exec(); + self::$db->query('DELETE FROM managed_items')->exec(); + self::$db->query('DELETE FROM auth_user')->exec(); + } + + + public function testRegistration(): void + { + $user = self::$auth->register('alice@example.com', 'secret', ['name' => 'Alice']); + $this->assertArrayHasKey('id', $user); + $this->assertSame(1, (int) $user['id']); + $this->assertArrayHasKey('uuid', $user); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $user['uuid'] + ); + $this->assertSame('Alice', $user['name']); + $this->assertArrayNotHasKey('password', $user); + $this->assertArrayNotHasKey('token', $user); + $this->assertArrayNotHasKey('token_expires_at', $user); + + $stored = self::$auth->get($user['id']); + $this->assertArrayHasKey('password', $stored); + + $logged = self::$auth->login('alice@example.com', 'secret'); + $this->assertSame($user['id'], $logged['id']); + $this->assertMatchesRegularExpression( + '/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}/', + $logged['created'] + ); + $this->assertTrue(self::$auth->check()); + + $sessionUser = self::$auth->user(); + $this->assertSame($user['id'], $sessionUser['id']); + + self::$auth->logout(); + $this->assertFalse(self::$auth->check()); + } + + public function testInvalidLogin(): void + { + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + self::$auth->login('nouser@example.com', 'pass'); + } + + public function testInvalidPassword(): void + { + self::$auth->register('alice@example.com', 'secret'); + $this->expectException(AuthException::class); + self::$auth->login('alice@example.com', 'wrong'); + } + + public function testLoginWithPhoneProviderWhenConfiguredInLoginProviders(): void + { + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'token' => 'token', + 'created' => 'created', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'login_providers' => ['phone', 'email', 'local'], + ]); + + $auth->register('+5511999999999', 'secret', ['provider' => 'phone', 'name' => 'Phone User']); + $logged = $auth->login('+5511999999999', 'secret'); + + $this->assertSame('Phone User', $logged['name']); + } + + public function testUsesCredentialLastLoginDefaultWithConfiguredRoleTables(): void + { + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'token' => 'token', + 'created' => 'created', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + + $user = $auth->register('autodetect@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + + self::$db->update('auth_credentials', ['last_login' => null], [ + 'provider' => 'local', + 'uid' => 'autodetect@example.com', + ]); + + $logged = $auth->login('autodetect@example.com', 'secret'); + $credential = $auth->get_credential('local', 'autodetect@example.com'); + + $this->assertSame($user['id'], $logged['id']); + $this->assertSame(['admin'], $logged['roles']); + $this->assertNotNull($credential['last_login'] ?? null); + } + + public function testLoginHydratesRolesWhenRoleIsAssignedInDatabase(): void + { + $user = self::$auth->register('dbrole@example.com', 'secret', ['name' => 'DB Role User']); + + $roleId = self::$db->insert('auth_role', ['name' => 'manager']); + self::$db->insert('auth_user_role', [ + 'user_id' => $user['id'], + 'role_id' => $roleId, + ]); + + $logged = self::$auth->login('dbrole@example.com', 'secret'); + $this->assertArrayHasKey('roles', $logged); + $this->assertSame(['manager'], $logged['roles']); + + $sessionUser = self::$auth->user(); + $this->assertArrayHasKey('roles', $sessionUser); + $this->assertSame(['manager'], $sessionUser['roles']); + } + + public function testLoginHydratesRolesWhenRoleTablesAreConfiguredAtInstantiation(): void + { + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'token' => 'token', + 'created' => 'created', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + + $user = $auth->register('autorole@example.com', 'secret'); + $roleId = self::$db->insert('auth_role', ['name' => 'viewer']); + self::$db->insert('auth_user_role', [ + 'user_id' => $user['id'], + 'role_id' => $roleId, + ]); + + $logged = $auth->login('autorole@example.com', 'secret'); + $this->assertArrayHasKey('roles', $logged); + $this->assertSame(['viewer'], $logged['roles']); + } + + public function testPasswd(): void + { + $user = self::$auth->register('alice@example.com', 'secret'); + $account = self::$auth->get_credential('local', 'alice@example.com'); + + $this->assertNotFalse($account); + + $updated = self::$auth->passwd($account['user_id'], 'new-secret'); + $this->assertTrue($updated); + + $logged = self::$auth->login('alice@example.com', 'new-secret'); + $this->assertSame($user['id'], $logged['id']); + } + + public function testPasswdInvalidatesOutstandingResetToken(): void + { + $user = self::$auth->register('revoke-reset@example.com', 'original-secret'); + $token = self::$auth->update_token($user['id']); + $before = self::$auth->get($user['id']); + $this->assertSame(hash('sha256', $token), $before['token']); + $this->assertNotNull($before['token_expires_at']); + + $this->assertTrue(self::$auth->passwd($user['id'], 'changed-secret')); + $after = self::$auth->get($user['id']); + $this->assertTrue(password_verify('changed-secret', $after['password'])); + $this->assertNull($after['token']); + $this->assertNull($after['token_expires_at']); + + try { + self::$auth->passwd_reset($token, 'should-not-work'); + $this->fail('A reset link issued before password change was accepted'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + $this->assertSame($user['id'], self::$auth->login('revoke-reset@example.com', 'changed-secret')['id']); + $this->assertTrue(password_verify('changed-secret', self::$auth->get($user['id'])['password'])); + } + + public function testUpdate(): void + { + $user = self::$auth->register('alice@example.com', 'secret', ['name' => 'Alice']); + $account = self::$auth->get_credential('local', 'alice@example.com'); + + $current = self::$auth->get($account['user_id']); + $this->assertSame('Alice', $current['name']); + $this->assertSame($user['uuid'], $current['uuid']); + + self::$auth->update($user['id'], ['name' => 'Alice Updated', 'uuid' => 'manual-value']); + $updated = self::$auth->get($user['id']); + + $this->assertSame('Alice Updated', $updated['name']); + $this->assertSame($user['uuid'], $updated['uuid']); + } + + public function testQuery(): void + { + self::$auth->register('alice@example.com', 'secret', ['name' => 'Alice']); + $list = self::$auth->query(); + + $this->assertInstanceOf(\Objectiveweb\DB\Collection::class, $list); + $this->assertCount(1, $list); + $this->assertSame(1, $list->total()); + $this->assertSame('items 0-0/1', $list->contentRange()); + $this->assertSame('Alice', $list[0]['name']); + $this->assertArrayNotHasKey('password', $list[0]); + $this->assertArrayNotHasKey('token', $list[0]); + $this->assertArrayNotHasKey('token_expires_at', $list[0]); + } + + public function testQueryPaginatesAndSortsBeforeHydration(): void + { + self::$auth->register('charlie@example.com', 'secret', ['name' => 'Charlie']); + self::$auth->register('alice@example.com', 'secret', ['name' => 'Alice']); + self::$auth->register('bob@example.com', 'secret', ['name' => 'Bob']); + + $first = self::$auth->query([ + 'page' => 0, + 'size' => 2, + 'sort' => 'name ASC', + ]); + $second = self::$auth->query([ + 'page' => 1, + 'size' => 2, + 'sort' => 'name ASC', + ]); + + $this->assertSame(3, $first->total()); + $this->assertSame('items 0-1/3', $first->contentRange()); + $this->assertSame('items 2-2/3', $second->contentRange()); + $this->assertSame(['Alice', 'Bob'], array_column($first->data(), 'name')); + $this->assertSame(['Charlie'], array_column($second->data(), 'name')); + + foreach ($first as $user) { + $this->assertArrayHasKey('credentials', $user); + $this->assertCount(1, $user['credentials']); + } + } + + public function testQueryCombinesSearchRoleAndLifecycleFilters(): void + { + self::$auth->register('active-admin@example.com', 'secret', [ + 'name' => 'Active Admin', + 'roles' => ['admin'], + ]); + self::$auth->register('suspended-admin@example.com', 'secret', [ + 'name' => 'Suspended Admin', + 'roles' => ['admin'], + 'disabled_at' => '2026-09-30 12:00:00', + ]); + self::$auth->register('active-viewer@example.com', 'secret', [ + 'name' => 'Active Viewer', + 'roles' => ['viewer'], + ]); + + $result = self::$auth->query([ + 'q' => 'admin@example.com', + 'role' => 'admin', + 'status' => 'active', + 'size' => 10, + ]); + + $this->assertSame(1, $result->total()); + $this->assertSame('Active Admin', $result[0]['name']); + $this->assertSame(['admin'], $result[0]['roles']); + $this->assertSame( + 'active-admin@example.com', + $result[0]['credentials'][0]['uid'] + ); + } + + public function testQueryUnassignedWorksWithAndWithoutRoleTables(): void + { + self::$auth->register('assigned@example.com', 'secret', ['roles' => ['admin']]); + self::$auth->register('unassigned@example.com', 'secret'); + + $withRoles = self::$auth->query(['role' => 'unassigned', 'size' => 10]); + $this->assertSame(1, $withRoles->total()); + $this->assertSame('unassigned', $withRoles[0]['name']); + + $withoutRoles = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => 'created', + 'roles_table' => null, + 'user_roles_table' => null, + ]); + $result = $withoutRoles->query(['role' => 'unassigned', 'size' => 10]); + $this->assertSame(2, $result->total()); + } + + public function testRequestToken(): void + { + $user = self::$auth->register('alice@example.com', 'secret'); + $account = self::$auth->get_credential('local', 'alice@example.com'); + $this->assertSame($user['id'], $account['user_id']); + + $token = self::$auth->update_token($account['user_id']); + $stored = self::$db->select('auth_user', ['id' => $account['user_id']], ['limit' => 1])->fetch(); + $this->assertIsString($stored['token']); + $this->assertSame(hash('sha256', $token), $stored['token']); + $this->assertSame(64, strlen($stored['token'])); + $this->assertNotNull($stored['token_expires_at']); + $reset = self::$auth->passwd_reset($token, 'final-secret'); + $this->assertArrayNotHasKey('password', $reset); + $this->assertArrayNotHasKey('token', $reset); + $this->assertArrayNotHasKey('token_expires_at', $reset); + + $logged = self::$auth->login('alice@example.com', 'final-secret'); + $this->assertSame($user['id'], $logged['id']); + } + + public function testPasswdResetTokenCannotBeReused(): void + { + $user = self::$auth->register('single-use@example.com', 'original-secret'); + $token = self::$auth->update_token($user['id']); + + self::$auth->passwd_reset($token, 'first-password'); + + try { + self::$auth->passwd_reset($token, 'second-password'); + $this->fail('A consumed password reset token was accepted again'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + $row = self::$db->select('auth_user', ['id' => $user['id']], ['limit' => 1])->fetch(); + $this->assertTrue(password_verify('first-password', $row['password'])); + $this->assertFalse(password_verify('second-password', $row['password'])); + $this->assertNull($row['token']); + $this->assertNull($row['token_expires_at']); + } + + public function testPasswdResetConcurrentRequestsCannotOverwriteWinner(): void + { + // Interleave two requests after the first has read the token but + // before it updates the row. This deterministically reproduces the + // race that an ID-only UPDATE allowed. + $db = new class ('sqlite::memory:') extends DB { + public ?Closure $beforeResetUpdate = null; + + public function update(string $table, array $data, ?array $where = null): int + { + if ($table === 'user' && array_key_exists('password', $data) + && array_key_exists('token', $data) + && $this->beforeResetUpdate !== null) { + $callback = $this->beforeResetUpdate; + $this->beforeResetUpdate = null; + $callback(); + } + return parent::update($table, $data, $where); + } + }; + + $db->query( + 'CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + password TEXT, + token TEXT, + token_expires_at TEXT + )' + )->exec(); + + $auth = new DBAuth($db, [ + 'table' => 'user', + 'created' => null, + 'uuid' => null, + 'token' => 'token', + 'roles_table' => null, + 'user_roles_table' => null, + ]); + $userId = $db->insert('user', [ + 'password' => password_hash('original-secret', PASSWORD_BCRYPT), + 'token' => null, + 'token_expires_at' => null, + ]); + $token = $auth->update_token($userId); + + $competitorSucceeded = false; + $db->beforeResetUpdate = function () use ($auth, $token, &$competitorSucceeded): void { + // This reset wins first, consuming the only valid token. + $auth->passwd_reset($token, 'winning-password'); + $competitorSucceeded = true; + }; + + try { + $auth->passwd_reset($token, 'stale-password'); + $this->fail('The losing concurrent reset overwrote the winner'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + $this->assertTrue($competitorSucceeded); + $row = $db->select('user', ['id' => $userId], ['limit' => 1])->fetch(); + $this->assertTrue(password_verify('winning-password', $row['password'])); + $this->assertFalse(password_verify('stale-password', $row['password'])); + $this->assertNull($row['token']); + } + + public function testPasswdResetDoesNotOverwriteRotatedToken(): void + { + $db = new class ('sqlite::memory:') extends DB { + public ?Closure $beforeResetUpdate = null; + + public function update(string $table, array $data, ?array $where = null): int + { + if ($table === 'user' && array_key_exists('password', $data) + && array_key_exists('token', $data) + && $this->beforeResetUpdate !== null) { + $callback = $this->beforeResetUpdate; + $this->beforeResetUpdate = null; + $callback(); + } + return parent::update($table, $data, $where); + } + }; + + $db->query( + 'CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + password TEXT, + token TEXT, + token_expires_at TEXT + )' + )->exec(); + $auth = new DBAuth($db, [ + 'table' => 'user', + 'created' => null, + 'uuid' => null, + 'token' => 'token', + 'roles_table' => null, + 'user_roles_table' => null, + ]); + $userId = $db->insert('user', [ + 'password' => password_hash('original-secret', PASSWORD_BCRYPT), + 'token' => null, + 'token_expires_at' => null, + ]); + $oldToken = $auth->update_token($userId); + $newToken = null; + $db->beforeResetUpdate = function () use ($auth, $userId, &$newToken): void { + $newToken = $auth->update_token($userId); + }; + + try { + $auth->passwd_reset($oldToken, 'should-not-be-set'); + $this->fail('A rotated token was ignored by the conditional update'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + $row = $db->select('user', ['id' => $userId], ['limit' => 1])->fetch(); + $this->assertTrue(password_verify('original-secret', $row['password'])); + $this->assertSame(hash('sha256', $newToken), $row['token']); + $auth->passwd_reset($newToken, 'new-secret'); + $row = $db->select('user', ['id' => $userId], ['limit' => 1])->fetch(); + $this->assertTrue(password_verify('new-secret', $row['password'])); + } + + public function testPasswdResetRejectsExpiredToken(): void + { + $user = self::$auth->register('expired@example.com', 'secret'); + $token = self::$auth->update_token($user['id']); + self::$db->update('auth_user', ['token_expires_at' => '2000-01-01 00:00:00'], ['id' => $user['id']]); + + $this->expectException(UserException::class); + self::$auth->passwd_reset($token, 'new-secret'); + } + + public function testGetByConfiguredUuidUsesUserTable(): void + { + $user = self::$auth->register('uuid-lookup@example.com', 'secret'); + + $loaded = self::$auth->get($user['uuid'], 'uuid'); + + $this->assertSame((int) $user['id'], (int) $loaded['id']); + $this->assertSame($user['uuid'], $loaded['uuid']); + } + + public function testPasswdResetUsesConfiguredDatabasePrefix(): void + { + $db = new DB('sqlite::memory:', null, '', ['prefix' => 'app_']); + $db->query( + 'CREATE TABLE app_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + password TEXT, + token TEXT, + token_expires_at TEXT + )' + )->exec(); + + $auth = new DBAuth($db, [ + 'table' => 'user', + 'created' => null, + 'uuid' => null, + 'token' => 'token', + 'roles_table' => null, + 'user_roles_table' => null, + ]); + + $userId = $db->insert('user', ['password' => null, 'token' => null, 'token_expires_at' => null]); + $token = $auth->update_token($userId); + $user = $auth->passwd_reset($token, 'new-secret'); + + $this->assertSame((int) $userId, (int) $user['id']); + $stored = $db->select('user', ['id' => $userId], ['limit' => 1])->fetch(); + $this->assertTrue(password_verify('new-secret', $stored['password'])); + $this->assertNull($stored['token']); + $this->assertNull($stored['token_expires_at']); + } + + public function testDbAuthAuthenticatedPasswordChangeVerifiesCurrentPassword(): void + { + $user = self::$auth->register('db-password-change@example.com', 'old-secret'); + $outstandingReset = self::$auth->update_token($user['id']); + self::$auth->login('db-password-change@example.com', 'old-secret'); + $controller = new AuthController(self::$auth); + + $originalHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $controller->index()['_csrf']; + + try { + try { + $controller->postPassword([ + 'current_password' => 'invalid', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + $this->fail('Incorrect current password was accepted'); + } catch (AuthException $exception) { + $this->assertSame(401, $exception->getCode()); + } + + $this->assertTrue(password_verify('old-secret', self::$auth->get($user['id'])['password'])); + $this->assertTrue($controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ])); + $updated = self::$auth->get($user['id']); + $this->assertTrue(password_verify('new-secret', $updated['password'])); + $this->assertNull($updated['token']); + $this->assertNull($updated['token_expires_at']); + try { + self::$auth->passwd_reset($outstandingReset, 'stale-reset-secret'); + $this->fail('Authenticated password change left a reset token active'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + self::$auth->logout(); + $this->assertSame($user['id'], self::$auth->login('db-password-change@example.com', 'new-secret')['id']); + } finally { + if ($originalHeader === null) { + unset($_SERVER['HTTP_X_CSRF_TOKEN']); + } else { + $_SERVER['HTTP_X_CSRF_TOKEN'] = $originalHeader; + } + } + } + + public function testAnonymousRecoveryUsesOnlyVerifiedDbAuthCredentials(): void + { + $user = self::$auth->register('db-recovery@example.com', 'secret'); + $controller = new AuthController(self::$auth); + $deliveries = []; + $previousCallback = self::$auth->params['token_callback']; + self::$auth->params['token_callback'] = static function (array $credential) use (&$deliveries): void { + $deliveries[] = $credential; + }; + + try { + $existingToken = self::$auth->update_token($user['id']); + $existingDigest = self::$auth->get($user['id'])['token']; + + $this->assertSame([], $controller->postPassword(['uid' => 'db-recovery@example.com'])); + $this->assertSame([], $controller->postPassword(['uid' => 'unknown@example.com'])); + $this->assertSame([], $deliveries); + $this->assertSame($existingDigest, self::$auth->get($user['id'])['token']); + + $proof = self::$auth->issue_credential_token($user['id'], 'local', 'db-recovery@example.com'); + $this->assertTrue(self::$auth->verify_credential_token('local', 'db-recovery@example.com', $proof)); + + $this->assertSame([], $controller->postPassword(['uid' => 'db-recovery@example.com'])); + $this->assertCount(1, $deliveries); + $this->assertSame('local', $deliveries[0]['provider']); + $this->assertNotSame($existingToken, $deliveries[0]['token']); + $this->assertSame( + hash('sha256', $deliveries[0]['token']), + self::$auth->get($user['id'])['token'] + ); + } finally { + self::$auth->params['token_callback'] = $previousCallback; + } + } + + public function testAuthControllerResponsesDoNotExposeUserSecrets(): void + { + $controller = new AuthController(self::$auth); + + $registered = $controller->postRegister([ + 'uid' => 'controller@example.com', + 'password' => 'secret', + ]); + $this->assertArrayNotHasKey('password', $registered); + $this->assertArrayNotHasKey('token', $registered); + $this->assertArrayNotHasKey('token_expires_at', $registered); + + $token = self::$auth->update_token($registered['id']); + $reset = $controller->postToken([ + 'token' => $token, + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + $this->assertArrayNotHasKey('password', $reset); + $this->assertArrayNotHasKey('token', $reset); + $this->assertArrayNotHasKey('token_expires_at', $reset); + } + + public function testInvitationCallbackReceivesSanitizedUser(): void + { + $captured = null; + self::$auth->params['invitation_callback'] = function (array $user) use (&$captured): void { + $captured = $user; + }; + + try { + $user = self::$auth->register('invite@example.com', 'secret'); + self::$auth->invite($user['id']); + + $this->assertIsArray($captured); + $this->assertArrayNotHasKey('password', $captured); + $this->assertArrayNotHasKey('token', $captured); + $this->assertArrayNotHasKey('token_expires_at', $captured); + } finally { + self::$auth->params['invitation_callback'] = null; + } + } + + public function testDuplicateCredentialExceptionDoesNotExposeUserSecrets(): void + { + self::$auth->register('duplicate@example.com', 'secret'); + + try { + self::$auth->register('duplicate@example.com', 'another-secret'); + $this->fail('Expected duplicate credential exception'); + } catch (UserException $exception) { + $user = $exception->getUser(); + $this->assertIsArray($user); + $this->assertArrayNotHasKey('password', $user); + $this->assertArrayNotHasKey('token', $user); + $this->assertArrayNotHasKey('token_expires_at', $user); + } + } + + public function testDelete(): void + { + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + + self::$auth->register('alice@example.com', 'secret'); + $account = self::$auth->get_credential('local', 'alice@example.com'); + self::$auth->logout(); + + self::$auth->delete($account['user_id']); + self::$auth->login('alice@example.com', 'secret'); + } + + public function testGetCredentialsReturnsOnlyExpectedFields(): void + { + $user = self::$auth->register('alice@example.com', 'secret'); + self::$auth->update_credential($user['id'], 'phone', '+5511999999999', ['carrier' => 'test']); + + $credentials = self::$auth->get_credentials($user['id']); + + $this->assertCount(2, $credentials); + foreach ($credentials as $credential) { + $this->assertSame( + ['uid', 'provider', 'profile', 'verified_at', 'last_login', 'created'], + array_keys($credential) + ); + $this->assertArrayHasKey('last_login', $credential); + $this->assertNotNull($credential['created']); + } + } + + public function testUserCanChecksRoles(): void + { + self::$auth->register('scoped@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + + self::$auth->login('scoped@example.com', 'secret'); + + $this->assertTrue(self::$auth->user_can('admin')); + $this->assertFalse(self::$auth->user_can('operator')); + + $current = self::$auth->user(); + $this->assertSame(['admin'], $current['roles']); + } + + public function testUserCanRevalidatesRolesAfterChangesOutsideSession(): void + { + // Preserve an independent active admin while revoking this user's role. + self::$auth->register('role-refresh-backup@example.com', 'secret', ['roles' => ['admin']]); + $user = self::$auth->register('role-refresh@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + self::$auth->login('role-refresh@example.com', 'secret'); + $this->assertTrue(self::$auth->user_can('admin')); + + // Updating a user does not update the existing session by itself. + self::$auth->update($user['id'], ['roles' => ['editor']]); + $this->assertSame(['admin'], self::$auth->user()['roles']); + + $this->assertFalse(self::$auth->user_can('admin')); + $this->assertTrue(self::$auth->user_can('editor')); + $this->assertSame(['editor'], self::$auth->user()['roles']); + $this->assertTrue(self::$auth->check()); + + // Revocations and grants are both visible on the next permission check. + self::$auth->update($user['id'], ['roles' => []]); + $this->assertFalse(self::$auth->user_can('editor')); + $this->assertSame([], self::$auth->user()['roles']); + } + + public function testUserCanRejectsSuspendedAccountForAllCheckModes(): void + { + self::$auth->register('suspend-backup-admin@example.com', 'secret', ['roles' => ['admin']]); + $user = self::$auth->register('suspended-permissions@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + $sessionUser = self::$auth->login('suspended-permissions@example.com', 'secret'); + + self::$db->insert('item_users', [ + 'user_id' => $user['id'], + 'item_id' => 7, + 'ability' => 'manage', + ]); + self::$db->insert('user_delegations', [ + 'user_id' => $user['id'], + 'target_user_id' => 123, + 'ability' => 'manage', + ]); + $this->assertTrue(self::$auth->user_can('admin')); + $this->assertTrue(self::$auth->user_can('manage', 123)); + $this->assertTrue(self::$auth->user_can('manage', 'item', 7)); + $this->assertSame([7], self::$auth->user_can('manage', 'item')); + + self::$auth->update($user['id'], ['disabled_at' => '2026-10-20 12:00:00']); + $sessionKey = self::$auth->params['session_key']; + + // Each restored snapshot represents a different pre-existing session. + $_SESSION[$sessionKey] = $sessionUser; + $this->assertFalse(self::$auth->user_can('admin')); + $this->assertFalse(self::$auth->check()); + + $_SESSION[$sessionKey] = $sessionUser; + $this->assertFalse(self::$auth->user_can('manage', 123)); + $this->assertFalse(self::$auth->check()); + + $_SESSION[$sessionKey] = $sessionUser; + $this->assertFalse(self::$auth->user_can('manage', 'item', 7)); + $this->assertFalse(self::$auth->check()); + + $_SESSION[$sessionKey] = $sessionUser; + $this->assertSame([], self::$auth->user_can('manage', 'item')); + $this->assertFalse(self::$auth->check()); + } + + public function testUserCanRejectsSessionForDeletedUser(): void + { + self::$auth->register('delete-backup-admin@example.com', 'secret', ['roles' => ['admin']]); + $user = self::$auth->register('deleted-permissions@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + $sessionUser = self::$auth->login('deleted-permissions@example.com', 'secret'); + + self::$auth->logout(); + self::$auth->delete($user['id']); + + // A separate browser still carries an old session snapshot. + $_SESSION[self::$auth->params['session_key']] = $sessionUser; + $this->assertFalse(self::$auth->user_can('admin')); + $this->assertFalse(self::$auth->check()); + } + + public function testUpdateRolesReplacesAssignments(): void + { + $user = self::$auth->register('roles@example.com', 'secret', [ + 'name' => 'Before', + 'roles' => ['partner'], + ]); + + self::$auth->update($user['id'], ['name' => 'After', 'roles' => ['operator', 'admin']]); + $reloaded = self::$auth->get($user['id']); + + $this->assertSame('After', $reloaded['name']); + $this->assertSame(['admin', 'operator'], $reloaded['roles']); + } + + public function testUpdateRollsBackUserFieldsRoleDefinitionsAndMembershipsOnFailure(): void + { + $user = self::$auth->register('rollback-roles@example.com', 'secret', [ + 'name' => 'Before', + 'roles' => ['partner'], + ]); + + // Fail only during membership insertion, after the user update, + // new role creation and old membership deletion have run. + self::$db->query( + "CREATE TRIGGER auth_fail_role_assignment + BEFORE INSERT ON auth_user_role + BEGIN + SELECT RAISE(ABORT, 'forced role synchronization failure'); + END" + )->exec(); + + $failure = null; + try { + self::$auth->update($user['id'], [ + 'name' => 'After', + 'roles' => ['operator'], + ]); + } catch (\Throwable $exception) { + $failure = $exception; + } finally { + self::$db->query('DROP TRIGGER auth_fail_role_assignment')->exec(); + } + + $this->assertNotNull($failure, 'A failed role insertion must abort the update'); + $reloaded = self::$auth->get($user['id']); + $this->assertSame('Before', $reloaded['name']); + $this->assertSame(['partner'], $reloaded['roles']); + $this->assertSame(['partner'], self::$auth->get_roles()); + } + + public function testGetUsersByRoleReturnsOnlyUsersWithMatchingRole(): void + { + $admin = self::$auth->register('admin@example.com', 'secret', [ + 'name' => 'Admin', + 'roles' => ['admin', 'operator'], + ]); + self::$auth->register('viewer@example.com', 'secret', [ + 'name' => 'Viewer', + 'roles' => ['viewer'], + ]); + + $admins = self::$auth->get_users_by_role('admin'); + + $this->assertCount(1, $admins); + $this->assertSame($admin['id'], $admins[0]['id']); + $this->assertSame(['admin', 'operator'], $admins[0]['roles']); + } + + public function testUserCanChecksDelegationForSpecificUser(): void + { + $actor = self::$auth->register('owner@example.com', 'secret'); + $target = self::$auth->register('target@example.com', 'secret'); + self::$auth->login('owner@example.com', 'secret'); + + self::$db->insert('user_delegations', [ + 'user_id' => $actor['id'], + 'target_user_id' => $target['id'], + 'ability' => 'admin', + ]); + + $this->assertTrue(self::$auth->user_can('admin', (int) $target['id'])); + $this->assertFalse(self::$auth->user_can('manage', (int) $target['id'])); + } + + public function testUserCanChecksResourceAndListsResourceIds(): void + { + $actor = self::$auth->register('manager@example.com', 'secret'); + self::$auth->login('manager@example.com', 'secret'); + + self::$db->insert('item_users', [ + 'user_id' => $actor['id'], + 'item_id' => 10, + 'ability' => 'manage', + ]); + self::$db->insert('item_users', [ + 'user_id' => $actor['id'], + 'item_id' => 3, + 'ability' => 'manage', + ]); + self::$db->insert('item_users', [ + 'user_id' => $actor['id'], + 'item_id' => 3, + 'ability' => 'manage', + ]); + self::$db->insert('item_users', [ + 'user_id' => $actor['id'], + 'item_id' => 12, + 'ability' => 'view', + ]); + + $this->assertTrue(self::$auth->user_can('manage', 'item', 10)); + $this->assertFalse(self::$auth->user_can('manage', 'item', 12)); + $this->assertSame([3, 10], self::$auth->user_can('manage', 'item')); + $this->assertSame([12], self::$auth->user_can('view', 'item')); + } + + public function testUserCanReturnsFalseOrEmptyWhenUnauthenticatedForRelationModes(): void + { + self::$auth->register('anon@example.com', 'secret'); + $this->assertFalse(self::$auth->user_can('manage')); + $this->assertFalse(self::$auth->user_can('manage', 1)); + $this->assertFalse(self::$auth->user_can('manage', 'item', 1)); + $this->assertSame([], self::$auth->user_can('manage', 'item')); + } + + public function testUserCanGlobalCheckDoesNotReadRelationshipRows(): void + { + $actor = self::$auth->register('owner@example.com', 'secret'); + self::$auth->login('owner@example.com', 'secret'); + + self::$db->insert('item_users', [ + 'user_id' => $actor['id'], + 'item_id' => 10, + 'ability' => 'admin', + ]); + + $this->assertFalse(self::$auth->user_can('admin')); + $this->assertTrue(self::$auth->user_can('admin', 'item', 10)); + } + + public function testUserCanSupportsRoleBasedRelationshipMapping(): void + { + self::$db->query( + 'CREATE TABLE IF NOT EXISTS item_members ( + user_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + role TEXT NOT NULL + )' + )->exec(); + self::$db->query('DELETE FROM item_members')->exec(); + + $roleAuth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'roles' => 'roles', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'item' => [ + 'table' => 'item_members', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'role_key' => 'role', + 'role_abilities' => [ + 'owner' => ['manage', 'view'], + 'staff' => ['view'], + ], + ], + ], + ]); + + $actor = $roleAuth->register('rolemap@example.com', 'secret'); + $roleAuth->login('rolemap@example.com', 'secret'); + + self::$db->insert('item_members', [ + 'user_id' => $actor['id'], + 'item_id' => 20, + 'role' => 'owner', + ]); + self::$db->insert('item_members', [ + 'user_id' => $actor['id'], + 'item_id' => 22, + 'role' => 'staff', + ]); + + $this->assertTrue($roleAuth->user_can('manage', 'item', 20)); + $this->assertFalse($roleAuth->user_can('manage', 'item', 22)); + $this->assertSame([20, 22], $roleAuth->user_can('view', 'item')); + } + + public function testGetHydratesRelationWithEagerTrue(): void + { + $eagerAuth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'roles' => 'roles', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'item' => [ + 'table' => 'item_users', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'ability_key' => 'ability', + 'eager' => true, + ], + ], + ]); + + $user = $eagerAuth->register('eager1@example.com', 'secret'); + self::$db->insert('item_users', ['user_id' => $user['id'], 'item_id' => 4, 'ability' => 'view']); + self::$db->insert('item_users', ['user_id' => $user['id'], 'item_id' => 8, 'ability' => 'manage']); + + $loaded = $eagerAuth->get($user['id']); + $this->assertArrayHasKey('item', $loaded); + $ids = array_values(array_map(fn (array $item): int => (int) $item['item_id'], $loaded['item'])); + sort($ids); + $this->assertSame([4, 8], $ids); + } + + public function testEagerRelationHydrationDoesNotIncludeOtherUsersRowsForSharedTarget(): void + { + $eagerAuth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'roles' => 'roles', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'item' => [ + 'table' => 'item_users', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'ability_key' => 'ability', + 'eager' => true, + ], + ], + ]); + + $alice = $eagerAuth->register('eager-alice@example.com', 'secret'); + $bob = $eagerAuth->register('eager-bob@example.com', 'secret'); + + self::$db->insert('item_users', [ + 'user_id' => $alice['id'], + 'item_id' => 42, + 'ability' => 'view', + ]); + self::$db->insert('item_users', [ + 'user_id' => $bob['id'], + 'item_id' => 42, + 'ability' => 'manage', + ]); + + $loaded = $eagerAuth->get($alice['id']); + + $this->assertCount(1, $loaded['item']); + $this->assertSame((int) $alice['id'], (int) $loaded['item'][0]['user_id']); + $this->assertSame(42, (int) $loaded['item'][0]['item_id']); + $this->assertSame('view', $loaded['item'][0]['ability']); + } + + public function testGetHydratesRelationWithEagerArrayAsSelectParams(): void + { + $eagerAuth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'roles' => 'roles', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'item' => [ + 'table' => 'item_users', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'ability_key' => 'ability', + 'eager' => ['order' => 'item_id DESC'], + ], + ], + ]); + + $user = $eagerAuth->register('eager2@example.com', 'secret'); + self::$db->insert('item_users', ['user_id' => $user['id'], 'item_id' => 3, 'ability' => 'view']); + self::$db->insert('item_users', ['user_id' => $user['id'], 'item_id' => 9, 'ability' => 'view']); + + $loaded = $eagerAuth->get($user['id']); + $ids = array_values(array_map(fn (array $item): int => (int) $item['item_id'], $loaded['item'])); + $this->assertSame([9, 3], $ids); + } + + public function testUserCanThrowsForInvalidSignatureAndUnknownRelation(): void + { + self::$auth->register('owner@example.com', 'secret'); + self::$auth->login('owner@example.com', 'secret'); + + $this->expectException(\InvalidArgumentException::class); + self::$auth->user_can('manage', 'item', '1'); + } + + public function testUserCanThrowsForUnknownRelationType(): void + { + self::$auth->register('owner@example.com', 'secret'); + self::$auth->login('owner@example.com', 'secret'); + + $this->expectException(\InvalidArgumentException::class); + self::$auth->user_can('manage', 'invoice', 1); + } + + public function testRoleTablesMustBeConfiguredTogether(): void + { + $this->expectException(\InvalidArgumentException::class); + new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'roles_table' => 'auth_role', + 'user_roles_table' => null, + ]); + } + + public function testSuspendedUserCannotLoginAndExistingSessionIsInvalidated(): void + { + $user = self::$auth->register('suspended@example.com', 'secret'); + self::$auth->login('suspended@example.com', 'secret'); + self::$auth->update($user['id'], ['disabled_at' => '2026-07-25 12:00:00']); + + $this->assertFalse(self::$auth->revalidate()); + $this->assertFalse(self::$auth->check()); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + self::$auth->login('suspended@example.com', 'secret'); + } + + public function testVerificationTokensArePerCredentialInSqlite(): void + { + $user = self::$auth->register('account@example.com', 'secret'); + self::$auth->create_credential($user['id'], 'email', 'account@example.com'); + self::$auth->create_credential($user['id'], 'phone', '+5511888888888'); + + $emailToken = self::$auth->issue_credential_token($user['id'], 'email', 'account@example.com'); + $phoneToken = self::$auth->issue_credential_token($user['id'], 'phone', '+5511888888888'); + + $stored = self::$auth->get_credential('email', 'account@example.com'); + $this->assertSame(hash('sha256', $emailToken), $stored['token']); + $this->assertNotSame($emailToken, $stored['token']); + $this->assertFalse(self::$auth->verify_credential_token('email', 'account@example.com', $phoneToken)); + $this->assertFalse(self::$auth->verify_credential_token('phone', '+5511888888888', $emailToken)); + + $this->assertTrue(self::$auth->verify_credential_token('email', 'account@example.com', $emailToken)); + $this->assertFalse(self::$auth->verify_credential_token('email', 'account@example.com', $emailToken)); + $this->assertNotEmpty(self::$auth->get_credential('email', 'account@example.com')['verified_at']); + $this->assertNull(self::$auth->get_credential('email', 'account@example.com')['token']); + $this->assertNull(self::$auth->get_credential('phone', '+5511888888888')['verified_at']); + $this->assertTrue(self::$auth->verify_credential_token('phone', '+5511888888888', $phoneToken)); + + $listed = self::$auth->get_credentials($user['id']); + foreach ($listed as $credential) { + $this->assertArrayNotHasKey('token', $credential); + $this->assertArrayNotHasKey('token_expires_at', $credential); + } + } + + public function testVerificationTokenExpiresAndOnlyOwnerCanIssue(): void + { + $owner = self::$auth->register('owner@example.com', 'secret'); + $other = self::$auth->register('other@example.com', 'secret'); + self::$auth->create_credential($owner['id'], 'email', 'owner@example.com'); + + try { + self::$auth->issue_credential_token($other['id'], 'email', 'owner@example.com'); + $this->fail('A different user issued verification for this credential'); + } catch (UserException $e) { + $this->assertSame(404, $e->getCode()); + } + + $token = self::$auth->issue_credential_token($owner['id'], 'email', 'owner@example.com'); + self::$db->update('auth_credentials', [ + 'token_expires_at' => date('Y-m-d H:i:s', time() - 60), + ], ['provider' => 'email', 'uid' => 'owner@example.com']); + + $this->assertFalse(self::$auth->verify_credential_token('email', 'owner@example.com', $token)); + $this->assertNull(self::$auth->get_credential('email', 'owner@example.com')['verified_at']); + } + + /** + * An expiry changed after credential lookup must invalidate the pending + * compare-and-swap rather than marking the account verified. + */ + public function testVerificationRejectsExpiryChangedBetweenReadAndUpdate(): void + { + [$db, $auth, $token] = $this->verificationRaceFixture(); + $db->beforeVerificationUpdate = static function () use ($db): void { + $db->update('auth_credentials', [ + 'token_expires_at' => date('Y-m-d H:i:s', time() - 60), + ], ['provider' => 'email', 'uid' => 'race@example.com']); + }; + + $this->assertFalse($auth->verify_credential_token('email', 'race@example.com', $token)); + $credential = $auth->get_credential('email', 'race@example.com'); + $this->assertNull($credential['verified_at']); + $this->assertSame(hash('sha256', $token), $credential['token']); + } + + /** + * An expiry that passes during the database write must roll back the + * successful compare-and-swap; simply matching the old expiry is not enough. + */ + public function testVerificationRollsBackIfTokenExpiresDuringUpdate(): void + { + [$db, $auth, $token] = $this->verificationRaceFixture(1); + $deadline = $auth->get_credential('email', 'race@example.com')['token_expires_at']; + $db->beforeVerificationUpdate = static function () use ($deadline): void { + while (date('Y-m-d H:i:s') <= $deadline) { + usleep(100000); + } + }; + + $this->assertFalse($auth->verify_credential_token('email', 'race@example.com', $token)); + $credential = $auth->get_credential('email', 'race@example.com'); + $this->assertNull($credential['verified_at']); + $this->assertSame(hash('sha256', $token), $credential['token']); + $this->assertSame($deadline, $credential['token_expires_at']); + } + + /** @return array{DBAuthExpiryRaceDB, DBAuth, string} */ + private function verificationRaceFixture(int $ttl = 3600): array + { + $db = new DBAuthExpiryRaceDB('sqlite::memory:'); + $db->query( + 'CREATE TABLE auth_credentials ( + uid TEXT NOT NULL, + provider TEXT NOT NULL, + user_id INTEGER NOT NULL, + token TEXT NULL, + token_expires_at TEXT NULL, + verified_at TEXT NULL, + PRIMARY KEY(uid, provider) + )' + )->exec(); + + $token = bin2hex(random_bytes(16)); + $db->insert('auth_credentials', [ + 'uid' => 'race@example.com', + 'provider' => 'email', + 'user_id' => 1, + 'token' => hash('sha256', $token), + 'token_expires_at' => date('Y-m-d H:i:s', time() + $ttl), + 'verified_at' => null, + ]); + $auth = new DBAuth($db, [ + 'credentials_table' => 'auth_credentials', + 'roles_table' => null, + 'user_roles_table' => null, + ]); + return [$db, $auth, $token]; + } + + public function testCredentialRenameInvalidatesPriorVerification(): void + { + $owner = self::$auth->register('owner@example.com', 'secret'); + self::$auth->create_credential($owner['id'], 'phone', '+5511888888888'); + $token = self::$auth->issue_credential_token($owner['id'], 'phone', '+5511888888888'); + $this->assertTrue(self::$auth->verify_credential_token('phone', '+5511888888888', $token)); + $renamed = self::$auth->rename_credential( + $owner['id'], 'phone', '+5511888888888', 'phone', '+5511999999999' + ); + $this->assertNull($renamed['verified_at']); + $this->assertNull($renamed['token']); + $this->assertNull($renamed['token_expires_at']); + } + + public function testCredentialLifecycleEnforcesOwnershipUniquenessAndLastCredential(): void + { + $alice = self::$auth->register('alice@example.com', 'secret'); + $bob = self::$auth->register('bob@example.com', 'secret'); + self::$auth->create_credential($alice['id'], 'local', 'alice.secondary@example.com'); + $renamed = self::$auth->rename_credential( + $alice['id'], + 'local', + 'alice.secondary@example.com', + 'local', + 'alice.renamed@example.com' + ); + $this->assertSame('alice.renamed@example.com', $renamed['uid']); + self::$auth->delete_credential($alice['id'], 'local', 'alice.renamed@example.com'); + + try { + self::$auth->create_credential($bob['id'], 'local', 'alice@example.com'); + $this->fail('Expected duplicate credential rejection'); + } catch (UserException $exception) { + $this->assertSame(409, $exception->getCode()); + } + + $this->expectException(UserException::class); + $this->expectExceptionCode(409); + self::$auth->delete_credential($alice['id'], 'local', 'alice@example.com'); + } + + public function testUserDeletionCleansDelegationsButLeavesApplicationAssociationsToApplication(): void + { + $actor = self::$auth->register('actor@example.com', 'secret'); + $target = self::$auth->register('target@example.com', 'secret'); + self::$db->insert('managed_items', ['user_id' => $target['id'], 'item_id' => 3]); + self::$db->insert('item_users', [ + 'user_id' => $target['id'], + 'item_id' => 3, + 'ability' => 'manage', + ]); + + // Auth-owned delegation cleanup covers both the subject and target. + self::$db->insert('user_delegations', [ + 'user_id' => $actor['id'], + 'target_user_id' => $target['id'], + 'ability' => 'manage', + ]); + self::$db->insert('user_delegations', [ + 'user_id' => $target['id'], + 'target_user_id' => $actor['id'], + 'ability' => 'view', + ]); + + self::$auth->delete($target['id']); + $this->assertSame(0, self::$db->count('user_delegations', [])); + $this->assertSame(1, self::$db->count('managed_items', [])); + $this->assertSame(1, self::$db->count('item_users', [])); + } + + public function testUserDeletionDoesNotCleanUnownedRelationsEvenWhenTargetIsUser(): void + { + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'user' => [ + 'table' => 'user_delegations', + 'subject_key' => 'user_id', + 'target_key' => 'target_user_id', + 'ability_key' => 'ability', + 'target_is_user' => true, + // No auth_owned flag: application remains responsible. + ], + ], + ]); + + $actor = $auth->register('actor@example.com', 'secret'); + $target = $auth->register('target@example.com', 'secret'); + self::$db->insert('user_delegations', [ + 'user_id' => $actor['id'], + 'target_user_id' => $target['id'], + 'ability' => 'manage', + ]); + self::$db->insert('user_delegations', [ + 'user_id' => $target['id'], + 'target_user_id' => $actor['id'], + 'ability' => 'view', + ]); + + $this->assertTrue($auth->delete($target['id'])); + $this->assertSame(2, self::$db->count('user_delegations', [])); + $this->assertFalse($auth->get_credential('local', 'target@example.com')); + } + + public function testDbAuthSessionContextReloadsApplicationAssignments(): void + { + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'user_context_callback' => function (array $user): array { + $rows = self::$db->select( + 'managed_items', + ['user_id' => $user['id']], + ['order' => 'item_id'] + )->all(); + return ['venues' => array_map( + fn (array $row): int => (int) $row['item_id'], + $rows + )]; + }, + ]); + + $user = $auth->register('app-context@example.com', 'secret'); + self::$db->insert('managed_items', ['user_id' => $user['id'], 'item_id' => 4]); + $login = $auth->login('app-context@example.com', 'secret'); + $this->assertSame(['venues' => [4]], $login['context']); + + self::$db->insert('managed_items', ['user_id' => $user['id'], 'item_id' => 9]); + $this->assertSame(['venues' => [4]], $auth->user()['context']); + $this->assertTrue($auth->revalidate()); + $this->assertSame(['venues' => [4, 9]], $auth->user()['context']); + $controller = new AuthController($auth); + $this->assertSame(['venues' => [4, 9]], $controller->index()['context']); + } + + public function testRoleNamesAndCredentialUidSearchAreNormalized(): void + { + self::$auth->register('searchable@example.com', 'secret', ['roles' => ['partner']]); + $this->assertSame(['partner'], self::$auth->get_roles()); + + $result = self::$auth->query(['q' => 'searchable@example.com', 'size' => 10]); + $this->assertSame(1, $result->total()); + $this->assertSame('searchable@example.com', $result[0]['credentials'][0]['uid']); + } + public function testOAuthAccountChoiceLinksExistingUserWithSqlite(): void + { + require_once __DIR__ . '/OAuthTestProvider.php'; + $local = self::$auth->register('sqlite-user@example.com', 'secret', [ + 'name' => 'Existing User', + ]); + + \League\OAuth2\Client\Provider\Test::$resourceOwner = new class { + public function getId(): string { return 'sqlite-oauth-subject'; } + public function getName(): string { return 'OAuth Profile'; } + public function getEmail(): string { return 'sqlite-user@example.com'; } + public function toArray(): array { return ['id' => 'sqlite-oauth-subject']; } + }; + \League\OAuth2\Client\Provider\Test::$state = 'test-oauth-state'; + + $controller = new class (self::$auth, [ + 'test' => [ + 'clientId' => 'test-client', + 'clientSecret' => 'test-secret', + 'redirectUri' => 'https://example.test/oauth/test', + ], + ]) extends \Objectiveweb\Auth\Controller\OAuthController { + public ?string $redirectedTo = null; + protected function redirect(string $location): void + { + $this->redirectedTo = $location; + } + }; + + $controller->get('test', []); + $controller->get('test', [ + 'code' => 'oauth-code', 'state' => 'test-oauth-state', + ]); + $this->assertSame('/?oauth=choose-account', $controller->redirectedTo); + $this->assertFalse(self::$auth->check()); + $this->assertFalse(self::$auth->get_credential('test', 'sqlite-oauth-subject')); + + $choice = $controller->getPendingAccount(); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $choice['_csrf']; + $result = $controller->postExistingAccount([ + 'uid' => 'sqlite-user@example.com', + 'password' => 'secret', + ]); + $this->assertSame('linked', $result['status']); + $this->assertSame($local['id'], $result['user']['id']); + $credential = self::$auth->get_credential('test', 'sqlite-oauth-subject'); + $this->assertSame($local['id'], $credential['user_id']); + $this->assertNotEmpty($credential['verified_at']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + $this->assertCount(1, self::$db->select('auth_user')->all()); + unset($_SERVER['REQUEST_METHOD'], $_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN']); + } + +} diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php new file mode 100644 index 0000000..4d48a02 --- /dev/null +++ b/test/OAuthControllerTest.php @@ -0,0 +1,1129 @@ +sessionRegenerated = true; + } +} + +class OAuthTestController extends OAuthController +{ + public ?string $redirectedTo = null; + + protected function redirect(string $location): void + { + $this->redirectedTo = $location; + } +} + +class OAuthControllerTest extends TestCase +{ + protected function setUp(): void + { + $_SESSION = []; + $_SERVER = []; + OAuthTestProvider::$lastConfig = []; + OAuthTestProvider::$lastAuthorizationOptions = []; + OAuthTestProvider::$lastTokenOptions = []; + OAuthTestProvider::$resourceOwner = null; + OAuthTestProvider::$state = 'test-oauth-state'; + } + + public function testGetWithInvalidProviderThrows(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthController($auth, []); + + $this->expectException(\Exception::class); + $this->expectExceptionCode(406); + $controller->get('missing-provider', []); + } + + public function testAuthorizationUsesConfiguredRedirectUriAndStoresState(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + + $controller->get('test', []); + + $this->assertSame('https://app.example/oauth/test', OAuthTestProvider::$lastConfig['redirectUri']); + $flow = $_SESSION['oauth2flows']['test']; + $this->assertSame('test-oauth-state', $flow['state']); + $this->assertNull($flow['principal']); + $this->assertGreaterThanOrEqual(43, strlen($flow['code_verifier'])); + $this->assertSame('S256', OAuthTestProvider::$lastAuthorizationOptions['code_challenge_method']); + $expectedChallenge = rtrim( + strtr(base64_encode(hash('sha256', $flow['code_verifier'], true)), '+/', '-_'), + '=' + ); + $this->assertSame($expectedChallenge, OAuthTestProvider::$lastAuthorizationOptions['code_challenge']); + $this->assertSame('https://oauth.example/authorize', $controller->redirectedTo); + } + + public function testAuthorizationRequiresExplicitRedirectUriEvenWithRequestHeaders(): void + { + $_SERVER['HTTP_HOST'] = 'attacker.example'; + $_SERVER['HTTP_X_FORWARDED_HOST'] = 'trusted.example'; + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['REQUEST_SCHEME'] = 'https'; + $_SERVER['PHP_SELF'] = '/index.php/oauth'; + $_SERVER['PATH_INFO'] = '/test'; + + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'client-id', 'clientSecret' => 'client-secret'], + ]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('OAuth redirectUri must be explicitly configured'); + $controller->get('test', []); + } + + public function testOauthRejectsEmptyRedirectUri(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', 'clientSecret' => 'client-secret', + 'redirectUri' => '', + ], + ]); + $this->expectException(\InvalidArgumentException::class); + $controller->get('test', []); + } + + /** + * @dataProvider invalidRedirectUris + */ + public function testAuthorizationRejectsUnsafeConfiguredRedirectUris(mixed $redirectUri): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => $redirectUri, + ], + ]); + + $this->expectException(\InvalidArgumentException::class); + $controller->get('test', []); + } + + public static function invalidRedirectUris(): array + { + return [ + 'relative callback' => ['/oauth/test'], + 'scheme-relative callback' => ['//attacker.example/oauth/test'], + 'insecure public HTTP' => ['http://app.example/oauth/test'], + 'insecure non-loopback private address' => ['http://192.168.1.4/oauth/test'], + 'javascript scheme' => ['javascript:alert(1)'], + 'file scheme' => ['file:///tmp/callback'], + 'no host' => ['https:///oauth/test'], + 'embedded credentials' => ['https://alice:secret@app.example/oauth/test'], + 'URL fragment' => ['https://app.example/oauth/test#code'], + 'embedded newline' => ["https://app.example/oauth/test\r\nInjected: yes"], + 'backslash' => ['https://app.example\attacker.example/oauth/test'], + 'non-string value' => [['https://app.example/oauth/test']], + ]; + } + + /** + * @dataProvider validRedirectUris + */ + public function testAuthorizationAcceptsTrustedConfiguredRedirectUris(string $redirectUri): void + { + $_SERVER['HTTP_HOST'] = 'attacker.example'; + $_SERVER['HTTP_X_FORWARDED_HOST'] = 'attacker.example'; + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'http'; + + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => $redirectUri, + ], + ]); + $controller->get('test', []); + + $this->assertSame($redirectUri, OAuthTestProvider::$lastConfig['redirectUri']); + $this->assertSame('https://oauth.example/authorize', $controller->redirectedTo); + $this->assertArrayHasKey('test', $_SESSION['oauth2flows']); + } + + public static function validRedirectUris(): array + { + return [ + 'production HTTPS' => ['https://app.example/oauth/test'], + 'custom HTTPS port' => ['https://auth.example:8443/oauth/test'], + 'static query' => ['https://app.example/oauth/test?tenant=example'], + 'localhost development' => ['http://localhost:8000/oauth/test'], + 'IPv4 loopback development' => ['http://127.0.0.1:8080/oauth/test'], + 'IPv6 loopback development' => ['http://[::1]:8080/oauth/test'], + ]; + } + + public function testExplicitLinkAlsoRequiresTrustedRedirectUri(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $auth->register('owner@example.com', 'password'); + $auth->login('owner@example.com', 'password'); + $this->withLinkHeaders($auth); + $_SERVER['HTTP_HOST'] = 'trusted.example'; + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'client-id', 'clientSecret' => 'client-secret'], + ]); + try { + $controller->postLink('test'); + $this->fail('Explicit link permitted a request-derived callback URI'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('explicitly configured', $e->getMessage()); + } + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows'] ?? []); + } + + public function testOAuthCallbackAlsoRequiresConfiguredRedirectUri(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'client-id', 'clientSecret' => 'client-secret'], + ]); + $_SESSION['oauth2flows']['test'] = [ + 'state' => 'test-oauth-state', + 'created_at' => time(), + 'code_verifier' => 'test-verifier', + 'principal' => null, + 'intent' => 'login', + ]; + + $this->expectException(\InvalidArgumentException::class); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + } + + public function testFirstTimeOAuthOffersAccountChoiceWithoutCreatingUser(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $existing = $auth->register('linked@example.com', 'secret', ['provider' => 'email']); + OAuthTestProvider::$resourceOwner = $this->resourceOwner( + 'provider-user-id', 'linked@example.com' + ); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + $this->assertSame('/?oauth=choose-account', $controller->redirectedTo); + $this->assertFalse($auth->check()); + $this->assertFalse($auth->get_credential('test', 'provider-user-id')); + $this->assertSame($existing['id'], $auth->get_credential('email', 'linked@example.com')['user_id']); + + $choice = $controller->getPendingAccount(); + $this->assertSame('account_choice_required', $choice['status']); + $this->assertSame(['create', 'existing'], $choice['choices']); + $this->assertSame('test', $choice['provider']); + $this->assertSame('linked@example.com', $choice['email_hint']); + $this->assertNotEmpty($choice['_csrf']); + $this->assertArrayNotHasKey('uid', $choice); + $this->assertArrayNotHasKey('profile', $choice); + } + + public function testMinimalLeagueResourceOwnerDoesNotRequireOptionalMethods(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = new class { + public function getId(): string { return 'minimal-provider-subject'; } + public function toArray(): array { return ['id' => 'minimal-provider-subject']; } + }; + + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $pending = $controller->getPendingAccount(); + $this->assertSame('account_choice_required', $pending['status']); + $this->assertNull($pending['name']); + $this->assertNull($pending['image'] ?? null); + $this->assertNull($pending['email_hint']); + $this->assertFalse($auth->get_credential('test', 'minimal-provider-subject')); + + $this->withLinkHeaders($auth); + $created = $controller->postCreateAccount(); + $this->assertSame('created', $created['status']); + $this->assertSame( + $created['user']['id'], + $auth->get_credential('test', 'minimal-provider-subject')['user_id'] + ); + $this->assertNotEmpty($auth->get_credential('test', 'minimal-provider-subject')['verified_at']); + } + + public function testLeagueProfileArraySuppliesSafeNameAvatarAndEmailHints(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = new class { + public function getId(): int { return 92735; } + public function toArray(): array + { + return [ + 'name' => ' Profile Name ', + 'avatar_url' => 'https://example.com/avatar.png', + 'email' => 'profile@example.com', + ]; + } + }; + + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $pending = $controller->getPendingAccount(); + $this->assertSame('Profile Name', $pending['name']); + $this->assertSame('profile@example.com', $pending['email_hint']); + $this->assertSame('https://example.com/avatar.png', $_SESSION['oauth2pending']['image']); + $this->assertArrayNotHasKey('uid', $pending); + + $this->withLinkHeaders($auth); + $result = $controller->postCreateAccount(); + $this->assertSame('Profile Name', $result['user']['name']); + $this->assertSame('https://example.com/avatar.png', $result['user']['image']); + $this->assertNotFalse($auth->get_credential('test', '92735')); + } + + public function testNestedPictureAndSplitNameProfileFallbacks(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = new class { + public function getId(): string { return 'nested-fields'; } + public function toArray(): array + { + return [ + 'given_name' => ' Alice ', + 'family_name' => 'Smith', + 'picture' => ['data' => ['url' => 'https://cdn.example.com/photo.jpg']], + 'email_address' => 'alice@example.com', + ]; + } + }; + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $pending = $controller->getPendingAccount(); + $this->assertSame('Alice Smith', $pending['name']); + $this->assertSame('alice@example.com', $pending['email_hint']); + $this->assertSame('https://cdn.example.com/photo.jpg', $_SESSION['oauth2pending']['image']); + } + + public function testMalformedOrThrowingOptionalGettersSafelyFallBackToProfile(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = new class { + public function getId(): string { return 'broken-optional-fields'; } + public function getName(): string { throw new \RuntimeException('Name unavailable'); } + public function getAvatar(): array { return ['unexpected' => 'value']; } + public function getPictureUrl(): string { throw new \RuntimeException('Picture unavailable'); } + public function getEmail(): array { return ['bad' => 'value']; } + public function toArray(): array + { + return [ + 'display_name' => ' Resilient User ', + 'picture_url' => 'javascript:alert(1)', + 'image_url' => 'https://images.example.com/photo.jpg', + 'mail' => ' valid@example.com ', + ]; + } + }; + + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $pending = $controller->getPendingAccount(); + $this->assertSame('Resilient User', $pending['name']); + $this->assertSame('valid@example.com', $pending['email_hint']); + $this->assertSame('https://images.example.com/photo.jpg', $_SESSION['oauth2pending']['image']); + } + + public function testUnsafeAvatarAndInvalidEmailHintsAreIgnored(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = new class { + public function getId(): string { return 'unsafe-fields'; } + public function toArray(): array + { + return [ + 'name' => ['not' => 'text'], + 'picture' => 'data:image/svg+xml;base64,PHN2Zz4=', + 'avatar_url' => 'javascript:alert(1)', + 'email' => 'not-an-email', + ]; + } + }; + + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $pending = $controller->getPendingAccount(); + $this->assertNull($pending['name']); + $this->assertNull($pending['email_hint']); + $this->assertNull($_SESSION['oauth2pending']['image']); + } + + public function testInvalidProviderSubjectTypeIsRejected(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = $this->testController($auth); + $owner = new class { + public function getId(): array { return ['not' => 'an id']; } + public function toArray(): array { return []; } + }; + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + $this->invokeOAuthLogin($controller, 'test', $owner); + } + + public function testFirstTimeOAuthCanExplicitlyCreateNewAccount(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $local = $auth->register('matching@example.com', 'old-password'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner( + 'new-oauth-subject', 'matching@example.com' + ); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $this->withLinkHeaders($auth); + $result = $controller->postCreateAccount(['name' => 'New OAuth Account']); + $this->assertSame('created', $result['status']); + $created = $result['user']; + $this->assertNotSame($local['id'], $created['id']); + $this->assertSame('New OAuth Account', $created['name']); + $this->assertSame($created['id'], $auth->get_credential('test', 'new-oauth-subject')['user_id']); + $this->assertNotEmpty($auth->get_credential('test', 'new-oauth-subject')['verified_at']); + $this->assertSame($local['id'], $auth->get_credential('local', 'matching@example.com')['user_id']); + $this->assertFalse($auth->get_credential('local', 'new-oauth-subject')); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + $this->assertTrue($auth->check()); + } + + public function testFirstTimeOAuthCanAuthenticateExistingAccountAndLink(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $local = $auth->register('old@example.com', 'local-secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner( + 'pending-oauth-subject', 'provider-email@example.com' + ); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $this->withLinkHeaders($auth); + try { + $controller->postExistingAccount(['uid' => 'old@example.com', 'password' => 'wrong']); + $this->fail('Incorrect local password was accepted'); + } catch (AuthException $e) { + $this->assertSame(401, $e->getCode()); + } + $this->assertFalse($auth->check()); + $this->assertArrayHasKey('oauth2pending', $_SESSION); + $this->assertFalse($auth->get_credential('test', 'pending-oauth-subject')); + + $result = $controller->postExistingAccount([ + 'uid' => 'old@example.com', + 'password' => 'local-secret', + ]); + $this->assertSame('linked', $result['status']); + $this->assertSame($local['id'], $result['user']['id']); + $this->assertSame($local['id'], $auth->get_credential('test', 'pending-oauth-subject')['user_id']); + $this->assertNotEmpty($auth->get_credential('test', 'pending-oauth-subject')['verified_at']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + $this->assertTrue($auth->check()); + } + + + public function testExistingAccountLinkFailureDoesNotAuthenticateBrowser(): void + { + $auth = new class([], ['token' => 'token']) extends OAuthTrackingBasicAuth { + public function mark_credential_verified($userid, string $provider, string $uid): void + { + if ($provider === 'test') { + throw new \RuntimeException('Simulated OAuth credential verification failure'); + } + parent::mark_credential_verified($userid, $provider, $uid); + } + }; + $auth->register('owner@example.com', 'local-secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('pending-oauth-subject'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->withLinkHeaders($auth); + + try { + $controller->postExistingAccount([ + 'uid' => 'owner@example.com', + 'password' => 'local-secret', + ]); + $this->fail('Expected OAuth credential verification to fail'); + } catch (\RuntimeException $e) { + $this->assertSame('Simulated OAuth credential verification failure', $e->getMessage()); + } + + $this->assertFalse($auth->check()); + $this->assertFalse($auth->sessionRegenerated); + $this->assertArrayNotHasKey('ow_auth', $_SESSION); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + } + + public function testPendingAccountCannotBeCreatedWithoutExplicitChoice(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('new-id'); + $controller = $this->testController($auth); + + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->assertFalse($auth->get_credential('test', 'new-id')); + $this->assertFalse($auth->check()); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + try { + $controller->postCreateAccount(); + $this->fail('Creating without CSRF token succeeded'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(403, $e->getCode()); + } + $this->assertFalse($auth->get_credential('test', 'new-id')); + + $this->withLinkHeaders($auth); + try { + $controller->postCreateAccount(['provider' => 'local']); + $this->fail('Provider injection during OAuth account creation succeeded'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(400, $e->getCode()); + } + $this->assertArrayHasKey('oauth2pending', $_SESSION); + } + + public function testPendingAccountCreationHonorsRegistrationPolicy(): void + { + $auth = new BasicAuth([], [ + 'token' => 'token', + 'register_scope' => \Objectiveweb\Auth::AUTHENTICATED, + ]); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('restricted-id'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->withLinkHeaders($auth); + + try { + $controller->postCreateAccount(); + $this->fail('Restricted account creation succeeded'); + } catch (\Objectiveweb\Auth\AuthException $e) { + $this->assertSame(401, $e->getCode()); + } + $this->assertFalse($auth->get_credential('test', 'restricted-id')); + } + + public function testPendingAccountExpiresAndIsRemoved(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('expiring-id'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $_SESSION['oauth2pending']['created_at'] = time() - 601; + + try { + $controller->getPendingAccount(); + $this->fail('Expired pending account still available'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(410, $e->getCode()); + } + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + $this->assertFalse($auth->get_credential('test', 'expiring-id')); + } + + public function testAccountChoiceCannotBeReplayedOrUsedAfterCancellation(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('choice-id'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->withLinkHeaders($auth); + $this->assertSame('cancelled', $controller->postCancelPendingAccount()['status']); + try { + $controller->postCreateAccount(); + $this->fail('Cancelled account choice still usable'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(410, $e->getCode()); + } + + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $result = $controller->postCreateAccount(); + $this->assertSame('created', $result['status']); + $auth->logout(); + try { + $controller->postExistingAccount(['uid' => 'other@example.com', 'password' => 'secret']); + $this->fail('A consumed OAuth proof was reused'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(410, $e->getCode()); + } + } + + public function testAccountChoiceRejectsPreviouslyLinkedProviderIdentity(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $owner = $auth->register('existing@example.com', 'password'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('race-id'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + // A different request associates the identity while the choice is pending. + $auth->create_credential($owner['id'], 'test', 'race-id'); + $auth->mark_credential_verified($owner['id'], 'test', 'race-id'); + $this->withLinkHeaders($auth); + try { + $controller->postCreateAccount(); + $this->fail('Duplicate OAuth identity was accepted'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(409, $e->getCode()); + } + $this->assertSame($owner['id'], $auth->get_credential('test', 'race-id')['user_id']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + } + + public function testExistingAccountChoiceRejectsInvalidPayloadAndUntrustedMethods(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $auth->register('existing@example.com', 'secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('pending-id'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->withLinkHeaders($auth); + $_SERVER['REQUEST_METHOD'] = 'GET'; + try { + $controller->postExistingAccount(['uid' => 'existing@example.com', 'password' => 'secret']); + $this->fail('GET linked a provider credential'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(405, $e->getCode()); + } + $_SERVER['REQUEST_METHOD'] = 'POST'; + try { + $controller->postExistingAccount([ + 'uid' => 'existing@example.com', + 'password' => 'secret', + 'provider' => 'test', + ]); + $this->fail('Provider injection accepted in existing-account choice'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(400, $e->getCode()); + } + $this->assertFalse($auth->get_credential('test', 'pending-id')); + $this->assertArrayHasKey('oauth2pending', $_SESSION); + } + + public function testPendingChoiceRedirectRejectsExternalUrl(): void + { + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(400); + $this->testController(new BasicAuth(), '//attacker.example/receive'); + } + + public function testUnverifiedOAuthCredentialCannotBeUsedToLogInAnotherAccount(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $victim = $auth->register('victim@example.com', 'secret'); + $auth->create_credential($victim['id'], 'google', 'attacker-provider-subject'); + + $controller = new OAuthController($auth); + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + $this->invokeOAuthLogin($controller, 'google', $this->resourceOwner('attacker-provider-subject')); + } + + public function testAuthenticatedAccountCanConfirmItsOwnPendingOAuthCredential(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $owner = $auth->register('owner@example.com', 'secret'); + $auth->create_credential($owner['id'], 'test', 'owner-subject'); + $auth->login('owner@example.com', 'secret'); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('owner-subject'); + + $this->withLinkHeaders($auth); + $linkResponse = $controller->postLink('test'); + $this->assertSame('https://oauth.example/authorize', $linkResponse['authorization_url']); + $this->assertSame('link', $_SESSION['oauth2flows']['test']['intent']); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $this->assertSame($owner['id'], $auth->user()['id']); + $this->assertNotEmpty($auth->get_credential('test', 'owner-subject')['verified_at']); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + } + + public function testSignedInUserCannotSwitchToDifferentVerifiedOAuthAccount(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $other = $auth->register('other-subject', null, ['provider' => 'google']); + $auth->mark_credential_verified($other['id'], 'google', 'other-subject'); + $auth->register('current@example.com', 'secret'); + $auth->login('current@example.com', 'secret'); + $controller = new OAuthController($auth); + + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(409); + $this->invokeOAuthLogin($controller, 'google', $this->resourceOwner('other-subject')); + } + + public function testInvalidOAuthStateIsCleared(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + + $controller->get('test', []); + try { + $controller->get('test', [ + 'code' => 'oauth-code', + 'state' => 'wrong-state', + ]); + $this->fail('Expected invalid OAuth state to be rejected'); + } catch (\Exception $exception) { + $this->assertSame(406, $exception->getCode()); + } + + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + } + + public function testOAuthCallbackStateIsProviderSpecific(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'first', 'clientSecret' => 'first', + 'redirectUri' => 'https://app.example/oauth/test', + ], + 'other' => [ + 'clientId' => 'second', 'clientSecret' => 'second', + 'redirectUri' => 'https://app.example/oauth/other', + ], + ]); + // Initiating a flow for one provider does not authorize another. + $controller->get('test', []); + try { + $controller->get('other', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('State from a different provider was accepted'); + } catch (\Exception $e) { + $this->assertSame(406, $e->getCode()); + } + $this->assertArrayHasKey('test', $_SESSION['oauth2flows']); + } + + public function testOAuthCallbackRejectsExpiredState(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'id', 'clientSecret' => 'secret', + 'redirectUri' => 'https://app.example/oauth/test'], + ]); + $controller->get('test', []); + $_SESSION['oauth2flows']['test']['created_at'] = time() - 601; + + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Expired state was accepted'); + } catch (\Exception $e) { + $this->assertSame(406, $e->getCode()); + } + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + } + + public function testOAuthCallbackRejectsPrincipalChangesDuringFlow(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $auth->register('first@example.com', 'one'); + $auth->register('second@example.com', 'two'); + $auth->login('first@example.com', 'one'); + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'id', 'clientSecret' => 'secret', + 'redirectUri' => 'https://app.example/oauth/test'], + ]); + $controller->get('test', []); + $auth->logout(); + $auth->login('second@example.com', 'two'); + + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Changed principal was allowed to complete provider linking'); + } catch (\Exception $e) { + $this->assertSame(403, $e->getCode()); + } + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + $this->assertFalse($auth->get_credential('test', 'provider-user-id')); + } + + public function testOAuthCallbackRejectsReplay(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('new-subject'); + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'id', 'clientSecret' => 'secret', + 'redirectUri' => 'https://app.example/oauth/test'], + ]); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + $this->assertNotEmpty(OAuthTestProvider::$lastTokenOptions['code_verifier']); + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('OAuth state could be replayed'); + } catch (\Exception $e) { + $this->assertSame(406, $e->getCode()); + } + } + + public function testLoggedInUserCanLinkOAuthCredential(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $user = $auth->register('local@example.com', 'secret'); + $auth->login('local@example.com', 'secret'); + + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('new-linked-id'); + $this->withLinkHeaders($auth); + $linkResponse = $controller->postLink('test'); + $this->assertSame('link', $_SESSION['oauth2flows']['test']['intent']); + $this->assertSame((string) $user['id'], $_SESSION['oauth2flows']['test']['principal']); + $this->assertSame('https://oauth.example/authorize', $linkResponse['authorization_url']); + + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $credential = $auth->get_credential('test', 'new-linked-id'); + $this->assertNotFalse($credential); + $this->assertSame($user['id'], $credential['user_id']); + $this->assertNotEmpty($credential['verified_at']); + $this->assertSame($user['id'], $auth->user()['id']); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + $this->assertNotEmpty(OAuthTestProvider::$lastTokenOptions['code_verifier']); + } + + public function testOrdinaryOAuthLoginCannotLinkNewCredentialToLoggedInUser(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $owner = $auth->register('local@example.com', 'secret'); + $auth->login('local@example.com', 'secret'); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('unrelated-subject'); + + $controller->get('test', ['link' => '1']); + $this->assertSame('login', $_SESSION['oauth2flows']['test']['intent']); + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state', 'intent' => 'link']); + $this->fail('OAuth login silently linked a credential'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(409, $e->getCode()); + } + + $this->assertFalse($auth->get_credential('test', 'unrelated-subject')); + $this->assertSame($owner['id'], $auth->user()['id']); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + } + + public function testOrdinaryOAuthLoginCannotConfirmPendingCredential(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $owner = $auth->register('local@example.com', 'secret'); + $auth->create_credential($owner['id'], 'test', 'pending-subject'); + $auth->login('local@example.com', 'secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('pending-subject'); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + + $controller->get('test', []); + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Unverified OAuth credential was confirmed without explicit linking'); + } catch (AuthException $e) { + $this->assertSame(403, $e->getCode()); + } + $this->assertEmpty($auth->get_credential('test', 'pending-subject')['verified_at']); + } + + public function testLinkRequiresAuthenticationAndCsrfProtectedPost(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + try { + $controller->postLink('test'); + $this->fail('Anonymous account was allowed to link a credential'); + } catch (AuthException $e) { + $this->assertSame(401, $e->getCode()); + } + + $auth->register('owner@example.com', 'secret'); + $auth->login('owner@example.com', 'secret'); + try { + $controller->postLink('test'); + $this->fail('CSRF-free account linking was accepted'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(403, $e->getCode()); + } + + $_SERVER['HTTP_X_CSRF_TOKEN'] = $auth->management_csrf_token(); + $_SERVER['REQUEST_METHOD'] = 'GET'; + try { + $controller->postLink('test'); + $this->fail('GET was allowed to initiate linking'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(405, $e->getCode()); + } + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + try { + $controller->postLink('test'); + $this->fail('Cross-origin form POST was accepted'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(415, $e->getCode()); + } + + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows'] ?? []); + } + + public function testLinkCannotTakeOverProviderCredentialOfAnotherUser(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $victim = $auth->register('victim@example.com', 'secret'); + $auth->create_credential($victim['id'], 'test', 'victim-subject'); + $auth->mark_credential_verified($victim['id'], 'test', 'victim-subject'); + $attacker = $auth->register('attacker@example.com', 'secret'); + $auth->login('attacker@example.com', 'secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('victim-subject'); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + + $this->withLinkHeaders($auth); + $controller->postLink('test'); + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Linked credential was stolen from another user'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(409, $e->getCode()); + } + $this->assertSame($victim['id'], $auth->get_credential('test', 'victim-subject')['user_id']); + $this->assertSame($attacker['id'], $auth->user()['id']); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + } + + public function testExplicitLinkRejectsChangedSessionAndCannotBeReplayed(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $auth->register('first@example.com', 'secret'); + $auth->register('second@example.com', 'secret'); + $auth->login('first@example.com', 'secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('provider-subject'); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + + $this->withLinkHeaders($auth); + $controller->postLink('test'); + $auth->logout(); + $auth->login('second@example.com', 'secret'); + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Switched user completed someone else\'s OAuth linking flow'); + } catch (\Exception $e) { + $this->assertSame(403, $e->getCode()); + } + $this->assertFalse($auth->get_credential('test', 'provider-subject')); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Linking callback replayed'); + } catch (\Exception $e) { + $this->assertSame(406, $e->getCode()); + } + } + + public function testOAuthLoginRejectsSuspendedUser(): void + { + $auth = new OAuthTrackingBasicAuth([], [ + 'token' => 'token', + 'disabled_at' => 'disabled_at', + ]); + $auth->register('google-user-id', null, [ + 'provider' => 'google', + 'disabled_at' => '2026-09-27 12:00:00', + ]); + + $controller = new OAuthController($auth, []); + $resourceOwner = $this->resourceOwner('google-user-id'); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + + $this->invokeOAuthLogin($controller, 'google', $resourceOwner); + } + + public function testOAuthLoginRegeneratesSessionBeforeStoringPrincipal(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $user = $auth->register('google-user-id', null, ['provider' => 'google']); + $auth->mark_credential_verified($user['id'], 'google', 'google-user-id'); + + $controller = new OAuthController($auth, []); + $logged = $this->invokeOAuthLogin( + $controller, + 'google', + $this->resourceOwner('google-user-id') + ); + + $this->assertTrue($auth->sessionRegenerated); + $this->assertTrue($auth->check()); + $this->assertSame($user['id'], $logged['id']); + $this->assertSame($user['id'], $auth->user()['id']); + } + + private function testController(BasicAuth $auth, string $pendingRedirect = '/?oauth=choose-account'): OAuthTestController + { + return new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ], $pendingRedirect); + } + + private function withLinkHeaders(BasicAuth $auth): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json; charset=utf-8'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $auth->management_csrf_token(); + } + + private function invokeOAuthLogin( + OAuthController $controller, + string $provider, + object $resourceOwner + ): array { + $method = new ReflectionMethod($controller, 'login'); + return $method->invoke($controller, $provider, $resourceOwner); + } + + private function resourceOwner(string $id, ?string $email = null): object + { + return new class ($id, $email) { + public function __construct( + private string $id, + private ?string $email + ) { + } + + public function getId(): string + { + return $this->id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function getName(): string + { + return 'OAuth User'; + } + + public function getAvatar(): string + { + return 'https://example.com/avatar.png'; + } + + public function getPictureUrl(): string + { + return 'https://example.com/avatar.png'; + } + + public function toArray(): array + { + return ['id' => $this->id, 'email' => $this->email]; + } + }; + } +} diff --git a/test/OAuthTestProvider.php b/test/OAuthTestProvider.php new file mode 100644 index 0000000..7ced65e --- /dev/null +++ b/test/OAuthTestProvider.php @@ -0,0 +1,53 @@ + 'test-token']; + } + + public function getResourceOwner(object $token): object + { + if (self::$resourceOwner === null) { + throw new \RuntimeException('Missing OAuth test resource owner'); + } + + return self::$resourceOwner; + } +} + +// A second provider class for cross-provider OAuth state isolation tests. +class Other extends Test +{ +} diff --git a/test/RealOAuthProviderTest.php b/test/RealOAuthProviderTest.php new file mode 100644 index 0000000..584ef7e --- /dev/null +++ b/test/RealOAuthProviderTest.php @@ -0,0 +1,397 @@ + $this->testHttpClient]); + } + + protected function redirect(string $location): void + { + $this->redirectedTo = $location; + } +} + +final class RealOAuthProviderTest extends TestCase +{ + protected function setUp(): void + { + if (!class_exists(\League\OAuth2\Client\Provider\Google::class) + || !class_exists(\League\OAuth2\Client\Provider\Facebook::class) + || !class_exists(Client::class)) { + $this->markTestSkipped('Optional League OAuth2 provider libraries are not installed.'); + } + + $_SESSION = []; + $_SERVER = []; + } + + /** + * Use Guzzle's real request pipeline; return deterministic JSON bodies + * at the token and profile endpoints without connecting to the network. + * + * @return array{0: Client, 1: array} + */ + private function clientWithResponses(array $responses, array &$history): Client + { + $mock = new MockHandler(array_map( + static fn (array $json): Response => + new Response(200, ['Content-Type' => 'application/json'], json_encode($json, JSON_THROW_ON_ERROR)), + $responses + )); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + return new Client(['handler' => $stack]); + } + + private function providerConfig(string $provider): array + { + $config = [ + 'clientId' => 'integration-client-id', + 'clientSecret' => 'integration-client-secret', + 'redirectUri' => 'https://application.example/auth/oauth/' . $provider, + ]; + if ($provider === 'facebook') { + // Required by the real Facebook provider. + $config['graphApiVersion'] = 'v22.0'; + } + return [$provider => $config]; + } + + private function assertAuthorizationRequest( + RealOAuthProviderController $controller, + string $provider + ): array { + $uri = $controller->redirectedTo; + $this->assertIsString($uri); + $this->assertSame('https', parse_url($uri, PHP_URL_SCHEME)); + $this->assertSame( + $provider === 'google' ? 'accounts.google.com' : 'www.facebook.com', + parse_url($uri, PHP_URL_HOST) + ); + parse_str((string) parse_url($uri, PHP_URL_QUERY), $query); + + $this->assertSame('integration-client-id', $query['client_id']); + $this->assertSame( + 'https://application.example/auth/oauth/' . $provider, + $query['redirect_uri'] + ); + $this->assertSame('code', $query['response_type']); + $this->assertSame('S256', $query['code_challenge_method']); + $this->assertArrayHasKey('state', $query); + $this->assertNotSame('', $query['state']); + + $flow = $_SESSION['oauth2flows'][$provider]; + $this->assertSame($query['state'], $flow['state']); + $this->assertSame('login', $flow['intent']); + + $expectedChallenge = rtrim( + strtr(base64_encode(hash('sha256', $flow['code_verifier'], true)), '+/', '-_'), + '=' + ); + $this->assertSame($expectedChallenge, $query['code_challenge']); + + $scopes = explode(' ', str_replace(',', ' ', $query['scope'])); + if ($provider === 'google') { + $this->assertContains('openid', $scopes); + $this->assertContains('email', $scopes); + $this->assertContains('profile', $scopes); + } else { + $this->assertContains('public_profile', $scopes); + $this->assertContains('email', $scopes); + } + + return $flow; + } + + private function assertTokenAndProfileRequests( + array $history, + string $provider, + string $verifier + ): void { + $this->assertCount(2, $history); + $token = $history[0]['request']; + $profile = $history[1]['request']; + $this->assertSame('POST', $token->getMethod()); + $this->assertSame( + $provider === 'google' ? 'oauth2.googleapis.com' : 'graph.facebook.com', + $token->getUri()->getHost() + ); + $this->assertSame( + $provider === 'google' ? '/token' : '/v22.0/oauth/access_token', + $token->getUri()->getPath() + ); + parse_str((string) $token->getBody(), $body); + $this->assertSame('authorization_code', $body['grant_type']); + $this->assertSame('integration-auth-code', $body['code']); + $this->assertSame('integration-client-id', $body['client_id']); + $this->assertSame( + 'https://application.example/auth/oauth/' . $provider, + $body['redirect_uri'] + ); + $this->assertSame($verifier, $body['code_verifier']); + + $this->assertSame('GET', $profile->getMethod()); + if ($provider === 'google') { + $this->assertSame('openidconnect.googleapis.com', $profile->getUri()->getHost()); + $this->assertSame('/v1/userinfo', $profile->getUri()->getPath()); + $this->assertSame('Bearer integration-access-token', $profile->getHeaderLine('Authorization')); + } else { + $this->assertSame('graph.facebook.com', $profile->getUri()->getHost()); + $this->assertSame('/v22.0/me', $profile->getUri()->getPath()); + parse_str($profile->getUri()->getQuery(), $params); + $this->assertSame('integration-access-token', $params['access_token']); + $this->assertNotEmpty($params['appsecret_proof']); + } + } + + public function testRealGoogleProviderCanCompleteFirstTimeOAuthAndLinkExistingAccount(): void + { + $history = []; + $http = $this->clientWithResponses([ + ['access_token' => 'integration-access-token', 'token_type' => 'Bearer', 'expires_in' => 3600], + [ + 'sub' => 'google-stable-subject', 'name' => 'Google Person', + 'given_name' => 'Google', 'family_name' => 'Person', + 'picture' => 'https://images.example.com/google.png', + 'email' => 'google.profile@example.com', 'email_verified' => true, + ], + ], $history); + $auth = new BasicAuth([], ['token' => 'token']); + $existing = $auth->register('existing@example.com', 'correct-password'); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('google'), $http); + + $controller->get('google', []); + $flow = $this->assertAuthorizationRequest($controller, 'google'); + $controller->get('google', [ + 'code' => 'integration-auth-code', + 'state' => $flow['state'], + ]); + + $this->assertTokenAndProfileRequests($history, 'google', $flow['code_verifier']); + $this->assertSame('/account/choose', $controller->redirectedTo); + $this->assertArrayNotHasKey('google', $_SESSION['oauth2flows']); + $this->assertFalse($auth->check()); + $this->assertFalse($auth->get_credential('google', 'google-stable-subject')); + $this->assertSame('Google Person', $controller->getPendingAccount()['name']); + $this->assertSame('google.profile@example.com', $controller->getPendingAccount()['email_hint']); + $this->assertSame('https://images.example.com/google.png', $_SESSION['oauth2pending']['image']); + + $this->authorizeAccountChoice($auth); + $linked = $controller->postExistingAccount([ + 'uid' => 'existing@example.com', 'password' => 'correct-password', + ]); + $this->assertSame('linked', $linked['status']); + $this->assertSame($existing['id'], $linked['user']['id']); + $credential = $auth->get_credential('google', 'google-stable-subject'); + $this->assertSame($existing['id'], $credential['user_id']); + $this->assertNotEmpty($credential['verified_at']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + } + + public function testRealFacebookProviderCanCompleteFirstTimeOAuthAndCreateAccount(): void + { + $history = []; + $http = $this->clientWithResponses([ + ['access_token' => 'integration-access-token', 'token_type' => 'bearer', 'expires_in' => 3600], + [ + 'id' => 'facebook-stable-subject', 'name' => 'Facebook Person', + 'first_name' => 'Facebook', 'last_name' => 'Person', + 'email' => 'facebook.profile@example.com', + 'picture' => ['data' => [ + 'url' => 'https://images.example.com/facebook.png', + 'is_silhouette' => false, + ]], + ], + ], $history); + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('facebook'), $http); + + $controller->get('facebook', []); + $flow = $this->assertAuthorizationRequest($controller, 'facebook'); + $controller->get('facebook', [ + 'code' => 'integration-auth-code', + 'state' => $flow['state'], + ]); + + $this->assertTokenAndProfileRequests($history, 'facebook', $flow['code_verifier']); + $this->assertSame('/account/choose', $controller->redirectedTo); + $this->assertFalse($auth->check()); + $this->assertFalse($auth->get_credential('facebook', 'facebook-stable-subject')); + $pending = $controller->getPendingAccount(); + $this->assertSame('Facebook Person', $pending['name']); + $this->assertSame('facebook.profile@example.com', $pending['email_hint']); + $this->assertSame('https://images.example.com/facebook.png', $_SESSION['oauth2pending']['image']); + + $this->authorizeAccountChoice($auth); + $created = $controller->postCreateAccount(); + $this->assertSame('created', $created['status']); + $this->assertTrue($auth->check()); + $this->assertSame('Facebook Person', $created['user']['name']); + $this->assertSame('https://images.example.com/facebook.png', $created['user']['image']); + $credential = $auth->get_credential('facebook', 'facebook-stable-subject'); + $this->assertSame($created['user']['id'], $credential['user_id']); + $this->assertNotEmpty($credential['verified_at']); + } + + public function testRealGoogleProviderSignsInExistingLinkedAccountWithoutNewChoice(): void + { + $history = []; + $http = $this->clientWithResponses([ + ['access_token' => 'integration-access-token', 'token_type' => 'Bearer', 'expires_in' => 3600], + ['sub' => 'known-google-id', 'name' => 'Returning Google User', 'email' => 'returning@example.com'], + ], $history); + $auth = new BasicAuth([], ['token' => 'token']); + $owner = $auth->register('returning@example.com', 'secret'); + $auth->create_credential($owner['id'], 'google', 'known-google-id'); + $auth->mark_credential_verified($owner['id'], 'google', 'known-google-id'); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('google'), $http); + + $controller->get('google', []); + $flow = $this->assertAuthorizationRequest($controller, 'google'); + $controller->get('google', [ + 'code' => 'integration-auth-code', 'state' => $flow['state'], + ]); + + $this->assertTokenAndProfileRequests($history, 'google', $flow['code_verifier']); + $this->assertSame('/', $controller->redirectedTo); + $this->assertTrue($auth->check()); + $this->assertSame($owner['id'], $auth->user()['id']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + } + + public function testRealFacebookProviderExplicitLinkUsesSameAccountAndPkce(): void + { + $history = []; + $http = $this->clientWithResponses([ + ['access_token' => 'integration-access-token', 'token_type' => 'bearer', 'expires_in' => 3600], + ['id' => 'new-facebook-identity', 'name' => 'Linked Facebook User'], + ], $history); + $auth = new BasicAuth([], ['token' => 'token']); + $owner = $auth->register('facebook-link@example.com', 'correct-password'); + $auth->login('facebook-link@example.com', 'correct-password'); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('facebook'), $http); + $this->authorizeAccountChoice($auth); + + $link = $controller->postLink('facebook'); + $this->assertArrayHasKey('authorization_url', $link); + $controller->redirectedTo = $link['authorization_url']; + $flow = $this->assertLinkAuthorizationRequest($controller); + + $controller->get('facebook', [ + 'code' => 'integration-auth-code', 'state' => $flow['state'], + ]); + $this->assertTokenAndProfileRequests($history, 'facebook', $flow['code_verifier']); + $this->assertSame('/', $controller->redirectedTo); + $this->assertSame($owner['id'], $auth->user()['id']); + $credential = $auth->get_credential('facebook', 'new-facebook-identity'); + $this->assertSame($owner['id'], $credential['user_id']); + $this->assertNotEmpty($credential['verified_at']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + } + + private function assertLinkAuthorizationRequest(RealOAuthProviderController $controller): array + { + $flow = $_SESSION['oauth2flows']['facebook']; + $this->assertSame('link', $flow['intent']); + $this->assertNotNull($flow['principal']); + parse_str((string) parse_url($controller->redirectedTo, PHP_URL_QUERY), $query); + $this->assertSame($flow['state'], $query['state']); + $this->assertSame('S256', $query['code_challenge_method']); + $this->assertSame( + rtrim(strtr(base64_encode(hash('sha256', $flow['code_verifier'], true)), '+/', '-_'), '='), + $query['code_challenge'] + ); + return $flow; + } + + public function testRealGoogleProviderRejectsWrongOAuthStateBeforeHttp(): void + { + $history = []; + $http = $this->clientWithResponses([], $history); + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('google'), $http); + $controller->get('google', []); + + try { + $controller->get('google', [ + 'code' => 'integration-auth-code', 'state' => 'attacker-state', + ]); + $this->fail('Unexpectedly accepted mismatched provider state'); + } catch (\Exception $e) { + $this->assertSame(406, $e->getCode()); + } + $this->assertCount(0, $history); + $this->assertArrayNotHasKey('google', $_SESSION['oauth2flows']); + } + + public function testRealFacebookProviderRejectsTokenErrorsWithoutCreatingAccount(): void + { + $history = []; + $mock = new MockHandler([ + new Response(400, ['Content-Type' => 'application/json'], json_encode([ + 'error' => ['type' => 'OAuthException', 'message' => 'Invalid code', 'code' => 190], + ], JSON_THROW_ON_ERROR)), + ]); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + $http = new Client(['handler' => $stack]); + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('facebook'), $http); + $controller->get('facebook', []); + $flow = $_SESSION['oauth2flows']['facebook']; + + try { + $controller->get('facebook', [ + 'code' => 'integration-auth-code', 'state' => $flow['state'], + ]); + $this->fail('Token error unexpectedly created an account'); + } catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) { + $this->assertSame(190, $e->getCode()); + } + + $this->assertCount(1, $history); + $this->assertFalse($auth->check()); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + $this->assertArrayNotHasKey('facebook', $_SESSION['oauth2flows']); + } + + private function authorizeAccountChoice(BasicAuth $auth): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $auth->management_csrf_token(); + } +} diff --git a/test/RequireRoleTest.php b/test/RequireRoleTest.php new file mode 100644 index 0000000..3c8ec28 --- /dev/null +++ b/test/RequireRoleTest.php @@ -0,0 +1,107 @@ +auth = new BasicAuth( + ['admin@example.com' => 'secret'], + ['token' => 'token'] + ); + } + + public function testBeforeRejectsUnauthenticatedUser(): void + { + $mw = new RequireRole($this->auth, ['admin']); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + $mw->before('GET', 'index', []); + } + + public function testBeforeRejectsMissingRoleForAuthenticatedUser(): void + { + $this->auth->login('admin@example.com', 'secret'); + $mw = new RequireRole($this->auth, ['admin']); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + $mw->before('GET', 'index', []); + } + + public function testBeforeAllowsMatchingRoleFromUserData(): void + { + $this->auth->register('role@example.com', 'secret', ['roles' => ['admin']]); + $this->auth->login('role@example.com', 'secret'); + $mw = new RequireRole($this->auth, ['admin']); + + $out = $mw->before('GET', 'index', ['ok' => true]); + $this->assertSame(['ok' => true], $out); + } + + public function testBeforeUsesConfiguredRoleField(): void + { + $auth = new BasicAuth( + [], + [ + 'token' => 'token', + 'roles' => 'groups', + ] + ); + $auth->register('role2@example.com', 'secret', ['groups' => ['admin', 'editor']]); + $auth->login('role2@example.com', 'secret'); + + $mw = new RequireRole($auth, ['editor']); + $out = $mw->before('GET', 'index', ['ok' => true]); + $this->assertSame(['ok' => true], $out); + } + + public function testBeforeAllowsAnonymousMarkerWhenUnauthenticated(): void + { + $mw = new RequireRole($this->auth, Auth::ANONYMOUS); + $out = $mw->before('GET', 'index', ['ok' => true]); + $this->assertSame(['ok' => true], $out); + } + + public function testBeforeRejectsAnonymousMarkerWhenAuthenticated(): void + { + $this->auth->login('admin@example.com', 'secret'); + $mw = new RequireRole($this->auth, Auth::ANONYMOUS); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + $mw->before('GET', 'index', []); + } + + public function testBeforeAllowsAuthenticatedMarkerWhenLoggedIn(): void + { + $this->auth->login('admin@example.com', 'secret'); + $mw = new RequireRole($this->auth, Auth::AUTHENTICATED); + $out = $mw->before('GET', 'index', ['ok' => true]); + $this->assertSame(['ok' => true], $out); + } + + public function testBeforeRejectsAuthenticatedMarkerWhenUnauthenticated(): void + { + $mw = new RequireRole($this->auth, Auth::AUTHENTICATED); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + $mw->before('GET', 'index', []); + } +} diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php new file mode 100644 index 0000000..1716d2e --- /dev/null +++ b/test/UserControllerTest.php @@ -0,0 +1,559 @@ +query( + 'CREATE TABLE auth_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + name TEXT, + image TEXT, + created TEXT, + password TEXT, + token TEXT, + token_expires_at TEXT, + disabled_at TEXT + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE auth_credentials ( + uid TEXT NOT NULL, + provider TEXT NOT NULL, + user_id INTEGER NOT NULL, + profile TEXT NULL, + token TEXT NULL, + token_expires_at TEXT NULL, + verified_at TEXT NULL, + last_login TEXT NULL, + PRIMARY KEY(uid, provider) + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE auth_role ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE auth_user_role ( + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + PRIMARY KEY(user_id, role_id) + )' + )->exec(); + + self::$auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => null, + 'roles_table' => null, + 'user_roles_table' => null, + ]); + self::$controller = new UserController(self::$auth); + } + + protected function setUp(): void + { + $_SESSION = []; + self::$db->query('DELETE FROM auth_credentials')->exec(); + self::$db->query('DELETE FROM auth_user_role')->exec(); + self::$db->query('DELETE FROM auth_role')->exec(); + self::$db->query('DELETE FROM auth_user')->exec(); + } + + public function testManagedRelationsAreRejectedByAuthAdminCreate(): void + { + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(400); + + self::$controller->post([ + 'uid' => 'venue-create@example.com', + 'password' => 'test', + 'managed_relations' => ['venues' => [1, 2]], + ]); + } + + public function testManagedRelationsAreRejectedByAuthAdminUpdate(): void + { + $user = self::$controller->post([ + 'uid' => 'venue-update@example.com', + 'password' => 'test', + ]); + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(400); + + self::$controller->put((int) $user['id'], [ + 'managed_relations' => ['venues' => [1, 2]], + ]); + } + + public function testAdminUserDetailNoLongerOwnsApplicationAssociations(): void + { + $user = self::$controller->post([ + 'uid' => 'detail@example.com', + 'password' => 'test', + ]); + $detail = self::$controller->get((int) $user['id']); + $this->assertArrayNotHasKey('managed_relations', $detail); + } + + public function testPost(): void + { + $user = self::$controller->post([ + 'uid' => 'vagrant@localhost', + 'password' => 'test', + 'name' => 'Test User', + ]); + + $this->assertGreaterThan(0, (int) $user['id']); + } + + public function testPostMissingUid(): void + { + $this->expectException(\Objectiveweb\Auth\UserException::class); + self::$controller->post([ + 'name' => 'Invalid User', + ]); + } + + public function testGet(): void + { + $user = self::$controller->post([ + 'uid' => 'vagrant@localhost', + 'password' => 'test', + 'name' => 'Test User', + ]); + + $loaded = self::$controller->get((int) $user['id']); + $this->assertSame((int) $user['id'], (int) $loaded['id']); + } + + public function testQuery(): void + { + self::$controller->post([ + 'uid' => 'vagrant@localhost', + 'password' => 'test', + 'name' => 'Test User', + ]); + + $all = self::$controller->get(); + + $this->assertInstanceOf(\Objectiveweb\DB\Collection::class, $all); + $this->assertCount(1, $all); + $this->assertSame(1, $all->total()); + $this->assertSame('items 0-0/1', $all->contentRange()); + $this->assertSame('Test User', $all[0]['name']); + } + + public function testQueryInvalidFilterField(): void + { + $this->expectException(\Objectiveweb\Auth\UserException::class); + self::$controller->get(['unknown_field' => 'x']); + } + + public function testPostRejectsNonArrayRoles(): void + { + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(400); + + self::$controller->post([ + 'uid' => 'roles-string@example.com', + 'password' => 'test', + 'roles' => 'admin', + ]); + } + + public function testPostAllowsCredentialMetadata(): void + { + $user = self::$controller->post([ + 'uid' => 'oauth@example.com', + 'password' => 'test', + 'provider' => 'email', + 'profile' => ['source' => 'test'], + ]); + + $this->assertCount(1, $user['credentials']); + $this->assertSame('email', $user['credentials'][0]['provider']); + $this->assertSame(['source' => 'test'], $user['credentials'][0]['profile']); + } + + public function testPutRejectsNonArrayRoles(): void + { + $user = self::$controller->post([ + 'uid' => 'roles-update@example.com', + 'password' => 'test', + ]); + + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(400); + + self::$controller->put((int) $user['id'], ['roles' => 'admin']); + } + + public function testPutRejectsCredentialOnlyFields(): void + { + $user = self::$controller->post([ + 'uid' => 'credential-fields@example.com', + 'password' => 'test', + ]); + + foreach (['provider', 'profile'] as $field) { + try { + self::$controller->put((int) $user['id'], [$field => 'invalid']); + $this->fail("Expected {$field} to be rejected"); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(400, $exception->getCode()); + $this->assertSame("Invalid field `{$field}`", $exception->getMessage()); + } + } + } + + public function testPut(): void + { + $user = self::$controller->post([ + 'uid' => 'vagrant@localhost', + 'password' => 'test', + 'name' => 'Test User', + ]); + + self::$controller->put((int) $user['id'], ['name' => 'Updated name']); + $updated = self::$controller->get((int) $user['id']); + + $this->assertSame('Updated name', $updated['name']); + } + + public function testCannotSuspendOnlyActiveAdminWhenAnotherAdminIsSuspended(): void + { + self::$db->insert('auth_role', ['name' => 'admin']); + + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => null, + 'disabled_at' => 'disabled_at', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + $controller = new UserController($auth); + + $actor = $auth->register('actor@example.com', 'secret'); + $activeAdmin = $auth->register('active-admin@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + $suspendedAdmin = $auth->register('suspended-admin@example.com', 'secret', [ + 'roles' => ['admin'], + 'disabled_at' => '2026-09-27 12:00:00', + ]); + + $auth->login('actor@example.com', 'secret'); + + $admins = $auth->get_users_by_role('admin'); + $this->assertCount(2, $admins); + $suspended = array_values(array_filter( + $admins, + fn (array $user): bool => (int) $user['id'] === (int) $suspendedAdmin['id'] + )); + $this->assertCount(1, $suspended); + $this->assertSame('2026-09-27 12:00:00', $suspended[0]['disabled_at']); + + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(409); + $controller->post((int) $activeAdmin['id'], 'suspend'); + } + + public function testFinalAdminGuardCoversSuspendDemoteDeleteAndDirectDisable(): void + { + self::$db->insert('auth_role', ['name' => 'admin']); + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => null, + 'disabled_at' => 'disabled_at', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + $controller = new UserController($auth); + $admin = $auth->register('sole-admin@example.com', 'secret', ['roles' => ['admin']]); + $auth->register('admin-actor@example.com', 'secret'); + $auth->login('admin-actor@example.com', 'secret'); + $id = (int) $admin['id']; + + $mutations = [ + static fn () => $controller->post($id, 'suspend'), + static fn () => $controller->put($id, ['roles' => []]), + static fn () => $controller->put($id, ['disabled_at' => '2026-01-01 00:00:00']), + static fn () => $controller->put($id, ['roles' => [], 'disabled_at' => '2026-01-01 00:00:00']), + static fn () => $controller->delete($id), + ]; + foreach ($mutations as $mutate) { + try { + $mutate(); + $this->fail('A mutation removed the final active administrator'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(409, $exception->getCode()); + } + $stillAdmin = $auth->get($id); + $this->assertTrue($auth->is_active($stillAdmin)); + $this->assertContains('admin', $stillAdmin['roles']); + } + } + + public function testReducingTwoActiveAdminsToOnePreventsFurtherRemoval(): void + { + self::$db->insert('auth_role', ['name' => 'admin']); + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => null, + 'disabled_at' => 'disabled_at', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + $controller = new UserController($auth); + $first = $auth->register('first-admin@example.com', 'secret', ['roles' => ['admin']]); + $second = $auth->register('second-admin@example.com', 'secret', ['roles' => ['admin']]); + $auth->register('management-actor@example.com', 'secret'); + $auth->login('management-actor@example.com', 'secret'); + + $this->assertSame(['ok' => true], $controller->delete((int) $first['id'])); + $this->assertCount(1, array_filter( + $auth->get_users_by_role('admin'), + static fn (array $user): bool => $auth->is_active($user) + )); + try { + $controller->post((int) $second['id'], 'suspend'); + $this->fail('A second operation removed the last active admin'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(409, $exception->getCode()); + } + $this->assertTrue($auth->is_active($auth->get($second['id']))); + } + + /** + * Model a competing transaction committed before the role lock is + * acquired. The count must not be read or cached outside the lock. + */ + public function testAdminGuardReloadsActiveCountAfterAcquiringLock(): void + { + $db = new class('sqlite::memory:') extends DB { + public ?\Closure $beforeRoleLock = null; + public function update(string $table, array $data, ?array $where = null): int + { + if ($table === 'role' && $this->beforeRoleLock !== null) { + $callback = $this->beforeRoleLock; + $this->beforeRoleLock = null; + $callback(); + } + return parent::update($table, $data, $where); + } + }; + $db->query('CREATE TABLE user (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, name TEXT, password TEXT, disabled_at TEXT)')->exec(); + $db->query('CREATE TABLE user_credentials (uid TEXT NOT NULL, provider TEXT NOT NULL, user_id INTEGER NOT NULL, profile TEXT, token TEXT, token_expires_at TEXT, verified_at TEXT, last_login TEXT, created TEXT, PRIMARY KEY(uid, provider))')->exec(); + $db->query('CREATE TABLE role (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE)')->exec(); + $db->query('CREATE TABLE user_roles (user_id INTEGER NOT NULL, role_id INTEGER NOT NULL, PRIMARY KEY(user_id, role_id))')->exec(); + $db->insert('role', ['id' => 1, 'name' => 'admin']); + + $auth = new DBAuth($db, ['disabled_at' => 'disabled_at', 'created' => null]); + $controller = new UserController($auth); + $first = $auth->register('race-first@example.com', 'secret', ['roles' => ['admin']]); + $second = $auth->register('race-second@example.com', 'secret', ['roles' => ['admin']]); + $auth->register('race-actor@example.com', 'secret'); + $auth->login('race-actor@example.com', 'secret'); + + $db->beforeRoleLock = static function () use ($db, $first): void { + $db->delete('user_roles', ['user_id' => $first['id']]); + }; + try { + $controller->put((int) $second['id'], ['roles' => []]); + $this->fail('Stale admin count bypassed serialization guard'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(409, $exception->getCode()); + } + + // The simulated concurrent mutation must roll back with the guard. + $this->assertCount(2, $auth->get_users_by_role('admin')); + $this->assertContains('admin', $auth->get($second['id'])['roles']); + } + + public function testDirectDbAuthCallsCannotRemoveFinalActiveAdmin(): void + { + self::$db->insert('auth_role', ['name' => 'admin']); + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => null, + 'disabled_at' => 'disabled_at', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + $admin = $auth->register('direct-admin@example.com', 'secret', ['roles' => ['admin']]); + $id = (int) $admin['id']; + + foreach ([ + static fn () => $auth->update($id, ['roles' => []]), + static fn () => $auth->update($id, ['disabled_at' => '2026-01-01 00:00:00']), + static fn () => $auth->delete($id), + ] as $mutation) { + try { + $mutation(); + $this->fail('Direct DBAuth mutation removed the final active admin'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(409, $exception->getCode()); + } + $user = $auth->get($id); + $this->assertTrue($auth->is_active($user)); + $this->assertContains('admin', $user['roles']); + } + } + + public function testOrdinaryDbAuthUpdateSkipsAdminRoleLock(): void + { + $db = new class('sqlite::memory:') extends DB { + public int $roleLockCalls = 0; + public function update(string $table, array $data, ?array $where = null): int + { + if ($table === 'role' && isset($where['name']) + && $where['name'] === 'admin' && ($data['name'] ?? '') === 'admin') { + $this->roleLockCalls++; + } + return parent::update($table, $data, $where); + } + }; + $db->query('CREATE TABLE user (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, name TEXT, password TEXT, disabled_at TEXT)')->exec(); + $db->query('CREATE TABLE user_credentials (uid TEXT NOT NULL, provider TEXT NOT NULL, user_id INTEGER NOT NULL, profile TEXT, token TEXT, token_expires_at TEXT, verified_at TEXT, last_login TEXT, created TEXT, PRIMARY KEY(uid, provider))')->exec(); + $db->query('CREATE TABLE role (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE)')->exec(); + $db->query('CREATE TABLE user_roles (user_id INTEGER NOT NULL, role_id INTEGER NOT NULL, PRIMARY KEY(user_id, role_id))')->exec(); + $db->insert('role', ['id' => 1, 'name' => 'admin']); + + $auth = new DBAuth($db, ['disabled_at' => 'disabled_at', 'created' => null]); + $first = $auth->register('ordinary-admin@example.com', 'secret', ['roles' => ['admin']]); + $auth->register('second-ordinary-admin@example.com', 'secret', ['roles' => ['admin']]); + $id = (int) $first['id']; + + $auth->update($id, ['name' => 'Renamed Admin']); + $auth->update($id, ['disabled_at' => null]); + $auth->update($id, ['roles' => ['admin']]); + $this->assertSame(0, $db->roleLockCalls); + + $auth->update($id, ['roles' => []]); + $this->assertSame(1, $db->roleLockCalls); + } + + public function testDeleteRejectsUnknownSubresourcesWithoutDeletingUser(): void + { + self::$controller->post([ + 'uid' => 'actor@example.com', + 'password' => 'test', + ]); + $target = self::$controller->post([ + 'uid' => 'target@example.com', + 'password' => 'test', + ]); + self::$auth->login('actor@example.com', 'test'); + + foreach (['unknown', '', 'suspend', 'roles'] as $resource) { + try { + self::$controller->delete((int) $target['id'], $resource); + $this->fail('Expected invalid delete subresource to be rejected'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + $this->assertSame( + (int) $target['id'], + (int) self::$controller->get((int) $target['id'])['id'] + ); + $this->assertNotFalse(self::$auth->get_credential('local', 'target@example.com')); + } + + // Stray nested path parameters without a resource are also invalid. + try { + self::$controller->delete((int) $target['id'], null, 'local', 'target@example.com'); + $this->fail('Expected incomplete subresource path to be rejected'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + $this->assertSame( + (int) $target['id'], + (int) self::$controller->get((int) $target['id'])['id'] + ); + } + + public function testDeleteUserOnlyAtBaseResource(): void + { + self::$controller->post([ + 'uid' => 'actor@example.com', + 'password' => 'test', + ]); + $target = self::$controller->post([ + 'uid' => 'target@example.com', + 'password' => 'test', + 'name' => 'Test User', + ]); + self::$auth->login('actor@example.com', 'test'); + + $this->assertSame(['ok' => true], self::$controller->delete((int) $target['id'])); + $this->assertFalse(self::$auth->get_credential('local', 'target@example.com')); + + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(404); + self::$controller->get((int) $target['id']); + } + + public function testDeleteCredentialDoesNotDeleteUser(): void + { + self::$controller->post([ + 'uid' => 'actor@example.com', + 'password' => 'test', + ]); + $target = self::$controller->post([ + 'uid' => 'target@example.com', + 'password' => 'test', + ]); + self::$auth->create_credential((int) $target['id'], 'phone', '+5511999999999'); + self::$auth->login('actor@example.com', 'test'); + + $this->assertSame( + ['ok' => true], + self::$controller->delete((int) $target['id'], 'credentials', 'phone', '%2B5511999999999') + ); + $this->assertFalse(self::$auth->get_credential('phone', '+5511999999999')); + $this->assertNotFalse(self::$auth->get_credential('local', 'target@example.com')); + $this->assertSame( + (int) $target['id'], + (int) self::$controller->get((int) $target['id'])['id'] + ); + } +} diff --git a/test/phinx.php b/test/phinx.php new file mode 100644 index 0000000..a2953d5 --- /dev/null +++ b/test/phinx.php @@ -0,0 +1,30 @@ + [ + 'migrations' => dirname(__DIR__) . '/db', + ], + 'environments' => [ + 'default_migration_table' => 'phinxlog', + 'default_environment' => 'testing', + 'testing' => [ + 'adapter' => 'pgsql', + 'host' => getenv('PGHOST') ?: '127.0.0.1', + 'name' => getenv('PGDATABASE') ?: 'auth_test', + 'user' => getenv('PGUSER') ?: 'postgres', + 'pass' => getenv('PGPASSWORD') ?: 'postgres', + 'port' => (int) (getenv('PGPORT') ?: 5432), + ], + // Separate environment shares the package migrations with PostgreSQL. + // Provide MYSQL_* variables in the dedicated MySQL CI job. + 'mysql_testing' => [ + 'adapter' => 'mysql', + 'host' => getenv('MYSQL_HOST') ?: '127.0.0.1', + 'name' => getenv('MYSQL_DATABASE') ?: 'auth_test', + 'user' => getenv('MYSQL_USER') ?: 'auth_user', + 'pass' => getenv('MYSQL_PASSWORD') ?: '', + 'port' => (int) (getenv('MYSQL_PORT') ?: 3306), + 'charset' => 'utf8mb4', + ], + ], +]; diff --git a/test/verify_migrations.php b/test/verify_migrations.php new file mode 100644 index 0000000..4d7228d --- /dev/null +++ b/test/verify_migrations.php @@ -0,0 +1,87 @@ + PDO::ERRMODE_EXCEPTION] +); + +$expectedColumns = [ + 'user' => [ + 'id', + 'uuid', + 'password', + 'name', + 'image', + 'token', + 'token_expires_at', + 'disabled_at', + 'created', + ], + 'user_credentials' => [ + 'uid', + 'provider', + 'user_id', + 'profile', + 'token', + 'token_expires_at', + 'verified_at', + 'last_login', + 'created', + ], + 'role' => ['id', 'name'], + 'user_roles' => ['user_id', 'role_id'], +]; + +foreach ($expectedColumns as $table => $columns) { + $statement = $pdo->prepare( + 'SELECT column_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = :table' + ); + $statement->execute(['table' => $table]); + $actual = $statement->fetchAll(PDO::FETCH_COLUMN); + + foreach ($columns as $column) { + if (!in_array($column, $actual, true)) { + fwrite(STDERR, "Missing column {$table}.{$column}\n"); + exit(1); + } + } +} + +$uuidIndex = $pdo->query( + "SELECT indexdef + FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = 'user' + AND indexdef ILIKE '%UNIQUE%' + AND indexdef ILIKE '%uuid%'" +)->fetchColumn(); + +if ($uuidIndex === false) { + fwrite(STDERR, "Missing unique index for user.uuid\n"); + exit(1); +} + +$tokenIndex = $pdo->query( + "SELECT indexdef + FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = 'user' + AND indexdef ILIKE '%token%'" +)->fetchColumn(); + +if ($tokenIndex === false) { + fwrite(STDERR, "Missing index for user.token\n"); + exit(1); +} + +echo "Migration schema matches DBAuth defaults.\n"; diff --git a/test/verify_mysql_auth.php b/test/verify_mysql_auth.php new file mode 100644 index 0000000..0103f98 --- /dev/null +++ b/test/verify_mysql_auth.php @@ -0,0 +1,191 @@ + 'token', + 'disabled_at' => 'disabled_at', +]); + +$assert = static function (bool $condition, string $message): void { + if (!$condition) { + throw new RuntimeException($message); + } +}; + +$expectFailure = static function ( + callable $callback, string $expectedClass, int $expectedCode +) use ($assert): void { + try { + $callback(); + } catch (Throwable $exception) { + $assert($exception instanceof $expectedClass, 'Unexpected error type: ' . get_class($exception)); + $assert($exception->getCode() === $expectedCode, 'Unexpected error code: ' . $exception->getCode()); + return; + } + throw new RuntimeException("Expected {$expectedClass} ({$expectedCode})"); +}; + +// Real MySQL INSERTs and foreign-key-backed role assignment. +$admin = $auth->register('mysql-admin@example.com', 'original-secret', [ + 'name' => 'MySQL Admin', + 'roles' => ['admin', 'operator'], +]); +$viewer = $auth->register('mysql-viewer@example.com', 'viewer-secret', [ + 'name' => 'MySQL Viewer', + 'roles' => ['viewer'], +]); + +$assert(!empty($admin['uuid']) && $admin['id'] !== $viewer['id'], 'Missing distinct user IDs or UUID'); +$assert(!array_key_exists('password', $admin), 'Registration exposed password hash'); +$assert($auth->get_users_by_role('admin')[0]['id'] == $admin['id'], 'Role join returned wrong user'); +$assert($auth->get_users_by_role('admin')[0]['roles'] === ['admin', 'operator'], 'Role join lost other roles'); +$assert($auth->get_users_by_role('nonexistent') === [], 'Unknown role returned users'); + +$listing = $auth->query(['page' => 0, 'size' => 1, 'sort' => 'name ASC']); +$assert($listing instanceof Collection, 'Query must return a Collection'); +$assert($listing->total() === 2 && count($listing) === 1, 'Query pagination totals are wrong'); +$assert($listing->contentRange() === 'items 0-0/2', 'Query content range is wrong'); +$assert($listing[0]['name'] === 'MySQL Admin', 'Query returned wrong sorted user'); +$search = $auth->query(['q' => 'mysql-viewer@example.com', 'size' => 10]); +$assert($search->total() === 1, 'Credential UID search on MySQL failed'); + +$expectFailure( + static fn () => $auth->login('missing@example.com', 'invalid'), + AuthException::class, + 401 +); +$expectFailure( + static fn () => $auth->login('mysql-admin@example.com', 'invalid'), + AuthException::class, + 401 +); +$logged = $auth->login('mysql-admin@example.com', 'original-secret'); +$assert($logged['id'] == $admin['id'] && $auth->check(), 'MySQL login/session failed'); +$assert($auth->user()['roles'] === ['admin', 'operator'], 'Session did not hydrate roles'); +$auth->logout(); + +// Reset tokens must be hashed at rest, expired on consumption and usable once. +$reset = $auth->update_token($admin['id']); +$stored = $db->select('user', ['id' => $admin['id']], ['limit' => 1])->fetch(); +$assert($stored['token'] === hash('sha256', $reset), 'Reset token not stored as digest'); +$assert(!empty($stored['token_expires_at']), 'Reset token expiry missing'); +$auth->passwd_reset($reset, 'replacement-secret'); +$expectFailure( + static fn () => $auth->passwd_reset($reset, 'replay-secret'), + UserException::class, + 404 +); +$expectFailure( + static fn () => $auth->login('mysql-admin@example.com', 'original-secret'), + AuthException::class, + 401 +); +$auth->login('mysql-admin@example.com', 'replacement-secret'); +$auth->logout(); + +// Direct password updates must invalidate any previously issued reset link +// in the same SQL statement, without leaving a usable stale token. +$pendingReset = $auth->update_token($admin['id']); +$auth->passwd($admin['id'], 'changed-by-user-secret'); +$stored = $db->select('user', ['id' => $admin['id']], ['limit' => 1])->fetch(); +$assert($stored['token'] === null && $stored['token_expires_at'] === null, + 'Password change did not revoke the outstanding reset token'); +$expectFailure( + static fn () => $auth->passwd_reset($pendingReset, 'stale-reset-secret'), + UserException::class, + 404 +); +$auth->login('mysql-admin@example.com', 'changed-by-user-secret'); +$auth->logout(); + +// Each credential has its own token, expiration and verification state. +$credential = $auth->create_credential($admin['id'], 'email', 'admin-contact@example.com'); +$assert(empty($credential['verified_at']), 'New email credential should be unverified'); +$verification = $auth->issue_credential_token($admin['id'], 'email', 'admin-contact@example.com'); +$storedCredential = $auth->get_credential('email', 'admin-contact@example.com'); +$assert( + $storedCredential['token'] === hash('sha256', $verification), + 'Credential verification token not stored as digest' +); +$assert( + !$auth->verify_credential_token('email', 'admin-contact@example.com', 'bad-token'), + 'Bad credential token was accepted' +); +$assert( + $auth->verify_credential_token('email', 'admin-contact@example.com', $verification), + 'Valid credential token failed' +); +$assert( + !$auth->verify_credential_token('email', 'admin-contact@example.com', $verification), + 'Credential verification token was reused' +); +$assert( + !empty($auth->get_credential('email', 'admin-contact@example.com')['verified_at']), + 'Verified credential is missing verified_at' +); + +// Roles are synced portably with MySQL's SQL dialect. Preserve another +// active admin while demoting the account under test. +$backupAdmin = $auth->register('mysql-backup-admin@example.com', 'backup-secret', [ + 'roles' => ['admin'], +]); +$auth->update($admin['id'], ['roles' => ['operator']]); +$assert($auth->get($admin['id'])['roles'] === ['operator'], 'Role replacement failed'); +$remainingAdmins = $auth->get_users_by_role('admin'); +$assert(count($remainingAdmins) === 1 && $remainingAdmins[0]['id'] == $backupAdmin['id'], + 'Removed role still grants access or backup admin was lost'); +$auth->delete($viewer['id']); +$assert($auth->get_users_by_role('viewer') === [], 'Deleted user still appears in role lookup'); +$assert(count($auth->query()) === 2, 'Deleted user still appears in MySQL query'); + +// Provider identity comparisons must remain exact even when the database's +// default collation is case-insensitive. These are distinct OAuth subjects. +$subjects = [ + ['google', 'OAuthSubjectAbC'], + ['google', 'oauthsubjectabc'], + ['google', 'OAuthSubjectAbC '], + ['Google', 'OAuthSubjectAbC'], +]; +$identityUserIds = []; +foreach ($subjects as [$provider, $uid]) { + $user = $auth->register($uid, null, ['provider' => $provider]); + $identityUserIds[] = $user['id']; + $credential = $auth->get_credential($provider, $uid); + $assert( + $credential !== false && $credential['user_id'] == $user['id'], + "MySQL returned another account for {$provider}/{$uid}" + ); +} +$assert( + count(array_unique($identityUserIds)) === count($subjects), + 'Distinct OAuth subjects must never resolve to the same user' +); +$assert( + $auth->get_credential('google', 'OAUTHSUBJECTABC') === false, + 'MySQL incorrectly resolved an OAuth subject using case-insensitive lookup' +); + +echo "MySQL 8 DBAuth registration, roles, login, pagination, password reset, credential verification and deletion passed.\n"; diff --git a/test/verify_mysql_migrations.php b/test/verify_mysql_migrations.php new file mode 100644 index 0000000..3e0eace --- /dev/null +++ b/test/verify_mysql_migrations.php @@ -0,0 +1,142 @@ + PDO::ERRMODE_EXCEPTION] +); + +$expectedColumns = [ + 'user' => [ + 'id', 'uuid', 'password', 'name', 'image', 'token', + 'token_expires_at', 'disabled_at', 'created', + ], + 'user_credentials' => [ + 'uid', 'provider', 'user_id', 'profile', 'token', + 'token_expires_at', 'verified_at', 'last_login', 'created', + ], + 'role' => ['id', 'name'], + 'user_roles' => ['user_id', 'role_id'], + 'delegations' => ['user_id', 'target_user_id', 'ability'], +]; + +$assert = static function (bool $condition, string $message): void { + if (!$condition) { + throw new RuntimeException($message); + } +}; + +$columnsStmt = $pdo->prepare( + 'SELECT COLUMN_NAME FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table' +); +$engineStmt = $pdo->prepare( + 'SELECT ENGINE FROM information_schema.TABLES + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table' +); + +foreach ($expectedColumns as $table => $columns) { + $columnsStmt->execute(['db' => $database, 'table' => $table]); + $actual = $columnsStmt->fetchAll(PDO::FETCH_COLUMN); + foreach ($columns as $column) { + $assert(in_array($column, $actual, true), "Missing MySQL column {$table}.{$column}"); + } + $engineStmt->execute(['db' => $database, 'table' => $table]); + $assert($engineStmt->fetchColumn() === 'InnoDB', "Expected InnoDB table {$table}"); +} + +// OAuth provider names and subject IDs must use exact comparisons on MySQL, +// regardless of the database/table's default case-insensitive collation. +$collationStmt = $pdo->prepare( + 'SELECT COLUMN_NAME, COLLATION_NAME FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table' +); +$collationStmt->execute(['db' => $database, 'table' => 'user_credentials']); +$collations = $collationStmt->fetchAll(PDO::FETCH_KEY_PAIR); +foreach (['uid', 'provider'] as $column) { + $assert( + ($collations[$column] ?? null) === 'utf8mb4_0900_bin', + "MySQL credential identity column {$column} must use utf8mb4_0900_bin" + ); +} + +// Check indexes by indexed column, including whether they enforce uniqueness. +$indexStmt = $pdo->prepare( + 'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE, SEQ_IN_INDEX + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table + ORDER BY INDEX_NAME, SEQ_IN_INDEX' +); +$requiredIndexes = [ + 'user' => [ + ['uuid', true], + ['token', false], + ], + 'user_credentials' => [ + ['uid', true], + ['provider', true], + ], + 'role' => [['name', true]], + 'user_roles' => [ + ['user_id', true], + ['role_id', false], + ], +]; +foreach ($requiredIndexes as $table => $requirements) { + $indexStmt->execute(['db' => $database, 'table' => $table]); + $indexes = $indexStmt->fetchAll(PDO::FETCH_ASSOC); + foreach ($requirements as [$column, $unique]) { + $found = false; + foreach ($indexes as $index) { + if ($index['COLUMN_NAME'] === $column + && (!$unique || (int) $index['NON_UNIQUE'] === 0)) { + $found = true; + break; + } + } + $assert($found, "Missing MySQL index for {$table}.{$column}"); + } +} + +$foreignKeyStmt = $pdo->prepare( + 'SELECT COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table + AND REFERENCED_TABLE_NAME IS NOT NULL' +); +$foreignKeys = [ + 'user_credentials' => ['user_id' => ['user', 'id']], + 'user_roles' => [ + 'user_id' => ['user', 'id'], + 'role_id' => ['role', 'id'], + ], +]; +foreach ($foreignKeys as $table => $requirements) { + $foreignKeyStmt->execute(['db' => $database, 'table' => $table]); + $actual = $foreignKeyStmt->fetchAll(PDO::FETCH_ASSOC); + foreach ($requirements as $column => [$referencedTable, $referencedColumn]) { + $found = false; + foreach ($actual as $key) { + if ($key['COLUMN_NAME'] === $column + && $key['REFERENCED_TABLE_NAME'] === $referencedTable + && $key['REFERENCED_COLUMN_NAME'] === $referencedColumn) { + $found = true; + break; + } + } + $assert($found, "Missing MySQL FK {$table}.{$column} -> {$referencedTable}.{$referencedColumn}"); + } +} + +echo "MySQL 8 migration schema, indexes and foreign keys verified.\n"; diff --git a/test/verify_postgresql_role_lookup.php b/test/verify_postgresql_role_lookup.php new file mode 100644 index 0000000..cf1c569 --- /dev/null +++ b/test/verify_postgresql_role_lookup.php @@ -0,0 +1,89 @@ + 'token', + 'disabled_at' => 'disabled_at', +]); + +$db->insert('role', ['name' => 'admin']); +$db->insert('role', ['name' => 'operator']); + +$admin = $auth->register('postgres-admin@example.com', 'secret', [ + 'roles' => ['admin', 'operator'], +]); +$auth->register('postgres-viewer@example.com', 'secret', [ + 'roles' => ['viewer'], +]); + +$admins = $auth->get_users_by_role('admin'); + +if (count($admins) !== 1) { + fwrite(STDERR, "Expected exactly one admin\n"); + exit(1); +} + +if ((int) $admins[0]['id'] !== (int) $admin['id']) { + fwrite(STDERR, "Role lookup returned the wrong user\n"); + exit(1); +} + +if (($admins[0]['roles'] ?? []) !== ['admin', 'operator']) { + fwrite(STDERR, "Role hydration did not preserve all user roles\n"); + exit(1); +} + +foreach (['password', 'token', 'token_expires_at'] as $secret) { + if (array_key_exists($secret, $admins[0])) { + fwrite(STDERR, "Role lookup exposed {$secret}\n"); + exit(1); + } +} + +if ($auth->get_users_by_role('missing') !== []) { + fwrite(STDERR, "Unknown role should return an empty list\n"); + exit(1); +} + +$users = $auth->query([ + 'page' => 0, + 'size' => 1, + 'sort' => 'name ASC', +]); + +if (!$users instanceof Collection) { + fwrite(STDERR, "DBAuth query did not return a Collection\n"); + exit(1); +} +if ($users->total() !== 2 || count($users) !== 1 || $users->contentRange() !== 'items 0-0/2') { + fwrite(STDERR, "Collection pagination metadata is incorrect\n"); + exit(1); +} + +$search = $auth->query([ + 'q' => 'viewer@example.com', + 'size' => 10, +]); + +if ($search->total() !== 1 || ($search[0]['credentials'][0]['uid'] ?? null) !== 'postgres-viewer@example.com') { + fwrite(STDERR, "Collection credential search failed\n"); + exit(1); +} + +echo "PostgreSQL role lookup and Collection query passed.\n"; diff --git a/test/verify_production_install.php b/test/verify_production_install.php new file mode 100644 index 0000000..9d9964f --- /dev/null +++ b/test/verify_production_install.php @@ -0,0 +1,61 @@ +register('production@example.com', 'production-password', ['name' => 'Production User']); +$assert(!empty($new['id']), 'BasicAuth registration failed without dev dependencies'); +$assert(!array_key_exists('password', $new), 'Registration response exposed password'); +$logged = $auth->login('production@example.com', 'production-password'); +$assert($logged['id'] === $new['id'] && $auth->check(), 'BasicAuth login failed'); +$assert($auth->user()['name'] === 'Production User', 'BasicAuth session context failed'); +$auth->logout(); +$assert(!$auth->check(), 'BasicAuth logout failed'); + +// The DB abstraction must resolve from require, not require-dev. Instantiate +// DBAuth against SQLite without relying on Phinx or PHPUnit to create schema. +$db = new \Objectiveweb\DB('sqlite::memory:'); +$dbAuth = new \Objectiveweb\Auth\DBAuth($db, ['roles_table' => null, 'user_roles_table' => null]); +$assert(!$dbAuth->check(), 'DBAuth did not initialize in production'); + +echo "Production Composer autoload, runtime dependencies and authentication smoke test passed.\n";