diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..931aef9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @abmmhasan diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8a5f881 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,70 @@ +name: Bug report +description: Report a reproducible problem +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug. Please include enough detail to reproduce it. + - type: textarea + id: summary + attributes: + label: Summary + description: What is wrong? + placeholder: Clear and short description of the bug. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Share exact commands, config, and steps. + placeholder: | + 1. Run `composer ic:tests` + 2. ... + 3. Observe ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + placeholder: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + placeholder: What happened instead? Include full error output if possible. + validations: + required: true + - type: input + id: php_version + attributes: + label: PHP version + placeholder: "e.g. 8.3.8" + validations: + required: true + - type: input + id: composer_version + attributes: + label: Composer version + placeholder: "e.g. 2.9.2" + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment details + description: OS, CI provider, shell, and anything else relevant. + placeholder: Ubuntu 24.04, GitHub Actions, bash... + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional context + description: Links, screenshots, logs, or related issues. diff --git a/.github/ISSUE_TEMPLATE/ci_failure.yml b/.github/ISSUE_TEMPLATE/ci_failure.yml new file mode 100644 index 0000000..3dcbac9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ci_failure.yml @@ -0,0 +1,48 @@ +name: CI failure +description: Report a reproducible CI or workflow failure +title: "[CI]: " +labels: + - ci +body: + - type: markdown + attributes: + value: | + Use this form when CI fails unexpectedly and can be reproduced. + - type: input + id: workflow + attributes: + label: Workflow/job name + placeholder: security-standards / phpforge + validations: + required: true + - type: input + id: run_url + attributes: + label: Failing run URL + placeholder: https://github.com/OWNER/REPOSITORY/actions/runs/... + validations: + required: true + - type: textarea + id: command + attributes: + label: Failing command + description: Exact command or step that failed. + placeholder: composer ic:ci + validations: + required: true + - type: textarea + id: logs + attributes: + label: Error output + description: Paste the relevant error section. + render: shell + validations: + required: true + - type: textarea + id: local_check + attributes: + label: Local reproduction + description: Can you reproduce locally? If yes, include steps. + placeholder: Yes/No + details + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/docs_improvement.yml b/.github/ISSUE_TEMPLATE/docs_improvement.yml new file mode 100644 index 0000000..80b9607 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs_improvement.yml @@ -0,0 +1,34 @@ +name: Docs improvement +description: Report missing, unclear, or incorrect documentation +title: "[Docs]: " +labels: + - documentation +body: + - type: textarea + id: location + attributes: + label: Documentation location + description: File path or URL. + placeholder: README.md section "Quick Start" + validations: + required: true + - type: textarea + id: issue + attributes: + label: What is unclear or incorrect? + placeholder: This section says... + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: Suggested improvement + description: Propose revised wording, structure, or examples. + placeholder: It would be clearer if... + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional context + description: Related links, screenshots, or prior discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..cc29614 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,44 @@ +name: Feature request +description: Suggest an improvement or new capability +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Thanks for the idea. Please describe the use case first, then the proposed solution. + - type: textarea + id: problem + attributes: + label: Problem or use case + description: What limitation are you hitting? + placeholder: I need to... + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What should happen? + placeholder: Add a command/config/workflow option that... + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any workaround or alternative approach you evaluated. + - type: textarea + id: impact + attributes: + label: Expected impact + description: Who benefits and what changes for users/CI? + placeholder: This would improve... + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional context + description: Related issues, links, examples, or prior art. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..2ca776f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,40 @@ +name: Question +description: Ask a usage or integration question +title: "[Question]: " +labels: + - question +body: + - type: markdown + attributes: + value: | + Use this form for usage questions. For confirmed defects, use the bug report form. + - type: textarea + id: context + attributes: + label: What are you trying to do? + description: Describe your goal and expected outcome. + placeholder: I want to... + validations: + required: true + - type: textarea + id: attempted + attributes: + label: What have you tried? + description: Include commands, config snippets, or links you already checked. + placeholder: I tried... + validations: + required: true + - type: textarea + id: output + attributes: + label: Current output or behavior + description: Include relevant command output, logs, or errors. + render: shell + - type: textarea + id: environment + attributes: + label: Environment details + description: PHP version, Composer version, OS, CI provider (if relevant). + placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/regression_report.yml b/.github/ISSUE_TEMPLATE/regression_report.yml new file mode 100644 index 0000000..36392bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/regression_report.yml @@ -0,0 +1,51 @@ +name: Regression report +description: Report behavior that previously worked but now fails +title: "[Regression]: " +labels: + - regression + - bug +body: + - type: textarea + id: summary + attributes: + label: Regression summary + placeholder: This worked before, but now... + validations: + required: true + - type: input + id: last_known_good + attributes: + label: Last known working version/commit + placeholder: v1.2.3 or abc1234 + validations: + required: true + - type: input + id: first_bad + attributes: + label: First broken version/commit + placeholder: v1.2.4 or def5678 + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: expected_actual + attributes: + label: Expected vs actual behavior + placeholder: Expected ..., but got ... + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment details + description: PHP version, Composer version, OS, CI provider (if relevant). + placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..59ae734 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ +## Summary + +Describe what changed and why. + +## Related Issues + +Link issues with `Closes #...` or `Relates #...`. + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor +- [ ] Documentation update +- [ ] CI or tooling update +- [ ] Other (describe in summary) + +## Validation + +List the commands you ran and their result. + +```bash +composer ic:tests +``` + +If full suite was not run, explain why and list focused checks. + +## Checklist + +- [ ] I followed `CONTRIBUTING.md`. +- [ ] I added or updated tests for behavior changes. +- [ ] I updated docs/config/examples when needed. +- [ ] I confirmed no security-sensitive data is exposed. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e9d271d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml new file mode 100644 index 0000000..dbac569 --- /dev/null +++ b/.github/workflows/security-standards.yml @@ -0,0 +1,40 @@ +name: "Security & Standards" + +on: + schedule: + - cron: "0 0 * * 0" + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master", "develop", "development" ] + +jobs: + phpforge: + uses: infocyph/phpforge/.github/workflows/security-standards.yml@main + permissions: + security-events: write + actions: read + contents: read + with: + php_versions: '["8.4","8.5"]' + dependency_versions: '["prefer-lowest","prefer-stable"]' + php_extensions: "" + composer_flags: "" + phpstan_memory_limit: "1G" + psalm_threads: "1" + run_analysis: true + run_svg_report: true + fail_on_skipped_tests: false + enable_redis_service: false + enable_valkey_service: false + enable_memcached_service: false + enable_postgres_service: false + enable_mysql_service: false + enable_scylladb_service: false + enable_elasticsearch_service: false + enable_mongodb_service: false + service_db_name: "phpforge" + service_db_user: "phpforge" + service_db_password: "phpforge" + artifact_retention_days: 61 + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9c2638f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,50 @@ +# Code of Conduct + +## Our Commitment + +We are committed to making participation in this project a harassment-free +experience for everyone, regardless of age, body size, disability, ethnicity, +gender identity and expression, level of experience, nationality, personal +appearance, race, religion or sexual identity and orientation. + +## Expected Behavior + +Examples of behavior that contributes to a positive environment: + +- Be respectful and constructive. +- Assume good intent and ask clarifying questions. +- Give and receive feedback professionally. +- Focus on what is best for the community and project. + +## Unacceptable Behavior + +Examples of unacceptable behavior include: + +- Harassment, discrimination or personal attacks. +- Trolling, insulting or derogatory comments. +- Publishing private information without consent. +- Any conduct that is inappropriate in a professional setting. + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing this code of +conduct. They may remove, edit or reject comments, commits, code, issues, and +other contributions that violate this policy. + +## Scope + +This code of conduct applies in all project spaces, including: + +- Issue trackers +- Pull requests +- Discussions and chat related to the project +- Any public or private communication where someone represents the project + +## Reporting + +To report unacceptable behavior, contact project maintainers privately. + +## Enforcement + +Maintainers may take any action they deem appropriate, including warnings, +temporary bans or permanent bans from community participation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9950065 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing + +Thanks for contributing. + +## Before You Start + +- Review the project code of conduct. +- For security issues, use private reporting and avoid opening a public issue. +- Check existing issues and pull requests first to avoid duplicates. + +## Local Setup + +Requirements: + +- See `README.md` for current PHP and Composer requirements. + +Install dependencies: + +```bash +composer install +``` + +## Development Workflow + +Typical contributor workflow: + +1. Create a branch from `main`. +2. Make focused changes. +3. Run quality checks locally. +4. Open a pull request with context and verification notes. + +Recommended checks: + +```bash +composer ic:tests +``` + +Useful targeted commands: + +```bash +composer ic:test:syntax +composer ic:test:code +composer ic:test:lint +composer ic:test:sniff +composer ic:test:static +composer ic:test:security +composer ic:test:architecture +``` + +Auto-fix and processing helpers: + +```bash +composer ic:process +``` + +## Pull Request Guidelines + +- Keep pull requests scoped to one logical change. +- Include why the change is needed and what behavior changed. +- Add or update tests when behavior changes. +- Update docs when command behavior, config, or workflow behavior changes. +- Ensure CI is green before requesting review. + +## Reporting Bugs and Requesting Features + +- Use issue templates for bugs, regressions, CI failures, documentation updates, questions, and feature requests. +- Include reproducible steps, expected behavior, and actual behavior. +- Share environment details (PHP version, OS, Composer version). diff --git a/README.md b/README.md index ceaccbf..68089db 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,65 @@ # AuthLayer +[![Security & Standards](https://github.com/infocyph/AuthLayer/actions/workflows/security-standards.yml/badge.svg)](https://github.com/infocyph/AuthLayer/actions/workflows/security-standards.yml) +![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/AuthLayer?color=green\&link=https%3A%2F%2Fpackagist.org%2Fpackages%2Finfocyph%2FAuthLayer) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) +![Packagist Version](https://img.shields.io/packagist/v/infocyph/AuthLayer) +![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/infocyph/AuthLayer/php) +![GitHub Code Size](https://img.shields.io/github/languages/code-size/infocyph/AuthLayer) +[![Documentation](https://img.shields.io/badge/Documentation-AuthLayer-blue?logo=readthedocs&logoColor=white)](https://docs.infocyph.com/projects/AuthLayer/en/latest/) + Dependency-free authentication and authorization core for PHP. -## Scope +## Overview + +AuthLayer owns authentication and authorization orchestration, domain contracts, value objects, decisions, audit events, and notification intents. + +AuthLayer does not implement or require concrete: + +- password hashing +- token signing or encryption +- OTP algorithms +- database persistence +- cache backends +- notification delivery +- HTTP or framework runtime integration + +Those concerns belong in bridge packages. + +## Package + +- Composer: `infocyph/auth-layer` +- Namespace: `Infocyph\AuthLayer` +- PHP: `>=8.4` + +## Core Surface + +AuthLayer currently provides source modules for: + +- accounts and principals +- login and logout orchestration +- sessions and remember-me +- password reset and password change +- email verification +- passwordless flows +- access and refresh token lifecycle +- MFA orchestration +- passkey orchestration +- authorization gates and permission authorizers +- delegation and grants +- device trust and lockout +- audit events and notification intents +- in-memory support stores +- local clock, ID, and security contracts + +## Current Status + +The package contains: + +- concrete contracts and DTOs +- orchestration managers +- in-memory stores for development and testing +- Pest coverage across the main library surface +- PhpBench benchmarks for core authentication, authorization, and support paths -AuthLayer owns orchestration, domain contracts, value objects, decisions, audit events, and notification intents. -Concrete crypto, OTP, UID, persistence, cache, transport, and framework integrations belong in bridge packages. +Framework adapters, transport integrations, and concrete crypto or OTP implementations are intentionally out of scope for this package. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..37a355e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,49 @@ +# Security Policy + +## Supported Versions + +The project currently supports security updates for the latest release. + +## Reporting a Vulnerability + +Please report vulnerabilities privately. + +1. Use GitHub private vulnerability reporting for this repository (`Security` -> `Advisories` -> `Report a vulnerability`). +2. If private reporting is unavailable, contact maintainers through a private channel. +3. Do not open a public issue for security vulnerabilities. + +Please include: + +- Affected package version(s) +- PHP version and runtime environment +- Reproduction steps or proof of concept +- Impact assessment (confidentiality/integrity/availability) +- Any known workaround + +## Response Process + +- Initial acknowledgment: best effort, typically within a few days +- Triage: best effort, based on maintainer availability +- Fix and release timeline depends on severity and exploitability + +If a report is accepted, a patched release will be prepared and published. Credit will be provided unless you request otherwise. + +## Protected by PHPForge + +This project is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automated quality and security tooling layer for Infocyph PHP projects. + +PHPForge helps keep the project reliable by running checks for: + +- Code style and standards +- Tests and syntax validation +- Static analysis and type safety +- Security and taint analysis +- Dependency vulnerability audit +- Architecture boundary validation +- Duplicate-code detection +- API snapshot and comment-policy checks +- Refactor safety checks +- Benchmark and release-readiness checks +- Git hooks and CI workflow protection + +These automated gates strengthen code quality, reduce security risk and help prevent regressions before merge or release. diff --git a/benchmarks/Authentication/AuthenticationCoreBench.php b/benchmarks/Authentication/AuthenticationCoreBench.php new file mode 100644 index 0000000..d7ef202 --- /dev/null +++ b/benchmarks/Authentication/AuthenticationCoreBench.php @@ -0,0 +1,90 @@ +authenticator->login(new LoginRequest( + 'alice@example.com', + 'secret', + context: ['device_id' => 'dev-1', 'session_id' => 'sess-bench'], + )); + } + + #[Bench\BeforeMethods('setUpSessionManager')] + public function benchSessionCreateAndRotate(): void + { + $session = $this->sessions->create('acct-1', 'dev-1', ['ip' => '127.0.0.1']); + $this->sessions->rotate($session->id); + } + + public function setUpAuthenticator(): void + { + $clock = new FrozenClock(1000); + $ids = new RandomAuthIdGenerator(); + $accounts = new InMemoryAccountStore(); + $accounts->save(new Account('acct-1', 'alice@example.com', AccountStatus::ACTIVE, 'secret')); + $audit = new InMemoryAuditEventStore(); + + $this->authenticator = new Authenticator( + $accounts, + $accounts, + new class implements PasswordVerifierInterface { + public function verify(string $plainPassword, string $storedHash): PasswordVerificationResult + { + return new PasswordVerificationResult($plainPassword === $storedHash); + } + }, + new SessionManager(new InMemorySessionStore(), $ids, new SessionConfig(3600, 900), $clock), + $ids, + $audit, + new LockoutManager( + new InMemoryCounterStore($clock), + new InMemoryLockoutStore($clock), + $audit, + $ids, + new LockoutConfig(10, 10, 10, 60, 120), + $clock, + ), + $clock, + ); + } + + public function setUpSessionManager(): void + { + $this->sessions = new SessionManager( + new InMemorySessionStore(), + new RandomAuthIdGenerator(), + new SessionConfig(3600, 900), + new FrozenClock(1000), + ); + } +} diff --git a/benchmarks/Authorization/AuthorizationCoreBench.php b/benchmarks/Authorization/AuthorizationCoreBench.php new file mode 100644 index 0000000..58a9a1c --- /dev/null +++ b/benchmarks/Authorization/AuthorizationCoreBench.php @@ -0,0 +1,76 @@ +gate->can($this->principal, 'documents:view'); + } + + #[Bench\BeforeMethods('setUpPermissionAuthorizer')] + public function benchPermissionAuthorizerCan(): void + { + $this->permissionAuthorizer->can( + $this->principal, + 'documents:view', + ['type' => 'document', 'id' => 'doc-1'], + ); + } + + public function setUpGate(): void + { + $this->principal = new Principal('principal-1', PrincipalType::ACCOUNT, 'acct-1'); + $this->gate = new Gate(); + $this->gate->define('documents:view', static fn(): bool => true); + } + + public function setUpPermissionAuthorizer(): void + { + $roles = new InMemoryRoleStore(); + $permissions = new InMemoryPermissionStore(); + $grants = new InMemoryGrantStore(new FrozenClock(1000)); + + $role = new Role('role-1', 'editor'); + $permission = new Permission('perm-1', 'documents:*'); + + $roles->save($role); + $roles->assignRole('acct-1', $role->id); + $permissions->save($permission); + $permissions->assignPermissionToRole($role->id, $permission->id); + $grants->save(new AccessGrant('grant-1', 'principal-1', 'documents:view', 'document', 'doc-1', 1300)); + + $this->principal = new Principal('principal-1', PrincipalType::ACCOUNT, 'acct-1'); + $this->permissionAuthorizer = new PermissionAuthorizer( + new PermissionResolver($permissions), + new RolePermissionResolver($roles, $permissions), + new GrantResolver($grants, clock: new FrozenClock(1000)), + ); + } +} diff --git a/benchmarks/Support/SupportCoreBench.php b/benchmarks/Support/SupportCoreBench.php new file mode 100644 index 0000000..4ba496a --- /dev/null +++ b/benchmarks/Support/SupportCoreBench.php @@ -0,0 +1,41 @@ +ttl->put('auth:bench', ['account_id' => 'acct-1'], 300); + $this->ttl->get('auth:bench'); + $this->ttl->pull('auth:bench'); + } + + #[Bench\BeforeMethods('setUpIds')] + public function benchRandomSessionIdGeneration(): void + { + $this->ids->sessionId(); + } + + public function setUpIds(): void + { + $this->ids = new RandomAuthIdGenerator(); + } + + public function setUpTtlStore(): void + { + $this->ttl = new ArrayTtlStore(new FrozenClock(1000)); + } +} diff --git a/captainhook.json b/captainhook.json new file mode 100644 index 0000000..782a292 --- /dev/null +++ b/captainhook.json @@ -0,0 +1,55 @@ +{ + "commit-msg": { + "enabled": false, + "actions": [] + }, + "pre-push": { + "enabled": false, + "actions": [] + }, + "pre-commit": { + "enabled": true, + "actions": [ + { + "action": "composer validate --strict", + "options": [] + }, + { + "action": "composer normalize --dry-run", + "options": [] + }, + { + "action": "composer ic:release:audit", + "options": [] + }, + { + "action": "composer ic:ci", + "options": [] + } + ] + }, + "prepare-commit-msg": { + "enabled": false, + "actions": [] + }, + "post-commit": { + "enabled": false, + "actions": [] + }, + "post-merge": { + "enabled": false, + "actions": [] + }, + "post-checkout": { + "enabled": false, + "actions": [] + }, + "post-rewrite": { + "enabled": false, + "actions": [] + }, + "post-change": { + "enabled": false, + "actions": [] + } +} diff --git a/composer.json b/composer.json index e76aa71..1e3dcf9 100644 --- a/composer.json +++ b/composer.json @@ -1,42 +1,42 @@ { "name": "infocyph/auth-layer", "description": "Framework-agnostic authentication and authorization core for PHP.", - "type": "library", "license": "MIT", + "type": "library", "require": { "php": ">=8.4" }, - "autoload": { - "psr-4": { - "Infocyph\\AuthLayer\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Infocyph\\AuthLayer\\Tests\\": "tests/" - } - }, "require-dev": { - "pestphp/pest": "^4.0", - "phpstan/phpstan": "^2.0", - "vimeo/psalm": "^6.0", - "laravel/pint": "^1.0", - "rector/rector": "^2.0", - "phpbench/phpbench": "^1.0" + "infocyph/phpforge": "dev-main" }, "suggest": { + "infocyph/cachelayer": "Cache/counter/TTL implementation through bridge.", + "infocyph/dblayer": "Durable persistence implementation through bridge.", "infocyph/epicrypt": "Security/token/password implementation through bridge.", "infocyph/otp": "MFA/OTP implementation through bridge.", - "infocyph/uid": "Identifier generation through bridge.", - "infocyph/dblayer": "Durable persistence implementation through bridge.", - "infocyph/cachelayer": "Cache/counter/TTL implementation through bridge.", "infocyph/talkingbytes": "Notification delivery through bridge.", + "infocyph/uid": "Identifier generation through bridge.", "infocyph/webrick": "HTTP middleware integration through bridge." }, "minimum-stability": "stable", "prefer-stable": true, + "autoload": { + "psr-4": { + "Infocyph\\AuthLayer\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Infocyph\\AuthLayer\\Tests\\": "tests/" + } + }, "config": { - "sort-packages": true, - "optimize-autoloader": true + "allow-plugins": { + "ergebnis/composer-normalize": true, + "infocyph/phpforge": true, + "pestphp/pest-plugin": true + }, + "optimize-autoloader": true, + "sort-packages": true } } diff --git a/src/Account/Account.php b/src/Account/Account.php index 7837ecf..dde580f 100644 --- a/src/Account/Account.php +++ b/src/Account/Account.php @@ -15,8 +15,7 @@ public function __construct( private AccountStatus $status = AccountStatus::ACTIVE, private ?string $passwordHash = null, private array $metadata = [], - ) { - } + ) {} public function id(): string { @@ -28,9 +27,9 @@ public function identifier(): string return $this->identifier; } - public function status(): AccountStatus + public function metadata(): array { - return $this->status; + return $this->metadata; } public function passwordHash(): ?string @@ -38,19 +37,22 @@ public function passwordHash(): ?string return $this->passwordHash; } - public function metadata(): array + public function status(): AccountStatus { - return $this->metadata; + return $this->status; } - public function withStatus(AccountStatus $status): self + /** + * @param array $metadata + */ + public function withMetadata(array $metadata): self { return new self( id: $this->id, identifier: $this->identifier, - status: $status, + status: $this->status, passwordHash: $this->passwordHash, - metadata: $this->metadata, + metadata: $metadata, ); } @@ -65,17 +67,14 @@ public function withPasswordHash(string $passwordHash): self ); } - /** - * @param array $metadata - */ - public function withMetadata(array $metadata): self + public function withStatus(AccountStatus $status): self { return new self( id: $this->id, identifier: $this->identifier, - status: $this->status, + status: $status, passwordHash: $this->passwordHash, - metadata: $metadata, + metadata: $this->metadata, ); } } diff --git a/src/Account/AccountActionStatus.php b/src/Account/AccountActionStatus.php index a8c51a0..ce08c93 100644 --- a/src/Account/AccountActionStatus.php +++ b/src/Account/AccountActionStatus.php @@ -6,8 +6,11 @@ enum AccountActionStatus: string { - case CREATED = 'created'; - case UPDATED = 'updated'; case ALREADY_EXISTS = 'already_exists'; + + case CREATED = 'created'; + case NOT_FOUND = 'not_found'; + + case UPDATED = 'updated'; } diff --git a/src/Account/AccountInterface.php b/src/Account/AccountInterface.php index ec261fe..c8b654f 100644 --- a/src/Account/AccountInterface.php +++ b/src/Account/AccountInterface.php @@ -10,12 +10,12 @@ public function id(): string; public function identifier(): string; - public function status(): AccountStatus; - - public function passwordHash(): ?string; - /** * @return array */ public function metadata(): array; + + public function passwordHash(): ?string; + + public function status(): AccountStatus; } diff --git a/src/Account/AccountManager.php b/src/Account/AccountManager.php index 2278418..bf30203 100644 --- a/src/Account/AccountManager.php +++ b/src/Account/AccountManager.php @@ -17,8 +17,7 @@ public function __construct( private AccountStoreInterface $store, private AuthIdGeneratorInterface $ids, private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} /** * @param array $metadata @@ -40,21 +39,11 @@ public function disable(string $accountId): AccountResult return $this->updateStatus($accountId, AccountStatus::DISABLED, 'account_disabled'); } - public function suspend(string $accountId): AccountResult - { - return $this->updateStatus($accountId, AccountStatus::SUSPENDED, 'account_suspended'); - } - public function lock(string $accountId): AccountResult { return $this->updateStatus($accountId, AccountStatus::LOCKED, 'account_locked'); } - public function unlock(string $accountId): AccountResult - { - return $this->updateStatus($accountId, AccountStatus::ACTIVE, 'account_unlocked'); - } - public function markVerified(string $accountId): AccountResult { $account = $this->accounts->findById($accountId); @@ -68,6 +57,26 @@ public function markVerified(string $accountId): AccountResult return new AccountResult(AccountActionStatus::UPDATED, $this->accounts->findById($accountId), 'account_verified'); } + public function requireMfaEnrollment(string $accountId): AccountResult + { + return $this->updateStatus($accountId, AccountStatus::MFA_ENROLLMENT_REQUIRED, 'mfa_enrollment_required'); + } + + public function requirePasswordChange(string $accountId): AccountResult + { + return $this->updateStatus($accountId, AccountStatus::PASSWORD_CHANGE_REQUIRED, 'password_change_required'); + } + + public function suspend(string $accountId): AccountResult + { + return $this->updateStatus($accountId, AccountStatus::SUSPENDED, 'account_suspended'); + } + + public function unlock(string $accountId): AccountResult + { + return $this->updateStatus($accountId, AccountStatus::ACTIVE, 'account_unlocked'); + } + /** * @param array $metadata */ @@ -84,16 +93,6 @@ public function updateMetadata(string $accountId, array $metadata): AccountResul return new AccountResult(AccountActionStatus::UPDATED, $this->accounts->findById($accountId), 'account_metadata_updated', $metadata); } - public function requirePasswordChange(string $accountId): AccountResult - { - return $this->updateStatus($accountId, AccountStatus::PASSWORD_CHANGE_REQUIRED, 'password_change_required'); - } - - public function requireMfaEnrollment(string $accountId): AccountResult - { - return $this->updateStatus($accountId, AccountStatus::MFA_ENROLLMENT_REQUIRED, 'mfa_enrollment_required'); - } - private function updateStatus(string $accountId, AccountStatus $status, string $code): AccountResult { $account = $this->accounts->findById($accountId); diff --git a/src/Account/AccountResult.php b/src/Account/AccountResult.php index 9251e3c..c933d80 100644 --- a/src/Account/AccountResult.php +++ b/src/Account/AccountResult.php @@ -14,6 +14,16 @@ public function __construct( public ?AccountInterface $account = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->status === AccountActionStatus::CREATED + || $this->status === AccountActionStatus::UPDATED; } } diff --git a/src/Account/AccountStatus.php b/src/Account/AccountStatus.php index 5af8a74..126ad84 100644 --- a/src/Account/AccountStatus.php +++ b/src/Account/AccountStatus.php @@ -7,10 +7,16 @@ enum AccountStatus: string { case ACTIVE = 'active'; + case DISABLED = 'disabled'; - case SUSPENDED = 'suspended'; + case LOCKED = 'locked'; - case PENDING_VERIFICATION = 'pending_verification'; - case PASSWORD_CHANGE_REQUIRED = 'password_change_required'; + case MFA_ENROLLMENT_REQUIRED = 'mfa_enrollment_required'; + + case PASSWORD_CHANGE_REQUIRED = 'password_change_required'; + + case PENDING_VERIFICATION = 'pending_verification'; + + case SUSPENDED = 'suspended'; } diff --git a/src/Audit/AuthEvent.php b/src/Audit/AuthEvent.php index 35f6a72..e663993 100644 --- a/src/Audit/AuthEvent.php +++ b/src/Audit/AuthEvent.php @@ -20,6 +20,5 @@ public function __construct( public string $correlationId, public int $occurredAt, public array $metadata = [], - ) { - } + ) {} } diff --git a/src/Audit/AuthEventSeverity.php b/src/Audit/AuthEventSeverity.php index 89008ec..5e9f40f 100644 --- a/src/Audit/AuthEventSeverity.php +++ b/src/Audit/AuthEventSeverity.php @@ -6,8 +6,11 @@ enum AuthEventSeverity: string { + case CRITICAL = 'critical'; + case INFO = 'info'; + case NOTICE = 'notice'; + case WARNING = 'warning'; - case CRITICAL = 'critical'; } diff --git a/src/Audit/AuthEventType.php b/src/Audit/AuthEventType.php index a737f68..049f4ac 100644 --- a/src/Audit/AuthEventType.php +++ b/src/Audit/AuthEventType.php @@ -6,36 +6,67 @@ enum AuthEventType: string { - case LOGIN_SUCCESS = 'login_success'; - case LOGIN_FAILURE = 'login_failure'; - case LOGOUT = 'logout'; - case PASSWORD_CHANGED = 'password_changed'; - case PASSWORD_RESET_REQUESTED = 'password_reset_requested'; - case PASSWORD_RESET_COMPLETED = 'password_reset_completed'; + case ACCESS_TOKEN_ISSUED = 'access_token_issued'; + + case AUTHORIZATION_DENIED = 'authorization_denied'; + + case DELEGATED_ACCESS_GRANTED = 'delegated_access_granted'; + + case DELEGATED_ACCESS_REVOKED = 'delegated_access_revoked'; + case EMAIL_VERIFICATION_REQUESTED = 'email_verification_requested'; + case EMAIL_VERIFIED = 'email_verified'; - case MFA_ENROLLED = 'mfa_enrolled'; + + case IMPERSONATION_STARTED = 'impersonation_started'; + + case IMPERSONATION_STOPPED = 'impersonation_stopped'; + + case LOCKOUT_CLEARED = 'lockout_cleared'; + + case LOCKOUT_TRIGGERED = 'lockout_triggered'; + + case LOGIN_FAILURE = 'login_failure'; + + case LOGIN_SUCCESS = 'login_success'; + + case LOGOUT = 'logout'; + case MFA_CHALLENGED = 'mfa_challenged'; + case MFA_DISABLED = 'mfa_disabled'; - case RECOVERY_CODE_USED = 'recovery_code_used'; + + case MFA_ENROLLED = 'mfa_enrolled'; + case PASSKEY_REGISTERED = 'passkey_registered'; - case PASSKEY_USED = 'passkey_used'; + case PASSKEY_REMOVED = 'passkey_removed'; - case SESSION_CREATED = 'session_created'; - case SESSION_REVOKED = 'session_revoked'; - case SESSION_EXPIRED = 'session_expired'; - case REMEMBER_TOKEN_ISSUED = 'remember_token_issued'; - case REMEMBER_TOKEN_REVOKED = 'remember_token_revoked'; - case ACCESS_TOKEN_ISSUED = 'access_token_issued'; + + case PASSKEY_USED = 'passkey_used'; + + case PASSWORD_CHANGED = 'password_changed'; + + case PASSWORD_RESET_COMPLETED = 'password_reset_completed'; + + case PASSWORD_RESET_REQUESTED = 'password_reset_requested'; + + case RECOVERY_CODE_USED = 'recovery_code_used'; + case REFRESH_TOKEN_ISSUED = 'refresh_token_issued'; - case REFRESH_TOKEN_ROTATED = 'refresh_token_rotated'; - case REFRESH_TOKEN_REVOKED = 'refresh_token_revoked'; + case REFRESH_TOKEN_REUSE_DETECTED = 'refresh_token_reuse_detected'; - case AUTHORIZATION_DENIED = 'authorization_denied'; - case LOCKOUT_TRIGGERED = 'lockout_triggered'; - case LOCKOUT_CLEARED = 'lockout_cleared'; - case IMPERSONATION_STARTED = 'impersonation_started'; - case IMPERSONATION_STOPPED = 'impersonation_stopped'; - case DELEGATED_ACCESS_GRANTED = 'delegated_access_granted'; - case DELEGATED_ACCESS_REVOKED = 'delegated_access_revoked'; + + case REFRESH_TOKEN_REVOKED = 'refresh_token_revoked'; + + case REFRESH_TOKEN_ROTATED = 'refresh_token_rotated'; + + case REMEMBER_TOKEN_ISSUED = 'remember_token_issued'; + + case REMEMBER_TOKEN_REVOKED = 'remember_token_revoked'; + + case SESSION_CREATED = 'session_created'; + + case SESSION_EXPIRED = 'session_expired'; + + case SESSION_REVOKED = 'session_revoked'; } diff --git a/src/Authentication/EmailVerification/EmailVerificationManager.php b/src/Authentication/EmailVerification/EmailVerificationManager.php index 6c6afa9..9a5b856 100644 --- a/src/Authentication/EmailVerification/EmailVerificationManager.php +++ b/src/Authentication/EmailVerification/EmailVerificationManager.php @@ -4,7 +4,6 @@ namespace Infocyph\AuthLayer\Authentication\EmailVerification; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; use Infocyph\AuthLayer\Contract\Clock\ClockInterface; @@ -16,10 +15,14 @@ use Infocyph\AuthLayer\Contract\Storage\EmailVerificationStoreInterface; use Infocyph\AuthLayer\Notification\AuthNotification; use Infocyph\AuthLayer\Notification\AuthNotificationType; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class EmailVerificationManager { + private const string REQUEST_CONTEXT_KEY = 'request_id'; + public function __construct( private EmailVerificationTokenServiceInterface $tokens, private EmailVerificationStoreInterface $store, @@ -29,8 +32,7 @@ public function __construct( private AuthIdGeneratorInterface $ids, private int $ttlSeconds = 3600, private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} /** * @param array $context @@ -42,20 +44,10 @@ public function issue(string $accountId, string $email, array $context = []): Em $request = new EmailVerificationRequest($requestId, $accountId, $email, $now, $now + $this->ttlSeconds, context: $context); $this->store->save($request); - $token = $this->tokens->issue($accountId, $email, ['request_id' => $requestId] + $context); - $this->notifier->send(new AuthNotification(AuthNotificationType::EMAIL_VERIFICATION_REQUESTED, $accountId, ['email' => $email, 'request_id' => $requestId, 'token' => $token] + $context)); - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: AuthEventType::EMAIL_VERIFICATION_REQUESTED, - severity: AuthEventSeverity::INFO, - accountId: $accountId, - actorId: $accountId, - sessionId: $context['session_id'] ?? null, - deviceId: $context['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $now, - metadata: ['request_id' => $requestId, 'email' => $email] + $context, - )); + $requestContext = [self::REQUEST_CONTEXT_KEY => $requestId]; + $token = $this->tokens->issue($accountId, $email, $requestContext + $context); + $this->notifier->send(new AuthNotification(AuthNotificationType::EMAIL_VERIFICATION_REQUESTED, $accountId, ['email' => $email] + $requestContext + ['token' => $token] + $context)); + $this->recordEvent(AuthEventType::EMAIL_VERIFICATION_REQUESTED, $accountId, $context, ['email' => $email] + $requestContext, AuthEventSeverity::INFO); return new EmailVerificationResult(EmailVerificationStatus::ISSUED, $request, $token, 'email_verification_requested', $context); } @@ -65,48 +57,112 @@ public function issue(string $accountId, string $email, array $context = []): Em */ public function verify(string $token, array $context = []): EmailVerificationResult { + $now = $this->clock->now(); $verification = $this->tokens->verify($token); - if (! $verification->verified) { - return new EmailVerificationResult(EmailVerificationStatus::INVALID, code: $verification->failureReason ?? 'invalid_token', context: $context); + if (!$verification->verified) { + return $this->invalidVerificationResult($verification->failureReason ?? 'invalid_token', $context); } $request = $this->resolveRequest($verification); if ($request === null) { - return new EmailVerificationResult(EmailVerificationStatus::INVALID, code: 'verification_request_not_found', context: $context); + return $this->missingVerificationResult($context); } if ($request->isConsumed()) { - return new EmailVerificationResult(EmailVerificationStatus::CONSUMED, $request, code: 'verification_already_consumed', context: $context); + return $this->consumedVerificationResult($request, $context); } - if ($request->isExpiredAt($this->clock->now())) { - return new EmailVerificationResult(EmailVerificationStatus::EXPIRED, $request, code: 'verification_expired', context: $context); + if ($request->isExpiredAt($now)) { + return $this->expiredVerificationResult($request, $context); } + return $this->completeVerification($request, $context); + } + + /** + * @param array $context + */ + private function completeVerification(EmailVerificationRequest $request, array $context): EmailVerificationResult + { $this->store->consume($request->id); $this->accounts->markVerified($request->accountId, $this->clock->now()); - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: AuthEventType::EMAIL_VERIFIED, - severity: AuthEventSeverity::INFO, - accountId: $request->accountId, - actorId: $request->accountId, - sessionId: $context['session_id'] ?? null, - deviceId: $context['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), - metadata: ['request_id' => $request->id, 'email' => $request->email] + $context, - )); + $this->recordEvent( + AuthEventType::EMAIL_VERIFIED, + $request->accountId, + $context, + [self::REQUEST_CONTEXT_KEY => $request->id, 'email' => $request->email], + AuthEventSeverity::INFO, + ); return new EmailVerificationResult(EmailVerificationStatus::VERIFIED, $request, code: 'email_verified', context: $context); } + /** + * @param array $context + */ + private function consumedVerificationResult(EmailVerificationRequest $request, array $context): EmailVerificationResult + { + return new EmailVerificationResult(EmailVerificationStatus::CONSUMED, $request, code: 'verification_already_consumed', context: $context); + } + + /** + * @param array $context + */ + private function expiredVerificationResult(EmailVerificationRequest $request, array $context): EmailVerificationResult + { + return new EmailVerificationResult(EmailVerificationStatus::EXPIRED, $request, code: 'verification_expired', context: $context); + } + + /** + * @param array $context + */ + private function invalidVerificationResult(string $reason, array $context): EmailVerificationResult + { + return new EmailVerificationResult(EmailVerificationStatus::INVALID, code: $reason, context: $context); + } + + /** + * @param array $context + */ + private function missingVerificationResult(array $context): EmailVerificationResult + { + return new EmailVerificationResult(EmailVerificationStatus::INVALID, code: 'verification_request_not_found', context: $context); + } + + /** + * @param array $context + * @param array $metadata + */ + private function recordEvent( + AuthEventType $type, + string $accountId, + array $context = [], + array $metadata = [], + AuthEventSeverity $severity = AuthEventSeverity::INFO, + ): void { + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + $type, + $accountId, + metadata: $metadata + $context, + severity: $severity, + sessionId: ContextValue::stringOrNull($context, 'session_id'), + deviceId: ContextValue::stringOrNull($context, 'device_id'), + ); + } + private function resolveRequest(TokenVerificationResult $verification): ?EmailVerificationRequest { - $requestId = $verification->claims['request_id'] ?? $verification->tokenId; + $requestId = $verification->claims[self::REQUEST_CONTEXT_KEY] ?? $verification->tokenId; + + if (!is_string($requestId) || $requestId === '') { + return null; + } - return is_string($requestId) && $requestId !== '' ? $this->store->find($requestId) : null; + return $this->store->find($requestId); } } diff --git a/src/Authentication/EmailVerification/EmailVerificationRequest.php b/src/Authentication/EmailVerification/EmailVerificationRequest.php index 716cae8..9d6ac35 100644 --- a/src/Authentication/EmailVerification/EmailVerificationRequest.php +++ b/src/Authentication/EmailVerification/EmailVerificationRequest.php @@ -4,29 +4,35 @@ namespace Infocyph\AuthLayer\Authentication\EmailVerification; -final readonly class EmailVerificationRequest +use Infocyph\AuthLayer\Support\AbstractConsumableRequest; + +final readonly class EmailVerificationRequest extends AbstractConsumableRequest { /** * @param array $context */ public function __construct( - public string $id, - public string $accountId, + string $id, + string $accountId, public string $email, - public int $requestedAt, - public int $expiresAt, - public ?int $consumedAt = null, - public array $context = [], + int $requestedAt, + int $expiresAt, + ?int $consumedAt = null, + array $context = [], ) { + parent::__construct($id, $accountId, $requestedAt, $expiresAt, $consumedAt, $context); } - public function isConsumed(): bool - { - return $this->consumedAt !== null; - } - - public function isExpiredAt(?int $timestamp = null): bool + public function withConsumedAt(int $consumedAt): self { - return $this->expiresAt <= ($timestamp ?? time()); + return new self( + email: $this->email, + id: $this->id, + accountId: $this->accountId, + requestedAt: $this->requestedAt, + expiresAt: $this->expiresAt, + consumedAt: $consumedAt, + context: $this->context, + ); } } diff --git a/src/Authentication/EmailVerification/EmailVerificationResult.php b/src/Authentication/EmailVerification/EmailVerificationResult.php index ed38b19..3ea0cea 100644 --- a/src/Authentication/EmailVerification/EmailVerificationResult.php +++ b/src/Authentication/EmailVerification/EmailVerificationResult.php @@ -15,7 +15,22 @@ public function __construct( public ?string $token = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function email(): ?string + { + return $this->request?->email; + } + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->status === EmailVerificationStatus::ISSUED + || $this->status === EmailVerificationStatus::VERIFIED; } public function verified(): bool diff --git a/src/Authentication/EmailVerification/EmailVerificationStatus.php b/src/Authentication/EmailVerification/EmailVerificationStatus.php index 9513aa7..e423db1 100644 --- a/src/Authentication/EmailVerification/EmailVerificationStatus.php +++ b/src/Authentication/EmailVerification/EmailVerificationStatus.php @@ -6,9 +6,13 @@ enum EmailVerificationStatus: string { + case CONSUMED = 'consumed'; + + case EXPIRED = 'expired'; + + case INVALID = 'invalid'; + case ISSUED = 'issued'; + case VERIFIED = 'verified'; - case INVALID = 'invalid'; - case EXPIRED = 'expired'; - case CONSUMED = 'consumed'; } diff --git a/src/Authentication/Impersonation/ImpersonationManager.php b/src/Authentication/Impersonation/ImpersonationManager.php index ea576e6..49796af 100644 --- a/src/Authentication/Impersonation/ImpersonationManager.php +++ b/src/Authentication/Impersonation/ImpersonationManager.php @@ -5,7 +5,6 @@ namespace Infocyph\AuthLayer\Authentication\Impersonation; use Infocyph\AuthLayer\Account\AccountInterface; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; use Infocyph\AuthLayer\Contract\Clock\ClockInterface; @@ -14,6 +13,8 @@ use Infocyph\AuthLayer\Principal\Principal; use Infocyph\AuthLayer\Principal\PrincipalInterface; use Infocyph\AuthLayer\Principal\PrincipalType; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class ImpersonationManager @@ -22,8 +23,7 @@ public function __construct( private AuditEventStoreInterface $audit, private AuthIdGeneratorInterface $ids, private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} /** * @param array $context @@ -65,17 +65,17 @@ public function stopImpersonation(ImpersonationSession $session, array $context */ private function record(AuthEventType $type, string $accountId, string $actorId, array $metadata): void { - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: $type, - severity: AuthEventSeverity::NOTICE, - accountId: $accountId, + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + $type, + $accountId, actorId: $actorId, - sessionId: $metadata['session_id'] ?? null, - deviceId: $metadata['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), metadata: $metadata, - )); + severity: AuthEventSeverity::NOTICE, + sessionId: ContextValue::stringOrNull($metadata, 'session_id'), + deviceId: ContextValue::stringOrNull($metadata, 'device_id'), + ); } } diff --git a/src/Authentication/Impersonation/ImpersonationResult.php b/src/Authentication/Impersonation/ImpersonationResult.php index 0fba2b2..7ed070f 100644 --- a/src/Authentication/Impersonation/ImpersonationResult.php +++ b/src/Authentication/Impersonation/ImpersonationResult.php @@ -16,6 +16,15 @@ public function __construct( public ?ImpersonationSession $session = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->principal !== null && $this->session !== null; } } diff --git a/src/Authentication/Impersonation/ImpersonationSession.php b/src/Authentication/Impersonation/ImpersonationSession.php index c4a28ef..c33fd41 100644 --- a/src/Authentication/Impersonation/ImpersonationSession.php +++ b/src/Authentication/Impersonation/ImpersonationSession.php @@ -14,6 +14,5 @@ public function __construct( public string $targetAccountId, public int $startedAt, public array $metadata = [], - ) { - } + ) {} } diff --git a/src/Authentication/Lockout/LockoutConfig.php b/src/Authentication/Lockout/LockoutConfig.php index 77eea90..7c92c26 100644 --- a/src/Authentication/Lockout/LockoutConfig.php +++ b/src/Authentication/Lockout/LockoutConfig.php @@ -12,6 +12,5 @@ public function __construct( public int $maxPasskeyFailures = 5, public int $windowSeconds = 900, public int $lockSeconds = 900, - ) { - } + ) {} } diff --git a/src/Authentication/Lockout/LockoutManager.php b/src/Authentication/Lockout/LockoutManager.php index 1587f5f..eb1ceca 100644 --- a/src/Authentication/Lockout/LockoutManager.php +++ b/src/Authentication/Lockout/LockoutManager.php @@ -4,7 +4,6 @@ namespace Infocyph\AuthLayer\Authentication\Lockout; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; use Infocyph\AuthLayer\Contract\Cache\CounterStoreInterface; @@ -13,6 +12,8 @@ use Infocyph\AuthLayer\Contract\Storage\AuditEventStoreInterface; use Infocyph\AuthLayer\Contract\Storage\LockoutReason; use Infocyph\AuthLayer\Contract\Storage\LockoutStoreInterface; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class LockoutManager @@ -24,23 +25,7 @@ public function __construct( private AuthIdGeneratorInterface $ids, private LockoutConfig $config = new LockoutConfig(), private ClockInterface $clock = new SystemClock(), - ) { - } - - public function recordLoginFailure(string $accountId, array $context = []): LockoutResult - { - return $this->recordFailure($accountId, 'login', $this->config->maxLoginFailures, LockoutReason::TOO_MANY_LOGIN_ATTEMPTS, $context); - } - - public function recordMfaFailure(string $accountId, array $context = []): LockoutResult - { - return $this->recordFailure($accountId, 'mfa', $this->config->maxMfaFailures, LockoutReason::TOO_MANY_MFA_FAILURES, $context); - } - - public function recordPasskeyFailure(string $accountId, array $context = []): LockoutResult - { - return $this->recordFailure($accountId, 'passkey', $this->config->maxPasskeyFailures, LockoutReason::TOO_MANY_PASSKEY_FAILURES, $context); - } + ) {} public function clearFailures(string $accountId): void { @@ -54,6 +39,9 @@ public function isLocked(string $accountId): bool return $this->locks->isLocked($accountId); } + /** + * @param array $context + */ public function lock(string $accountId, LockoutReason $reason, ?int $until = null, array $context = []): LockoutResult { $lockedUntil = $until ?? ($this->clock->now() + $this->config->lockSeconds); @@ -63,6 +51,33 @@ public function lock(string $accountId, LockoutReason $reason, ?int $until = nul return new LockoutResult(LockoutStatus::LOCKED, $accountId, $reason, $lockedUntil, code: 'account_locked', context: $context); } + /** + * @param array $context + */ + public function recordLoginFailure(string $accountId, array $context = []): LockoutResult + { + return $this->recordFailure($accountId, 'login', $this->config->maxLoginFailures, LockoutReason::TOO_MANY_LOGIN_ATTEMPTS, $context); + } + + /** + * @param array $context + */ + public function recordMfaFailure(string $accountId, array $context = []): LockoutResult + { + return $this->recordFailure($accountId, 'mfa', $this->config->maxMfaFailures, LockoutReason::TOO_MANY_MFA_FAILURES, $context); + } + + /** + * @param array $context + */ + public function recordPasskeyFailure(string $accountId, array $context = []): LockoutResult + { + return $this->recordFailure($accountId, 'passkey', $this->config->maxPasskeyFailures, LockoutReason::TOO_MANY_PASSKEY_FAILURES, $context); + } + + /** + * @param array $context + */ public function unlock(string $accountId, array $context = []): LockoutResult { $this->locks->unlock($accountId); @@ -72,6 +87,32 @@ public function unlock(string $accountId, array $context = []): LockoutResult return new LockoutResult(LockoutStatus::UNLOCKED, $accountId, code: 'account_unlocked', context: $context); } + private function counterKey(string $type, string $accountId): string + { + return 'lockout:' . $type . ':' . $accountId; + } + + /** + * @param array $metadata + */ + private function recordAudit(AuthEventType $type, string $accountId, array $metadata = [], AuthEventSeverity $severity = AuthEventSeverity::INFO): void + { + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + $type, + $accountId, + metadata: $metadata, + severity: $severity, + sessionId: ContextValue::stringOrNull($metadata, 'session_id'), + deviceId: ContextValue::stringOrNull($metadata, 'device_id'), + ); + } + + /** + * @param array $context + */ private function recordFailure(string $accountId, string $type, int $threshold, LockoutReason $reason, array $context): LockoutResult { $attempts = $this->counters->increment($this->counterKey($type, $accountId), ttlSeconds: $this->config->windowSeconds); @@ -89,25 +130,4 @@ private function recordFailure(string $accountId, string $type, int $threshold, context: $context, ); } - - private function counterKey(string $type, string $accountId): string - { - return 'lockout:' . $type . ':' . $accountId; - } - - private function recordAudit(AuthEventType $type, string $accountId, array $metadata = [], AuthEventSeverity $severity = AuthEventSeverity::INFO): void - { - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: $type, - severity: $severity, - accountId: $accountId, - actorId: $accountId, - sessionId: $metadata['session_id'] ?? null, - deviceId: $metadata['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), - metadata: $metadata, - )); - } } diff --git a/src/Authentication/Lockout/LockoutResult.php b/src/Authentication/Lockout/LockoutResult.php index 2392e59..c2a59cf 100644 --- a/src/Authentication/Lockout/LockoutResult.php +++ b/src/Authentication/Lockout/LockoutResult.php @@ -19,6 +19,20 @@ public function __construct( public ?int $attempts = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return match ($this->status) { + LockoutStatus::FAILURE_RECORDED, + LockoutStatus::LOCKED, + LockoutStatus::UNLOCKED, + LockoutStatus::CLEAR => true, + }; } } diff --git a/src/Authentication/Lockout/LockoutStatus.php b/src/Authentication/Lockout/LockoutStatus.php index d4f8f30..86ea4a7 100644 --- a/src/Authentication/Lockout/LockoutStatus.php +++ b/src/Authentication/Lockout/LockoutStatus.php @@ -7,7 +7,10 @@ enum LockoutStatus: string { case CLEAR = 'clear'; + case FAILURE_RECORDED = 'failure_recorded'; + case LOCKED = 'locked'; + case UNLOCKED = 'unlocked'; } diff --git a/src/Authentication/Login/Authenticator.php b/src/Authentication/Login/Authenticator.php index 6de6350..9f1af5d 100644 --- a/src/Authentication/Login/Authenticator.php +++ b/src/Authentication/Login/Authenticator.php @@ -6,7 +6,6 @@ use Infocyph\AuthLayer\Account\AccountInterface; use Infocyph\AuthLayer\Account\AccountStatus; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; use Infocyph\AuthLayer\Authentication\Lockout\LockoutManager; @@ -21,6 +20,8 @@ use Infocyph\AuthLayer\Principal\Principal; use Infocyph\AuthLayer\Principal\PrincipalInterface; use Infocyph\AuthLayer\Principal\PrincipalType; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class Authenticator implements AuthenticatorInterface @@ -34,8 +35,7 @@ public function __construct( private AuditEventStoreInterface $audit, private LockoutManager $lockouts, private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} public function login(LoginRequest $request): LoginResult { @@ -63,7 +63,7 @@ public function login(LoginRequest $request): LoginResult $verification = $this->passwords->verify($request->password, $hash); - if (! $verification->verified) { + if (!$verification->verified) { $lockout = $this->lockouts->recordLoginFailure($account->id(), $request->context); return $this->failure( @@ -81,20 +81,19 @@ public function login(LoginRequest $request): LoginResult } $principal = new Principal($account->id(), PrincipalType::ACCOUNT, $account->id(), $account->metadata()); - $session = $this->sessions->create($account->id(), $request->context['device_id'] ?? null, $request->context); - - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: AuthEventType::LOGIN_SUCCESS, - severity: AuthEventSeverity::INFO, - accountId: $account->id(), + $session = $this->sessions->create($account->id(), ContextValue::stringOrNull($request->context, 'device_id'), $request->context); + + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + AuthEventType::LOGIN_SUCCESS, + $account->id(), actorId: $principal->id(), - sessionId: $session->id, - deviceId: $session->deviceId, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), metadata: $request->context, - )); + deviceId: $session->deviceId, + sessionId: $session->id, + ); return new LoginResult(LoginStatus::AUTHENTICATED, $principal, $session, 'authenticated', $verification->needsRehash, $request->context); } @@ -107,19 +106,39 @@ public function logout(PrincipalInterface $principal, ?string $sessionId = null) $this->sessions->revokeAllForAccount($principal->accountId()); } - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: AuthEventType::LOGOUT, - severity: AuthEventSeverity::INFO, - accountId: $principal->accountId(), + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + AuthEventType::LOGOUT, + $principal->accountId(), actorId: $principal->id(), sessionId: $sessionId, - deviceId: null, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), - )); + ); } + /** + * @param array $context + */ + private function failure(LoginStatus $status, ?AccountInterface $account, string $code, array $context): LoginResult + { + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + AuthEventType::LOGIN_FAILURE, + $account?->id(), + metadata: ['code' => $code] + $context, + severity: AuthEventSeverity::WARNING, + deviceId: ContextValue::stringOrNull($context, 'device_id'), + ); + + return new LoginResult($status, code: $code, context: $context); + } + + /** + * @param array $context + */ private function guardStatus(AccountInterface $account, array $context): ?LoginResult { return match ($account->status()) { @@ -130,25 +149,4 @@ private function guardStatus(AccountInterface $account, array $context): ?LoginR default => null, }; } - - /** - * @param array $context - */ - private function failure(LoginStatus $status, ?AccountInterface $account, string $code, array $context): LoginResult - { - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: AuthEventType::LOGIN_FAILURE, - severity: AuthEventSeverity::WARNING, - accountId: $account?->id(), - actorId: $account?->id(), - sessionId: null, - deviceId: $context['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), - metadata: ['code' => $code] + $context, - )); - - return new LoginResult($status, code: $code, context: $context); - } } diff --git a/src/Authentication/Login/LoginRequest.php b/src/Authentication/Login/LoginRequest.php index db2119d..7422f62 100644 --- a/src/Authentication/Login/LoginRequest.php +++ b/src/Authentication/Login/LoginRequest.php @@ -14,6 +14,5 @@ public function __construct( public string $password, public bool $rememberMe = false, public array $context = [], - ) { - } + ) {} } diff --git a/src/Authentication/Login/LoginResult.php b/src/Authentication/Login/LoginResult.php index 092d10b..a3fd6bf 100644 --- a/src/Authentication/Login/LoginResult.php +++ b/src/Authentication/Login/LoginResult.php @@ -19,11 +19,20 @@ public function __construct( public ?string $code = null, public bool $rehashRecommended = false, public array $context = [], - ) { - } + ) {} public function authenticated(): bool { return $this->status === LoginStatus::AUTHENTICATED; } + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->authenticated(); + } } diff --git a/src/Authentication/Login/LoginStatus.php b/src/Authentication/Login/LoginStatus.php index 5cd391e..c6051c8 100644 --- a/src/Authentication/Login/LoginStatus.php +++ b/src/Authentication/Login/LoginStatus.php @@ -6,13 +6,21 @@ enum LoginStatus: string { - case AUTHENTICATED = 'authenticated'; - case INVALID_CREDENTIALS = 'invalid_credentials'; case ACCOUNT_DISABLED = 'account_disabled'; + case ACCOUNT_LOCKED = 'account_locked'; + + case AUTHENTICATED = 'authenticated'; + case EMAIL_VERIFICATION_REQUIRED = 'email_verification_required'; - case PASSWORD_CHANGE_REQUIRED = 'password_change_required'; + + case INVALID_CREDENTIALS = 'invalid_credentials'; + case MFA_REQUIRED = 'mfa_required'; + case PASSKEY_REQUIRED = 'passkey_required'; + + case PASSWORD_CHANGE_REQUIRED = 'password_change_required'; + case STEP_UP_REQUIRED = 'step_up_required'; } diff --git a/src/Authentication/PasswordChange/PasswordChangeManager.php b/src/Authentication/PasswordChange/PasswordChangeManager.php index 18db3a9..e63e683 100644 --- a/src/Authentication/PasswordChange/PasswordChangeManager.php +++ b/src/Authentication/PasswordChange/PasswordChangeManager.php @@ -4,7 +4,6 @@ namespace Infocyph\AuthLayer\Authentication\PasswordChange; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; use Infocyph\AuthLayer\Contract\Clock\ClockInterface; @@ -18,6 +17,8 @@ use Infocyph\AuthLayer\Contract\Storage\AuditEventStoreInterface; use Infocyph\AuthLayer\Notification\AuthNotification; use Infocyph\AuthLayer\Notification\AuthNotificationType; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class PasswordChangeManager @@ -30,8 +31,7 @@ public function __construct( private AuthNotifierInterface $notifier, private AuthIdGeneratorInterface $ids, private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} /** * @param array $context @@ -46,23 +46,22 @@ public function change(string $accountId, string $currentPassword, string $newPa $verification = $this->passwords->verify($currentPassword, $account->passwordHash()); - if (! $verification->verified) { + if (!$verification->verified) { return new PasswordChangeResult(PasswordChangeStatus::INVALID_CREDENTIALS, 'invalid_credentials', $context); } $this->accountStore->updatePasswordHash($accountId, $newPasswordHash); - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: AuthEventType::PASSWORD_CHANGED, - severity: AuthEventSeverity::NOTICE, - accountId: $accountId, - actorId: $accountId, - sessionId: $context['session_id'] ?? null, - deviceId: $context['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + AuthEventType::PASSWORD_CHANGED, + $accountId, metadata: $context, - )); + severity: AuthEventSeverity::NOTICE, + sessionId: ContextValue::stringOrNull($context, 'session_id'), + deviceId: ContextValue::stringOrNull($context, 'device_id'), + ); $this->notifier->send(new AuthNotification(AuthNotificationType::PASSWORD_CHANGED, $accountId, $context)); return new PasswordChangeResult(PasswordChangeStatus::CHANGED, 'password_changed', $context); @@ -82,8 +81,12 @@ public function changeWithPlainPassword( if ($policy !== null) { $policyResult = $policy->validate($newPlainPassword, $context); - if (! $policyResult->valid) { - return new PasswordChangeResult(PasswordChangeStatus::INVALID_CREDENTIALS, $policyResult->code ?? 'password_policy_failed', ['violations' => $policyResult->violations] + $context); + if (!$policyResult->valid) { + return new PasswordChangeResult( + PasswordChangeStatus::POLICY_FAILED, + $policyResult->code ?? 'password_policy_failed', + ['violations' => $policyResult->violations] + $context, + ); } } diff --git a/src/Authentication/PasswordChange/PasswordChangeResult.php b/src/Authentication/PasswordChange/PasswordChangeResult.php index db3244f..0200fee 100644 --- a/src/Authentication/PasswordChange/PasswordChangeResult.php +++ b/src/Authentication/PasswordChange/PasswordChangeResult.php @@ -13,6 +13,15 @@ public function __construct( public PasswordChangeStatus $status, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->status === PasswordChangeStatus::CHANGED; } } diff --git a/src/Authentication/PasswordChange/PasswordChangeStatus.php b/src/Authentication/PasswordChange/PasswordChangeStatus.php index 8cbacbb..ca5d1f7 100644 --- a/src/Authentication/PasswordChange/PasswordChangeStatus.php +++ b/src/Authentication/PasswordChange/PasswordChangeStatus.php @@ -6,7 +6,11 @@ enum PasswordChangeStatus: string { + case ACCOUNT_NOT_FOUND = 'account_not_found'; + case CHANGED = 'changed'; + case INVALID_CREDENTIALS = 'invalid_credentials'; - case ACCOUNT_NOT_FOUND = 'account_not_found'; + + case POLICY_FAILED = 'policy_failed'; } diff --git a/src/Authentication/PasswordReset/PasswordResetManager.php b/src/Authentication/PasswordReset/PasswordResetManager.php index cb9ec4c..23a8323 100644 --- a/src/Authentication/PasswordReset/PasswordResetManager.php +++ b/src/Authentication/PasswordReset/PasswordResetManager.php @@ -4,7 +4,6 @@ namespace Infocyph\AuthLayer\Authentication\PasswordReset; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; use Infocyph\AuthLayer\Contract\Clock\ClockInterface; @@ -18,6 +17,8 @@ use Infocyph\AuthLayer\Contract\Storage\PasswordResetStoreInterface; use Infocyph\AuthLayer\Notification\AuthNotification; use Infocyph\AuthLayer\Notification\AuthNotificationType; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class PasswordResetManager @@ -31,36 +32,7 @@ public function __construct( private AuthIdGeneratorInterface $ids, private int $ttlSeconds = 3600, private ClockInterface $clock = new SystemClock(), - ) { - } - - /** - * @param array $context - */ - public function issue(string $accountId, array $context = []): PasswordResetResult - { - $now = $this->clock->now(); - $requestId = $this->ids->challengeId(); - $request = new PasswordResetRequest($requestId, $accountId, $now, $now + $this->ttlSeconds, context: $context); - - $this->store->save($request); - $token = $this->tokens->issue($accountId, ['request_id' => $requestId] + $context); - $this->notifier->send(new AuthNotification(AuthNotificationType::PASSWORD_RESET_REQUESTED, $accountId, ['request_id' => $requestId, 'token' => $token] + $context)); - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: AuthEventType::PASSWORD_RESET_REQUESTED, - severity: AuthEventSeverity::NOTICE, - accountId: $accountId, - actorId: $accountId, - sessionId: $context['session_id'] ?? null, - deviceId: $context['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $now, - metadata: ['request_id' => $requestId] + $context, - )); - - return new PasswordResetResult(PasswordResetStatus::REQUESTED, $request, $token, 'password_reset_requested', $context); - } + ) {} /** * @param array $context @@ -69,7 +41,7 @@ public function complete(string $token, string $passwordHash, array $context = [ { $verification = $this->tokens->verify($token); - if (! $verification->verified) { + if (!$verification->verified) { return new PasswordResetResult(PasswordResetStatus::INVALID, code: $verification->failureReason ?? 'invalid_token', context: $context); } @@ -89,18 +61,7 @@ public function complete(string $token, string $passwordHash, array $context = [ $this->store->consume($request->id); $this->accounts->updatePasswordHash($request->accountId, $passwordHash); - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: AuthEventType::PASSWORD_RESET_COMPLETED, - severity: AuthEventSeverity::NOTICE, - accountId: $request->accountId, - actorId: $request->accountId, - sessionId: $context['session_id'] ?? null, - deviceId: $context['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), - metadata: ['request_id' => $request->id] + $context, - )); + $this->recordEvent(AuthEventType::PASSWORD_RESET_COMPLETED, $request->accountId, $context, ['request_id' => $request->id], AuthEventSeverity::NOTICE); return new PasswordResetResult(PasswordResetStatus::COMPLETED, $request, code: 'password_reset_completed', context: $context); } @@ -118,18 +79,67 @@ public function completeWithPlainPassword( if ($policy !== null) { $policyResult = $policy->validate($plainPassword, $context); - if (! $policyResult->valid) { - return new PasswordResetResult(PasswordResetStatus::INVALID, code: $policyResult->code ?? 'password_policy_failed', context: ['violations' => $policyResult->violations] + $context); + if (!$policyResult->valid) { + return new PasswordResetResult( + PasswordResetStatus::POLICY_FAILED, + code: $policyResult->code ?? 'password_policy_failed', + context: ['violations' => $policyResult->violations] + $context, + ); } } return $this->complete($token, $hasher->hash($plainPassword, $context), $context); } + /** + * @param array $context + */ + public function issue(string $accountId, array $context = []): PasswordResetResult + { + $now = $this->clock->now(); + $requestId = $this->ids->challengeId(); + $request = new PasswordResetRequest($requestId, $accountId, $now, $now + $this->ttlSeconds, context: $context); + + $this->store->save($request); + $token = $this->tokens->issue($accountId, ['request_id' => $requestId] + $context); + $this->notifier->send(new AuthNotification(AuthNotificationType::PASSWORD_RESET_REQUESTED, $accountId, ['request_id' => $requestId, 'token' => $token] + $context)); + $this->recordEvent(AuthEventType::PASSWORD_RESET_REQUESTED, $accountId, $context, ['request_id' => $requestId], AuthEventSeverity::NOTICE); + + return new PasswordResetResult(PasswordResetStatus::REQUESTED, $request, $token, 'password_reset_requested', $context); + } + + /** + * @param array $context + * @param array $metadata + */ + private function recordEvent( + AuthEventType $type, + string $accountId, + array $context = [], + array $metadata = [], + AuthEventSeverity $severity = AuthEventSeverity::INFO, + ): void { + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + $type, + $accountId, + metadata: $metadata + $context, + severity: $severity, + sessionId: ContextValue::stringOrNull($context, 'session_id'), + deviceId: ContextValue::stringOrNull($context, 'device_id'), + ); + } + private function resolveRequest(TokenVerificationResult $verification): ?PasswordResetRequest { $requestId = $verification->claims['request_id'] ?? $verification->tokenId; - return is_string($requestId) && $requestId !== '' ? $this->store->find($requestId) : null; + if (!is_string($requestId) || $requestId === '') { + return null; + } + + return $this->store->find($requestId); } } diff --git a/src/Authentication/PasswordReset/PasswordResetRequest.php b/src/Authentication/PasswordReset/PasswordResetRequest.php index 5330a38..02608eb 100644 --- a/src/Authentication/PasswordReset/PasswordResetRequest.php +++ b/src/Authentication/PasswordReset/PasswordResetRequest.php @@ -4,28 +4,19 @@ namespace Infocyph\AuthLayer\Authentication\PasswordReset; -final readonly class PasswordResetRequest -{ - /** - * @param array $context - */ - public function __construct( - public string $id, - public string $accountId, - public int $requestedAt, - public int $expiresAt, - public ?int $consumedAt = null, - public array $context = [], - ) { - } - - public function isConsumed(): bool - { - return $this->consumedAt !== null; - } +use Infocyph\AuthLayer\Support\AbstractConsumableRequest; - public function isExpiredAt(?int $timestamp = null): bool +final readonly class PasswordResetRequest extends AbstractConsumableRequest +{ + public function withConsumedAt(int $consumedAt): self { - return $this->expiresAt <= ($timestamp ?? time()); + return new self( + id: $this->id, + accountId: $this->accountId, + requestedAt: $this->requestedAt, + expiresAt: $this->expiresAt, + consumedAt: $consumedAt, + context: $this->context, + ); } } diff --git a/src/Authentication/PasswordReset/PasswordResetResult.php b/src/Authentication/PasswordReset/PasswordResetResult.php index 34b7f72..9978675 100644 --- a/src/Authentication/PasswordReset/PasswordResetResult.php +++ b/src/Authentication/PasswordReset/PasswordResetResult.php @@ -15,11 +15,21 @@ public function __construct( public ?string $token = null, public ?string $code = null, public array $context = [], - ) { - } + ) {} public function completed(): bool { return $this->status === PasswordResetStatus::COMPLETED; } + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->status === PasswordResetStatus::REQUESTED + || $this->status === PasswordResetStatus::COMPLETED; + } } diff --git a/src/Authentication/PasswordReset/PasswordResetStatus.php b/src/Authentication/PasswordReset/PasswordResetStatus.php index aa04466..d5c7131 100644 --- a/src/Authentication/PasswordReset/PasswordResetStatus.php +++ b/src/Authentication/PasswordReset/PasswordResetStatus.php @@ -6,9 +6,15 @@ enum PasswordResetStatus: string { - case REQUESTED = 'requested'; case COMPLETED = 'completed'; - case INVALID = 'invalid'; - case EXPIRED = 'expired'; + case CONSUMED = 'consumed'; + + case EXPIRED = 'expired'; + + case INVALID = 'invalid'; + + case POLICY_FAILED = 'policy_failed'; + + case REQUESTED = 'requested'; } diff --git a/src/Authentication/Passwordless/PasswordlessManager.php b/src/Authentication/Passwordless/PasswordlessManager.php index e22b39e..eb6da06 100644 --- a/src/Authentication/Passwordless/PasswordlessManager.php +++ b/src/Authentication/Passwordless/PasswordlessManager.php @@ -13,8 +13,7 @@ public function __construct( private PasswordlessTokenServiceInterface $tokens, private AuthNotifierInterface $notifier, - ) { - } + ) {} /** * @param array $context diff --git a/src/Authentication/Passwordless/PasswordlessResult.php b/src/Authentication/Passwordless/PasswordlessResult.php index 9876104..a7d7dc1 100644 --- a/src/Authentication/Passwordless/PasswordlessResult.php +++ b/src/Authentication/Passwordless/PasswordlessResult.php @@ -17,6 +17,16 @@ public function __construct( public ?TokenVerificationResult $verification = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->status === PasswordlessStatus::ISSUED + || $this->status === PasswordlessStatus::VERIFIED; } } diff --git a/src/Authentication/Passwordless/PasswordlessStatus.php b/src/Authentication/Passwordless/PasswordlessStatus.php index 59dca73..6827da3 100644 --- a/src/Authentication/Passwordless/PasswordlessStatus.php +++ b/src/Authentication/Passwordless/PasswordlessStatus.php @@ -6,8 +6,11 @@ enum PasswordlessStatus: string { + case EXPIRED = 'expired'; + + case INVALID = 'invalid'; + case ISSUED = 'issued'; + case VERIFIED = 'verified'; - case INVALID = 'invalid'; - case EXPIRED = 'expired'; } diff --git a/src/Authentication/RememberMe/RememberMeManager.php b/src/Authentication/RememberMe/RememberMeManager.php index 054a319..5f41336 100644 --- a/src/Authentication/RememberMe/RememberMeManager.php +++ b/src/Authentication/RememberMe/RememberMeManager.php @@ -4,13 +4,14 @@ namespace Infocyph\AuthLayer\Authentication\RememberMe; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; use Infocyph\AuthLayer\Contract\Clock\ClockInterface; use Infocyph\AuthLayer\Contract\Id\AuthIdGeneratorInterface; use Infocyph\AuthLayer\Contract\Storage\AuditEventStoreInterface; use Infocyph\AuthLayer\Contract\Storage\RememberTokenStoreInterface; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class RememberMeManager @@ -21,8 +22,7 @@ public function __construct( private AuditEventStoreInterface $audit, private AuthIdGeneratorInterface $ids, private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} /** * @param array $context @@ -51,27 +51,10 @@ public function issue(string $accountId, string $deviceId, array $context = []): /** * @param array $context */ - public function verify(string $token, array $context = []): RememberMeResult + public function revokeFamily(string $familyId, ?string $accountId = null, ?string $deviceId = null, array $context = []): void { - $verification = $this->tokens->verify($token); - $record = $verification->record; - - if ($verification->suspiciousReuse && $record !== null) { - $this->store->revokeFamily($record->familyId); - $this->recordAudit(AuthEventType::REMEMBER_TOKEN_REVOKED, $record->accountId, $record->deviceId, ['reason' => 'suspicious_reuse', 'selector' => $record->selector] + $context, AuthEventSeverity::WARNING); - - return new RememberMeResult(RememberTokenStatus::REUSED, record: $record, code: $verification->failureReason ?? 'remember_token_reused', context: $context); - } - - if (! $verification->verified || $record === null || $this->store->wasFamilyRevoked($record->familyId)) { - return new RememberMeResult(RememberTokenStatus::INVALID, code: $verification->failureReason ?? 'invalid_remember_token', context: $context); - } - - if ($record->revokedAt !== null || $record->isExpiredAt($this->clock->now())) { - return new RememberMeResult(RememberTokenStatus::EXPIRED, record: $record, code: 'remember_token_expired', context: $context); - } - - return new RememberMeResult(RememberTokenStatus::VERIFIED, record: $record, code: 'remember_token_verified', context: $context); + $this->store->revokeFamily($familyId); + $this->recordAudit(AuthEventType::REMEMBER_TOKEN_REVOKED, $accountId, $deviceId, ['family_id' => $familyId] + $context); } /** @@ -100,10 +83,31 @@ public function rotate(RememberTokenRecord $current, array $context = []): Remem /** * @param array $context */ - public function revokeFamily(string $familyId, ?string $accountId = null, ?string $deviceId = null, array $context = []): void + public function verify(string $token, array $context = []): RememberMeResult { - $this->store->revokeFamily($familyId); - $this->recordAudit(AuthEventType::REMEMBER_TOKEN_REVOKED, $accountId, $deviceId, ['family_id' => $familyId] + $context); + $verification = $this->tokens->verify($token); + $record = $verification->record; + + if ($verification->suspiciousReuse && $record !== null) { + $this->store->revokeFamily($record->familyId); + $this->recordAudit(AuthEventType::REMEMBER_TOKEN_REVOKED, $record->accountId, $record->deviceId, ['reason' => 'suspicious_reuse', 'selector' => $record->selector] + $context, AuthEventSeverity::WARNING); + + return new RememberMeResult(RememberTokenStatus::REUSED, record: $record, code: $verification->failureReason ?? 'remember_token_reused', context: $context); + } + + if (!$verification->verified || $record === null || $this->store->wasFamilyRevoked($record->familyId)) { + return new RememberMeResult(RememberTokenStatus::INVALID, code: $verification->failureReason ?? 'invalid_remember_token', context: $context); + } + + if ($record->isRevoked() || $record->isExpiredAt($this->clock->now())) { + return new RememberMeResult(RememberTokenStatus::EXPIRED, record: $record, code: 'remember_token_expired', context: $context); + } + + $usedAt = $this->clock->now(); + $this->store->markUsed($record->id, $usedAt); + $record = $record->withLastUsedAt($usedAt); + + return new RememberMeResult(RememberTokenStatus::VERIFIED, record: $record, code: 'remember_token_verified', context: $context); } /** @@ -111,17 +115,16 @@ public function revokeFamily(string $familyId, ?string $accountId = null, ?strin */ private function recordAudit(AuthEventType $type, ?string $accountId, ?string $deviceId, array $metadata, AuthEventSeverity $severity = AuthEventSeverity::INFO): void { - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: $type, + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + $type, + $accountId, + metadata: $metadata, severity: $severity, - accountId: $accountId, - actorId: $accountId, - sessionId: $metadata['session_id'] ?? null, deviceId: $deviceId, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), - metadata: $metadata, - )); + sessionId: ContextValue::stringOrNull($metadata, 'session_id'), + ); } } diff --git a/src/Authentication/RememberMe/RememberMeResult.php b/src/Authentication/RememberMe/RememberMeResult.php index 553dfe9..05084f5 100644 --- a/src/Authentication/RememberMe/RememberMeResult.php +++ b/src/Authentication/RememberMe/RememberMeResult.php @@ -15,7 +15,19 @@ public function __construct( public ?RememberTokenRecord $record = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->status === RememberTokenStatus::ISSUED + || $this->status === RememberTokenStatus::ROTATED + || $this->status === RememberTokenStatus::VERIFIED + || $this->status === RememberTokenStatus::REVOKED; } public function verified(): bool diff --git a/src/Authentication/RememberMe/RememberToken.php b/src/Authentication/RememberMe/RememberToken.php index f4abb72..6bb7d49 100644 --- a/src/Authentication/RememberMe/RememberToken.php +++ b/src/Authentication/RememberMe/RememberToken.php @@ -12,6 +12,5 @@ public function __construct( public string $familyId, public string $verifierHash, public int $expiresAt, - ) { - } + ) {} } diff --git a/src/Authentication/RememberMe/RememberTokenRecord.php b/src/Authentication/RememberMe/RememberTokenRecord.php index bda9c71..93fb987 100644 --- a/src/Authentication/RememberMe/RememberTokenRecord.php +++ b/src/Authentication/RememberMe/RememberTokenRecord.php @@ -4,29 +4,63 @@ namespace Infocyph\AuthLayer\Authentication\RememberMe; -final readonly class RememberTokenRecord +use Infocyph\AuthLayer\Support\AbstractFamilyTokenRecord; + +final readonly class RememberTokenRecord extends AbstractFamilyTokenRecord { /** * @param array $metadata */ public function __construct( - public string $id, - public string $accountId, public string $deviceId, public string $selector, public string $verifierHash, - public string $familyId, - public int $issuedAt, - public int $expiresAt, + string $id, + string $accountId, + string $familyId, + int $issuedAt, + int $expiresAt, public ?int $lastUsedAt = null, - public ?int $rotatedAt = null, - public ?int $revokedAt = null, - public array $metadata = [], + ?int $rotatedAt = null, + ?int $revokedAt = null, + array $metadata = [], ) { + parent::__construct($id, $accountId, $familyId, $issuedAt, $expiresAt, $rotatedAt, $revokedAt, $metadata); + } + + public function withLastUsedAt(int $lastUsedAt): self + { + return new self( + deviceId: $this->deviceId, + selector: $this->selector, + verifierHash: $this->verifierHash, + lastUsedAt: $lastUsedAt, + id: $this->id, + accountId: $this->accountId, + familyId: $this->familyId, + issuedAt: $this->issuedAt, + expiresAt: $this->expiresAt, + rotatedAt: $this->rotatedAt, + revokedAt: $this->revokedAt, + metadata: $this->metadata, + ); } - public function isExpiredAt(?int $timestamp = null): bool + protected function recreate(?int $rotatedAt, ?int $revokedAt): static { - return $this->expiresAt <= ($timestamp ?? time()); + return new self( + deviceId: $this->deviceId, + selector: $this->selector, + verifierHash: $this->verifierHash, + lastUsedAt: $this->lastUsedAt, + id: $this->id, + accountId: $this->accountId, + familyId: $this->familyId, + issuedAt: $this->issuedAt, + expiresAt: $this->expiresAt, + rotatedAt: $rotatedAt, + revokedAt: $revokedAt, + metadata: $this->metadata, + ); } } diff --git a/src/Authentication/RememberMe/RememberTokenStatus.php b/src/Authentication/RememberMe/RememberTokenStatus.php index d89c205..382d6c8 100644 --- a/src/Authentication/RememberMe/RememberTokenStatus.php +++ b/src/Authentication/RememberMe/RememberTokenStatus.php @@ -6,11 +6,17 @@ enum RememberTokenStatus: string { - case ISSUED = 'issued'; - case VERIFIED = 'verified'; - case ROTATED = 'rotated'; - case REVOKED = 'revoked'; - case REUSED = 'reused'; case EXPIRED = 'expired'; + case INVALID = 'invalid'; + + case ISSUED = 'issued'; + + case REUSED = 'reused'; + + case REVOKED = 'revoked'; + + case ROTATED = 'rotated'; + + case VERIFIED = 'verified'; } diff --git a/src/Authentication/RememberMe/RememberTokenVerificationResult.php b/src/Authentication/RememberMe/RememberTokenVerificationResult.php index 701b3da..87ed615 100644 --- a/src/Authentication/RememberMe/RememberTokenVerificationResult.php +++ b/src/Authentication/RememberMe/RememberTokenVerificationResult.php @@ -11,6 +11,5 @@ public function __construct( public ?RememberTokenRecord $record = null, public bool $suspiciousReuse = false, public ?string $failureReason = null, - ) { - } + ) {} } diff --git a/src/Authentication/Session/AuthSession.php b/src/Authentication/Session/AuthSession.php index e9ec481..df7774a 100644 --- a/src/Authentication/Session/AuthSession.php +++ b/src/Authentication/Session/AuthSession.php @@ -18,8 +18,7 @@ public function __construct( public int $expiresAt, public ?int $recentAuthAt = null, public array $metadata = [], - ) { - } + ) {} public function isExpiredAt(?int $timestamp = null): bool { diff --git a/src/Authentication/Session/SessionConfig.php b/src/Authentication/Session/SessionConfig.php index 0a6b20f..6ff8332 100644 --- a/src/Authentication/Session/SessionConfig.php +++ b/src/Authentication/Session/SessionConfig.php @@ -9,6 +9,5 @@ public function __construct( public int $absoluteTtlSeconds = 3600, public int $recentAuthWindowSeconds = 900, - ) { - } + ) {} } diff --git a/src/Authentication/Session/SessionManager.php b/src/Authentication/Session/SessionManager.php index bc7e70d..92b4bee 100644 --- a/src/Authentication/Session/SessionManager.php +++ b/src/Authentication/Session/SessionManager.php @@ -17,8 +17,7 @@ public function __construct( private AuthIdGeneratorInterface $ids, private SessionConfig $config = new SessionConfig(), private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} /** * @param array $metadata @@ -42,6 +41,26 @@ public function create(string $accountId, ?string $deviceId = null, array $metad return $session; } + public function isExpired(AuthSession $session): bool + { + return $session->isExpiredAt($this->clock->now()); + } + + public function isRecentlyAuthenticated(AuthSession $session, int $windowSeconds): bool + { + return $session->recentAuthAt !== null && $session->recentAuthAt >= ($this->clock->now() - $windowSeconds); + } + + public function revoke(string $sessionId): void + { + $this->sessions->revoke($sessionId); + } + + public function revokeAllForAccount(string $accountId, ?string $exceptSessionId = null): void + { + $this->sessions->revokeAllForAccount($accountId, $exceptSessionId); + } + public function rotate(string $sessionId): AuthSession { $existing = $this->sessions->find($sessionId); @@ -67,6 +86,11 @@ public function rotate(string $sessionId): AuthSession return $replacement; } + public function status(AuthSession $session): SessionStatus + { + return $this->isExpired($session) ? SessionStatus::EXPIRED : SessionStatus::ACTIVE; + } + public function touch(string $sessionId): ?AuthSession { $session = $this->sessions->find($sessionId); @@ -80,29 +104,4 @@ public function touch(string $sessionId): ?AuthSession return $session->seenAt($now); } - - public function revoke(string $sessionId): void - { - $this->sessions->revoke($sessionId); - } - - public function revokeAllForAccount(string $accountId, ?string $exceptSessionId = null): void - { - $this->sessions->revokeAllForAccount($accountId, $exceptSessionId); - } - - public function isExpired(AuthSession $session): bool - { - return $session->isExpiredAt($this->clock->now()); - } - - public function isRecentlyAuthenticated(AuthSession $session, int $windowSeconds): bool - { - return $session->recentAuthAt !== null && $session->recentAuthAt >= ($this->clock->now() - $windowSeconds); - } - - public function status(AuthSession $session): SessionStatus - { - return $this->isExpired($session) ? SessionStatus::EXPIRED : SessionStatus::ACTIVE; - } } diff --git a/src/Authentication/Session/SessionStatus.php b/src/Authentication/Session/SessionStatus.php index 695016c..cf5e659 100644 --- a/src/Authentication/Session/SessionStatus.php +++ b/src/Authentication/Session/SessionStatus.php @@ -7,5 +7,6 @@ enum SessionStatus: string { case ACTIVE = 'active'; + case EXPIRED = 'expired'; } diff --git a/src/Authentication/StepUp/StepUpManager.php b/src/Authentication/StepUp/StepUpManager.php index d72f852..6138abd 100644 --- a/src/Authentication/StepUp/StepUpManager.php +++ b/src/Authentication/StepUp/StepUpManager.php @@ -7,6 +7,7 @@ use Infocyph\AuthLayer\Authentication\Session\AuthSession; use Infocyph\AuthLayer\Contract\Cache\TtlStoreInterface; use Infocyph\AuthLayer\Contract\Clock\ClockInterface; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class StepUpManager @@ -14,8 +15,7 @@ public function __construct( private TtlStoreInterface $ttl, private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} /** * @param array $context @@ -24,17 +24,20 @@ public function evaluate(AuthSession $session, string $ability, array $context = { $method = $context['method'] ?? StepUpMethod::RECENT_AUTH; - if (! $method instanceof StepUpMethod) { - $method = StepUpMethod::tryFrom((string) $method) ?? StepUpMethod::RECENT_AUTH; + if (is_string($method)) { + $method = StepUpMethod::tryFrom($method) ?? StepUpMethod::RECENT_AUTH; + } elseif (!$method instanceof StepUpMethod) { + $method = StepUpMethod::RECENT_AUTH; } $requirement = new StepUpRequirement( ability: $ability, - maxAgeSeconds: $context['max_age_seconds'] ?? 900, + maxAgeSeconds: ContextValue::int($context, 'max_age_seconds', 900), method: $method, ); - $satisfiedAt = $this->ttl->get($this->key($session->accountId, $session->id, $ability)); + $satisfiedAt = $this->ttl->get($this->key($session->accountId, $session->id, $ability, $method)); + $satisfiedAt = is_int($satisfiedAt) ? $satisfiedAt : null; if (is_int($satisfiedAt) && $satisfiedAt >= ($this->clock->now() - $requirement->maxAgeSeconds)) { return new StepUpResult(false, $requirement, $satisfiedAt, 'step_up_already_satisfied', $context); @@ -45,18 +48,21 @@ public function evaluate(AuthSession $session, string $ability, array $context = return new StepUpResult($required, $requirement, $satisfiedAt, $required ? 'step_up_required' : 'step_up_not_required', $context); } - public function requiresStepUp(AuthSession $session, string $ability, array $context = []): bool + public function markSatisfied(string $accountId, string $sessionId, string $ability, StepUpMethod $method = StepUpMethod::RECENT_AUTH, int $ttlSeconds = 900): void { - return $this->evaluate($session, $ability, $context)->required; + $this->ttl->put($this->key($accountId, $sessionId, $ability, $method), $this->clock->now(), $ttlSeconds); } - public function markSatisfied(string $accountId, string $sessionId, string $ability, StepUpMethod $method = StepUpMethod::RECENT_AUTH, int $ttlSeconds = 900): void + /** + * @param array $context + */ + public function requiresStepUp(AuthSession $session, string $ability, array $context = []): bool { - $this->ttl->put($this->key($accountId, $sessionId, $ability), $this->clock->now(), $ttlSeconds); + return $this->evaluate($session, $ability, $context)->required; } - private function key(string $accountId, string $sessionId, string $ability): string + private function key(string $accountId, string $sessionId, string $ability, StepUpMethod $method): string { - return sprintf('step-up:%s:%s:%s', $accountId, $sessionId, $ability); + return sprintf('step-up:%s:%s:%s:%s', $accountId, $sessionId, $ability, $method->value); } } diff --git a/src/Authentication/StepUp/StepUpMethod.php b/src/Authentication/StepUp/StepUpMethod.php index afd9afe..a03e51c 100644 --- a/src/Authentication/StepUp/StepUpMethod.php +++ b/src/Authentication/StepUp/StepUpMethod.php @@ -6,8 +6,11 @@ enum StepUpMethod: string { - case RECENT_AUTH = 'recent_auth'; case MFA = 'mfa'; + case PASSKEY = 'passkey'; + case PASSWORD = 'password'; + + case RECENT_AUTH = 'recent_auth'; } diff --git a/src/Authentication/StepUp/StepUpRequirement.php b/src/Authentication/StepUp/StepUpRequirement.php index 81df66f..95d536b 100644 --- a/src/Authentication/StepUp/StepUpRequirement.php +++ b/src/Authentication/StepUp/StepUpRequirement.php @@ -10,6 +10,5 @@ public function __construct( public string $ability, public int $maxAgeSeconds = 900, public StepUpMethod $method = StepUpMethod::RECENT_AUTH, - ) { - } + ) {} } diff --git a/src/Authentication/StepUp/StepUpResult.php b/src/Authentication/StepUp/StepUpResult.php index 366d4ab..a13039c 100644 --- a/src/Authentication/StepUp/StepUpResult.php +++ b/src/Authentication/StepUp/StepUpResult.php @@ -15,6 +15,15 @@ public function __construct( public ?int $satisfiedAt = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return !$this->required; } } diff --git a/src/Authentication/TokenAuth/AccessTokenClaims.php b/src/Authentication/TokenAuth/AccessTokenClaims.php index 57af31b..7fea069 100644 --- a/src/Authentication/TokenAuth/AccessTokenClaims.php +++ b/src/Authentication/TokenAuth/AccessTokenClaims.php @@ -17,6 +17,5 @@ public function __construct( public int $expiresAt, public array $scopes = [], public array $metadata = [], - ) { - } + ) {} } diff --git a/src/Authentication/TokenAuth/IssuedRefreshToken.php b/src/Authentication/TokenAuth/IssuedRefreshToken.php index 9bc2ba9..0061602 100644 --- a/src/Authentication/TokenAuth/IssuedRefreshToken.php +++ b/src/Authentication/TokenAuth/IssuedRefreshToken.php @@ -12,6 +12,5 @@ public function __construct( public string $tokenId, public string $familyId, public int $expiresAt, - ) { - } + ) {} } diff --git a/src/Authentication/TokenAuth/RefreshTokenClaims.php b/src/Authentication/TokenAuth/RefreshTokenClaims.php index c000612..9d8dfd3 100644 --- a/src/Authentication/TokenAuth/RefreshTokenClaims.php +++ b/src/Authentication/TokenAuth/RefreshTokenClaims.php @@ -18,6 +18,5 @@ public function __construct( public int $issuedAt, public int $expiresAt, public array $metadata = [], - ) { - } + ) {} } diff --git a/src/Authentication/TokenAuth/RefreshTokenRecord.php b/src/Authentication/TokenAuth/RefreshTokenRecord.php index 375d05b..38b85c8 100644 --- a/src/Authentication/TokenAuth/RefreshTokenRecord.php +++ b/src/Authentication/TokenAuth/RefreshTokenRecord.php @@ -4,28 +4,43 @@ namespace Infocyph\AuthLayer\Authentication\TokenAuth; -final readonly class RefreshTokenRecord +use Infocyph\AuthLayer\Support\AbstractFamilyTokenRecord; + +final readonly class RefreshTokenRecord extends AbstractFamilyTokenRecord { /** * @param array $metadata */ public function __construct( - public string $id, - public string $accountId, public string $tokenHash, - public string $familyId, public ?string $clientId, public ?string $deviceId, - public int $issuedAt, - public int $expiresAt, - public ?int $rotatedAt = null, - public ?int $revokedAt = null, - public array $metadata = [], + string $id, + string $accountId, + string $familyId, + int $issuedAt, + int $expiresAt, + ?int $rotatedAt = null, + ?int $revokedAt = null, + array $metadata = [], ) { + parent::__construct($id, $accountId, $familyId, $issuedAt, $expiresAt, $rotatedAt, $revokedAt, $metadata); } - public function isExpiredAt(?int $timestamp = null): bool + protected function recreate(?int $rotatedAt, ?int $revokedAt): static { - return $this->expiresAt <= ($timestamp ?? time()); + return new self( + tokenHash: $this->tokenHash, + clientId: $this->clientId, + deviceId: $this->deviceId, + id: $this->id, + accountId: $this->accountId, + familyId: $this->familyId, + issuedAt: $this->issuedAt, + expiresAt: $this->expiresAt, + rotatedAt: $rotatedAt, + revokedAt: $revokedAt, + metadata: $this->metadata, + ); } } diff --git a/src/Authentication/TokenAuth/RefreshTokenRotationResult.php b/src/Authentication/TokenAuth/RefreshTokenRotationResult.php index 2bc6d25..4b153de 100644 --- a/src/Authentication/TokenAuth/RefreshTokenRotationResult.php +++ b/src/Authentication/TokenAuth/RefreshTokenRotationResult.php @@ -15,6 +15,15 @@ public function __construct( public ?RefreshTokenRecord $record = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->rotated; } } diff --git a/src/Authentication/TokenAuth/TokenAuthManager.php b/src/Authentication/TokenAuth/TokenAuthManager.php index 4b739c5..40e86a7 100644 --- a/src/Authentication/TokenAuth/TokenAuthManager.php +++ b/src/Authentication/TokenAuth/TokenAuthManager.php @@ -4,7 +4,6 @@ namespace Infocyph\AuthLayer\Authentication\TokenAuth; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; use Infocyph\AuthLayer\Contract\Clock\ClockInterface; @@ -12,6 +11,8 @@ use Infocyph\AuthLayer\Contract\Security\AccessTokenServiceInterface; use Infocyph\AuthLayer\Contract\Storage\AuditEventStoreInterface; use Infocyph\AuthLayer\Contract\Storage\RefreshTokenStoreInterface; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class TokenAuthManager @@ -24,8 +25,7 @@ public function __construct( private AuthIdGeneratorInterface $ids, private int $refreshTtlSeconds = 1209600, private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} /** * @param array $context @@ -38,22 +38,6 @@ public function issueAccessToken(AccessTokenClaims $claims, array $context = []) return new TokenAuthResult(TokenType::ACCESS, token: $token, code: 'access_token_issued', context: $context); } - /** - * @param array $context - */ - public function verifyAccessToken(string $token, array $context = []): TokenAuthResult - { - $verification = $this->accessTokens->verify($token); - - return new TokenAuthResult( - TokenType::ACCESS, - token: $token, - verification: $verification, - code: $verification->verified ? 'access_token_verified' : ($verification->failureReason ?? 'access_token_invalid'), - context: $context, - ); - } - /** * @param array $metadata */ @@ -91,19 +75,23 @@ public function issueRefreshToken(string $accountId, ?string $clientId = null, ? } /** - * @param array $metadata + * @param array $context */ - public function verifyRefreshToken(string $token, array $metadata = []): TokenAuthResult + public function revokeRefreshFamily(string $familyId, array $context = []): TokenRevocationResult { - $verification = $this->refreshTokenService->verify($token); + if ($this->refreshTokens->wasFamilyRevoked($familyId)) { + return new TokenRevocationResult( + TokenRevocationStatus::ALREADY_REVOKED, + $familyId, + 'refresh_token_family_already_revoked', + $context, + ); + } - return new TokenAuthResult( - TokenType::REFRESH, - token: $token, - verification: $verification, - code: $verification->verified ? 'refresh_token_verified' : ($verification->failureReason ?? 'refresh_token_invalid'), - context: $metadata, - ); + $this->refreshTokens->revokeFamily($familyId); + $this->record(AuthEventType::REFRESH_TOKEN_REVOKED, ContextValue::stringOrNull($context, 'account_id'), ['family_id' => $familyId] + $context, AuthEventSeverity::NOTICE); + + return new TokenRevocationResult(TokenRevocationStatus::REVOKED, $familyId, 'refresh_token_family_revoked', $context); } /** @@ -117,7 +105,7 @@ public function rotateRefreshToken(RefreshTokenRecord $current, array $metadata return new RefreshTokenRotationResult(false, record: $current, code: 'refresh_token_family_revoked', context: $metadata); } - if ($current->isExpiredAt($this->clock->now()) || $current->revokedAt !== null) { + if ($current->isExpiredAt($this->clock->now()) || $current->isRevoked()) { $this->refreshTokens->revokeFamily($current->familyId); $this->auditReuse($current, ['reason' => 'stale_token_reuse'] + $metadata); @@ -158,16 +146,33 @@ public function rotateRefreshToken(RefreshTokenRecord $current, array $metadata /** * @param array $context */ - public function revokeRefreshFamily(string $familyId, array $context = []): TokenRevocationResult + public function verifyAccessToken(string $token, array $context = []): TokenAuthResult { - if ($this->refreshTokens->wasFamilyRevoked($familyId)) { - return new TokenRevocationResult(TokenRevocationStatus::NOT_FOUND, $familyId, 'refresh_token_family_not_found', $context); - } + $verification = $this->accessTokens->verify($token); - $this->refreshTokens->revokeFamily($familyId); - $this->record(AuthEventType::REFRESH_TOKEN_REVOKED, $context['account_id'] ?? null, ['family_id' => $familyId] + $context, AuthEventSeverity::NOTICE); + return new TokenAuthResult( + TokenType::ACCESS, + token: $token, + verification: $verification, + code: $verification->verified ? 'access_token_verified' : ($verification->failureReason ?? 'access_token_invalid'), + context: $context, + ); + } - return new TokenRevocationResult(TokenRevocationStatus::REVOKED, $familyId, 'refresh_token_family_revoked', $context); + /** + * @param array $metadata + */ + public function verifyRefreshToken(string $token, array $metadata = []): TokenAuthResult + { + $verification = $this->refreshTokenService->verify($token); + + return new TokenAuthResult( + TokenType::REFRESH, + token: $token, + verification: $verification, + code: $verification->verified ? 'refresh_token_verified' : ($verification->failureReason ?? 'refresh_token_invalid'), + context: $metadata, + ); } /** @@ -194,17 +199,16 @@ private function record( AuthEventSeverity $severity = AuthEventSeverity::INFO, ?string $deviceId = null, ): void { - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: $type, - severity: $severity, - accountId: $accountId, - actorId: $accountId, - sessionId: $metadata['session_id'] ?? null, - deviceId: $deviceId ?? ($metadata['device_id'] ?? null), - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + $type, + $accountId, metadata: $metadata, - )); + severity: $severity, + deviceId: $deviceId ?? ContextValue::stringOrNull($metadata, 'device_id'), + sessionId: ContextValue::stringOrNull($metadata, 'session_id'), + ); } } diff --git a/src/Authentication/TokenAuth/TokenAuthResult.php b/src/Authentication/TokenAuth/TokenAuthResult.php index 0f642c9..7e7969b 100644 --- a/src/Authentication/TokenAuth/TokenAuthResult.php +++ b/src/Authentication/TokenAuth/TokenAuthResult.php @@ -18,6 +18,19 @@ public function __construct( public ?TokenVerificationResult $verification = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + if ($this->verification !== null) { + return $this->verification->verified; + } + + return $this->token !== null; } } diff --git a/src/Authentication/TokenAuth/TokenRevocationResult.php b/src/Authentication/TokenAuth/TokenRevocationResult.php index 0c054a1..5303899 100644 --- a/src/Authentication/TokenAuth/TokenRevocationResult.php +++ b/src/Authentication/TokenAuth/TokenRevocationResult.php @@ -14,6 +14,18 @@ public function __construct( public string $familyId, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return match ($this->status) { + TokenRevocationStatus::REVOKED, + TokenRevocationStatus::ALREADY_REVOKED => true, + }; } } diff --git a/src/Authentication/TokenAuth/TokenRevocationStatus.php b/src/Authentication/TokenAuth/TokenRevocationStatus.php index c491a42..a634a5c 100644 --- a/src/Authentication/TokenAuth/TokenRevocationStatus.php +++ b/src/Authentication/TokenAuth/TokenRevocationStatus.php @@ -6,6 +6,7 @@ enum TokenRevocationStatus: string { + case ALREADY_REVOKED = 'already_revoked'; + case REVOKED = 'revoked'; - case NOT_FOUND = 'not_found'; } diff --git a/src/Authentication/TokenAuth/TokenType.php b/src/Authentication/TokenAuth/TokenType.php index 866fc18..9dd078b 100644 --- a/src/Authentication/TokenAuth/TokenType.php +++ b/src/Authentication/TokenAuth/TokenType.php @@ -7,5 +7,6 @@ enum TokenType: string { case ACCESS = 'access'; + case REFRESH = 'refresh'; } diff --git a/src/Authorization/Decision/AuthorizationDecision.php b/src/Authorization/Decision/AuthorizationDecision.php index 6e37d50..597c84e 100644 --- a/src/Authorization/Decision/AuthorizationDecision.php +++ b/src/Authorization/Decision/AuthorizationDecision.php @@ -14,8 +14,7 @@ public function __construct( public string $code, public ?string $reason = null, public array $context = [], - ) { - } + ) {} /** * @param array $context diff --git a/src/Authorization/Gate/Ability.php b/src/Authorization/Gate/Ability.php index 47cc7bd..e4686eb 100644 --- a/src/Authorization/Gate/Ability.php +++ b/src/Authorization/Gate/Ability.php @@ -14,8 +14,7 @@ public function __construct( public ?string $resourceType = null, public ?string $resourceId = null, public array $context = [], - ) { - } + ) {} /** * @param array $context diff --git a/src/Authorization/Gate/AuditingAuthorizer.php b/src/Authorization/Gate/AuditingAuthorizer.php index 2f6d989..9620fec 100644 --- a/src/Authorization/Gate/AuditingAuthorizer.php +++ b/src/Authorization/Gate/AuditingAuthorizer.php @@ -4,13 +4,16 @@ namespace Infocyph\AuthLayer\Authorization\Gate; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; +use Infocyph\AuthLayer\Authorization\Decision\AuthorizationDecision; use Infocyph\AuthLayer\Contract\Clock\ClockInterface; use Infocyph\AuthLayer\Contract\Id\AuthIdGeneratorInterface; use Infocyph\AuthLayer\Contract\Storage\AuditEventStoreInterface; +use Infocyph\AuthLayer\Exception\AuthorizationException; use Infocyph\AuthLayer\Principal\PrincipalInterface; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class AuditingAuthorizer implements AuthorizerInterface @@ -20,33 +23,39 @@ public function __construct( private AuditEventStoreInterface $audit, private AuthIdGeneratorInterface $ids, private ClockInterface $clock = new SystemClock(), - ) { + ) {} + + public function authorize(PrincipalInterface $principal, string $ability, mixed $resource = null, array $context = []): void + { + $decision = $this->can($principal, $ability, $resource, $context); + + if (!$decision->allowed) { + throw new AuthorizationException( + $decision->reason ?? 'Authorization failed.', + $decision->code, + ); + } } - public function can(PrincipalInterface $principal, string $ability, mixed $resource = null, array $context = []): \Infocyph\AuthLayer\Authorization\Decision\AuthorizationDecision + public function can(PrincipalInterface $principal, string $ability, mixed $resource = null, array $context = []): AuthorizationDecision { $decision = $this->inner->can($principal, $ability, $resource, $context); - if (! $decision->allowed) { - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: AuthEventType::AUTHORIZATION_DENIED, - severity: AuthEventSeverity::WARNING, - accountId: $principal->accountId(), + if (!$decision->allowed) { + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + AuthEventType::AUTHORIZATION_DENIED, + $principal->accountId(), actorId: $principal->id(), - sessionId: $context['session_id'] ?? null, - deviceId: $context['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), metadata: ['ability' => $ability, 'code' => $decision->code, 'reason' => $decision->reason] + $context, - )); + severity: AuthEventSeverity::WARNING, + sessionId: ContextValue::stringOrNull($context, 'session_id'), + deviceId: ContextValue::stringOrNull($context, 'device_id'), + ); } return $decision; } - - public function authorize(PrincipalInterface $principal, string $ability, mixed $resource = null, array $context = []): void - { - $this->inner->authorize($principal, $ability, $resource, $context); - } } diff --git a/src/Authorization/Gate/AuthorizerInterface.php b/src/Authorization/Gate/AuthorizerInterface.php index 2469802..c934620 100644 --- a/src/Authorization/Gate/AuthorizerInterface.php +++ b/src/Authorization/Gate/AuthorizerInterface.php @@ -12,20 +12,20 @@ interface AuthorizerInterface /** * @param array $context */ - public function can( + public function authorize( PrincipalInterface $principal, string $ability, mixed $resource = null, array $context = [], - ): AuthorizationDecision; + ): void; /** * @param array $context */ - public function authorize( + public function can( PrincipalInterface $principal, string $ability, mixed $resource = null, array $context = [], - ): void; + ): AuthorizationDecision; } diff --git a/src/Authorization/Gate/Gate.php b/src/Authorization/Gate/Gate.php index 24560c8..1b4a9c2 100644 --- a/src/Authorization/Gate/Gate.php +++ b/src/Authorization/Gate/Gate.php @@ -12,37 +12,51 @@ final class Gate implements AuthorizerInterface { - /** @var array */ + /** @var array): (AuthorizationDecision|bool|null)> */ private array $abilities = []; - /** @var list */ - private array $beforeCallbacks = []; - - /** @var list */ + /** @var list): (AuthorizationDecision|bool|null)> */ private array $afterCallbacks = []; + /** @var list): (AuthorizationDecision|bool|null)> */ + private array $beforeCallbacks = []; + public function __construct( private readonly ?PolicyResolverInterface $policyResolver = null, - ) { - } + ) {} - public function define(string $ability, callable $callback): self + /** + * @param callable(PrincipalInterface, string, mixed, AuthorizationDecision, array): (AuthorizationDecision|bool|null) $callback + */ + public function after(callable $callback): self { - $this->abilities[$ability] = $callback; + $this->afterCallbacks[] = $callback; return $this; } - public function before(callable $callback): self - { - $this->beforeCallbacks[] = $callback; + public function authorize( + PrincipalInterface $principal, + string $ability, + mixed $resource = null, + array $context = [], + ): void { + $decision = $this->can($principal, $ability, $resource, $context); - return $this; + if (!$decision->allowed) { + throw new AuthorizationException( + $decision->reason ?? 'Authorization failed.', + $decision->code, + ); + } } - public function after(callable $callback): self + /** + * @param callable(PrincipalInterface, string, mixed, array): (AuthorizationDecision|bool|null) $callback + */ + public function before(callable $callback): self { - $this->afterCallbacks[] = $callback; + $this->beforeCallbacks[] = $callback; return $this; } @@ -56,15 +70,17 @@ public function can( foreach ($this->beforeCallbacks as $callback) { $result = $callback($principal, $ability, $resource, $context); - if ($result !== null) { - return $this->runAfterCallbacks( - $principal, - $ability, - $resource, - $context, - $this->normalizeDecision($result), - ); + if ($result === null) { + continue; } + + return $this->runAfterCallbacks( + $principal, + $ability, + $resource, + $context, + $this->normalizeDecision($result), + ); } $decision = $this->resolveDecision($principal, $ability, $resource, $context); @@ -72,22 +88,28 @@ public function can( return $this->runAfterCallbacks($principal, $ability, $resource, $context, $decision); } - public function authorize( - PrincipalInterface $principal, - string $ability, - mixed $resource = null, - array $context = [], - ): void { - $decision = $this->can($principal, $ability, $resource, $context); + /** + * @param callable(PrincipalInterface, mixed, array): (AuthorizationDecision|bool|null) $callback + */ + public function define(string $ability, callable $callback): self + { + $this->abilities[$ability] = $callback; - if (! $decision->allowed) { - throw new AuthorizationException( - $decision->reason ?? 'Authorization failed.', - $decision->code, - ); - } + return $this; + } + + private function normalizeDecision(AuthorizationDecision|bool|null $decision): AuthorizationDecision + { + return match (true) { + $decision instanceof AuthorizationDecision => $decision, + $decision === true => AuthorizationDecision::allow(), + default => AuthorizationDecision::deny(), + }; } + /** + * @param array $context + */ private function resolveDecision( PrincipalInterface $principal, string $ability, @@ -95,9 +117,9 @@ private function resolveDecision( array $context, ): AuthorizationDecision { if (array_key_exists($ability, $this->abilities)) { - return $this->normalizeDecision( - ($this->abilities[$ability])($principal, $resource, $context), - ); + $result = ($this->abilities[$ability])($principal, $resource, $context); + + return $this->normalizeDecision($result); } $policy = $this->resolvePolicy($resource); @@ -123,6 +145,9 @@ private function resolvePolicy(mixed $resource): ?PolicyInterface return $this->policyResolver->resolve($resource); } + /** + * @param array $context + */ private function runAfterCallbacks( PrincipalInterface $principal, string $ability, @@ -133,20 +158,13 @@ private function runAfterCallbacks( foreach ($this->afterCallbacks as $callback) { $result = $callback($principal, $ability, $resource, $decision, $context); - if ($result !== null) { - $decision = $this->normalizeDecision($result); + if ($result === null) { + continue; } + + $decision = $this->normalizeDecision($result); } return $decision; } - - private function normalizeDecision(AuthorizationDecision|bool|null $decision): AuthorizationDecision - { - return match (true) { - $decision instanceof AuthorizationDecision => $decision, - $decision === true => AuthorizationDecision::allow(), - default => AuthorizationDecision::deny(), - }; - } } diff --git a/src/Authorization/Gate/PermissionAuthorizer.php b/src/Authorization/Gate/PermissionAuthorizer.php index 22ae2c6..5f375e6 100644 --- a/src/Authorization/Gate/PermissionAuthorizer.php +++ b/src/Authorization/Gate/PermissionAuthorizer.php @@ -20,7 +20,19 @@ public function __construct( private GrantResolver $grants, private AbilityMatcher $matcher = new AbilityMatcher(), private bool $allowGuests = false, - ) { + ) {} + + public function authorize( + PrincipalInterface $principal, + string $ability, + mixed $resource = null, + array $context = [], + ): void { + $decision = $this->can($principal, $ability, $resource, $context); + + if (!$decision->allowed) { + throw new AuthorizationException($decision->reason ?? 'Authorization failed.', $decision->code); + } } public function can( @@ -52,19 +64,6 @@ public function can( return AuthorizationDecision::deny('permission_denied', 'No matching permission or grant was found.', $context); } - public function authorize( - PrincipalInterface $principal, - string $ability, - mixed $resource = null, - array $context = [], - ): void { - $decision = $this->can($principal, $ability, $resource, $context); - - if (! $decision->allowed) { - throw new AuthorizationException($decision->reason ?? 'Authorization failed.', $decision->code); - } - } - /** * @return list */ diff --git a/src/Authorization/Grant/AccessGrant.php b/src/Authorization/Grant/AccessGrant.php index 5f4aa06..d055e70 100644 --- a/src/Authorization/Grant/AccessGrant.php +++ b/src/Authorization/Grant/AccessGrant.php @@ -16,12 +16,17 @@ public function __construct( public ?string $resourceType = null, public ?string $resourceId = null, public ?int $expiresAt = null, + public ?int $revokedAt = null, public array $metadata = [], - ) { - } + ) {} public function isExpiredAt(?int $timestamp = null): bool { return $this->expiresAt !== null && $this->expiresAt <= ($timestamp ?? time()); } + + public function isRevoked(): bool + { + return $this->revokedAt !== null; + } } diff --git a/src/Authorization/Grant/DelegationManager.php b/src/Authorization/Grant/DelegationManager.php index 9246b11..d91c7dc 100644 --- a/src/Authorization/Grant/DelegationManager.php +++ b/src/Authorization/Grant/DelegationManager.php @@ -4,12 +4,13 @@ namespace Infocyph\AuthLayer\Authorization\Grant; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; use Infocyph\AuthLayer\Contract\Clock\ClockInterface; use Infocyph\AuthLayer\Contract\Id\AuthIdGeneratorInterface; use Infocyph\AuthLayer\Contract\Storage\AuditEventStoreInterface; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class DelegationManager @@ -19,8 +20,7 @@ public function __construct( private AuditEventStoreInterface $audit, private AuthIdGeneratorInterface $ids, private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} /** * @param array $metadata @@ -43,6 +43,11 @@ public function grant(string $principalId, string $permission, ?string $resource return new DelegationResult(DelegationStatus::GRANTED, grant: $grant, code: 'delegated_access_granted', context: $metadata); } + public function listForPrincipal(string $principalId): DelegationResult + { + return new DelegationResult(DelegationStatus::LISTED, grants: $this->grants->grantsForPrincipal($principalId), code: 'delegated_access_listed'); + } + /** * @param array $metadata */ @@ -54,27 +59,21 @@ public function revoke(string $grantId, ?string $principalId = null, array $meta return new DelegationResult(DelegationStatus::REVOKED, code: 'delegated_access_revoked', context: $metadata); } - public function listForPrincipal(string $principalId): DelegationResult - { - return new DelegationResult(DelegationStatus::LISTED, grants: $this->grants->grantsForPrincipal($principalId), code: 'delegated_access_listed'); - } - /** * @param array $metadata */ private function recordAudit(AuthEventType $type, ?string $principalId, array $metadata): void { - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: $type, - severity: AuthEventSeverity::NOTICE, - accountId: $principalId, - actorId: $principalId, - sessionId: $metadata['session_id'] ?? null, - deviceId: $metadata['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + $type, + $principalId, metadata: $metadata, - )); + severity: AuthEventSeverity::NOTICE, + sessionId: ContextValue::stringOrNull($metadata, 'session_id'), + deviceId: ContextValue::stringOrNull($metadata, 'device_id'), + ); } } diff --git a/src/Authorization/Grant/DelegationResult.php b/src/Authorization/Grant/DelegationResult.php index 3090736..1c65f74 100644 --- a/src/Authorization/Grant/DelegationResult.php +++ b/src/Authorization/Grant/DelegationResult.php @@ -16,6 +16,19 @@ public function __construct( public array $grants = [], public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return match ($this->status) { + DelegationStatus::GRANTED, + DelegationStatus::LISTED, + DelegationStatus::REVOKED => true, + }; } } diff --git a/src/Authorization/Grant/DelegationStatus.php b/src/Authorization/Grant/DelegationStatus.php index 19c8d1c..6318db1 100644 --- a/src/Authorization/Grant/DelegationStatus.php +++ b/src/Authorization/Grant/DelegationStatus.php @@ -7,6 +7,8 @@ enum DelegationStatus: string { case GRANTED = 'granted'; - case REVOKED = 'revoked'; + case LISTED = 'listed'; + + case REVOKED = 'revoked'; } diff --git a/src/Authorization/Grant/GrantResolver.php b/src/Authorization/Grant/GrantResolver.php index ebd7c6e..b895213 100644 --- a/src/Authorization/Grant/GrantResolver.php +++ b/src/Authorization/Grant/GrantResolver.php @@ -15,8 +15,7 @@ public function __construct( private GrantStoreInterface $grants, private AbilityMatcher $matcher = new AbilityMatcher(), private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} /** * @return list @@ -27,11 +26,11 @@ public function forPrincipal(string $principalId, Ability $ability): array $matches = []; foreach ($this->grants->grantsForPrincipal($principalId) as $grant) { - if ($grant->isExpiredAt($now)) { + if ($grant->isRevoked() || $grant->isExpiredAt($now)) { continue; } - if (! $this->matcher->matches($grant->permission, $ability->name)) { + if (!$this->matcher->matches($grant->permission, $ability->name)) { continue; } diff --git a/src/Authorization/Grant/GrantStoreInterface.php b/src/Authorization/Grant/GrantStoreInterface.php index 760ac39..187bd82 100644 --- a/src/Authorization/Grant/GrantStoreInterface.php +++ b/src/Authorization/Grant/GrantStoreInterface.php @@ -11,7 +11,7 @@ interface GrantStoreInterface */ public function grantsForPrincipal(string $principalId): array; - public function save(AccessGrant $grant): void; - public function revoke(string $grantId): void; + + public function save(AccessGrant $grant): void; } diff --git a/src/Authorization/Permission/Permission.php b/src/Authorization/Permission/Permission.php index ad40ad3..374d6b1 100644 --- a/src/Authorization/Permission/Permission.php +++ b/src/Authorization/Permission/Permission.php @@ -13,6 +13,5 @@ public function __construct( public string $id, public string $name, public array $metadata = [], - ) { - } + ) {} } diff --git a/src/Authorization/Permission/PermissionAssignmentStoreInterface.php b/src/Authorization/Permission/PermissionAssignmentStoreInterface.php index 3b43e8b..a9fedc8 100644 --- a/src/Authorization/Permission/PermissionAssignmentStoreInterface.php +++ b/src/Authorization/Permission/PermissionAssignmentStoreInterface.php @@ -6,13 +6,13 @@ interface PermissionAssignmentStoreInterface { - public function save(Permission $permission): void; - public function assignPermissionToAccount(string $accountId, string $permissionId): void; - public function revokePermissionFromAccount(string $accountId, string $permissionId): void; - public function assignPermissionToRole(string $roleId, string $permissionId): void; + public function revokePermissionFromAccount(string $accountId, string $permissionId): void; + public function revokePermissionFromRole(string $roleId, string $permissionId): void; + + public function save(Permission $permission): void; } diff --git a/src/Authorization/Permission/PermissionManager.php b/src/Authorization/Permission/PermissionManager.php index e0ed96e..7fe2eb0 100644 --- a/src/Authorization/Permission/PermissionManager.php +++ b/src/Authorization/Permission/PermissionManager.php @@ -12,7 +12,16 @@ public function __construct( private PermissionStoreInterface $permissions, private PermissionAssignmentStoreInterface $assignments, private AuthIdGeneratorInterface $ids, - ) { + ) {} + + public function assignToAccount(string $accountId, string $permissionId): void + { + $this->assignments->assignPermissionToAccount($accountId, $permissionId); + } + + public function assignToRole(string $roleId, string $permissionId): void + { + $this->assignments->assignPermissionToRole($roleId, $permissionId); } /** @@ -20,37 +29,40 @@ public function __construct( */ public function create(string $name, array $metadata = []): Permission { - $permission = new Permission($this->ids->permissionId(), $name, $metadata); + $permission = new Permission( + id: $this->ids->permissionId(), + name: $name, + metadata: $metadata, + ); $this->assignments->save($permission); return $permission; } - public function assignToAccount(string $accountId, string $permissionId): void + /** + * @return list + */ + public function forAccount(string $accountId): array { - $this->assignments->assignPermissionToAccount($accountId, $permissionId); + return $this->permissions->permissionsForAccount($accountId); } - public function revokeFromAccount(string $accountId, string $permissionId): void + /** + * @param list $roleIds + * @return list + */ + public function forRoles(array $roleIds): array { - $this->assignments->revokePermissionFromAccount($accountId, $permissionId); + return $this->permissions->permissionsForRoles($roleIds); } - public function assignToRole(string $roleId, string $permissionId): void + public function revokeFromAccount(string $accountId, string $permissionId): void { - $this->assignments->assignPermissionToRole($roleId, $permissionId); + $this->assignments->revokePermissionFromAccount($accountId, $permissionId); } public function revokeFromRole(string $roleId, string $permissionId): void { $this->assignments->revokePermissionFromRole($roleId, $permissionId); } - - /** - * @return list - */ - public function forAccount(string $accountId): array - { - return $this->permissions->permissionsForAccount($accountId); - } } diff --git a/src/Authorization/Permission/PermissionResolver.php b/src/Authorization/Permission/PermissionResolver.php index 2153b43..0a9fa5e 100644 --- a/src/Authorization/Permission/PermissionResolver.php +++ b/src/Authorization/Permission/PermissionResolver.php @@ -8,8 +8,7 @@ { public function __construct( private PermissionStoreInterface $permissions, - ) { - } + ) {} /** * @return list diff --git a/src/Authorization/Policy/PolicyResolverInterface.php b/src/Authorization/Policy/PolicyResolverInterface.php index ab69368..ebccd22 100644 --- a/src/Authorization/Policy/PolicyResolverInterface.php +++ b/src/Authorization/Policy/PolicyResolverInterface.php @@ -6,5 +6,5 @@ interface PolicyResolverInterface { - public function resolve(mixed $resource): PolicyInterface|null; + public function resolve(mixed $resource): ?PolicyInterface; } diff --git a/src/Authorization/Role/Role.php b/src/Authorization/Role/Role.php index edc9016..d42c01c 100644 --- a/src/Authorization/Role/Role.php +++ b/src/Authorization/Role/Role.php @@ -13,6 +13,5 @@ public function __construct( public string $id, public string $name, public array $metadata = [], - ) { - } + ) {} } diff --git a/src/Authorization/Role/RoleAssignmentStoreInterface.php b/src/Authorization/Role/RoleAssignmentStoreInterface.php index 57ca3fc..40289e3 100644 --- a/src/Authorization/Role/RoleAssignmentStoreInterface.php +++ b/src/Authorization/Role/RoleAssignmentStoreInterface.php @@ -6,9 +6,9 @@ interface RoleAssignmentStoreInterface { - public function save(Role $role): void; - public function assignRole(string $accountId, string $roleId): void; public function revokeRole(string $accountId, string $roleId): void; + + public function save(Role $role): void; } diff --git a/src/Authorization/Role/RoleManager.php b/src/Authorization/Role/RoleManager.php index c382711..eabb089 100644 --- a/src/Authorization/Role/RoleManager.php +++ b/src/Authorization/Role/RoleManager.php @@ -12,7 +12,11 @@ public function __construct( private RoleStoreInterface $roles, private RoleAssignmentStoreInterface $assignments, private AuthIdGeneratorInterface $ids, - ) { + ) {} + + public function assign(string $accountId, string $roleId): void + { + $this->assignments->assignRole($accountId, $roleId); } /** @@ -26,16 +30,6 @@ public function create(string $name, array $metadata = []): Role return $role; } - public function assign(string $accountId, string $roleId): void - { - $this->assignments->assignRole($accountId, $roleId); - } - - public function revoke(string $accountId, string $roleId): void - { - $this->assignments->revokeRole($accountId, $roleId); - } - /** * @return list */ @@ -43,4 +37,9 @@ public function forAccount(string $accountId): array { return $this->roles->rolesForAccount($accountId); } + + public function revoke(string $accountId, string $roleId): void + { + $this->assignments->revokeRole($accountId, $roleId); + } } diff --git a/src/Authorization/Role/RolePermissionResolver.php b/src/Authorization/Role/RolePermissionResolver.php index 7928e16..2beed5f 100644 --- a/src/Authorization/Role/RolePermissionResolver.php +++ b/src/Authorization/Role/RolePermissionResolver.php @@ -12,8 +12,7 @@ public function __construct( private RoleStoreInterface $roles, private PermissionStoreInterface $permissions, - ) { - } + ) {} /** * @return list @@ -21,7 +20,7 @@ public function __construct( public function forAccount(string $accountId): array { $roles = $this->roles->rolesForAccount($accountId); - $roleIds = array_map(static fn (Role $role): string => $role->id, $roles); + $roleIds = array_map(static fn(Role $role): string => $role->id, $roles); if ($roleIds === []) { return []; diff --git a/src/Authorization/Scope/AuthScope.php b/src/Authorization/Scope/AuthScope.php index 0a02ec7..965b88b 100644 --- a/src/Authorization/Scope/AuthScope.php +++ b/src/Authorization/Scope/AuthScope.php @@ -14,6 +14,5 @@ public function __construct( public ?string $workspaceId = null, public ?string $organizationId = null, public array $metadata = [], - ) { - } + ) {} } diff --git a/src/Authorization/Scope/ScopeType.php b/src/Authorization/Scope/ScopeType.php index e021c87..df00743 100644 --- a/src/Authorization/Scope/ScopeType.php +++ b/src/Authorization/Scope/ScopeType.php @@ -6,7 +6,9 @@ enum ScopeType: string { + case ORGANIZATION = 'organization'; + case TENANT = 'tenant'; + case WORKSPACE = 'workspace'; - case ORGANIZATION = 'organization'; } diff --git a/src/Contract/Cache/TtlStoreInterface.php b/src/Contract/Cache/TtlStoreInterface.php index a49bb62..b80943d 100644 --- a/src/Contract/Cache/TtlStoreInterface.php +++ b/src/Contract/Cache/TtlStoreInterface.php @@ -6,11 +6,11 @@ interface TtlStoreInterface { - public function put(string $key, mixed $value, int $ttlSeconds): void; + public function delete(string $key): void; public function get(string $key, mixed $default = null): mixed; public function pull(string $key, mixed $default = null): mixed; - public function delete(string $key): void; + public function put(string $key, mixed $value, int $ttlSeconds): void; } diff --git a/src/Contract/Id/AuthIdGeneratorInterface.php b/src/Contract/Id/AuthIdGeneratorInterface.php index af18fb2..6710607 100644 --- a/src/Contract/Id/AuthIdGeneratorInterface.php +++ b/src/Contract/Id/AuthIdGeneratorInterface.php @@ -8,21 +8,21 @@ interface AuthIdGeneratorInterface { public function accountId(): string; - public function sessionId(): string; - - public function deviceId(): string; + public function auditEventId(): string; public function challengeId(): string; - public function credentialId(): string; + public function correlationId(): string; - public function roleId(): string; + public function credentialId(): string; - public function permissionId(): string; + public function deviceId(): string; public function grantId(): string; - public function auditEventId(): string; + public function permissionId(): string; - public function correlationId(): string; + public function roleId(): string; + + public function sessionId(): string; } diff --git a/src/Contract/Security/PasswordPolicyResult.php b/src/Contract/Security/PasswordPolicyResult.php index 240ee55..d77cfc4 100644 --- a/src/Contract/Security/PasswordPolicyResult.php +++ b/src/Contract/Security/PasswordPolicyResult.php @@ -13,6 +13,5 @@ public function __construct( public bool $valid, public array $violations = [], public ?string $code = null, - ) { - } + ) {} } diff --git a/src/Contract/Security/PasswordVerificationResult.php b/src/Contract/Security/PasswordVerificationResult.php index 3110070..c5a9a30 100644 --- a/src/Contract/Security/PasswordVerificationResult.php +++ b/src/Contract/Security/PasswordVerificationResult.php @@ -10,6 +10,5 @@ public function __construct( public bool $verified, public bool $needsRehash = false, public ?string $rehash = null, - ) { - } + ) {} } diff --git a/src/Contract/Security/TokenVerificationResult.php b/src/Contract/Security/TokenVerificationResult.php index 8bd10bf..5f0629b 100644 --- a/src/Contract/Security/TokenVerificationResult.php +++ b/src/Contract/Security/TokenVerificationResult.php @@ -16,6 +16,5 @@ public function __construct( public array $claims = [], public ?int $expiresAt = null, public ?string $failureReason = null, - ) { - } + ) {} } diff --git a/src/Contract/Storage/AccountStoreInterface.php b/src/Contract/Storage/AccountStoreInterface.php index ef13a19..c1c7d71 100644 --- a/src/Contract/Storage/AccountStoreInterface.php +++ b/src/Contract/Storage/AccountStoreInterface.php @@ -9,16 +9,16 @@ interface AccountStoreInterface { - public function save(AccountInterface $account): void; - public function markVerified(string $accountId, int $verifiedAt): void; - public function updatePasswordHash(string $accountId, string $passwordHash): void; - - public function updateStatus(string $accountId, AccountStatus $status): void; + public function save(AccountInterface $account): void; /** * @param array $metadata */ public function updateMetadata(string $accountId, array $metadata): void; + + public function updatePasswordHash(string $accountId, string $passwordHash): void; + + public function updateStatus(string $accountId, AccountStatus $status): void; } diff --git a/src/Contract/Storage/EmailVerificationStoreInterface.php b/src/Contract/Storage/EmailVerificationStoreInterface.php index 42ee0e4..fb6475d 100644 --- a/src/Contract/Storage/EmailVerificationStoreInterface.php +++ b/src/Contract/Storage/EmailVerificationStoreInterface.php @@ -8,9 +8,9 @@ interface EmailVerificationStoreInterface { - public function save(EmailVerificationRequest $request): void; + public function consume(string $requestId): void; public function find(string $requestId): ?EmailVerificationRequest; - public function consume(string $requestId): void; + public function save(EmailVerificationRequest $request): void; } diff --git a/src/Contract/Storage/LockoutReason.php b/src/Contract/Storage/LockoutReason.php index 9e1c709..b2eaaf6 100644 --- a/src/Contract/Storage/LockoutReason.php +++ b/src/Contract/Storage/LockoutReason.php @@ -6,9 +6,13 @@ enum LockoutReason: string { + case ABUSE_DETECTED = 'abuse_detected'; + + case ADMINISTRATIVE = 'administrative'; + case TOO_MANY_LOGIN_ATTEMPTS = 'too_many_login_attempts'; + case TOO_MANY_MFA_FAILURES = 'too_many_mfa_failures'; + case TOO_MANY_PASSKEY_FAILURES = 'too_many_passkey_failures'; - case ABUSE_DETECTED = 'abuse_detected'; - case ADMINISTRATIVE = 'administrative'; } diff --git a/src/Contract/Storage/LockoutStoreInterface.php b/src/Contract/Storage/LockoutStoreInterface.php index f368e8f..fcbcff4 100644 --- a/src/Contract/Storage/LockoutStoreInterface.php +++ b/src/Contract/Storage/LockoutStoreInterface.php @@ -6,9 +6,9 @@ interface LockoutStoreInterface { + public function isLocked(string $accountId): bool; + public function lock(string $accountId, LockoutReason $reason, ?int $until = null): void; public function unlock(string $accountId): void; - - public function isLocked(string $accountId): bool; } diff --git a/src/Contract/Storage/PasswordResetStoreInterface.php b/src/Contract/Storage/PasswordResetStoreInterface.php index 500fa6f..ba9527a 100644 --- a/src/Contract/Storage/PasswordResetStoreInterface.php +++ b/src/Contract/Storage/PasswordResetStoreInterface.php @@ -8,11 +8,11 @@ interface PasswordResetStoreInterface { - public function save(PasswordResetRequest $request): void; + public function consume(string $requestId): void; public function find(string $requestId): ?PasswordResetRequest; - public function consume(string $requestId): void; + public function save(PasswordResetRequest $request): void; public function wasConsumed(string $requestId): bool; } diff --git a/src/Contract/Storage/RefreshTokenStoreInterface.php b/src/Contract/Storage/RefreshTokenStoreInterface.php index 31e7fa7..78fc875 100644 --- a/src/Contract/Storage/RefreshTokenStoreInterface.php +++ b/src/Contract/Storage/RefreshTokenStoreInterface.php @@ -8,13 +8,13 @@ interface RefreshTokenStoreInterface { - public function save(RefreshTokenRecord $record): void; - public function find(string $tokenId): ?RefreshTokenRecord; + public function revokeFamily(string $familyId): void; + public function rotate(string $tokenId, RefreshTokenRecord $replacement): void; - public function revokeFamily(string $familyId): void; + public function save(RefreshTokenRecord $record): void; public function wasFamilyRevoked(string $familyId): bool; } diff --git a/src/Contract/Storage/RememberTokenStoreInterface.php b/src/Contract/Storage/RememberTokenStoreInterface.php index 7f9334c..cb720c2 100644 --- a/src/Contract/Storage/RememberTokenStoreInterface.php +++ b/src/Contract/Storage/RememberTokenStoreInterface.php @@ -8,15 +8,17 @@ interface RememberTokenStoreInterface { - public function save(RememberTokenRecord $record): void; - public function find(string $recordId): ?RememberTokenRecord; public function findBySelector(string $selector): ?RememberTokenRecord; - public function rotate(string $recordId, RememberTokenRecord $replacement): void; + public function markUsed(string $recordId, int $usedAt): void; public function revokeFamily(string $familyId): void; + public function rotate(string $recordId, RememberTokenRecord $replacement): void; + + public function save(RememberTokenRecord $record): void; + public function wasFamilyRevoked(string $familyId): bool; } diff --git a/src/Contract/Storage/SessionStoreInterface.php b/src/Contract/Storage/SessionStoreInterface.php index 127a914..6f9cd45 100644 --- a/src/Contract/Storage/SessionStoreInterface.php +++ b/src/Contract/Storage/SessionStoreInterface.php @@ -12,11 +12,11 @@ public function create(AuthSession $session): void; public function find(string $sessionId): ?AuthSession; - public function rotate(string $sessionId, AuthSession $replacement): void; - - public function touch(string $sessionId, int $lastSeenAt): void; - public function revoke(string $sessionId): void; public function revokeAllForAccount(string $accountId, ?string $exceptSessionId = null): void; + + public function rotate(string $sessionId, AuthSession $replacement): void; + + public function touch(string $sessionId, int $lastSeenAt): void; } diff --git a/src/Device/DeviceManager.php b/src/Device/DeviceManager.php index 7aa861a..968b993 100644 --- a/src/Device/DeviceManager.php +++ b/src/Device/DeviceManager.php @@ -14,7 +14,14 @@ public function __construct( private DeviceStoreInterface $devices, private AuthIdGeneratorInterface $ids, private ClockInterface $clock = new SystemClock(), - ) { + ) {} + + /** + * @return list + */ + public function listForAccount(string $accountId): array + { + return $this->devices->findForAccount($accountId); } /** @@ -37,7 +44,7 @@ public function register(string $accountId, ?string $label, ?string $fingerprint return new DeviceResult(DeviceStatus::REGISTERED, device: $device, code: 'device_registered', context: $metadata); } - public function trust(string $deviceId): DeviceResult + public function revoke(string $deviceId): DeviceResult { $device = $this->devices->find($deviceId); @@ -45,12 +52,12 @@ public function trust(string $deviceId): DeviceResult return new DeviceResult(DeviceStatus::NOT_FOUND, code: 'device_not_found'); } - $this->devices->markTrusted($deviceId, true); + $this->devices->revoke($deviceId); - return new DeviceResult(DeviceStatus::TRUSTED, device: $this->devices->find($deviceId), code: 'device_trusted'); + return new DeviceResult(DeviceStatus::REVOKED, device: $device, code: 'device_revoked'); } - public function revoke(string $deviceId): DeviceResult + public function touch(string $deviceId, ?int $seenAt = null): DeviceResult { $device = $this->devices->find($deviceId); @@ -58,20 +65,12 @@ public function revoke(string $deviceId): DeviceResult return new DeviceResult(DeviceStatus::NOT_FOUND, code: 'device_not_found'); } - $this->devices->revoke($deviceId); - - return new DeviceResult(DeviceStatus::REVOKED, device: $device, code: 'device_revoked'); - } + $this->devices->touch($deviceId, $seenAt ?? $this->clock->now()); - /** - * @return list - */ - public function listForAccount(string $accountId): array - { - return $this->devices->findForAccount($accountId); + return new DeviceResult(DeviceStatus::TOUCHED, device: $this->devices->find($deviceId), code: 'device_touched'); } - public function touch(string $deviceId, ?int $seenAt = null): DeviceResult + public function trust(string $deviceId): DeviceResult { $device = $this->devices->find($deviceId); @@ -79,8 +78,8 @@ public function touch(string $deviceId, ?int $seenAt = null): DeviceResult return new DeviceResult(DeviceStatus::NOT_FOUND, code: 'device_not_found'); } - $this->devices->touch($deviceId, $seenAt ?? $this->clock->now()); + $this->devices->markTrusted($deviceId, true); - return new DeviceResult(DeviceStatus::TOUCHED, device: $this->devices->find($deviceId), code: 'device_touched'); + return new DeviceResult(DeviceStatus::TRUSTED, device: $this->devices->find($deviceId), code: 'device_trusted'); } } diff --git a/src/Device/DeviceRecord.php b/src/Device/DeviceRecord.php index 0084d86..ee44fbe 100644 --- a/src/Device/DeviceRecord.php +++ b/src/Device/DeviceRecord.php @@ -19,20 +19,24 @@ public function __construct( public ?int $lastSeenAt = null, public ?int $revokedAt = null, public array $metadata = [], - ) { + ) {} + + public function isRevoked(): bool + { + return $this->revokedAt !== null; } - public function trusted(): self + public function revokedAt(int $timestamp): self { return new self( id: $this->id, accountId: $this->accountId, label: $this->label, fingerprint: $this->fingerprint, - trusted: true, + trusted: false, createdAt: $this->createdAt, lastSeenAt: $this->lastSeenAt, - revokedAt: $this->revokedAt, + revokedAt: $timestamp, metadata: $this->metadata, ); } @@ -52,17 +56,17 @@ public function seenAt(int $timestamp): self ); } - public function revokedAt(int $timestamp): self + public function trusted(): self { return new self( id: $this->id, accountId: $this->accountId, label: $this->label, fingerprint: $this->fingerprint, - trusted: false, + trusted: true, createdAt: $this->createdAt, lastSeenAt: $this->lastSeenAt, - revokedAt: $timestamp, + revokedAt: $this->revokedAt, metadata: $this->metadata, ); } diff --git a/src/Device/DeviceResult.php b/src/Device/DeviceResult.php index 0b2a1da..efe6e9a 100644 --- a/src/Device/DeviceResult.php +++ b/src/Device/DeviceResult.php @@ -16,6 +16,18 @@ public function __construct( public array $devices = [], public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->status === DeviceStatus::REGISTERED + || $this->status === DeviceStatus::TRUSTED + || $this->status === DeviceStatus::TOUCHED + || $this->status === DeviceStatus::REVOKED; } } diff --git a/src/Device/DeviceStatus.php b/src/Device/DeviceStatus.php index b4e3d2e..50bae44 100644 --- a/src/Device/DeviceStatus.php +++ b/src/Device/DeviceStatus.php @@ -6,9 +6,13 @@ enum DeviceStatus: string { + case NOT_FOUND = 'not_found'; + case REGISTERED = 'registered'; - case TRUSTED = 'trusted'; - case TOUCHED = 'touched'; + case REVOKED = 'revoked'; - case NOT_FOUND = 'not_found'; + + case TOUCHED = 'touched'; + + case TRUSTED = 'trusted'; } diff --git a/src/Device/DeviceStoreInterface.php b/src/Device/DeviceStoreInterface.php index be27571..1e55929 100644 --- a/src/Device/DeviceStoreInterface.php +++ b/src/Device/DeviceStoreInterface.php @@ -6,8 +6,6 @@ interface DeviceStoreInterface { - public function save(DeviceRecord $device): void; - public function find(string $deviceId): ?DeviceRecord; /** @@ -17,7 +15,9 @@ public function findForAccount(string $accountId): array; public function markTrusted(string $deviceId, bool $trusted): void; - public function touch(string $deviceId, int $lastSeenAt): void; - public function revoke(string $deviceId): void; + + public function save(DeviceRecord $device): void; + + public function touch(string $deviceId, int $lastSeenAt): void; } diff --git a/src/Device/DeviceTrustStatus.php b/src/Device/DeviceTrustStatus.php index 544043a..8eb684d 100644 --- a/src/Device/DeviceTrustStatus.php +++ b/src/Device/DeviceTrustStatus.php @@ -6,7 +6,9 @@ enum DeviceTrustStatus: string { + case REVOKED = 'revoked'; + case TRUSTED = 'trusted'; + case UNTRUSTED = 'untrusted'; - case REVOKED = 'revoked'; } diff --git a/src/Event/DomainEventInterface.php b/src/Event/DomainEventInterface.php index 5b281a4..35fba9b 100644 --- a/src/Event/DomainEventInterface.php +++ b/src/Event/DomainEventInterface.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Event; -interface DomainEventInterface -{ -} +interface DomainEventInterface {} diff --git a/src/Exception/AccountException.php b/src/Exception/AccountException.php index d8dfc53..b180d6c 100644 --- a/src/Exception/AccountException.php +++ b/src/Exception/AccountException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class AccountException extends AuthLayerException -{ -} +class AccountException extends AuthLayerException {} diff --git a/src/Exception/AuditException.php b/src/Exception/AuditException.php index 12e6d29..3b061c6 100644 --- a/src/Exception/AuditException.php +++ b/src/Exception/AuditException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class AuditException extends AuthLayerException -{ -} +class AuditException extends AuthLayerException {} diff --git a/src/Exception/AuthException.php b/src/Exception/AuthException.php index 781f091..7ff7757 100644 --- a/src/Exception/AuthException.php +++ b/src/Exception/AuthException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class AuthException extends AuthLayerException -{ -} +class AuthException extends AuthLayerException {} diff --git a/src/Exception/AuthLayerException.php b/src/Exception/AuthLayerException.php index 365982a..121a56e 100644 --- a/src/Exception/AuthLayerException.php +++ b/src/Exception/AuthLayerException.php @@ -6,6 +6,4 @@ use RuntimeException; -class AuthLayerException extends RuntimeException -{ -} +class AuthLayerException extends RuntimeException {} diff --git a/src/Exception/AuthenticationException.php b/src/Exception/AuthenticationException.php index 57ca812..f0eba8f 100644 --- a/src/Exception/AuthenticationException.php +++ b/src/Exception/AuthenticationException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class AuthenticationException extends AuthLayerException -{ -} +class AuthenticationException extends AuthLayerException {} diff --git a/src/Exception/ConfigurationException.php b/src/Exception/ConfigurationException.php index d43cfd6..5f42bf7 100644 --- a/src/Exception/ConfigurationException.php +++ b/src/Exception/ConfigurationException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class ConfigurationException extends AuthLayerException -{ -} +class ConfigurationException extends AuthLayerException {} diff --git a/src/Exception/DelegationException.php b/src/Exception/DelegationException.php index 6165188..b59ad76 100644 --- a/src/Exception/DelegationException.php +++ b/src/Exception/DelegationException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class DelegationException extends AuthLayerException -{ -} +class DelegationException extends AuthLayerException {} diff --git a/src/Exception/DeviceException.php b/src/Exception/DeviceException.php index 3de0688..e59b440 100644 --- a/src/Exception/DeviceException.php +++ b/src/Exception/DeviceException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class DeviceException extends AuthLayerException -{ -} +class DeviceException extends AuthLayerException {} diff --git a/src/Exception/EmailVerificationException.php b/src/Exception/EmailVerificationException.php index d80b321..aa6252f 100644 --- a/src/Exception/EmailVerificationException.php +++ b/src/Exception/EmailVerificationException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class EmailVerificationException extends AuthLayerException -{ -} +class EmailVerificationException extends AuthLayerException {} diff --git a/src/Exception/ImpersonationException.php b/src/Exception/ImpersonationException.php index 3b86f02..48f0fd3 100644 --- a/src/Exception/ImpersonationException.php +++ b/src/Exception/ImpersonationException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class ImpersonationException extends AuthLayerException -{ -} +class ImpersonationException extends AuthLayerException {} diff --git a/src/Exception/LockoutException.php b/src/Exception/LockoutException.php index df30e27..26e2c30 100644 --- a/src/Exception/LockoutException.php +++ b/src/Exception/LockoutException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class LockoutException extends AuthLayerException -{ -} +class LockoutException extends AuthLayerException {} diff --git a/src/Exception/MfaException.php b/src/Exception/MfaException.php index 21f8bd6..4558cdc 100644 --- a/src/Exception/MfaException.php +++ b/src/Exception/MfaException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class MfaException extends AuthLayerException -{ -} +class MfaException extends AuthLayerException {} diff --git a/src/Exception/NotificationException.php b/src/Exception/NotificationException.php index 074ed88..e684a99 100644 --- a/src/Exception/NotificationException.php +++ b/src/Exception/NotificationException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class NotificationException extends AuthLayerException -{ -} +class NotificationException extends AuthLayerException {} diff --git a/src/Exception/PasskeyException.php b/src/Exception/PasskeyException.php index 9f2ee6c..df61524 100644 --- a/src/Exception/PasskeyException.php +++ b/src/Exception/PasskeyException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class PasskeyException extends AuthLayerException -{ -} +class PasskeyException extends AuthLayerException {} diff --git a/src/Exception/PasswordResetException.php b/src/Exception/PasswordResetException.php index 5b11860..e279fb4 100644 --- a/src/Exception/PasswordResetException.php +++ b/src/Exception/PasswordResetException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class PasswordResetException extends AuthLayerException -{ -} +class PasswordResetException extends AuthLayerException {} diff --git a/src/Exception/PasswordlessException.php b/src/Exception/PasswordlessException.php index a81f91f..abcc54e 100644 --- a/src/Exception/PasswordlessException.php +++ b/src/Exception/PasswordlessException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class PasswordlessException extends AuthLayerException -{ -} +class PasswordlessException extends AuthLayerException {} diff --git a/src/Exception/RememberMeException.php b/src/Exception/RememberMeException.php index 7bfe318..bc2b26c 100644 --- a/src/Exception/RememberMeException.php +++ b/src/Exception/RememberMeException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class RememberMeException extends AuthLayerException -{ -} +class RememberMeException extends AuthLayerException {} diff --git a/src/Exception/SessionException.php b/src/Exception/SessionException.php index d4c0ea8..14b9a64 100644 --- a/src/Exception/SessionException.php +++ b/src/Exception/SessionException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class SessionException extends AuthLayerException -{ -} +class SessionException extends AuthLayerException {} diff --git a/src/Exception/StorageException.php b/src/Exception/StorageException.php index f04b476..fea8f7f 100644 --- a/src/Exception/StorageException.php +++ b/src/Exception/StorageException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class StorageException extends AuthLayerException -{ -} +class StorageException extends AuthLayerException {} diff --git a/src/Exception/TokenAuthException.php b/src/Exception/TokenAuthException.php index 89e3674..855d174 100644 --- a/src/Exception/TokenAuthException.php +++ b/src/Exception/TokenAuthException.php @@ -4,6 +4,4 @@ namespace Infocyph\AuthLayer\Exception; -class TokenAuthException extends AuthLayerException -{ -} +class TokenAuthException extends AuthLayerException {} diff --git a/src/Mfa/MfaChallenge.php b/src/Mfa/MfaChallenge.php index fd9781c..5a4e344 100644 --- a/src/Mfa/MfaChallenge.php +++ b/src/Mfa/MfaChallenge.php @@ -17,8 +17,7 @@ public function __construct( public int $issuedAt, public int $expiresAt, public array $metadata = [], - ) { - } + ) {} public function isExpiredAt(?int $timestamp = null): bool { diff --git a/src/Mfa/MfaChallengePurpose.php b/src/Mfa/MfaChallengePurpose.php index 591c9bc..80f5b7c 100644 --- a/src/Mfa/MfaChallengePurpose.php +++ b/src/Mfa/MfaChallengePurpose.php @@ -6,8 +6,11 @@ enum MfaChallengePurpose: string { - case LOGIN = 'login'; - case STEP_UP = 'step_up'; case ENROLLMENT = 'enrollment'; + case FACTOR_REMOVAL = 'factor_removal'; + + case LOGIN = 'login'; + + case STEP_UP = 'step_up'; } diff --git a/src/Mfa/MfaChallengeResult.php b/src/Mfa/MfaChallengeResult.php index 3041926..2348d0b 100644 --- a/src/Mfa/MfaChallengeResult.php +++ b/src/Mfa/MfaChallengeResult.php @@ -16,6 +16,17 @@ public function __construct( public ?MfaFactor $factor = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->status === MfaStatus::CHALLENGE_ISSUED + || $this->status === MfaStatus::VERIFIED + || $this->status === MfaStatus::RECOVERY_CODE_VERIFIED; } } diff --git a/src/Mfa/MfaEnrollmentResult.php b/src/Mfa/MfaEnrollmentResult.php index 0d6bc96..3eea92b 100644 --- a/src/Mfa/MfaEnrollmentResult.php +++ b/src/Mfa/MfaEnrollmentResult.php @@ -16,6 +16,17 @@ public function __construct( public array $recoveryCodes = [], public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->status === MfaStatus::ENROLLED + || $this->status === MfaStatus::ACTIVATED + || $this->status === MfaStatus::REMOVED; } } diff --git a/src/Mfa/MfaFactor.php b/src/Mfa/MfaFactor.php index 9e56cfc..c0e7741 100644 --- a/src/Mfa/MfaFactor.php +++ b/src/Mfa/MfaFactor.php @@ -17,8 +17,7 @@ public function __construct( public bool $enabled, public int $createdAt, public array $metadata = [], - ) { - } + ) {} public function activated(): self { diff --git a/src/Mfa/MfaFactorStoreInterface.php b/src/Mfa/MfaFactorStoreInterface.php index 8e69d72..3965308 100644 --- a/src/Mfa/MfaFactorStoreInterface.php +++ b/src/Mfa/MfaFactorStoreInterface.php @@ -6,12 +6,12 @@ interface MfaFactorStoreInterface { - public function save(MfaFactor $factor): void; - /** * @return list */ public function findForAccount(string $accountId): array; public function remove(string $factorId): void; + + public function save(MfaFactor $factor): void; } diff --git a/src/Mfa/MfaFactorType.php b/src/Mfa/MfaFactorType.php index 77c63c5..6da04b7 100644 --- a/src/Mfa/MfaFactorType.php +++ b/src/Mfa/MfaFactorType.php @@ -6,11 +6,17 @@ enum MfaFactorType: string { - case TOTP = 'totp'; - case HOTP = 'hotp'; - case SMS = 'sms'; + case CUSTOM = 'custom'; + case EMAIL = 'email'; + + case HOTP = 'hotp'; + case PASSKEY = 'passkey'; + case RECOVERY_CODE = 'recovery_code'; - case CUSTOM = 'custom'; + + case SMS = 'sms'; + + case TOTP = 'totp'; } diff --git a/src/Mfa/MfaManager.php b/src/Mfa/MfaManager.php index 40277a7..c07af38 100644 --- a/src/Mfa/MfaManager.php +++ b/src/Mfa/MfaManager.php @@ -4,7 +4,6 @@ namespace Infocyph\AuthLayer\Mfa; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; use Infocyph\AuthLayer\Contract\Cache\TtlStoreInterface; @@ -14,6 +13,8 @@ use Infocyph\AuthLayer\Contract\Storage\AuditEventStoreInterface; use Infocyph\AuthLayer\Notification\AuthNotification; use Infocyph\AuthLayer\Notification\AuthNotificationType; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class MfaManager @@ -29,7 +30,23 @@ public function __construct( private int $challengeTtlSeconds = 300, private int $satisfiedTtlSeconds = 900, private ClockInterface $clock = new SystemClock(), - ) { + ) {} + + /** + * @param array $context + */ + public function activateFactor(string $accountId, string $factorId, array $context = []): MfaEnrollmentResult + { + $factor = $this->findFactor($accountId, $factorId); + + if ($factor === null) { + return new MfaEnrollmentResult(MfaStatus::INVALID, code: 'mfa_factor_not_found', context: $context); + } + + $enabledFactor = $factor->activated(); + $this->factors->save($enabledFactor); + + return new MfaEnrollmentResult(MfaStatus::ACTIVATED, $enabledFactor, code: 'mfa_factor_activated', context: $context); } /** @@ -54,21 +71,9 @@ public function enrollFactor(string $accountId, MfaFactorType|string $type, stri return new MfaEnrollmentResult(MfaStatus::ENROLLED, $factor, $recoveryCodes, 'mfa_factor_enrolled', $metadata); } - /** - * @param array $context - */ - public function activateFactor(string $accountId, string $factorId, array $context = []): MfaEnrollmentResult + public function isSatisfied(string $accountId, ?string $sessionId = null): bool { - $factor = $this->findFactor($accountId, $factorId); - - if ($factor === null) { - return new MfaEnrollmentResult(MfaStatus::INVALID, code: 'mfa_factor_not_found', context: $context); - } - - $enabledFactor = $factor->activated(); - $this->factors->save($enabledFactor); - - return new MfaEnrollmentResult(MfaStatus::ACTIVATED, $enabledFactor, code: 'mfa_factor_activated', context: $context); + return (bool) $this->ttl->get($this->satisfiedKey($accountId, $sessionId), false); } /** @@ -104,6 +109,23 @@ public function issueChallenge(string $accountId, MfaChallengePurpose|string $pu return new MfaChallengeResult(MfaStatus::CHALLENGE_ISSUED, $challenge, factor: $factor, code: 'mfa_challenge_issued', context: $context); } + /** + * @param array $context + */ + public function removeFactor(string $accountId, string $factorId, array $context = []): MfaEnrollmentResult + { + $factor = $this->findFactor($accountId, $factorId); + + if ($factor === null) { + return new MfaEnrollmentResult(MfaStatus::INVALID, code: 'mfa_factor_not_found', context: $context); + } + + $this->factors->remove($factorId); + $this->record(AuthEventType::MFA_DISABLED, $accountId, ['factor_id' => $factorId] + $context, AuthEventSeverity::NOTICE); + + return new MfaEnrollmentResult(MfaStatus::REMOVED, $factor, code: 'mfa_factor_removed', context: $context); + } + /** * @param array $context */ @@ -111,7 +133,7 @@ public function verifyChallenge(string $challengeId, string $code, array $contex { $challenge = $this->ttl->get($this->challengeKey($challengeId)); - if (! $challenge instanceof MfaChallenge) { + if (!$challenge instanceof MfaChallenge) { return new MfaChallengeResult(MfaStatus::INVALID, code: 'mfa_challenge_not_found', context: $context); } @@ -123,12 +145,12 @@ public function verifyChallenge(string $challengeId, string $code, array $contex $verification = $this->verifier->verify($challenge, $code); - if (! $verification->verified) { + if (!$verification->verified) { return new MfaChallengeResult(MfaStatus::INVALID, $challenge, $verification, code: $verification->reason ?? 'mfa_code_invalid', context: $context); } $this->ttl->delete($this->challengeKey($challengeId)); - $this->markSatisfied($challenge->accountId, $context['session_id'] ?? null); + $this->markSatisfied($challenge->accountId, ContextValue::stringOrNull($context, 'session_id')); return new MfaChallengeResult(MfaStatus::VERIFIED, $challenge, $verification, $challenge->factorId !== null ? $this->findFactor($challenge->accountId, $challenge->factorId) : null, 'mfa_verified', $context); } @@ -140,57 +162,25 @@ public function verifyRecoveryCode(string $accountId, string $code, array $conte { $verification = $this->recoveryCodes->verify($accountId, $code); - if (! $verification->verified) { + if (!$verification->verified) { return new MfaChallengeResult(MfaStatus::INVALID, code: $verification->reason ?? 'recovery_code_invalid', context: $context); } - $this->markSatisfied($accountId, $context['session_id'] ?? null); + $this->markSatisfied($accountId, ContextValue::stringOrNull($context, 'session_id')); $this->record(AuthEventType::RECOVERY_CODE_USED, $accountId, $context, AuthEventSeverity::WARNING); return new MfaChallengeResult(MfaStatus::RECOVERY_CODE_VERIFIED, verification: new MfaVerificationResult(true, recoveryCodeUsed: true, context: $context), code: 'recovery_code_verified', context: $context); } - /** - * @param array $context - */ - public function removeFactor(string $accountId, string $factorId, array $context = []): MfaEnrollmentResult - { - $factor = $this->findFactor($accountId, $factorId); - - if ($factor === null) { - return new MfaEnrollmentResult(MfaStatus::INVALID, code: 'mfa_factor_not_found', context: $context); - } - - $this->factors->remove($factorId); - $this->record(AuthEventType::MFA_DISABLED, $accountId, ['factor_id' => $factorId] + $context, AuthEventSeverity::NOTICE); - - return new MfaEnrollmentResult(MfaStatus::REMOVED, $factor, code: 'mfa_factor_removed', context: $context); - } - - public function isSatisfied(string $accountId, ?string $sessionId = null): bool - { - return (bool) $this->ttl->get($this->satisfiedKey($accountId, $sessionId), false); - } - - private function markSatisfied(string $accountId, ?string $sessionId): void - { - $this->ttl->put($this->satisfiedKey($accountId, $sessionId), true, $this->satisfiedTtlSeconds); - } - private function challengeKey(string $challengeId): string { return 'mfa:challenge:' . $challengeId; } - private function satisfiedKey(string $accountId, ?string $sessionId): string - { - return 'mfa:satisfied:' . $accountId . ':' . ($sessionId ?? 'global'); - } - - private function firstEnabledFactor(string $accountId): ?MfaFactor + private function findFactor(string $accountId, string $factorId): ?MfaFactor { foreach ($this->factors->findForAccount($accountId) as $factor) { - if ($factor->enabled) { + if ($factor->id === $factorId) { return $factor; } } @@ -198,10 +188,10 @@ private function firstEnabledFactor(string $accountId): ?MfaFactor return null; } - private function findFactor(string $accountId, string $factorId): ?MfaFactor + private function firstEnabledFactor(string $accountId): ?MfaFactor { foreach ($this->factors->findForAccount($accountId) as $factor) { - if ($factor->id === $factorId) { + if ($factor->enabled) { return $factor; } } @@ -209,19 +199,31 @@ private function findFactor(string $accountId, string $factorId): ?MfaFactor return null; } + private function markSatisfied(string $accountId, ?string $sessionId): void + { + $this->ttl->put($this->satisfiedKey($accountId, $sessionId), true, $this->satisfiedTtlSeconds); + } + + /** + * @param array $metadata + */ private function record(AuthEventType $type, string $accountId, array $metadata = [], AuthEventSeverity $severity = AuthEventSeverity::INFO): void { - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: $type, - severity: $severity, - accountId: $accountId, - actorId: $accountId, - sessionId: $metadata['session_id'] ?? null, - deviceId: $metadata['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + $type, + $accountId, metadata: $metadata, - )); + severity: $severity, + sessionId: ContextValue::stringOrNull($metadata, 'session_id'), + deviceId: ContextValue::stringOrNull($metadata, 'device_id'), + ); + } + + private function satisfiedKey(string $accountId, ?string $sessionId): string + { + return 'mfa:satisfied:' . $accountId . ':' . ($sessionId ?? 'global'); } } diff --git a/src/Mfa/MfaStatus.php b/src/Mfa/MfaStatus.php index 62858fe..a499f8b 100644 --- a/src/Mfa/MfaStatus.php +++ b/src/Mfa/MfaStatus.php @@ -6,12 +6,19 @@ enum MfaStatus: string { - case ENROLLED = 'enrolled'; case ACTIVATED = 'activated'; + case CHALLENGE_ISSUED = 'challenge_issued'; - case VERIFIED = 'verified'; + + case ENROLLED = 'enrolled'; + + case EXPIRED = 'expired'; + + case INVALID = 'invalid'; + case RECOVERY_CODE_VERIFIED = 'recovery_code_verified'; + case REMOVED = 'removed'; - case INVALID = 'invalid'; - case EXPIRED = 'expired'; + + case VERIFIED = 'verified'; } diff --git a/src/Mfa/MfaVerificationResult.php b/src/Mfa/MfaVerificationResult.php index d5a05a5..3583d3b 100644 --- a/src/Mfa/MfaVerificationResult.php +++ b/src/Mfa/MfaVerificationResult.php @@ -15,6 +15,5 @@ public function __construct( public bool $recoveryCodeUsed = false, public ?string $reason = null, public array $context = [], - ) { - } + ) {} } diff --git a/src/Mfa/RecoveryCodeVerificationResult.php b/src/Mfa/RecoveryCodeVerificationResult.php index c504a14..72e4301 100644 --- a/src/Mfa/RecoveryCodeVerificationResult.php +++ b/src/Mfa/RecoveryCodeVerificationResult.php @@ -9,6 +9,5 @@ public function __construct( public bool $verified, public ?string $reason = null, - ) { - } + ) {} } diff --git a/src/Notification/AuthNotification.php b/src/Notification/AuthNotification.php index e20dd12..5d375ea 100644 --- a/src/Notification/AuthNotification.php +++ b/src/Notification/AuthNotification.php @@ -13,6 +13,5 @@ public function __construct( public AuthNotificationType $type, public ?string $accountId, public array $payload = [], - ) { - } + ) {} } diff --git a/src/Notification/AuthNotificationType.php b/src/Notification/AuthNotificationType.php index 13e6285..3d38de6 100644 --- a/src/Notification/AuthNotificationType.php +++ b/src/Notification/AuthNotificationType.php @@ -6,17 +6,29 @@ enum AuthNotificationType: string { - case PASSWORD_RESET_REQUESTED = 'password_reset_requested'; + case ACCOUNT_LOCKED = 'account_locked'; + + case DELEGATED_ACCESS_GRANTED = 'delegated_access_granted'; + + case DELEGATED_ACCESS_REVOKED = 'delegated_access_revoked'; + case EMAIL_VERIFICATION_REQUESTED = 'email_verification_requested'; - case MFA_CHALLENGE_REQUESTED = 'mfa_challenge_requested'; + case LOGIN_ALERT = 'login_alert'; + + case MFA_CHALLENGE_REQUESTED = 'mfa_challenge_requested'; + case NEW_DEVICE_ALERT = 'new_device_alert'; - case ACCOUNT_LOCKED = 'account_locked'; - case PASSWORD_CHANGED = 'password_changed'; + case PASSKEY_REGISTERED = 'passkey_registered'; + case PASSKEY_REMOVED = 'passkey_removed'; - case SUSPICIOUS_ACTIVITY = 'suspicious_activity'; + + case PASSWORD_CHANGED = 'password_changed'; + + case PASSWORD_RESET_REQUESTED = 'password_reset_requested'; + case PASSWORDLESS_LOGIN_REQUESTED = 'passwordless_login_requested'; - case DELEGATED_ACCESS_GRANTED = 'delegated_access_granted'; - case DELEGATED_ACCESS_REVOKED = 'delegated_access_revoked'; + + case SUSPICIOUS_ACTIVITY = 'suspicious_activity'; } diff --git a/src/Passkey/PasskeyAuthenticationOutcome.php b/src/Passkey/PasskeyAuthenticationOutcome.php index de45130..8d75543 100644 --- a/src/Passkey/PasskeyAuthenticationOutcome.php +++ b/src/Passkey/PasskeyAuthenticationOutcome.php @@ -15,6 +15,16 @@ public function __construct( public ?PasskeyVerificationResult $verification = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->status === PasskeyAuthenticationStatus::STARTED + || $this->status === PasskeyAuthenticationStatus::VERIFIED; } } diff --git a/src/Passkey/PasskeyAuthenticationResult.php b/src/Passkey/PasskeyAuthenticationResult.php index f6db1be..6ac4504 100644 --- a/src/Passkey/PasskeyAuthenticationResult.php +++ b/src/Passkey/PasskeyAuthenticationResult.php @@ -17,6 +17,5 @@ public function __construct( public string $signature, public ?string $userHandle = null, public array $metadata = [], - ) { - } + ) {} } diff --git a/src/Passkey/PasskeyAuthenticationStatus.php b/src/Passkey/PasskeyAuthenticationStatus.php index 56ab3d1..dc29420 100644 --- a/src/Passkey/PasskeyAuthenticationStatus.php +++ b/src/Passkey/PasskeyAuthenticationStatus.php @@ -6,7 +6,9 @@ enum PasskeyAuthenticationStatus: string { + case INVALID = 'invalid'; + case STARTED = 'started'; + case VERIFIED = 'verified'; - case INVALID = 'invalid'; } diff --git a/src/Passkey/PasskeyChallenge.php b/src/Passkey/PasskeyChallenge.php index 0a5fa5c..69ceccd 100644 --- a/src/Passkey/PasskeyChallenge.php +++ b/src/Passkey/PasskeyChallenge.php @@ -17,8 +17,7 @@ public function __construct( public int $issuedAt, public int $expiresAt, public array $metadata = [], - ) { - } + ) {} public function isExpiredAt(?int $timestamp = null): bool { diff --git a/src/Passkey/PasskeyChallengePurpose.php b/src/Passkey/PasskeyChallengePurpose.php index bcdc7e8..4aa749e 100644 --- a/src/Passkey/PasskeyChallengePurpose.php +++ b/src/Passkey/PasskeyChallengePurpose.php @@ -6,7 +6,9 @@ enum PasskeyChallengePurpose: string { - case REGISTRATION = 'registration'; case AUTHENTICATION = 'authentication'; + + case REGISTRATION = 'registration'; + case STEP_UP = 'step_up'; } diff --git a/src/Passkey/PasskeyCredential.php b/src/Passkey/PasskeyCredential.php index 9f15c20..6ea1efc 100644 --- a/src/Passkey/PasskeyCredential.php +++ b/src/Passkey/PasskeyCredential.php @@ -19,7 +19,28 @@ public function __construct( public array $transports, public int $createdAt, public ?int $lastUsedAt = null, + public ?int $revokedAt = null, public array $metadata = [], - ) { + ) {} + + public function isRevoked(): bool + { + return $this->revokedAt !== null; + } + + public function revokedAt(int $timestamp): self + { + return new self( + id: $this->id, + accountId: $this->accountId, + credentialId: $this->credentialId, + publicKey: $this->publicKey, + signCount: $this->signCount, + transports: $this->transports, + createdAt: $this->createdAt, + lastUsedAt: $this->lastUsedAt, + revokedAt: $timestamp, + metadata: $this->metadata, + ); } } diff --git a/src/Passkey/PasskeyCredentialStoreInterface.php b/src/Passkey/PasskeyCredentialStoreInterface.php index 5135718..0f67458 100644 --- a/src/Passkey/PasskeyCredentialStoreInterface.php +++ b/src/Passkey/PasskeyCredentialStoreInterface.php @@ -6,8 +6,6 @@ interface PasskeyCredentialStoreInterface { - public function save(PasskeyCredential $credential): void; - public function findByCredentialId(string $credentialId): ?PasskeyCredential; /** @@ -15,7 +13,9 @@ public function findByCredentialId(string $credentialId): ?PasskeyCredential; */ public function findForAccount(string $accountId): array; - public function updateSignCount(string $credentialId, int $signCount): void; - public function revoke(string $credentialId): void; + + public function save(PasskeyCredential $credential): void; + + public function updateUsage(string $credentialId, int $signCount, int $usedAt): void; } diff --git a/src/Passkey/PasskeyManager.php b/src/Passkey/PasskeyManager.php index 4107f86..effc1d4 100644 --- a/src/Passkey/PasskeyManager.php +++ b/src/Passkey/PasskeyManager.php @@ -4,7 +4,6 @@ namespace Infocyph\AuthLayer\Passkey; -use Infocyph\AuthLayer\Audit\AuthEvent; use Infocyph\AuthLayer\Audit\AuthEventSeverity; use Infocyph\AuthLayer\Audit\AuthEventType; use Infocyph\AuthLayer\Contract\Clock\ClockInterface; @@ -13,6 +12,8 @@ use Infocyph\AuthLayer\Contract\Storage\AuditEventStoreInterface; use Infocyph\AuthLayer\Notification\AuthNotification; use Infocyph\AuthLayer\Notification\AuthNotificationType; +use Infocyph\AuthLayer\Support\AuthEventRecorder; +use Infocyph\AuthLayer\Support\ContextValue; use Infocyph\AuthLayer\Support\SystemClock; final readonly class PasskeyManager @@ -24,17 +25,29 @@ public function __construct( private AuthNotifierInterface $notifier, private AuthIdGeneratorInterface $ids, private ClockInterface $clock = new SystemClock(), - ) { - } + ) {} /** * @param array $context */ - public function startRegistration(string $accountId, array $context = []): PasskeyRegistrationOutcome + public function finishAuthentication(PasskeyAuthenticationResult $result, array $context = []): PasskeyAuthenticationOutcome { - $challenge = $this->service->startRegistration($accountId); + $verification = $this->service->finishAuthentication($result); - return new PasskeyRegistrationOutcome(PasskeyRegistrationStatus::STARTED, $challenge, code: 'passkey_registration_started', context: $context); + if ($verification->verified && $verification->accountId !== null) { + if ($verification->credentialId !== null && $verification->signCount !== null) { + $this->credentials->updateUsage($verification->credentialId, $verification->signCount, $this->clock->now()); + } + + $this->record(AuthEventType::PASSKEY_USED, $verification->accountId, ['credential_id' => $verification->credentialId] + $context); + } + + return new PasskeyAuthenticationOutcome( + $verification->verified ? PasskeyAuthenticationStatus::VERIFIED : PasskeyAuthenticationStatus::INVALID, + verification: $verification, + code: $verification->verified ? 'passkey_verified' : ($verification->reason ?? 'passkey_invalid'), + context: $context, + ); } /** @@ -53,59 +66,48 @@ public function finishRegistration(PasskeyRegistrationResult $result, array $con /** * @param array $context */ - public function startAuthentication(?string $accountId = null, array $context = []): PasskeyAuthenticationOutcome + public function revokeCredential(string $accountId, string $credentialId, array $context = []): void { - $challenge = $this->service->startAuthentication($accountId); - - return new PasskeyAuthenticationOutcome(PasskeyAuthenticationStatus::STARTED, $challenge, code: 'passkey_authentication_started', context: $context); + $this->credentials->revoke($credentialId); + $this->record(AuthEventType::PASSKEY_REMOVED, $accountId, ['credential_id' => $credentialId] + $context, AuthEventSeverity::NOTICE); + $this->notifier->send(new AuthNotification(AuthNotificationType::PASSKEY_REMOVED, $accountId, ['credential_id' => $credentialId] + $context)); } /** * @param array $context */ - public function finishAuthentication(PasskeyAuthenticationResult $result, array $context = []): PasskeyAuthenticationOutcome + public function startAuthentication(?string $accountId = null, array $context = []): PasskeyAuthenticationOutcome { - $verification = $this->service->finishAuthentication($result); - - if ($verification->verified && $verification->accountId !== null) { - if ($verification->credentialId !== null && $verification->signCount !== null) { - $this->credentials->updateSignCount($verification->credentialId, $verification->signCount); - } - - $this->record(AuthEventType::PASSKEY_USED, $verification->accountId, ['credential_id' => $verification->credentialId] + $context); - } + $challenge = $this->service->startAuthentication($accountId); - return new PasskeyAuthenticationOutcome( - $verification->verified ? PasskeyAuthenticationStatus::VERIFIED : PasskeyAuthenticationStatus::INVALID, - verification: $verification, - code: $verification->verified ? 'passkey_verified' : ($verification->reason ?? 'passkey_invalid'), - context: $context, - ); + return new PasskeyAuthenticationOutcome(PasskeyAuthenticationStatus::STARTED, $challenge, code: 'passkey_authentication_started', context: $context); } /** * @param array $context */ - public function revokeCredential(string $accountId, string $credentialId, array $context = []): void + public function startRegistration(string $accountId, array $context = []): PasskeyRegistrationOutcome { - $this->credentials->revoke($credentialId); - $this->record(AuthEventType::PASSKEY_REMOVED, $accountId, ['credential_id' => $credentialId] + $context, AuthEventSeverity::NOTICE); - $this->notifier->send(new AuthNotification(AuthNotificationType::PASSKEY_REMOVED, $accountId, ['credential_id' => $credentialId] + $context)); + $challenge = $this->service->startRegistration($accountId); + + return new PasskeyRegistrationOutcome(PasskeyRegistrationStatus::STARTED, $challenge, code: 'passkey_registration_started', context: $context); } + /** + * @param array $metadata + */ private function record(AuthEventType $type, string $accountId, array $metadata = [], AuthEventSeverity $severity = AuthEventSeverity::INFO): void { - $this->audit->record(new AuthEvent( - id: $this->ids->auditEventId(), - type: $type, - severity: $severity, - accountId: $accountId, - actorId: $accountId, - sessionId: $metadata['session_id'] ?? null, - deviceId: $metadata['device_id'] ?? null, - correlationId: $this->ids->correlationId(), - occurredAt: $this->clock->now(), + AuthEventRecorder::record( + $this->audit, + $this->ids, + $this->clock, + $type, + $accountId, metadata: $metadata, - )); + severity: $severity, + sessionId: ContextValue::stringOrNull($metadata, 'session_id'), + deviceId: ContextValue::stringOrNull($metadata, 'device_id'), + ); } } diff --git a/src/Passkey/PasskeyRegistrationOutcome.php b/src/Passkey/PasskeyRegistrationOutcome.php index ad0b5bc..95ae146 100644 --- a/src/Passkey/PasskeyRegistrationOutcome.php +++ b/src/Passkey/PasskeyRegistrationOutcome.php @@ -15,6 +15,16 @@ public function __construct( public ?PasskeyCredential $credential = null, public ?string $code = null, public array $context = [], - ) { + ) {} + + public function failed(): bool + { + return !$this->successful(); + } + + public function successful(): bool + { + return $this->status === PasskeyRegistrationStatus::STARTED + || $this->status === PasskeyRegistrationStatus::REGISTERED; } } diff --git a/src/Passkey/PasskeyRegistrationResult.php b/src/Passkey/PasskeyRegistrationResult.php index 1c75a09..4f1edd8 100644 --- a/src/Passkey/PasskeyRegistrationResult.php +++ b/src/Passkey/PasskeyRegistrationResult.php @@ -18,6 +18,5 @@ public function __construct( public array $transports = [], public int $signCount = 0, public array $metadata = [], - ) { - } + ) {} } diff --git a/src/Passkey/PasskeyRegistrationStatus.php b/src/Passkey/PasskeyRegistrationStatus.php index 1e79170..e5adbac 100644 --- a/src/Passkey/PasskeyRegistrationStatus.php +++ b/src/Passkey/PasskeyRegistrationStatus.php @@ -6,7 +6,9 @@ enum PasskeyRegistrationStatus: string { - case STARTED = 'started'; - case REGISTERED = 'registered'; case INVALID = 'invalid'; + + case REGISTERED = 'registered'; + + case STARTED = 'started'; } diff --git a/src/Passkey/PasskeyServiceInterface.php b/src/Passkey/PasskeyServiceInterface.php index f2ae99b..b4c70a6 100644 --- a/src/Passkey/PasskeyServiceInterface.php +++ b/src/Passkey/PasskeyServiceInterface.php @@ -6,11 +6,11 @@ interface PasskeyServiceInterface { - public function startRegistration(string $accountId): PasskeyChallenge; + public function finishAuthentication(PasskeyAuthenticationResult $result): PasskeyVerificationResult; public function finishRegistration(PasskeyRegistrationResult $result): PasskeyCredential; public function startAuthentication(?string $accountId = null): PasskeyChallenge; - public function finishAuthentication(PasskeyAuthenticationResult $result): PasskeyVerificationResult; + public function startRegistration(string $accountId): PasskeyChallenge; } diff --git a/src/Passkey/PasskeyVerificationResult.php b/src/Passkey/PasskeyVerificationResult.php index 2e82351..99ed041 100644 --- a/src/Passkey/PasskeyVerificationResult.php +++ b/src/Passkey/PasskeyVerificationResult.php @@ -16,6 +16,5 @@ public function __construct( public ?int $signCount = null, public ?string $reason = null, public array $context = [], - ) { - } + ) {} } diff --git a/src/Principal/CurrentPrincipalContext.php b/src/Principal/CurrentPrincipalContext.php index 1411fcc..9749b6e 100644 --- a/src/Principal/CurrentPrincipalContext.php +++ b/src/Principal/CurrentPrincipalContext.php @@ -10,9 +10,9 @@ final class CurrentPrincipalContext implements CurrentPrincipalProviderInterface { private ?PrincipalInterface $principal = null; - public function set(?PrincipalInterface $principal): void + public function clear(): void { - $this->principal = $principal; + $this->principal = null; } public function get(): ?PrincipalInterface @@ -29,8 +29,8 @@ public function require(): PrincipalInterface return $this->principal; } - public function clear(): void + public function set(?PrincipalInterface $principal): void { - $this->principal = null; + $this->principal = $principal; } } diff --git a/src/Principal/Principal.php b/src/Principal/Principal.php index e2cfdf5..92ef06c 100644 --- a/src/Principal/Principal.php +++ b/src/Principal/Principal.php @@ -14,26 +14,25 @@ public function __construct( private PrincipalType $type = PrincipalType::ACCOUNT, private ?string $accountId = null, private array $metadata = [], - ) { - } + ) {} - public function id(): string + public function accountId(): ?string { - return $this->id; + return $this->accountId; } - public function type(): PrincipalType + public function id(): string { - return $this->type; + return $this->id; } - public function accountId(): ?string + public function metadata(): array { - return $this->accountId; + return $this->metadata; } - public function metadata(): array + public function type(): PrincipalType { - return $this->metadata; + return $this->type; } } diff --git a/src/Principal/PrincipalInterface.php b/src/Principal/PrincipalInterface.php index 28d9cb3..378c661 100644 --- a/src/Principal/PrincipalInterface.php +++ b/src/Principal/PrincipalInterface.php @@ -6,14 +6,14 @@ interface PrincipalInterface { - public function id(): string; - - public function type(): PrincipalType; - public function accountId(): ?string; + public function id(): string; + /** * @return array */ public function metadata(): array; + + public function type(): PrincipalType; } diff --git a/src/Principal/PrincipalType.php b/src/Principal/PrincipalType.php index 6c98133..a7b2e84 100644 --- a/src/Principal/PrincipalType.php +++ b/src/Principal/PrincipalType.php @@ -7,7 +7,10 @@ enum PrincipalType: string { case ACCOUNT = 'account'; + case GUEST = 'guest'; - case SERVICE = 'service'; + case IMPERSONATED = 'impersonated'; + + case SERVICE = 'service'; } diff --git a/src/Support/AbstractConsumableRequest.php b/src/Support/AbstractConsumableRequest.php new file mode 100644 index 0000000..97a10f2 --- /dev/null +++ b/src/Support/AbstractConsumableRequest.php @@ -0,0 +1,30 @@ + $context + */ + public function __construct( + public string $id, + public string $accountId, + public int $requestedAt, + public int $expiresAt, + public ?int $consumedAt = null, + public array $context = [], + ) {} + + public function isConsumed(): bool + { + return $this->consumedAt !== null; + } + + public function isExpiredAt(?int $timestamp = null): bool + { + return $this->expiresAt <= ($timestamp ?? time()); + } +} diff --git a/src/Support/AbstractFamilyTokenRecord.php b/src/Support/AbstractFamilyTokenRecord.php new file mode 100644 index 0000000..844b6cb --- /dev/null +++ b/src/Support/AbstractFamilyTokenRecord.php @@ -0,0 +1,44 @@ + $metadata + */ + public function __construct( + public string $id, + public string $accountId, + public string $familyId, + public int $issuedAt, + public int $expiresAt, + public ?int $rotatedAt = null, + public ?int $revokedAt = null, + public array $metadata = [], + ) {} + + abstract protected function recreate(?int $rotatedAt, ?int $revokedAt): static; + + public function isExpiredAt(?int $timestamp = null): bool + { + return $this->expiresAt <= ($timestamp ?? time()); + } + + public function isRevoked(): bool + { + return $this->revokedAt !== null; + } + + public function withRevokedAt(int $revokedAt): static + { + return $this->recreate($this->rotatedAt, $revokedAt); + } + + public function withRotatedAt(int $rotatedAt): static + { + return $this->recreate($rotatedAt, $this->revokedAt); + } +} diff --git a/src/Support/AbstractInMemoryConsumableStore.php b/src/Support/AbstractInMemoryConsumableStore.php new file mode 100644 index 0000000..e35a9a6 --- /dev/null +++ b/src/Support/AbstractInMemoryConsumableStore.php @@ -0,0 +1,42 @@ + + */ + protected array $requests = []; + + public function __construct( + protected readonly ClockInterface $clock = new SystemClock(), + ) {} + + abstract protected function consumeRequest(object $request, int $consumedAt): object; + + protected function consumeStored(string $requestId): void + { + $request = $this->requests[$requestId] ?? null; + + if ($request === null) { + return; + } + + $this->requests[$requestId] = $this->consumeRequest($request, $this->clock->now()); + } + + protected function findStored(string $requestId): ?object + { + return $this->requests[$requestId] ?? null; + } + + protected function saveStored(object $request, string $requestId): void + { + $this->requests[$requestId] = $request; + } +} diff --git a/src/Support/AbstractInMemoryFamilyTokenStore.php b/src/Support/AbstractInMemoryFamilyTokenStore.php new file mode 100644 index 0000000..0ce5f9c --- /dev/null +++ b/src/Support/AbstractInMemoryFamilyTokenStore.php @@ -0,0 +1,69 @@ + + */ + protected array $records = []; + + /** + * @var array + */ + protected array $revokedFamilies = []; + + public function __construct( + protected readonly ClockInterface $clock = new SystemClock(), + ) {} + + abstract protected function familyId(object $record): string; + + abstract protected function revokeRecord(object $record, int $revokedAt): object; + + abstract protected function rotateRecord(object $record, int $rotatedAt): object; + + protected function findStored(string $recordId): ?object + { + return $this->records[$recordId] ?? null; + } + + protected function revokeStoredFamily(string $familyId): void + { + $this->revokedFamilies[$familyId] = true; + + foreach ($this->records as $recordId => $record) { + if ($this->familyId($record) !== $familyId) { + continue; + } + + $this->records[$recordId] = $this->revokeRecord($record, $this->clock->now()); + } + } + + protected function rotateStored(string $recordId, object $replacement, string $replacementId): void + { + $current = $this->records[$recordId] ?? null; + + if ($current !== null) { + $this->records[$recordId] = $this->rotateRecord($current, $this->clock->now()); + } + + $this->records[$replacementId] = $replacement; + } + + protected function saveStored(object $record, string $recordId): void + { + $this->records[$recordId] = $record; + } + + protected function wasStoredFamilyRevoked(string $familyId): bool + { + return isset($this->revokedFamilies[$familyId]); + } +} diff --git a/src/Support/AcceptAllPasswordPolicy.php b/src/Support/AcceptAllPasswordPolicy.php new file mode 100644 index 0000000..13e2d85 --- /dev/null +++ b/src/Support/AcceptAllPasswordPolicy.php @@ -0,0 +1,18 @@ +items[$key] = [ - 'value' => $value, - 'expires_at' => $this->clock->now() + $ttlSeconds, - ]; + unset($this->items[$key]); } public function get(string $key, mixed $default = null): mixed @@ -46,9 +42,12 @@ public function pull(string $key, mixed $default = null): mixed return $value; } - public function delete(string $key): void + public function put(string $key, mixed $value, int $ttlSeconds): void { - unset($this->items[$key]); + $this->items[$key] = [ + 'value' => $value, + 'expires_at' => $this->clock->now() + $ttlSeconds, + ]; } private function expired(string $key, int $expiresAt): bool diff --git a/src/Support/AuthEventRecorder.php b/src/Support/AuthEventRecorder.php new file mode 100644 index 0000000..4a20758 --- /dev/null +++ b/src/Support/AuthEventRecorder.php @@ -0,0 +1,44 @@ + $metadata + */ + public static function record( + AuditEventStoreInterface $audit, + AuthIdGeneratorInterface $ids, + ClockInterface $clock, + AuthEventType $type, + ?string $accountId, + ?string $actorId = null, + array $metadata = [], + AuthEventSeverity $severity = AuthEventSeverity::INFO, + ?string $deviceId = null, + ?string $sessionId = null, + ): void { + $audit->record(new AuthEvent( + id: $ids->auditEventId(), + type: $type, + severity: $severity, + accountId: $accountId, + actorId: $actorId ?? $accountId, + sessionId: $sessionId ?? ContextValue::stringOrNull($metadata, 'session_id'), + deviceId: $deviceId ?? ContextValue::stringOrNull($metadata, 'device_id'), + correlationId: $ids->correlationId(), + occurredAt: $clock->now(), + metadata: $metadata, + )); + } +} diff --git a/src/Support/CollectingAuthNotifier.php b/src/Support/CollectingAuthNotifier.php index f40bed2..18d33d9 100644 --- a/src/Support/CollectingAuthNotifier.php +++ b/src/Support/CollectingAuthNotifier.php @@ -14,9 +14,9 @@ final class CollectingAuthNotifier implements AuthNotifierInterface */ private array $notifications = []; - public function send(AuthNotification $notification): void + public function flush(): void { - $this->notifications[] = $notification; + $this->notifications = []; } /** @@ -27,8 +27,8 @@ public function notifications(): array return $this->notifications; } - public function flush(): void + public function send(AuthNotification $notification): void { - $this->notifications = []; + $this->notifications[] = $notification; } } diff --git a/src/Support/ContextValue.php b/src/Support/ContextValue.php new file mode 100644 index 0000000..5bc281f --- /dev/null +++ b/src/Support/ContextValue.php @@ -0,0 +1,28 @@ + $context + */ + public static function int(array $context, string $key, int $default): int + { + $value = $context[$key] ?? null; + + return is_int($value) ? $value : $default; + } + + /** + * @param array $context + */ + public static function stringOrNull(array $context, string $key): ?string + { + $value = $context[$key] ?? null; + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/Support/FrozenClock.php b/src/Support/FrozenClock.php index 60bf8f3..f50b956 100644 --- a/src/Support/FrozenClock.php +++ b/src/Support/FrozenClock.php @@ -10,13 +10,7 @@ final class FrozenClock implements ClockInterface { public function __construct( private int $now, - ) { - } - - public function now(): int - { - return $this->now; - } + ) {} public function freezeAt(int $now): self { @@ -25,6 +19,11 @@ public function freezeAt(int $now): self return $this; } + public function now(): int + { + return $this->now; + } + public function tick(int $seconds = 1): self { $this->now += $seconds; diff --git a/src/Support/InMemoryAccountStore.php b/src/Support/InMemoryAccountStore.php index ee6573c..f75402e 100644 --- a/src/Support/InMemoryAccountStore.php +++ b/src/Support/InMemoryAccountStore.php @@ -18,11 +18,6 @@ final class InMemoryAccountStore implements AccountProviderInterface, AccountSto */ private array $accounts = []; - public function save(AccountInterface $account): void - { - $this->accounts[$account->id()] = $account; - } - public function findById(string $id): ?AccountInterface { return $this->accounts[$id] ?? null; @@ -52,41 +47,46 @@ public function markVerified(string $accountId, int $verifiedAt): void $this->accounts[$accountId] = $updated; } - public function updatePasswordHash(string $accountId, string $passwordHash): void + public function save(AccountInterface $account): void + { + $this->accounts[$account->id()] = $account; + } + + public function updateMetadata(string $accountId, array $metadata): void { $account = $this->requireConcreteAccount($accountId); - $this->accounts[$accountId] = $account->withPasswordHash($passwordHash); + $this->accounts[$accountId] = $account->withMetadata($metadata); } - public function updateStatus(string $accountId, AccountStatus $status): void + public function updatePasswordHash(string $accountId, string $passwordHash): void { $account = $this->requireConcreteAccount($accountId); - $this->accounts[$accountId] = $account->withStatus($status); + $this->accounts[$accountId] = $account->withPasswordHash($passwordHash); } - public function updateMetadata(string $accountId, array $metadata): void + public function updateStatus(string $accountId, AccountStatus $status): void { $account = $this->requireConcreteAccount($accountId); - $this->accounts[$accountId] = $account->withMetadata($metadata); + $this->accounts[$accountId] = $account->withStatus($status); } - private function requireConcreteAccount(string $accountId): Account + private function requireAccount(string $accountId): AccountInterface { - $account = $this->requireAccount($accountId); + $account = $this->accounts[$accountId] ?? null; - if (! $account instanceof Account) { - throw new StorageException(sprintf('Account "%s" must be an %s instance for in-memory mutation.', $accountId, Account::class)); + if ($account === null) { + throw new StorageException(sprintf('Account "%s" was not found.', $accountId)); } return $account; } - private function requireAccount(string $accountId): AccountInterface + private function requireConcreteAccount(string $accountId): Account { - $account = $this->accounts[$accountId] ?? null; + $account = $this->requireAccount($accountId); - if ($account === null) { - throw new StorageException(sprintf('Account "%s" was not found.', $accountId)); + if (!$account instanceof Account) { + throw new StorageException(sprintf('Account "%s" must be an %s instance for in-memory mutation.', $accountId, Account::class)); } return $account; diff --git a/src/Support/InMemoryAuditEventStore.php b/src/Support/InMemoryAuditEventStore.php index 02d5b88..f2bba9d 100644 --- a/src/Support/InMemoryAuditEventStore.php +++ b/src/Support/InMemoryAuditEventStore.php @@ -14,11 +14,6 @@ final class InMemoryAuditEventStore implements AuditEventStoreInterface */ private array $events = []; - public function record(AuthEvent $event): void - { - $this->events[] = $event; - } - /** * @return list */ @@ -31,4 +26,9 @@ public function flush(): void { $this->events = []; } + + public function record(AuthEvent $event): void + { + $this->events[] = $event; + } } diff --git a/src/Support/InMemoryCounterStore.php b/src/Support/InMemoryCounterStore.php index e08a691..ed4d506 100644 --- a/src/Support/InMemoryCounterStore.php +++ b/src/Support/InMemoryCounterStore.php @@ -5,19 +5,36 @@ namespace Infocyph\AuthLayer\Support; use Infocyph\AuthLayer\Contract\Cache\CounterStoreInterface; +use Infocyph\AuthLayer\Contract\Clock\ClockInterface; final class InMemoryCounterStore implements CounterStoreInterface { /** - * @var array + * @var array */ private array $values = []; + public function __construct( + private readonly ClockInterface $clock = new SystemClock(), + ) {} + public function increment(string $key, int $by = 1, ?int $ttlSeconds = null): int { - $this->values[$key] = ($this->values[$key] ?? 0) + $by; + $current = $this->values[$key] ?? null; + + if ($current !== null && $current['expires_at'] !== null && $current['expires_at'] <= $this->clock->now()) { + unset($this->values[$key]); + $current = null; + } + + $value = ($current['value'] ?? 0) + $by; + + $this->values[$key] = [ + 'value' => $value, + 'expires_at' => $ttlSeconds !== null ? $this->clock->now() + $ttlSeconds : ($current['expires_at'] ?? null), + ]; - return $this->values[$key]; + return $value; } public function reset(string $key): void diff --git a/src/Support/InMemoryDeviceStore.php b/src/Support/InMemoryDeviceStore.php index f0190c3..e49984d 100644 --- a/src/Support/InMemoryDeviceStore.php +++ b/src/Support/InMemoryDeviceStore.php @@ -17,13 +17,7 @@ final class InMemoryDeviceStore implements DeviceStoreInterface public function __construct( private readonly ClockInterface $clock = new SystemClock(), - ) { - } - - public function save(DeviceRecord $device): void - { - $this->devices[$device->id] = $device; - } + ) {} public function find(string $deviceId): ?DeviceRecord { @@ -34,7 +28,7 @@ public function findForAccount(string $accountId): array { return array_values(array_filter( $this->devices, - static fn (DeviceRecord $device): bool => $device->accountId === $accountId, + static fn(DeviceRecord $device): bool => $device->accountId === $accountId, )); } @@ -61,25 +55,30 @@ public function markTrusted(string $deviceId, bool $trusted): void ); } - public function touch(string $deviceId, int $lastSeenAt): void + public function revoke(string $deviceId): void { $device = $this->devices[$deviceId] ?? null; - if ($device === null || $device->revokedAt !== null) { + if ($device === null) { return; } - $this->devices[$deviceId] = $device->seenAt($lastSeenAt); + $this->devices[$deviceId] = $device->revokedAt($this->clock->now()); } - public function revoke(string $deviceId): void + public function save(DeviceRecord $device): void + { + $this->devices[$device->id] = $device; + } + + public function touch(string $deviceId, int $lastSeenAt): void { $device = $this->devices[$deviceId] ?? null; - if ($device === null) { + if ($device === null || $device->isRevoked()) { return; } - $this->devices[$deviceId] = $device->revokedAt($this->clock->now()); + $this->devices[$deviceId] = $device->seenAt($lastSeenAt); } } diff --git a/src/Support/InMemoryEmailVerificationStore.php b/src/Support/InMemoryEmailVerificationStore.php index ee847a4..3f676fd 100644 --- a/src/Support/InMemoryEmailVerificationStore.php +++ b/src/Support/InMemoryEmailVerificationStore.php @@ -5,47 +5,34 @@ namespace Infocyph\AuthLayer\Support; use Infocyph\AuthLayer\Authentication\EmailVerification\EmailVerificationRequest; -use Infocyph\AuthLayer\Contract\Clock\ClockInterface; use Infocyph\AuthLayer\Contract\Storage\EmailVerificationStoreInterface; -final class InMemoryEmailVerificationStore implements EmailVerificationStoreInterface +final class InMemoryEmailVerificationStore extends AbstractInMemoryConsumableStore implements EmailVerificationStoreInterface { - /** - * @var array - */ - private array $requests = []; - - public function __construct( - private readonly ClockInterface $clock = new SystemClock(), - ) { - } - - public function save(EmailVerificationRequest $request): void + public function consume(string $requestId): void { - $this->requests[$request->id] = $request; + $storedRequestId = $requestId; + $this->consumeStored($storedRequestId); } public function find(string $requestId): ?EmailVerificationRequest { - return $this->requests[$requestId] ?? null; + $request = $this->findStored($requestId); + + if (!$request instanceof EmailVerificationRequest) { + return null; + } + + return $request; } - public function consume(string $requestId): void + public function save(EmailVerificationRequest $request): void { - $request = $this->requests[$requestId] ?? null; - - if ($request === null) { - return; - } + $this->saveStored($request, requestId: $request->id); + } - $this->requests[$requestId] = new EmailVerificationRequest( - id: $request->id, - accountId: $request->accountId, - email: $request->email, - requestedAt: $request->requestedAt, - expiresAt: $request->expiresAt, - consumedAt: $this->clock->now(), - context: $request->context, - ); + protected function consumeRequest(object $request, int $consumedAt): object + { + return $request instanceof EmailVerificationRequest ? $request->withConsumedAt($consumedAt) : $request; } } diff --git a/src/Support/InMemoryGrantStore.php b/src/Support/InMemoryGrantStore.php index 8621d18..fb5d623 100644 --- a/src/Support/InMemoryGrantStore.php +++ b/src/Support/InMemoryGrantStore.php @@ -6,6 +6,7 @@ use Infocyph\AuthLayer\Authorization\Grant\AccessGrant; use Infocyph\AuthLayer\Authorization\Grant\GrantStoreInterface; +use Infocyph\AuthLayer\Contract\Clock\ClockInterface; final class InMemoryGrantStore implements GrantStoreInterface { @@ -14,21 +15,40 @@ final class InMemoryGrantStore implements GrantStoreInterface */ private array $grants = []; + public function __construct( + private readonly ClockInterface $clock = new SystemClock(), + ) {} + public function grantsForPrincipal(string $principalId): array { return array_values(array_filter( $this->grants, - static fn (AccessGrant $grant): bool => $grant->principalId === $principalId, + static fn(AccessGrant $grant): bool => $grant->principalId === $principalId, )); } - public function save(AccessGrant $grant): void + public function revoke(string $grantId): void { - $this->grants[$grant->id] = $grant; + $grant = $this->grants[$grantId] ?? null; + + if ($grant === null || $grant->isRevoked()) { + return; + } + + $this->grants[$grantId] = new AccessGrant( + id: $grant->id, + principalId: $grant->principalId, + permission: $grant->permission, + resourceType: $grant->resourceType, + resourceId: $grant->resourceId, + expiresAt: $grant->expiresAt, + revokedAt: $this->clock->now(), + metadata: $grant->metadata, + ); } - public function revoke(string $grantId): void + public function save(AccessGrant $grant): void { - unset($this->grants[$grantId]); + $this->grants[$grant->id] = $grant; } } diff --git a/src/Support/InMemoryLockoutStore.php b/src/Support/InMemoryLockoutStore.php index 588a7d4..8c3a678 100644 --- a/src/Support/InMemoryLockoutStore.php +++ b/src/Support/InMemoryLockoutStore.php @@ -17,18 +17,7 @@ final class InMemoryLockoutStore implements LockoutStoreInterface public function __construct( private readonly ClockInterface $clock = new SystemClock(), - ) { - } - - public function lock(string $accountId, LockoutReason $reason, ?int $until = null): void - { - $this->locks[$accountId] = ['reason' => $reason, 'until' => $until]; - } - - public function unlock(string $accountId): void - { - unset($this->locks[$accountId]); - } + ) {} public function isLocked(string $accountId): bool { @@ -46,4 +35,14 @@ public function isLocked(string $accountId): bool return true; } + + public function lock(string $accountId, LockoutReason $reason, ?int $until = null): void + { + $this->locks[$accountId] = ['reason' => $reason, 'until' => $until]; + } + + public function unlock(string $accountId): void + { + unset($this->locks[$accountId]); + } } diff --git a/src/Support/InMemoryMfaFactorStore.php b/src/Support/InMemoryMfaFactorStore.php index 9142b33..3965463 100644 --- a/src/Support/InMemoryMfaFactorStore.php +++ b/src/Support/InMemoryMfaFactorStore.php @@ -14,16 +14,11 @@ final class InMemoryMfaFactorStore implements MfaFactorStoreInterface */ private array $factors = []; - public function save(MfaFactor $factor): void - { - $this->factors[$factor->id] = $factor; - } - public function findForAccount(string $accountId): array { return array_values(array_filter( $this->factors, - static fn (MfaFactor $factor): bool => $factor->accountId === $accountId, + static fn(MfaFactor $factor): bool => $factor->accountId === $accountId, )); } @@ -31,4 +26,9 @@ public function remove(string $factorId): void { unset($this->factors[$factorId]); } + + public function save(MfaFactor $factor): void + { + $this->factors[$factor->id] = $factor; + } } diff --git a/src/Support/InMemoryPasskeyCredentialStore.php b/src/Support/InMemoryPasskeyCredentialStore.php index 31cf929..62b2def 100644 --- a/src/Support/InMemoryPasskeyCredentialStore.php +++ b/src/Support/InMemoryPasskeyCredentialStore.php @@ -4,6 +4,7 @@ namespace Infocyph\AuthLayer\Support; +use Infocyph\AuthLayer\Contract\Clock\ClockInterface; use Infocyph\AuthLayer\Passkey\PasskeyCredential; use Infocyph\AuthLayer\Passkey\PasskeyCredentialStoreInterface; @@ -14,15 +15,14 @@ final class InMemoryPasskeyCredentialStore implements PasskeyCredentialStoreInte */ private array $credentials = []; - public function save(PasskeyCredential $credential): void - { - $this->credentials[$credential->id] = $credential; - } + public function __construct( + private readonly ClockInterface $clock = new SystemClock(), + ) {} public function findByCredentialId(string $credentialId): ?PasskeyCredential { foreach ($this->credentials as $credential) { - if ($credential->credentialId === $credentialId) { + if ($credential->credentialId === $credentialId && !$credential->isRevoked()) { return $credential; } } @@ -34,14 +34,28 @@ public function findForAccount(string $accountId): array { return array_values(array_filter( $this->credentials, - static fn (PasskeyCredential $credential): bool => $credential->accountId === $accountId, + static fn(PasskeyCredential $credential): bool => $credential->accountId === $accountId && !$credential->isRevoked(), )); } - public function updateSignCount(string $credentialId, int $signCount): void + public function revoke(string $credentialId): void { foreach ($this->credentials as $id => $credential) { - if ($credential->credentialId !== $credentialId) { + if (($credential->credentialId === $credentialId || $credential->id === $credentialId) && !$credential->isRevoked()) { + $this->credentials[$id] = $credential->revokedAt($this->clock->now()); + } + } + } + + public function save(PasskeyCredential $credential): void + { + $this->credentials[$credential->id] = $credential; + } + + public function updateUsage(string $credentialId, int $signCount, int $usedAt): void + { + foreach ($this->credentials as $id => $credential) { + if ($credential->credentialId !== $credentialId || $credential->isRevoked()) { continue; } @@ -53,18 +67,10 @@ public function updateSignCount(string $credentialId, int $signCount): void signCount: $signCount, transports: $credential->transports, createdAt: $credential->createdAt, - lastUsedAt: $credential->lastUsedAt, + lastUsedAt: $usedAt, + revokedAt: $credential->revokedAt, metadata: $credential->metadata, ); } } - - public function revoke(string $credentialId): void - { - foreach ($this->credentials as $id => $credential) { - if ($credential->credentialId === $credentialId || $credential->id === $credentialId) { - unset($this->credentials[$id]); - } - } - } } diff --git a/src/Support/InMemoryPasswordResetStore.php b/src/Support/InMemoryPasswordResetStore.php index 9ced731..01835b3 100644 --- a/src/Support/InMemoryPasswordResetStore.php +++ b/src/Support/InMemoryPasswordResetStore.php @@ -5,51 +5,34 @@ namespace Infocyph\AuthLayer\Support; use Infocyph\AuthLayer\Authentication\PasswordReset\PasswordResetRequest; -use Infocyph\AuthLayer\Contract\Clock\ClockInterface; use Infocyph\AuthLayer\Contract\Storage\PasswordResetStoreInterface; -final class InMemoryPasswordResetStore implements PasswordResetStoreInterface +final class InMemoryPasswordResetStore extends AbstractInMemoryConsumableStore implements PasswordResetStoreInterface { - /** - * @var array - */ - private array $requests = []; - - public function __construct( - private readonly ClockInterface $clock = new SystemClock(), - ) { - } - - public function save(PasswordResetRequest $request): void + public function consume(string $requestId): void { - $this->requests[$request->id] = $request; + $this->consumeStored($requestId); } public function find(string $requestId): ?PasswordResetRequest { - return $this->requests[$requestId] ?? null; + $request = $this->findStored($requestId); + + return $request instanceof PasswordResetRequest ? $request : null; } - public function consume(string $requestId): void + public function save(PasswordResetRequest $request): void { - $request = $this->requests[$requestId] ?? null; - - if ($request === null) { - return; - } - - $this->requests[$requestId] = new PasswordResetRequest( - id: $request->id, - accountId: $request->accountId, - requestedAt: $request->requestedAt, - expiresAt: $request->expiresAt, - consumedAt: $this->clock->now(), - context: $request->context, - ); + $this->saveStored($request, $request->id); } public function wasConsumed(string $requestId): bool { - return ($this->requests[$requestId] ?? null)?->isConsumed() ?? false; + return $this->find($requestId)?->isConsumed() ?? false; + } + + protected function consumeRequest(object $request, int $consumedAt): object + { + return $request instanceof PasswordResetRequest ? $request->withConsumedAt($consumedAt) : $request; } } diff --git a/src/Support/InMemoryPermissionStore.php b/src/Support/InMemoryPermissionStore.php index d5eb5e1..45ee4ec 100644 --- a/src/Support/InMemoryPermissionStore.php +++ b/src/Support/InMemoryPermissionStore.php @@ -11,45 +11,30 @@ final class InMemoryPermissionStore implements PermissionStoreInterface, PermissionAssignmentStoreInterface { /** - * @var array + * @var array> */ - private array $permissions = []; + private array $accountPermissions = []; /** - * @var array> + * @var array */ - private array $accountPermissions = []; + private array $permissions = []; /** * @var array> */ private array $rolePermissions = []; - public function save(Permission $permission): void - { - $this->permissions[$permission->id] = $permission; - } - public function assignPermissionToAccount(string $accountId, string $permissionId): void { $this->assign($this->accountPermissions, $accountId, $permissionId); } - public function revokePermissionFromAccount(string $accountId, string $permissionId): void - { - $this->revoke($this->accountPermissions, $accountId, $permissionId); - } - public function assignPermissionToRole(string $roleId, string $permissionId): void { $this->assign($this->rolePermissions, $roleId, $permissionId); } - public function revokePermissionFromRole(string $roleId, string $permissionId): void - { - $this->revoke($this->rolePermissions, $roleId, $permissionId); - } - public function permissionsForAccount(string $accountId): array { return $this->resolve($this->accountPermissions[$accountId] ?? []); @@ -68,6 +53,21 @@ public function permissionsForRoles(array $roleIds): array return $this->resolve(array_keys($ids)); } + public function revokePermissionFromAccount(string $accountId, string $permissionId): void + { + $this->revoke($this->accountPermissions, $accountId, $permissionId); + } + + public function revokePermissionFromRole(string $roleId, string $permissionId): void + { + $this->revoke($this->rolePermissions, $roleId, $permissionId); + } + + public function save(Permission $permission): void + { + $this->permissions[$permission->id] = $permission; + } + /** * @param array> $bucket */ @@ -75,22 +75,11 @@ private function assign(array &$bucket, string $key, string $permissionId): void { $bucket[$key] ??= []; - if (! in_array($permissionId, $bucket[$key], true)) { + if (!in_array($permissionId, $bucket[$key], true)) { $bucket[$key][] = $permissionId; } } - /** - * @param array> $bucket - */ - private function revoke(array &$bucket, string $key, string $permissionId): void - { - $bucket[$key] = array_values(array_filter( - $bucket[$key] ?? [], - static fn (string $assignedPermissionId): bool => $assignedPermissionId !== $permissionId, - )); - } - /** * @param list $ids * @return list @@ -107,4 +96,15 @@ private function resolve(array $ids): array return array_values($permissions); } + + /** + * @param array> $bucket + */ + private function revoke(array &$bucket, string $key, string $permissionId): void + { + $bucket[$key] = array_values(array_filter( + $bucket[$key] ?? [], + static fn(string $assignedPermissionId): bool => $assignedPermissionId !== $permissionId, + )); + } } diff --git a/src/Support/InMemoryRefreshTokenStore.php b/src/Support/InMemoryRefreshTokenStore.php index 8fbd81d..46d7cd4 100644 --- a/src/Support/InMemoryRefreshTokenStore.php +++ b/src/Support/InMemoryRefreshTokenStore.php @@ -5,86 +5,68 @@ namespace Infocyph\AuthLayer\Support; use Infocyph\AuthLayer\Authentication\TokenAuth\RefreshTokenRecord; -use Infocyph\AuthLayer\Contract\Clock\ClockInterface; use Infocyph\AuthLayer\Contract\Storage\RefreshTokenStoreInterface; -final class InMemoryRefreshTokenStore implements RefreshTokenStoreInterface +final class InMemoryRefreshTokenStore extends AbstractInMemoryFamilyTokenStore implements RefreshTokenStoreInterface { - /** - * @var array - */ - private array $records = []; - - /** - * @var array - */ - private array $revokedFamilies = []; - - public function __construct( - private readonly ClockInterface $clock = new SystemClock(), - ) { + public function find(string $tokenId): ?RefreshTokenRecord + { + $record = $this->findStored($tokenId); + + if (!$record instanceof RefreshTokenRecord) { + return null; + } + + return $record; + } + + public function revokeFamily(string $familyId): void + { + $revokedFamilyId = $familyId; + $this->revokeStoredFamily($revokedFamilyId); + } + + public function rotate(string $tokenId, RefreshTokenRecord $replacement): void + { + $replacementId = $replacement->id; + $this->rotateStored($tokenId, $replacement, $replacementId); } public function save(RefreshTokenRecord $record): void { - $this->records[$record->id] = $record; + $tokenId = $record->id; + $this->saveStored($record, $tokenId); } - public function find(string $tokenId): ?RefreshTokenRecord + public function wasFamilyRevoked(string $familyId): bool { - return $this->records[$tokenId] ?? null; + return $this->wasStoredFamilyRevoked($familyId); } - public function rotate(string $tokenId, RefreshTokenRecord $replacement): void + protected function familyId(object $record): string { - $current = $this->records[$tokenId] ?? null; - - if ($current !== null) { - $this->records[$tokenId] = new RefreshTokenRecord( - id: $current->id, - accountId: $current->accountId, - tokenHash: $current->tokenHash, - familyId: $current->familyId, - clientId: $current->clientId, - deviceId: $current->deviceId, - issuedAt: $current->issuedAt, - expiresAt: $current->expiresAt, - rotatedAt: $this->clock->now(), - revokedAt: $current->revokedAt, - metadata: $current->metadata, - ); + if (!$record instanceof RefreshTokenRecord) { + return ''; } - $this->records[$replacement->id] = $replacement; + return $record->familyId; } - public function revokeFamily(string $familyId): void + protected function revokeRecord(object $record, int $revokedAt): object { - $this->revokedFamilies[$familyId] = true; - - foreach ($this->records as $tokenId => $record) { - if ($record->familyId !== $familyId) { - continue; - } - - $this->records[$tokenId] = new RefreshTokenRecord( - id: $record->id, - accountId: $record->accountId, - tokenHash: $record->tokenHash, - familyId: $record->familyId, - clientId: $record->clientId, - deviceId: $record->deviceId, - issuedAt: $record->issuedAt, - expiresAt: $record->expiresAt, - rotatedAt: $record->rotatedAt, - revokedAt: $this->clock->now(), - metadata: $record->metadata, - ); + if (!$record instanceof RefreshTokenRecord) { + return $record; } + + return $record->withRevokedAt($revokedAt); } - public function wasFamilyRevoked(string $familyId): bool + protected function rotateRecord(object $record, int $rotatedAt): object { - return isset($this->revokedFamilies[$familyId]); + if (!$record instanceof RefreshTokenRecord) { + return $record; + } + + return $record->withRotatedAt($rotatedAt); } } diff --git a/src/Support/InMemoryRememberTokenStore.php b/src/Support/InMemoryRememberTokenStore.php index 92cd565..798134a 100644 --- a/src/Support/InMemoryRememberTokenStore.php +++ b/src/Support/InMemoryRememberTokenStore.php @@ -5,40 +5,21 @@ namespace Infocyph\AuthLayer\Support; use Infocyph\AuthLayer\Authentication\RememberMe\RememberTokenRecord; -use Infocyph\AuthLayer\Contract\Clock\ClockInterface; use Infocyph\AuthLayer\Contract\Storage\RememberTokenStoreInterface; -final class InMemoryRememberTokenStore implements RememberTokenStoreInterface +final class InMemoryRememberTokenStore extends AbstractInMemoryFamilyTokenStore implements RememberTokenStoreInterface { - /** - * @var array - */ - private array $records = []; - - /** - * @var array - */ - private array $revokedFamilies = []; - - public function __construct( - private readonly ClockInterface $clock = new SystemClock(), - ) { - } - - public function save(RememberTokenRecord $record): void - { - $this->records[$record->id] = $record; - } - public function find(string $recordId): ?RememberTokenRecord { - return $this->records[$recordId] ?? null; + $record = $this->findStored($recordId); + + return $record instanceof RememberTokenRecord ? $record : null; } public function findBySelector(string $selector): ?RememberTokenRecord { foreach ($this->records as $record) { - if ($record->selector === $selector) { + if ($record instanceof RememberTokenRecord && $record->selector === $selector) { return $record; } } @@ -46,58 +27,49 @@ public function findBySelector(string $selector): ?RememberTokenRecord return null; } - public function rotate(string $recordId, RememberTokenRecord $replacement): void + public function markUsed(string $recordId, int $usedAt): void { - $current = $this->records[$recordId] ?? null; - - if ($current !== null) { - $this->records[$recordId] = new RememberTokenRecord( - id: $current->id, - accountId: $current->accountId, - deviceId: $current->deviceId, - selector: $current->selector, - verifierHash: $current->verifierHash, - familyId: $current->familyId, - issuedAt: $current->issuedAt, - expiresAt: $current->expiresAt, - lastUsedAt: $current->lastUsedAt, - rotatedAt: $this->clock->now(), - revokedAt: $current->revokedAt, - metadata: $current->metadata, - ); + $record = $this->find($recordId); + + if ($record === null || $record->isRevoked()) { + return; } - $this->records[$replacement->id] = $replacement; + $this->saveStored($record->withLastUsedAt($usedAt), $recordId); } public function revokeFamily(string $familyId): void { - $this->revokedFamilies[$familyId] = true; + $this->revokeStoredFamily($familyId); + } - foreach ($this->records as $recordId => $record) { - if ($record->familyId !== $familyId) { - continue; - } + public function rotate(string $recordId, RememberTokenRecord $replacement): void + { + $this->rotateStored($recordId, $replacement, $replacement->id); + } - $this->records[$recordId] = new RememberTokenRecord( - id: $record->id, - accountId: $record->accountId, - deviceId: $record->deviceId, - selector: $record->selector, - verifierHash: $record->verifierHash, - familyId: $record->familyId, - issuedAt: $record->issuedAt, - expiresAt: $record->expiresAt, - lastUsedAt: $record->lastUsedAt, - rotatedAt: $record->rotatedAt, - revokedAt: $this->clock->now(), - metadata: $record->metadata, - ); - } + public function save(RememberTokenRecord $record): void + { + $this->saveStored($record, $record->id); } public function wasFamilyRevoked(string $familyId): bool { - return isset($this->revokedFamilies[$familyId]); + return $this->wasStoredFamilyRevoked($familyId); + } + + protected function familyId(object $record): string + { + return $record instanceof RememberTokenRecord ? $record->familyId : ''; + } + + protected function revokeRecord(object $record, int $revokedAt): object + { + return $record instanceof RememberTokenRecord ? $record->withRevokedAt($revokedAt) : $record; + } + + protected function rotateRecord(object $record, int $rotatedAt): object + { + return $record instanceof RememberTokenRecord ? $record->withRotatedAt($rotatedAt) : $record; } } diff --git a/src/Support/InMemoryRoleStore.php b/src/Support/InMemoryRoleStore.php index 6d28837..f568204 100644 --- a/src/Support/InMemoryRoleStore.php +++ b/src/Support/InMemoryRoleStore.php @@ -10,26 +10,21 @@ final class InMemoryRoleStore implements RoleStoreInterface, RoleAssignmentStoreInterface { - /** - * @var array - */ - private array $roles = []; - /** * @var array> */ private array $accountRoles = []; - public function save(Role $role): void - { - $this->roles[$role->id] = $role; - } + /** + * @var array + */ + private array $roles = []; public function assignRole(string $accountId, string $roleId): void { $this->accountRoles[$accountId] ??= []; - if (! in_array($roleId, $this->accountRoles[$accountId], true)) { + if (!in_array($roleId, $this->accountRoles[$accountId], true)) { $this->accountRoles[$accountId][] = $roleId; } } @@ -38,7 +33,7 @@ public function revokeRole(string $accountId, string $roleId): void { $this->accountRoles[$accountId] = array_values(array_filter( $this->accountRoles[$accountId] ?? [], - static fn (string $assignedRoleId): bool => $assignedRoleId !== $roleId, + static fn(string $assignedRoleId): bool => $assignedRoleId !== $roleId, )); } @@ -54,4 +49,9 @@ public function rolesForAccount(string $accountId): array return $roles; } + + public function save(Role $role): void + { + $this->roles[$role->id] = $role; + } } diff --git a/src/Support/InMemorySessionStore.php b/src/Support/InMemorySessionStore.php index 3d6bc1a..72c3c93 100644 --- a/src/Support/InMemorySessionStore.php +++ b/src/Support/InMemorySessionStore.php @@ -24,23 +24,6 @@ public function find(string $sessionId): ?AuthSession return $this->sessions[$sessionId] ?? null; } - public function rotate(string $sessionId, AuthSession $replacement): void - { - unset($this->sessions[$sessionId]); - $this->sessions[$replacement->id] = $replacement; - } - - public function touch(string $sessionId, int $lastSeenAt): void - { - $session = $this->sessions[$sessionId] ?? null; - - if ($session === null) { - return; - } - - $this->sessions[$sessionId] = $session->seenAt($lastSeenAt); - } - public function revoke(string $sessionId): void { unset($this->sessions[$sessionId]); @@ -60,4 +43,21 @@ public function revokeAllForAccount(string $accountId, ?string $exceptSessionId unset($this->sessions[$sessionId]); } } + + public function rotate(string $sessionId, AuthSession $replacement): void + { + unset($this->sessions[$sessionId]); + $this->sessions[$replacement->id] = $replacement; + } + + public function touch(string $sessionId, int $lastSeenAt): void + { + $session = $this->sessions[$sessionId] ?? null; + + if ($session === null) { + return; + } + + $this->sessions[$sessionId] = $session->seenAt($lastSeenAt); + } } diff --git a/src/Support/NullAuthNotifier.php b/src/Support/NullAuthNotifier.php index 2e1ca34..0083223 100644 --- a/src/Support/NullAuthNotifier.php +++ b/src/Support/NullAuthNotifier.php @@ -9,7 +9,5 @@ final class NullAuthNotifier implements AuthNotifierInterface { - public function send(AuthNotification $notification): void - { - } + public function send(AuthNotification $notification): void {} } diff --git a/src/Support/NullCounterStore.php b/src/Support/NullCounterStore.php index 4f2eb01..b4b75af 100644 --- a/src/Support/NullCounterStore.php +++ b/src/Support/NullCounterStore.php @@ -13,7 +13,5 @@ public function increment(string $key, int $by = 1, ?int $ttlSeconds = null): in return 0; } - public function reset(string $key): void - { - } + public function reset(string $key): void {} } diff --git a/src/Support/NullEventDispatcher.php b/src/Support/NullEventDispatcher.php index 051c71d..4d96a8d 100644 --- a/src/Support/NullEventDispatcher.php +++ b/src/Support/NullEventDispatcher.php @@ -8,7 +8,5 @@ final class NullEventDispatcher implements EventDispatcherInterface { - public function dispatch(object $event): void - { - } + public function dispatch(object $event): void {} } diff --git a/src/Support/NullTtlStore.php b/src/Support/NullTtlStore.php index a4d4aa9..bd21b17 100644 --- a/src/Support/NullTtlStore.php +++ b/src/Support/NullTtlStore.php @@ -8,9 +8,7 @@ final class NullTtlStore implements TtlStoreInterface { - public function put(string $key, mixed $value, int $ttlSeconds): void - { - } + public function delete(string $key): void {} public function get(string $key, mixed $default = null): mixed { @@ -22,7 +20,5 @@ public function pull(string $key, mixed $default = null): mixed return $default; } - public function delete(string $key): void - { - } + public function put(string $key, mixed $value, int $ttlSeconds): void {} } diff --git a/src/Support/RandomAuthIdGenerator.php b/src/Support/RandomAuthIdGenerator.php index 27c1e05..5cf83e9 100644 --- a/src/Support/RandomAuthIdGenerator.php +++ b/src/Support/RandomAuthIdGenerator.php @@ -13,19 +13,19 @@ public function accountId(): string return $this->generate('acct'); } - public function sessionId(): string + public function auditEventId(): string { - return $this->generate('sess'); + return $this->generate('evt'); } - public function deviceId(): string + public function challengeId(): string { - return $this->generate('dev'); + return $this->generate('chl'); } - public function challengeId(): string + public function correlationId(): string { - return $this->generate('chl'); + return $this->generate('corr'); } public function credentialId(): string @@ -33,29 +33,29 @@ public function credentialId(): string return $this->generate('cred'); } - public function roleId(): string + public function deviceId(): string { - return $this->generate('role'); + return $this->generate('dev'); } - public function permissionId(): string + public function grantId(): string { - return $this->generate('perm'); + return $this->generate('grant'); } - public function grantId(): string + public function permissionId(): string { - return $this->generate('grant'); + return $this->generate('perm'); } - public function auditEventId(): string + public function roleId(): string { - return $this->generate('evt'); + return $this->generate('role'); } - public function correlationId(): string + public function sessionId(): string { - return $this->generate('corr'); + return $this->generate('sess'); } private function generate(string $prefix): string diff --git a/testing/Fixtures/TestDoubles.php b/testing/Fixtures/TestDoubles.php new file mode 100644 index 0000000..eefc255 --- /dev/null +++ b/testing/Fixtures/TestDoubles.php @@ -0,0 +1,431 @@ + */ + private array $counters = []; + + public function accountId(): string + { + return $this->next('acct'); + } + + public function auditEventId(): string + { + return $this->next('evt'); + } + + public function challengeId(): string + { + return $this->next('chl'); + } + + public function correlationId(): string + { + return $this->next('corr'); + } + + public function credentialId(): string + { + return $this->next('cred'); + } + + public function deviceId(): string + { + return $this->next('dev'); + } + + public function grantId(): string + { + return $this->next('grant'); + } + + public function permissionId(): string + { + return $this->next('perm'); + } + + public function roleId(): string + { + return $this->next('role'); + } + + public function sessionId(): string + { + return $this->next('sess'); + } + + private function next(string $prefix): string + { + $this->counters[$prefix] = ($this->counters[$prefix] ?? 0) + 1; + + return sprintf('%s-%d', $prefix, $this->counters[$prefix]); + } +} + +final class TestPasswordVerifier implements PasswordVerifierInterface +{ + public bool $needsRehash = false; + + public ?string $rehash = null; + + public ?PasswordVerificationResult $result = null; + + public function verify(string $plainPassword, string $storedHash): PasswordVerificationResult + { + if ($this->result instanceof PasswordVerificationResult) { + return $this->result; + } + + return new PasswordVerificationResult($plainPassword === $storedHash, $this->needsRehash, $this->rehash); + } +} + +final readonly class TestPasswordHasher implements PasswordHasherInterface +{ + public function __construct( + private string $prefix = 'hashed:', + ) {} + + public function hash(string $plainPassword, array $_context = []): string + { + unset($_context); + + return $this->prefix . $plainPassword; + } +} + +final readonly class TestPasswordPolicy implements PasswordPolicyInterface +{ + /** + * @param list $violations + */ + public function __construct( + private bool $valid = true, + private array $violations = [], + private ?string $code = null, + ) {} + + public function validate(string $plainPassword, array $context = []): PasswordPolicyResult + { + unset($plainPassword, $context); + + return new PasswordPolicyResult($this->valid, $this->violations, $this->code); + } +} + +abstract class AbstractIssuedTokenService +{ + /** @var list> */ + public array $issued = []; + + /** @var array */ + public array $verifications = []; + + private int $counter = 0; + + protected function defaultToken(string $prefix): string + { + $this->counter++; + + return sprintf('%s-%d', $prefix, $this->counter); + } + + /** + * @param array $payload + * @param array $claims + */ + protected function issueToken( + string $prefix, + string $subjectId, + array $payload, + array $claims = [], + ): string { + $token = $this->defaultToken($prefix); + $payload['token'] = $token; + $this->issued[] = $payload; + $this->verifications[$token] ??= new TokenVerificationResult(true, subjectId: $subjectId, claims: $claims); + + return $token; + } + + protected function verifyToken(string $token): TokenVerificationResult + { + return $this->verifications[$token] ?? new TokenVerificationResult(false, failureReason: 'invalid_token'); + } +} + +final class TestPasswordResetTokenService extends AbstractIssuedTokenService implements PasswordResetTokenServiceInterface +{ + public function issue(string $accountId, array $context = []): string + { + return $this->issueToken('reset-token', $accountId, ['account_id' => $accountId, 'context' => $context], $context); + } + + public function verify(string $token): TokenVerificationResult + { + return $this->verifyToken($token); + } +} + +final class TestEmailVerificationTokenService extends AbstractIssuedTokenService implements EmailVerificationTokenServiceInterface +{ + public function issue(string $accountId, string $email, array $context = []): string + { + return $this->issueToken('verify-token', $accountId, ['account_id' => $accountId, 'email' => $email, 'context' => $context], $context); + } + + public function verify(string $token): TokenVerificationResult + { + return $this->verifyToken($token); + } +} + +final class TestPasswordlessTokenService extends AbstractIssuedTokenService implements PasswordlessTokenServiceInterface +{ + public function issue(string $identifier, array $context = []): string + { + return $this->issueToken('passwordless-token', $identifier, ['identifier' => $identifier, 'context' => $context], $context); + } + + public function verify(string $token): TokenVerificationResult + { + return $this->verifyToken($token); + } +} + +final class TestAccessTokenService extends AbstractIssuedTokenService implements AccessTokenServiceInterface +{ + /** @var list */ + public array $claims = []; + + public function issue(AccessTokenClaims $claims): string + { + $token = $this->defaultToken('access-token'); + $this->claims[] = $claims; + $this->verifications[$token] ??= new TokenVerificationResult(true, subjectId: $claims->subjectId, claims: ['scopes' => $claims->scopes] + $claims->metadata, expiresAt: $claims->expiresAt); + + return $token; + } + + public function verify(string $token): TokenVerificationResult + { + return $this->verifyToken($token); + } +} + +final class TestRememberTokenService implements RememberTokenServiceInterface +{ + public int $expiresAt = 86400; + + /** @var list */ + public array $issued = []; + + /** @var array */ + public array $verifications = []; + + private int $counter = 0; + + public function issue(string $_accountId, string $_deviceId): RememberToken + { + unset($_accountId, $_deviceId); + $this->counter++; + $token = new RememberToken( + value: sprintf('remember-token-%d', $this->counter), + selector: sprintf('selector-%d', $this->counter), + familyId: sprintf('family-%d', $this->counter), + verifierHash: sprintf('verifier-hash-%d', $this->counter), + expiresAt: $this->expiresAt, + ); + + $this->issued[] = $token; + + return $token; + } + + public function verify(string $token): RememberTokenVerificationResult + { + return $this->verifications[$token] ?? new RememberTokenVerificationResult(false, failureReason: 'invalid_remember_token'); + } +} + +final class TestRefreshTokenService extends AbstractIssuedTokenService implements RefreshTokenServiceInterface +{ + /** @var list */ + public array $claims = []; + + public function issue(RefreshTokenClaims $claims): IssuedRefreshToken + { + $value = $this->defaultToken('refresh-token'); + $this->claims[] = $claims; + $this->verifications[$value] ??= new TokenVerificationResult(true, subjectId: $claims->accountId, tokenId: $claims->tokenId, claims: $claims->metadata, expiresAt: $claims->expiresAt); + + return new IssuedRefreshToken( + value: $value, + tokenHash: 'hash:' . $value, + tokenId: $claims->tokenId, + familyId: $claims->familyId, + expiresAt: $claims->expiresAt, + ); + } + + public function verify(string $token): TokenVerificationResult + { + return $this->verifyToken($token); + } +} + +final class TestMfaVerifier implements MfaVerifierInterface +{ + public ?MfaVerificationResult $result = null; + + public function verify(MfaChallenge $challenge, string $code): MfaVerificationResult + { + if ($this->result instanceof MfaVerificationResult) { + return $this->result; + } + + return new MfaVerificationResult($code === '123456', factorId: $challenge->factorId); + } +} + +final class TestRecoveryCodeService implements RecoveryCodeServiceInterface +{ + /** @var list */ + public array $generated = []; + + /** @var array */ + public array $verificationMap = []; + + public function generate(string $accountId, int $count = 10): array + { + unset($accountId); + $this->generated = array_map( + static fn(int $index): string => sprintf('recovery-%d', $index), + range(1, $count), + ); + + return $this->generated; + } + + public function verify(string $accountId, string $code): RecoveryCodeVerificationResult + { + unset($accountId); + + return $this->verificationMap[$code] ?? new RecoveryCodeVerificationResult(in_array($code, $this->generated, true), $code === 'invalid' ? 'invalid_recovery_code' : null); + } +} + +final class TestPasskeyService implements PasskeyServiceInterface +{ + public ?PasskeyChallenge $authenticationChallenge = null; + + public ?PasskeyCredential $credential = null; + + public ?PasskeyChallenge $registrationChallenge = null; + + public ?PasskeyVerificationResult $verification = null; + + public function finishAuthentication(PasskeyAuthenticationResult $result): PasskeyVerificationResult + { + return $this->verification ?? new PasskeyVerificationResult(true, accountId: 'acct-1', credentialId: $result->credentialId, signCount: 1); + } + + public function finishRegistration(PasskeyRegistrationResult $result): PasskeyCredential + { + return $this->credential ?? new PasskeyCredential( + id: 'cred-record-1', + accountId: $result->accountId, + credentialId: $result->credentialId, + publicKey: $result->publicKey, + signCount: $result->signCount, + transports: $result->transports, + createdAt: 1000, + metadata: $result->metadata, + ); + } + + public function startAuthentication(?string $accountId = null): PasskeyChallenge + { + return $this->authenticationChallenge ?? new PasskeyChallenge('pk-auth-1', $accountId, 'login', 'challenge-auth', 1000, 1300); + } + + public function startRegistration(string $accountId): PasskeyChallenge + { + return $this->registrationChallenge ?? new PasskeyChallenge('pk-reg-1', $accountId, 'registration', 'challenge-reg', 1000, 1300); + } +} + +final readonly class TestPolicy implements PolicyInterface +{ + /** + * @param array $decisions + */ + public function __construct( + private array $decisions = [], + private AuthorizationDecision|bool|null $defaultDecision = null, + ) {} + + public function authorize(PrincipalInterface $principal, string $ability, mixed $resource = null, array $context = []): AuthorizationDecision|bool|null + { + unset($principal, $resource, $context); + + if (array_key_exists($ability, $this->decisions)) { + return $this->decisions[$ability]; + } + + return $this->defaultDecision; + } +} + +final readonly class TestPolicyResolver implements PolicyResolverInterface +{ + public function __construct( + private ?PolicyInterface $policy = null, + ) {} + + public function resolve(mixed $resource): ?PolicyInterface + { + unset($resource); + + return $this->policy; + } +} diff --git a/tests/Account/AccountAndPrincipalTest.php b/tests/Account/AccountAndPrincipalTest.php new file mode 100644 index 0000000..cf012f9 --- /dev/null +++ b/tests/Account/AccountAndPrincipalTest.php @@ -0,0 +1,70 @@ +create('alice@example.com', 'hash', ['role' => 'user']); + $duplicate = $manager->create('alice@example.com', 'hash'); + + expect($created->successful())->toBeTrue() + ->and($created->account?->id())->toBe('acct-1') + ->and($duplicate->failed())->toBeTrue() + ->and($duplicate->code)->toBe('account_already_exists'); +}); + +it('updates account status, metadata, and verification state', function (): void { + $store = new InMemoryAccountStore(); + $manager = new AccountManager($store, $store, new TestAuthIdGenerator(), new FrozenClock(1000)); + $created = $manager->create('alice@example.com', 'hash', status: AccountStatus::PENDING_VERIFICATION); + $accountId = $created->account?->id(); + + expect($accountId)->not->toBeNull(); + + $verified = $manager->markVerified($accountId); + $metadataUpdated = $manager->updateMetadata($accountId, ['risk' => 'low']); + $locked = $manager->lock($accountId); + $requiredPasswordChange = $manager->requirePasswordChange($accountId); + $requiredMfa = $manager->requireMfaEnrollment($accountId); + $suspended = $manager->suspend($accountId); + $unlocked = $manager->unlock($accountId); + $disabled = $manager->disable($accountId); + + expect($verified->successful())->toBeTrue() + ->and($verified->account?->metadata()['verified_at'])->toBe(1000) + ->and($metadataUpdated->account?->metadata())->toBe(['risk' => 'low']) + ->and($locked->account?->status())->toBe(AccountStatus::LOCKED) + ->and($requiredPasswordChange->account?->status())->toBe(AccountStatus::PASSWORD_CHANGE_REQUIRED) + ->and($requiredMfa->account?->status())->toBe(AccountStatus::MFA_ENROLLMENT_REQUIRED) + ->and($suspended->account?->status())->toBe(AccountStatus::SUSPENDED) + ->and($unlocked->account?->status())->toBe(AccountStatus::ACTIVE) + ->and($disabled->account?->status())->toBe(AccountStatus::DISABLED); +}); + +it('manages the current principal context', function (): void { + $context = new CurrentPrincipalContext(); + $principal = new Principal('principal-1', PrincipalType::ACCOUNT, 'acct-1', ['role' => 'admin']); + + $context->set($principal); + + expect($context->get())->toBe($principal) + ->and($context->require())->toBe($principal); + + $context->clear(); + + expect($context->get())->toBeNull(); + + $context->require(); +})->throws(AuthenticationException::class); diff --git a/tests/Authentication/LockoutPasswordlessStepUpImpersonationTest.php b/tests/Authentication/LockoutPasswordlessStepUpImpersonationTest.php new file mode 100644 index 0000000..0f60476 --- /dev/null +++ b/tests/Authentication/LockoutPasswordlessStepUpImpersonationTest.php @@ -0,0 +1,148 @@ +recordLoginFailure('acct-1', ['session_id' => 'sess-1']); + $second = $manager->recordLoginFailure('acct-1', ['session_id' => 'sess-1']); + $unlock = $manager->unlock('acct-1'); + + expect($first->status)->toBe(LockoutStatus::FAILURE_RECORDED) + ->and($second->status)->toBe(LockoutStatus::LOCKED) + ->and($manager->isLocked('acct-1'))->toBeFalse() + ->and($unlock->status)->toBe(LockoutStatus::UNLOCKED) + ->and(array_map(static fn($event) => $event->type, $audit->events()))->toBe([AuthEventType::LOCKOUT_TRIGGERED, AuthEventType::LOCKOUT_CLEARED]); +}); + +it('issues and verifies passwordless login tokens', function (): void { + $tokens = new TestPasswordlessTokenService(); + $notifier = new CollectingAuthNotifier(); + $manager = new PasswordlessManager($tokens, $notifier); + + $issued = $manager->issue('alice@example.com', ['ip' => '127.0.0.1']); + $verified = $manager->verify($issued->token ?? ''); + $tokens->verifications['expired'] = new Infocyph\AuthLayer\Contract\Security\TokenVerificationResult(false, failureReason: 'expired_token'); + $expired = $manager->verify('expired'); + + expect($issued->status)->toBe(PasswordlessStatus::ISSUED) + ->and($verified->status)->toBe(PasswordlessStatus::VERIFIED) + ->and($expired->status)->toBe(PasswordlessStatus::EXPIRED) + ->and($notifier->notifications())->toHaveCount(1); +}); + +it('surfaces invalid passwordless tokens and preserves notification payloads', function (): void { + $tokens = new TestPasswordlessTokenService(); + $notifier = new CollectingAuthNotifier(); + $manager = new PasswordlessManager($tokens, $notifier); + + $issued = $manager->issue('alice@example.com', ['ip' => '127.0.0.1']); + $invalid = $manager->verify('invalid'); + + expect($invalid->status)->toBe(PasswordlessStatus::INVALID) + ->and($notifier->notifications()[0]->payload)->toMatchArray([ + 'identifier' => 'alice@example.com', + 'token' => $issued->token, + 'ip' => '127.0.0.1', + ]); +}); + +it('tracks step-up satisfaction for sensitive abilities', function (): void { + $clock = new FrozenClock(1000); + $ttl = new ArrayTtlStore($clock); + $manager = new StepUpManager($ttl, $clock); + $session = new AuthSession('sess-1', 'acct-1', 'dev-1', 1000, 1000, 2000, 500); + + $required = $manager->evaluate($session, 'billing:update', ['max_age_seconds' => 100, 'method' => StepUpMethod::MFA]); + $manager->markSatisfied('acct-1', 'sess-1', 'billing:update', StepUpMethod::MFA, 60); + $satisfied = $manager->evaluate($session, 'billing:update', ['max_age_seconds' => 100, 'method' => StepUpMethod::MFA]); + + expect($required->required)->toBeTrue() + ->and($manager->requiresStepUp($session, 'billing:update', ['max_age_seconds' => 100, 'method' => StepUpMethod::MFA]))->toBeFalse() + ->and($satisfied->successful())->toBeTrue(); +}); + +it('handles alternate step-up method input and already-recent authentication', function (): void { + $clock = new FrozenClock(1000); + $manager = new StepUpManager(new ArrayTtlStore($clock), $clock); + $session = new AuthSession('sess-1', 'acct-1', 'dev-1', 1000, 1000, 2000, 980); + + $result = $manager->evaluate($session, 'profile:update', ['max_age_seconds' => 60, 'method' => 'passkey']); + + expect($result->required)->toBeFalse() + ->and($result->code)->toBe('step_up_not_required'); +}); + +it('records MFA and passkey lockout failures and supports manual locks', function (): void { + $clock = new FrozenClock(1000); + $audit = new InMemoryAuditEventStore(); + $manager = new LockoutManager( + new InMemoryCounterStore($clock), + new InMemoryLockoutStore($clock), + $audit, + new TestAuthIdGenerator(), + new LockoutConfig(5, 1, 1, 60, 120), + $clock, + ); + + $mfa = $manager->recordMfaFailure('acct-1', ['device_id' => 'dev-1']); + $manager->unlock('acct-1'); + $passkey = $manager->recordPasskeyFailure('acct-1'); + $manual = $manager->lock('acct-2', LockoutReason::ADMINISTRATIVE, 1500); + + expect($mfa->status)->toBe(LockoutStatus::LOCKED) + ->and($passkey->status)->toBe(LockoutStatus::LOCKED) + ->and($manual->status)->toBe(LockoutStatus::LOCKED) + ->and($manual->lockedUntil)->toBe(1500) + ->and(array_map(static fn($event) => $event->type, $audit->events()))->toContain(AuthEventType::LOCKOUT_TRIGGERED); +}); + +it('starts and stops impersonation sessions with audit records', function (): void { + $clock = new FrozenClock(1000); + $audit = new InMemoryAuditEventStore(); + $manager = new ImpersonationManager($audit, new TestAuthIdGenerator(), $clock); + $actor = new Principal('admin-1', PrincipalType::ACCOUNT, 'admin-1', ['role' => 'support']); + $target = new Account('acct-1', 'alice@example.com'); + + $started = $manager->startImpersonation($actor, $target, ['session_id' => 'sess-1']); + $stopped = $manager->stopImpersonation($started->session, ['session_id' => 'sess-1']); + + expect($started->successful())->toBeTrue() + ->and($started->principal?->type())->toBe(PrincipalType::IMPERSONATED) + ->and($stopped->successful())->toBeTrue() + ->and($stopped->principal?->type())->toBe(PrincipalType::ACCOUNT) + ->and(array_map(static fn($event) => $event->type, $audit->events()))->toBe([AuthEventType::IMPERSONATION_STARTED, AuthEventType::IMPERSONATION_STOPPED]); +}); diff --git a/tests/Authentication/LoginAndSessionTest.php b/tests/Authentication/LoginAndSessionTest.php new file mode 100644 index 0000000..f78cc17 --- /dev/null +++ b/tests/Authentication/LoginAndSessionTest.php @@ -0,0 +1,190 @@ +create('acct-1', 'dev-1', ['ip' => '127.0.0.1']); + $touched = $manager->touch($created->id); + $rotated = $manager->rotate($created->id); + + expect($created->id)->toBe('sess-1') + ->and($touched?->lastSeenAt)->toBe(1000) + ->and($rotated->id)->toBe('sess-2') + ->and($sessions->find('sess-1'))->toBeNull() + ->and($sessions->find('sess-2'))->not->toBeNull(); + + $clock->tick(301); + + expect($manager->status($rotated))->toBe(SessionStatus::EXPIRED) + ->and($manager->isRecentlyAuthenticated($rotated, 60))->toBeFalse(); + + $manager->revoke('sess-2'); + + expect($sessions->find('sess-2'))->toBeNull(); +}); + +it('reports active session state and supports account-wide logout', function (): void { + $clock = new FrozenClock(1000); + $ids = new TestAuthIdGenerator; + $store = new InMemorySessionStore; + $sessions = new SessionManager($store, $ids, new SessionConfig(300, 60), $clock); + $created = $sessions->create('acct-1', 'dev-1', ['recent' => true]); + $recent = new Infocyph\AuthLayer\Authentication\Session\AuthSession('sess-recent', 'acct-1', 'dev-1', 1000, 1000, 1300, 980); + + expect($sessions->status($created))->toBe(SessionStatus::ACTIVE) + ->and($sessions->isRecentlyAuthenticated($recent, 60))->toBeTrue(); + + $accounts = new InMemoryAccountStore; + $accounts->save(new Account('acct-1', 'alice@example.com', AccountStatus::ACTIVE, 'hash')); + $audit = new InMemoryAuditEventStore; + $lockouts = new LockoutManager(new InMemoryCounterStore($clock), new InMemoryLockoutStore($clock), $audit, $ids, new LockoutConfig, $clock); + $authenticator = new Authenticator($accounts, $accounts, new TestPasswordVerifier, $sessions, $ids, $audit, $lockouts, $clock); + + $authenticator->logout(new Principal('acct-1', PrincipalType::ACCOUNT, 'acct-1')); + + expect($store->find($created->id))->toBeNull() + ->and(array_map(static fn ($event) => $event->type, $audit->events()))->toBe([AuthEventType::LOGOUT]); +}); + +it('revokes all account sessions except the current one', function (): void { + $store = new InMemorySessionStore; + $manager = new SessionManager($store, new TestAuthIdGenerator, new SessionConfig(300, 60), new FrozenClock(1000)); + + $first = $manager->create('acct-1'); + $second = $manager->create('acct-1'); + $third = $manager->create('acct-2'); + + $manager->revokeAllForAccount('acct-1', $second->id); + + expect($store->find($first->id))->toBeNull() + ->and($store->find($second->id))->not->toBeNull() + ->and($store->find($third->id))->not->toBeNull(); +}); + +it('throws when rotating an unknown session', function (): void { + $manager = new SessionManager(new InMemorySessionStore, new TestAuthIdGenerator, new SessionConfig, new FrozenClock(1000)); + + $manager->rotate('missing'); +})->throws(SessionException::class); + +it('authenticates valid credentials, rehashes passwords, and records login/logout audit events', function (): void { + $clock = new FrozenClock(1000); + $accounts = new InMemoryAccountStore; + $accounts->save(new Account('acct-1', 'alice@example.com', AccountStatus::ACTIVE, 'legacy-hash', ['team' => 'ops'])); + + $ids = new TestAuthIdGenerator; + $audit = new InMemoryAuditEventStore; + $sessionManager = new SessionManager(new InMemorySessionStore, $ids, new SessionConfig(3600, 900), $clock); + $lockouts = new LockoutManager(new InMemoryCounterStore($clock), new InMemoryLockoutStore($clock), $audit, $ids, new LockoutConfig(3, 3, 3, 60, 120), $clock); + $verifier = new TestPasswordVerifier; + $verifier->result = new PasswordVerificationResult(true, true, 'rehash-hash'); + $authenticator = new Authenticator($accounts, $accounts, $verifier, $sessionManager, $ids, $audit, $lockouts, $clock); + + $result = $authenticator->login(new LoginRequest('alice@example.com', 'secret', context: ['device_id' => 'dev-1', 'ip' => '127.0.0.1'])); + + expect($result->successful())->toBeTrue() + ->and($result->status)->toBe(LoginStatus::AUTHENTICATED) + ->and($result->session?->deviceId)->toBe('dev-1') + ->and($accounts->findById('acct-1')?->passwordHash())->toBe('rehash-hash') + ->and(array_map(static fn ($event) => $event->type, $audit->events()))->toBe([AuthEventType::LOGIN_SUCCESS]); + + $authenticator->logout($result->principal ?? new Principal('acct-1', PrincipalType::ACCOUNT, 'acct-1'), $result->session?->id); + + expect(array_map(static fn ($event) => $event->type, $audit->events()))->toBe([AuthEventType::LOGIN_SUCCESS, AuthEventType::LOGOUT]); +}); + +it('rejects invalid credentials and account status constraints', function (): void { + $clock = new FrozenClock(1000); + $accounts = new InMemoryAccountStore; + $accounts->save(new Account('acct-1', 'alice@example.com', AccountStatus::PENDING_VERIFICATION, 'hash')); + $accounts->save(new Account('acct-2', 'bob@example.com', AccountStatus::ACTIVE, 'hash')); + + $ids = new TestAuthIdGenerator; + $audit = new InMemoryAuditEventStore; + $lockouts = new LockoutManager(new InMemoryCounterStore($clock), new InMemoryLockoutStore($clock), $audit, $ids, new LockoutConfig(2, 2, 2, 60, 120), $clock); + $authenticator = new Authenticator( + $accounts, + $accounts, + new TestPasswordVerifier, + new SessionManager(new InMemorySessionStore, $ids, new SessionConfig, $clock), + $ids, + $audit, + $lockouts, + $clock, + ); + + $missing = $authenticator->login(new LoginRequest('missing@example.com', 'secret')); + $pending = $authenticator->login(new LoginRequest('alice@example.com', 'hash')); + $invalid = $authenticator->login(new LoginRequest('bob@example.com', 'wrong')); + $locked = $authenticator->login(new LoginRequest('bob@example.com', 'wrong')); + + expect($missing->status)->toBe(LoginStatus::INVALID_CREDENTIALS) + ->and($pending->status)->toBe(LoginStatus::EMAIL_VERIFICATION_REQUIRED) + ->and($invalid->status)->toBe(LoginStatus::INVALID_CREDENTIALS) + ->and($locked->status)->toBe(LoginStatus::ACCOUNT_LOCKED) + ->and(array_map(static fn ($event) => $event->type, $audit->events()))->toContain(AuthEventType::LOGIN_FAILURE, AuthEventType::LOCKOUT_TRIGGERED); +}); + +it('maps all guarded account states and missing password hashes during login', function (): void { + $clock = new FrozenClock(1000); + $accounts = new InMemoryAccountStore; + $accounts->save(new Account('acct-disabled', 'disabled@example.com', AccountStatus::DISABLED, 'hash')); + $accounts->save(new Account('acct-suspended', 'suspended@example.com', AccountStatus::SUSPENDED, 'hash')); + $accounts->save(new Account('acct-change', 'change@example.com', AccountStatus::PASSWORD_CHANGE_REQUIRED, 'hash')); + $accounts->save(new Account('acct-mfa', 'mfa@example.com', AccountStatus::MFA_ENROLLMENT_REQUIRED, 'hash')); + $accounts->save(new Account('acct-empty', 'empty@example.com', AccountStatus::ACTIVE, null)); + + $ids = new TestAuthIdGenerator; + $audit = new InMemoryAuditEventStore; + $authenticator = new Authenticator( + $accounts, + $accounts, + new TestPasswordVerifier, + new SessionManager(new InMemorySessionStore, $ids, new SessionConfig, $clock), + $ids, + $audit, + new LockoutManager(new InMemoryCounterStore($clock), new InMemoryLockoutStore($clock), $audit, $ids, new LockoutConfig, $clock), + $clock, + ); + + $disabled = $authenticator->login(new LoginRequest('disabled@example.com', 'hash')); + $suspended = $authenticator->login(new LoginRequest('suspended@example.com', 'hash')); + $change = $authenticator->login(new LoginRequest('change@example.com', 'hash')); + $mfa = $authenticator->login(new LoginRequest('mfa@example.com', 'hash')); + $empty = $authenticator->login(new LoginRequest('empty@example.com', 'hash')); + + expect($disabled->status)->toBe(LoginStatus::ACCOUNT_DISABLED) + ->and($suspended->status)->toBe(LoginStatus::ACCOUNT_DISABLED) + ->and($change->status)->toBe(LoginStatus::PASSWORD_CHANGE_REQUIRED) + ->and($mfa->status)->toBe(LoginStatus::MFA_REQUIRED) + ->and($empty->status)->toBe(LoginStatus::INVALID_CREDENTIALS) + ->and($empty->code)->toBe('password_not_configured'); +}); diff --git a/tests/Authentication/PasswordAndVerificationFlowsTest.php b/tests/Authentication/PasswordAndVerificationFlowsTest.php new file mode 100644 index 0000000..2eac25d --- /dev/null +++ b/tests/Authentication/PasswordAndVerificationFlowsTest.php @@ -0,0 +1,175 @@ +save(new Account('acct-1', 'alice@example.com', AccountStatus::ACTIVE, 'current-hash')); + $audit = new InMemoryAuditEventStore(); + $notifier = new CollectingAuthNotifier(); + $manager = new PasswordChangeManager($accounts, $accounts, new TestPasswordVerifier(), $audit, $notifier, new TestAuthIdGenerator(), new FrozenClock(1000)); + + $changed = $manager->change('acct-1', 'current-hash', 'new-hash', ['session_id' => 'sess-1']); + $invalid = $manager->change('acct-1', 'wrong', 'new-hash'); + $policyFailed = $manager->changeWithPlainPassword('acct-1', 'current-hash', 'weak', new TestPasswordHasher(), new TestPasswordPolicy(valid: false, violations: ['too_short'], code: 'weak_password')); + + expect($changed->status)->toBe(PasswordChangeStatus::CHANGED) + ->and($accounts->findById('acct-1')?->passwordHash())->toBe('new-hash') + ->and($invalid->status)->toBe(PasswordChangeStatus::INVALID_CREDENTIALS) + ->and($policyFailed->status)->toBe(PasswordChangeStatus::POLICY_FAILED) + ->and($notifier->notifications())->toHaveCount(1) + ->and(array_map(static fn($event) => $event->type, $audit->events()))->toBe([AuthEventType::PASSWORD_CHANGED]); +}); + +it('returns account-not-found when password changes cannot be applied', function (): void { + $accounts = new InMemoryAccountStore(); + $accounts->save(new Account('acct-1', 'alice@example.com', AccountStatus::ACTIVE, null)); + + $result = (new PasswordChangeManager( + $accounts, + $accounts, + new TestPasswordVerifier(), + new InMemoryAuditEventStore(), + new CollectingAuthNotifier(), + new TestAuthIdGenerator(), + new FrozenClock(1000), + ))->change('acct-1', 'ignored', 'new-hash'); + + expect($result->status)->toBe(PasswordChangeStatus::ACCOUNT_NOT_FOUND) + ->and($result->failed())->toBeTrue(); +}); + +it('issues and completes password resets, including consumed and policy-failure paths', function (): void { + $clock = new FrozenClock(1000); + $accounts = new InMemoryAccountStore(); + $accounts->save(new Account('acct-1', 'alice@example.com', AccountStatus::ACTIVE, 'old-hash')); + $tokens = new TestPasswordResetTokenService(); + $store = new InMemoryPasswordResetStore($clock); + $audit = new InMemoryAuditEventStore(); + $notifier = new CollectingAuthNotifier(); + $manager = new PasswordResetManager($tokens, $store, $accounts, $notifier, $audit, new TestAuthIdGenerator(), 300, $clock); + + $issued = $manager->issue('acct-1', ['ip' => '127.0.0.1']); + $completed = $manager->complete($issued->token ?? '', 'new-hash'); + $consumed = $manager->complete($issued->token ?? '', 'other-hash'); + $policyFailed = $manager->completeWithPlainPassword('missing-token', 'weak', new TestPasswordHasher(), new TestPasswordPolicy(valid: false, violations: ['too_short'], code: 'weak_password')); + + expect($issued->status)->toBe(PasswordResetStatus::REQUESTED) + ->and($completed->status)->toBe(PasswordResetStatus::COMPLETED) + ->and($accounts->findById('acct-1')?->passwordHash())->toBe('new-hash') + ->and($consumed->status)->toBe(PasswordResetStatus::CONSUMED) + ->and($policyFailed->status)->toBe(PasswordResetStatus::POLICY_FAILED) + ->and($notifier->notifications())->toHaveCount(1) + ->and(array_map(static fn($event) => $event->type, $audit->events()))->toBe([AuthEventType::PASSWORD_RESET_REQUESTED, AuthEventType::PASSWORD_RESET_COMPLETED]); +}); + +it('hashes plain passwords during reset and rejects orphaned reset tokens', function (): void { + $clock = new FrozenClock(1000); + $accounts = new InMemoryAccountStore(); + $accounts->save(new Account('acct-1', 'alice@example.com', AccountStatus::ACTIVE, 'old-hash')); + $tokens = new TestPasswordResetTokenService(); + $store = new InMemoryPasswordResetStore($clock); + $manager = new PasswordResetManager( + $tokens, + $store, + $accounts, + new CollectingAuthNotifier(), + new InMemoryAuditEventStore(), + new TestAuthIdGenerator(), + 300, + $clock, + ); + + $issued = $manager->issue('acct-1'); + $completed = $manager->completeWithPlainPassword($issued->token ?? '', 'new-secret', new TestPasswordHasher('hashed::')); + $tokens->verifications['orphan-token'] = new Infocyph\AuthLayer\Contract\Security\TokenVerificationResult(true, subjectId: 'acct-1', claims: ['request_id' => 'missing']); + $orphan = $manager->complete('orphan-token', 'ignored'); + + expect($completed->status)->toBe(PasswordResetStatus::COMPLETED) + ->and($accounts->findById('acct-1')?->passwordHash())->toBe('hashed::new-secret') + ->and($orphan->status)->toBe(PasswordResetStatus::INVALID) + ->and($orphan->code)->toBe('reset_request_not_found'); +}); + +it('verifies email addresses and handles invalid and expired verification requests', function (): void { + $clock = new FrozenClock(1000); + $accounts = new InMemoryAccountStore(); + $accounts->save(new Account('acct-1', 'alice@example.com', AccountStatus::PENDING_VERIFICATION)); + $tokens = new TestEmailVerificationTokenService(); + $store = new InMemoryEmailVerificationStore($clock); + $audit = new InMemoryAuditEventStore(); + $notifier = new CollectingAuthNotifier(); + $manager = new EmailVerificationManager($tokens, $store, $accounts, $notifier, $audit, new TestAuthIdGenerator(), 60, $clock); + + $issued = $manager->issue('acct-1', 'alice@example.com', ['device_id' => 'dev-1']); + $verified = $manager->verify($issued->token ?? ''); + $consumed = $manager->verify($issued->token ?? ''); + + $expiredIssue = $manager->issue('acct-1', 'alice@example.com'); + $clock->tick(61); + $expired = $manager->verify($expiredIssue->token ?? ''); + $invalid = $manager->verify('missing-token'); + + expect($issued->status)->toBe(EmailVerificationStatus::ISSUED) + ->and($verified->status)->toBe(EmailVerificationStatus::VERIFIED) + ->and($accounts->findById('acct-1')?->status())->toBe(AccountStatus::ACTIVE) + ->and($consumed->status)->toBe(EmailVerificationStatus::CONSUMED) + ->and($expired->status)->toBe(EmailVerificationStatus::EXPIRED) + ->and($invalid->status)->toBe(EmailVerificationStatus::INVALID) + ->and($notifier->notifications())->toHaveCount(2) + ->and(array_map(static fn($event) => $event->type, $audit->events()))->toContain(AuthEventType::EMAIL_VERIFICATION_REQUESTED, AuthEventType::EMAIL_VERIFIED); +}); + +it('includes request metadata in verification notifications and rejects orphaned verification tokens', function (): void { + $clock = new FrozenClock(1000); + $accounts = new InMemoryAccountStore(); + $accounts->save(new Account('acct-1', 'alice@example.com', AccountStatus::PENDING_VERIFICATION)); + $tokens = new TestEmailVerificationTokenService(); + $notifier = new CollectingAuthNotifier(); + $manager = new EmailVerificationManager( + $tokens, + new InMemoryEmailVerificationStore($clock), + $accounts, + $notifier, + new InMemoryAuditEventStore(), + new TestAuthIdGenerator(), + 60, + $clock, + ); + + $issued = $manager->issue('acct-1', 'alice@example.com', ['session_id' => 'sess-1']); + $notification = $notifier->notifications()[0]; + $tokens->verifications['orphan-token'] = new Infocyph\AuthLayer\Contract\Security\TokenVerificationResult(true, subjectId: 'acct-1', claims: ['request_id' => 'missing']); + $orphan = $manager->verify('orphan-token'); + + expect($notification->payload)->toMatchArray([ + 'email' => 'alice@example.com', + 'session_id' => 'sess-1', + 'token' => $issued->token, + ]) + ->and($notification->payload['request_id'] ?? null)->not->toBeNull() + ->and($orphan->status)->toBe(EmailVerificationStatus::INVALID) + ->and($orphan->code)->toBe('verification_request_not_found'); +}); diff --git a/tests/Authentication/RememberTokenAndTokenAuthTest.php b/tests/Authentication/RememberTokenAndTokenAuthTest.php new file mode 100644 index 0000000..faf1cb9 --- /dev/null +++ b/tests/Authentication/RememberTokenAndTokenAuthTest.php @@ -0,0 +1,184 @@ +issue('acct-1', 'dev-1', ['session_id' => 'sess-1']); + $record = $issued->record; + expect($record)->not->toBeNull(); + + $tokens->verifications[$issued->token?->value ?? ''] = new RememberTokenVerificationResult(true, $record); + $verified = $manager->verify($issued->token?->value ?? ''); + $rotated = $manager->rotate($record); + + $reuseRecord = $rotated->record; + expect($reuseRecord)->not->toBeNull(); + $tokens->verifications[$rotated->token?->value ?? ''] = new RememberTokenVerificationResult(false, $reuseRecord, true, 'reuse_detected'); + $reused = $manager->verify($rotated->token?->value ?? ''); + + expect($issued->status)->toBe(RememberTokenStatus::ISSUED) + ->and($verified->status)->toBe(RememberTokenStatus::VERIFIED) + ->and($verified->record?->lastUsedAt)->toBe(1000) + ->and($rotated->status)->toBe(RememberTokenStatus::ROTATED) + ->and($reused->status)->toBe(RememberTokenStatus::REUSED) + ->and($store->wasFamilyRevoked($reuseRecord?->familyId ?? ''))->toBeTrue() + ->and(array_map(static fn($event) => $event->type, $audit->events()))->toContain(AuthEventType::REMEMBER_TOKEN_ISSUED, AuthEventType::REMEMBER_TOKEN_REVOKED); +}); + +it('issues, verifies, rotates, and revokes access and refresh tokens', function (): void { + $clock = new FrozenClock(1000); + $ids = new TestAuthIdGenerator(); + $audit = new InMemoryAuditEventStore(); + $access = new TestAccessTokenService(); + $refreshService = new TestRefreshTokenService(); + $refreshStore = new InMemoryRefreshTokenStore($clock); + $manager = new TokenAuthManager($access, $refreshService, $refreshStore, $audit, $ids, 300, $clock); + + $accessClaims = new AccessTokenClaims('acct-1', null, 1000, 1300, ['read'], ['device_id' => 'dev-1']); + $issuedAccess = $manager->issueAccessToken($accessClaims, ['ip' => '127.0.0.1']); + $verifiedAccess = $manager->verifyAccessToken($issuedAccess->token ?? ''); + + $issuedRefresh = $manager->issueRefreshToken('acct-1', 'client-1', 'dev-1', ['session_id' => 'sess-1']); + $refreshRecord = $issuedRefresh->refreshToken; + expect($refreshRecord)->not->toBeNull(); + + $verifiedRefresh = $manager->verifyRefreshToken($issuedRefresh->token ?? ''); + $rotated = $manager->rotateRefreshToken($refreshRecord, ['ip' => '127.0.0.1']); + $revoked = $manager->revokeRefreshFamily($refreshRecord->familyId, ['account_id' => 'acct-1']); + $alreadyRevoked = $manager->revokeRefreshFamily($refreshRecord->familyId, ['account_id' => 'acct-1']); + $reuse = $manager->rotateRefreshToken($refreshRecord, ['ip' => '127.0.0.1']); + + expect($issuedAccess->type)->toBe(TokenType::ACCESS) + ->and($verifiedAccess->successful())->toBeTrue() + ->and($issuedRefresh->type)->toBe(TokenType::REFRESH) + ->and($verifiedRefresh->verification?->verified)->toBeTrue() + ->and($rotated->successful())->toBeTrue() + ->and($revoked->status)->toBe(TokenRevocationStatus::REVOKED) + ->and($alreadyRevoked->status)->toBe(TokenRevocationStatus::ALREADY_REVOKED) + ->and($reuse->successful())->toBeFalse() + ->and(array_map(static fn($event) => $event->type, $audit->events()))->toContain( + AuthEventType::ACCESS_TOKEN_ISSUED, + AuthEventType::REFRESH_TOKEN_ISSUED, + AuthEventType::REFRESH_TOKEN_ROTATED, + AuthEventType::REFRESH_TOKEN_REVOKED, + AuthEventType::REFRESH_TOKEN_REUSE_DETECTED, + ); +}); + +it('surfaces token verification failures', function (): void { + $refreshService = new TestRefreshTokenService(); + $refreshService->verifications['bad-token'] = new TokenVerificationResult(false, failureReason: 'expired_token'); + + $manager = new TokenAuthManager( + new TestAccessTokenService(), + $refreshService, + new InMemoryRefreshTokenStore(new FrozenClock(1000)), + new InMemoryAuditEventStore(), + new TestAuthIdGenerator(), + 300, + new FrozenClock(1000), + ); + + $result = $manager->verifyRefreshToken('bad-token'); + + expect($result->failed())->toBeTrue() + ->and($result->code)->toBe('expired_token'); +}); + +it('surfaces invalid and expired remember-me states', function (): void { + $clock = new FrozenClock(1000); + $tokens = new TestRememberTokenService(); + $store = new InMemoryRememberTokenStore($clock); + $manager = new RememberMeManager($tokens, $store, new InMemoryAuditEventStore(), new TestAuthIdGenerator(), $clock); + + $issued = $manager->issue('acct-1', 'dev-1'); + $record = $issued->record; + expect($record)->not->toBeNull(); + + $tokens->verifications['missing-record'] = new RememberTokenVerificationResult(true, null); + $missing = $manager->verify('missing-record'); + + $clock->tick(86401); + $tokens->verifications[$issued->token?->value ?? ''] = new RememberTokenVerificationResult(true, $record); + $expired = $manager->verify($issued->token?->value ?? ''); + + $familyRevokedRecord = $manager->issue('acct-1', 'dev-1')->record; + expect($familyRevokedRecord)->not->toBeNull(); + $store->revokeFamily($familyRevokedRecord->familyId); + $tokens->verifications['revoked-family'] = new RememberTokenVerificationResult(true, $familyRevokedRecord); + $revokedFamily = $manager->verify('revoked-family'); + + expect($missing->status)->toBe(RememberTokenStatus::INVALID) + ->and($expired->status)->toBe(RememberTokenStatus::EXPIRED) + ->and($revokedFamily->status)->toBe(RememberTokenStatus::INVALID); +}); + +it('handles refresh token family-revoked and stale-token rotation paths', function (): void { + $clock = new FrozenClock(1000); + $ids = new TestAuthIdGenerator(); + $audit = new InMemoryAuditEventStore(); + $store = new InMemoryRefreshTokenStore($clock); + $manager = new TokenAuthManager(new TestAccessTokenService(), new TestRefreshTokenService(), $store, $audit, $ids, 60, $clock); + + $issued = $manager->issueRefreshToken('acct-1', 'client-1', 'dev-1'); + $record = $issued->refreshToken; + expect($record)->not->toBeNull(); + + $store->revokeFamily($record->familyId); + $familyRevoked = $manager->rotateRefreshToken($record, ['session_id' => 'sess-1']); + + $freshIssue = $manager->issueRefreshToken('acct-1', 'client-1', 'dev-1'); + $freshRecord = $freshIssue->refreshToken; + expect($freshRecord)->not->toBeNull(); + $clock->tick(61); + $stale = $manager->rotateRefreshToken($freshRecord, ['device_id' => 'dev-1']); + + expect($familyRevoked->successful())->toBeFalse() + ->and($familyRevoked->code)->toBe('refresh_token_family_revoked') + ->and($stale->successful())->toBeFalse() + ->and($stale->code)->toBe('refresh_token_reuse_detected') + ->and(array_map(static fn($event) => $event->type, $audit->events()))->toContain(AuthEventType::REFRESH_TOKEN_REUSE_DETECTED); +}); + +it('surfaces access token verification failures', function (): void { + $access = new TestAccessTokenService(); + $access->verifications['bad-access'] = new TokenVerificationResult(false, failureReason: 'bad_access_token'); + $manager = new TokenAuthManager( + $access, + new TestRefreshTokenService(), + new InMemoryRefreshTokenStore(new FrozenClock(1000)), + new InMemoryAuditEventStore(), + new TestAuthIdGenerator(), + 300, + new FrozenClock(1000), + ); + + $result = $manager->verifyAccessToken('bad-access'); + + expect($result->failed())->toBeTrue() + ->and($result->code)->toBe('bad_access_token'); +}); diff --git a/tests/Authorization/AuthorizationTest.php b/tests/Authorization/AuthorizationTest.php new file mode 100644 index 0000000..0571a46 --- /dev/null +++ b/tests/Authorization/AuthorizationTest.php @@ -0,0 +1,166 @@ + AuthorizationDecision::allow('policy_allowed')], + defaultDecision: AuthorizationDecision::deny('policy_denied'), + ); + $resolver = new TestPolicyResolver($policy); + $gate = new Gate($resolver); + + $gate->before(static function ($_principal, string $ability): ?bool { + unset($_principal); + + return $ability === 'system:shutdown' ? true : null; + }); + $gate->define('posts:view', static fn () => true); + $gate->after(static function ($_principal, string $ability, mixed $_resource, AuthorizationDecision $decision): AuthorizationDecision { + unset($_principal, $_resource); + + return $ability === 'posts:view' ? AuthorizationDecision::allow('after_override') : $decision; + }); + + $principal = new Principal('acct-1', PrincipalType::ACCOUNT, 'acct-1'); + + expect($gate->can($principal, 'system:shutdown')->allowed)->toBeTrue() + ->and($gate->can($principal, 'posts:view')->code)->toBe('after_override') + ->and($gate->can($principal, 'posts:edit', new stdClass)->code)->toBe('policy_allowed') + ->and($gate->can($principal, 'missing')->allowed)->toBeFalse(); +}); + +it('throws authorization exceptions for denied gate decisions', function (): void { + $gate = new Gate; + $principal = new Principal('acct-1', PrincipalType::ACCOUNT, 'acct-1'); + + $gate->authorize($principal, 'missing'); +})->throws(AuthorizationException::class); + +it('authorizes via direct permissions, role permissions, and grants', function (): void { + $ids = new TestAuthIdGenerator; + $roles = new InMemoryRoleStore; + $permissions = new InMemoryPermissionStore; + $grants = new InMemoryGrantStore(new FrozenClock(1000)); + + $roleManager = new RoleManager($roles, $roles, $ids); + $permissionManager = new PermissionManager($permissions, $permissions, $ids); + $role = $roleManager->create('editor'); + $permission = $permissionManager->create('posts:*'); + $directPermission = $permissionManager->create('reports:view'); + + $roleManager->assign('acct-1', $role->id); + $permissionManager->assignToRole($role->id, $permission->id); + $permissionManager->assignToAccount('acct-1', $directPermission->id); + $grants->save(new AccessGrant('grant-1', 'principal-1', 'documents:view', 'document', 'doc-1')); + + $authorizer = new PermissionAuthorizer( + new PermissionResolver($permissions), + new RolePermissionResolver($roles, $permissions), + new GrantResolver($grants, clock: new FrozenClock(1000)), + ); + + $principal = new Principal('principal-1', PrincipalType::ACCOUNT, 'acct-1'); + $guest = new Principal('guest-1', PrincipalType::GUEST, null); + + expect($authorizer->can($principal, 'posts:edit')->allowed)->toBeTrue() + ->and($authorizer->can($principal, 'reports:view')->allowed)->toBeTrue() + ->and($authorizer->can($principal, 'documents:view', ['type' => 'document', 'id' => 'doc-1'])->allowed)->toBeTrue() + ->and($authorizer->can($guest, 'posts:view')->allowed)->toBeFalse(); +}); + +it('audits denied authorizations through the wrapper authorizer', function (): void { + $audit = new InMemoryAuditEventStore; + $authorizer = new AuditingAuthorizer(new Gate, $audit, new TestAuthIdGenerator, new FrozenClock(1000)); + $principal = new Principal('principal-1', PrincipalType::ACCOUNT, 'acct-1'); + + $decision = $authorizer->can($principal, 'missing', null, ['session_id' => 'sess-1']); + + expect($decision->allowed)->toBeFalse() + ->and(array_map(static fn ($event) => $event->type, $audit->events()))->toBe([AuthEventType::AUTHORIZATION_DENIED]); +}); + +it('manages delegated access and exposes principal grants', function (): void { + $audit = new InMemoryAuditEventStore; + $store = new InMemoryGrantStore(new FrozenClock(1000)); + $manager = new DelegationManager($store, $audit, new TestAuthIdGenerator, new FrozenClock(1000)); + + $granted = $manager->grant('principal-1', 'documents:view', 'document', 'doc-1', 1300, ['session_id' => 'sess-1']); + $listed = $manager->listForPrincipal('principal-1'); + $revoked = $manager->revoke($granted->grant?->id ?? '', 'principal-1'); + + expect($granted->successful())->toBeTrue() + ->and($listed->grants)->toHaveCount(1) + ->and($revoked->successful())->toBeTrue() + ->and(array_map(static fn ($event) => $event->type, $audit->events()))->toBe([AuthEventType::DELEGATED_ACCESS_GRANTED, AuthEventType::DELEGATED_ACCESS_REVOKED]); +}); + +it('persists and revokes role and permission assignments', function (): void { + $ids = new TestAuthIdGenerator; + $roles = new InMemoryRoleStore; + $permissions = new InMemoryPermissionStore; + $roleManager = new RoleManager($roles, $roles, $ids); + $permissionManager = new PermissionManager($permissions, $permissions, $ids); + + $role = $roleManager->create('auditor', ['scope' => 'billing']); + $permission = $permissionManager->create('billing:view', ['scope' => 'billing']); + + $roleManager->assign('acct-1', $role->id); + $permissionManager->assignToAccount('acct-1', $permission->id); + $permissionManager->assignToRole($role->id, $permission->id); + + expect($roleManager->forAccount('acct-1'))->toHaveCount(1) + ->and($permissionManager->forAccount('acct-1'))->toHaveCount(1) + ->and($permissionManager->forRoles([$role->id]))->toHaveCount(1); + + $roleManager->revoke('acct-1', $role->id); + $permissionManager->revokeFromAccount('acct-1', $permission->id); + $permissionManager->revokeFromRole($role->id, $permission->id); + + expect($roleManager->forAccount('acct-1'))->toBe([]) + ->and($permissionManager->forAccount('acct-1'))->toBe([]) + ->and($permissionManager->forRoles([$role->id]))->toBe([]); +}); + +it('ignores expired and revoked grants during authorization', function (): void { + $clock = new FrozenClock(1000); + $store = new InMemoryGrantStore($clock); + $resolver = new GrantResolver($store, clock: $clock); + $principal = new Principal('principal-1', PrincipalType::ACCOUNT, 'acct-1'); + + $store->save(new AccessGrant('grant-1', 'principal-1', 'documents:*', 'document', 'doc-1', 900)); + + expect($resolver->forPrincipal($principal->id(), Infocyph\AuthLayer\Authorization\Gate\Ability::from('documents:view', ['type' => 'document', 'id' => 'doc-1'])))->toBe([]); + + $store->save(new AccessGrant('grant-2', 'principal-1', 'documents:*', 'document', 'doc-1', 1200)); + + expect($resolver->forPrincipal($principal->id(), Infocyph\AuthLayer\Authorization\Gate\Ability::from('documents:view', ['type' => 'document', 'id' => 'doc-1'])))->toHaveCount(1); + + $store->revoke('grant-2'); + + expect($resolver->forPrincipal($principal->id(), Infocyph\AuthLayer\Authorization\Gate\Ability::from('documents:view', ['type' => 'document', 'id' => 'doc-1'])))->toBe([]); +}); diff --git a/tests/Device/DeviceManagerTest.php b/tests/Device/DeviceManagerTest.php new file mode 100644 index 0000000..8f0f621 --- /dev/null +++ b/tests/Device/DeviceManagerTest.php @@ -0,0 +1,40 @@ +register('acct-1', 'Laptop', 'fp-1', ['os' => 'linux']); + $deviceId = $registered->device?->id ?? ''; + $trusted = $manager->trust($deviceId); + $touched = $manager->touch($deviceId, 1010); + $listed = $manager->listForAccount('acct-1'); + $revoked = $manager->revoke($deviceId); + $missing = $manager->touch('missing'); + + expect($registered->status)->toBe(DeviceStatus::REGISTERED) + ->and($trusted->device?->trusted)->toBeTrue() + ->and($touched->device?->lastSeenAt)->toBe(1010) + ->and($listed)->toHaveCount(1) + ->and($revoked->status)->toBe(DeviceStatus::REVOKED) + ->and($store->find($deviceId)?->isRevoked())->toBeTrue() + ->and($missing->status)->toBe(DeviceStatus::NOT_FOUND); +}); + +it('returns not-found results for unknown devices', function (): void { + $clock = new FrozenClock(1000); + $manager = new DeviceManager(new InMemoryDeviceStore($clock), new TestAuthIdGenerator(), $clock); + + expect($manager->trust('missing')->status)->toBe(DeviceStatus::NOT_FOUND) + ->and($manager->touch('missing')->status)->toBe(DeviceStatus::NOT_FOUND) + ->and($manager->revoke('missing')->status)->toBe(DeviceStatus::NOT_FOUND); +}); diff --git a/tests/Mfa/MfaManagerTest.php b/tests/Mfa/MfaManagerTest.php new file mode 100644 index 0000000..e05b159 --- /dev/null +++ b/tests/Mfa/MfaManagerTest.php @@ -0,0 +1,142 @@ +enrollFactor('acct-1', MfaFactorType::TOTP, 'Phone', ['device_id' => 'dev-1']); + $factorId = $enrolled->factor?->id ?? ''; + $activated = $manager->activateFactor('acct-1', $factorId); + $challenge = $manager->issueChallenge('acct-1', MfaChallengePurpose::LOGIN, $factorId, ['session_id' => 'sess-1']); + $verified = $manager->verifyChallenge($challenge->challenge?->id ?? '', '123456', ['session_id' => 'sess-1']); + $removed = $manager->removeFactor('acct-1', $factorId); + + expect($enrolled->status)->toBe(MfaStatus::ENROLLED) + ->and($enrolled->recoveryCodes)->toHaveCount(10) + ->and($activated->status)->toBe(MfaStatus::ACTIVATED) + ->and($challenge->status)->toBe(MfaStatus::CHALLENGE_ISSUED) + ->and($verified->status)->toBe(MfaStatus::VERIFIED) + ->and($manager->isSatisfied('acct-1', 'sess-1'))->toBeTrue() + ->and($removed->status)->toBe(MfaStatus::REMOVED) + ->and($notifier->notifications())->toHaveCount(1) + ->and(array_map(static fn ($event) => $event->type, $audit->events()))->toContain(AuthEventType::MFA_ENROLLED, AuthEventType::MFA_CHALLENGED, AuthEventType::MFA_DISABLED); +}); + +it('handles invalid, expired, and recovery-code MFA flows', function (): void { + $clock = new FrozenClock(1000); + $store = new InMemoryMfaFactorStore; + $verifier = new TestMfaVerifier; + $verifier->result = new MfaVerificationResult(false, reason: 'bad_code'); + $recoveryCodes = new TestRecoveryCodeService; + $recoveryCodes->verificationMap['recovery-1'] = new RecoveryCodeVerificationResult(true); + $recoveryCodes->verificationMap['other'] = new RecoveryCodeVerificationResult(false, 'bad_recovery'); + $ttl = new class implements TtlStoreInterface + { + /** @var array */ + private array $items = []; + + public function delete(string $key): void + { + unset($this->items[$key]); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->items[$key] ?? $default; + } + + public function pull(string $key, mixed $default = null): mixed + { + $value = $this->get($key, $default); + unset($this->items[$key]); + + return $value; + } + + public function put(string $key, mixed $value, int $_ttlSeconds): void + { + unset($_ttlSeconds); + $this->items[$key] = $value; + } + }; + $audit = new InMemoryAuditEventStore; + $manager = new MfaManager( + $store, + $verifier, + $recoveryCodes, + $ttl, + $audit, + new CollectingAuthNotifier, + new TestAuthIdGenerator, + 10, + 120, + $clock, + ); + + $enrolled = $manager->enrollFactor('acct-1', MfaFactorType::EMAIL, 'Email', enabled: true, recoveryCodeCount: 1); + $challenge = $manager->issueChallenge('acct-1'); + $invalid = $manager->verifyChallenge($challenge->challenge?->id ?? '', 'wrong'); + $recovery = $manager->verifyRecoveryCode('acct-1', 'recovery-1', ['session_id' => 'sess-1']); + $expiredChallenge = $manager->issueChallenge('acct-1'); + $clock->tick(11); + $expired = $manager->verifyChallenge($expiredChallenge->challenge?->id ?? '', '123456'); + + expect($enrolled->successful())->toBeTrue() + ->and($invalid->status)->toBe(MfaStatus::INVALID) + ->and($recovery->status)->toBe(MfaStatus::RECOVERY_CODE_VERIFIED) + ->and($expired->status)->toBe(MfaStatus::EXPIRED) + ->and(array_map(static fn ($event) => $event->type, $audit->events()))->toContain(AuthEventType::RECOVERY_CODE_USED); +}); + +it('handles missing MFA factors, missing challenges, and invalid recovery codes', function (): void { + $clock = new FrozenClock(1000); + $notifier = new CollectingAuthNotifier; + $manager = new MfaManager( + new InMemoryMfaFactorStore, + new TestMfaVerifier, + new TestRecoveryCodeService, + new ArrayTtlStore($clock), + new InMemoryAuditEventStore, + $notifier, + new TestAuthIdGenerator, + 60, + 120, + $clock, + ); + + $activate = $manager->activateFactor('acct-1', 'missing'); + $challenge = $manager->issueChallenge('acct-1'); + $verify = $manager->verifyChallenge('missing', '123456'); + $recovery = $manager->verifyRecoveryCode('acct-1', 'invalid'); + $remove = $manager->removeFactor('acct-1', 'missing'); + + expect($activate->status)->toBe(MfaStatus::INVALID) + ->and($challenge->status)->toBe(MfaStatus::INVALID) + ->and($verify->status)->toBe(MfaStatus::INVALID) + ->and($recovery->status)->toBe(MfaStatus::INVALID) + ->and($remove->status)->toBe(MfaStatus::INVALID) + ->and($notifier->notifications())->toBe([]); +}); diff --git a/tests/Passkey/PasskeyManagerTest.php b/tests/Passkey/PasskeyManagerTest.php new file mode 100644 index 0000000..666f0a3 --- /dev/null +++ b/tests/Passkey/PasskeyManagerTest.php @@ -0,0 +1,82 @@ +startRegistration('acct-1'); + $finishedRegistration = $manager->finishRegistration(new PasskeyRegistrationResult('pk-reg-1', 'acct-1', 'credential-1', 'public-key', ['usb'], 1), ['device_id' => 'dev-1']); + $startedAuthentication = $manager->startAuthentication('acct-1'); + $finishedAuthentication = $manager->finishAuthentication(new PasskeyAuthenticationResult('pk-auth-1', 'credential-1', 'client', 'auth', 'sig'), ['session_id' => 'sess-1']); + + expect($startedRegistration->status)->toBe(PasskeyRegistrationStatus::STARTED) + ->and($finishedRegistration->status)->toBe(PasskeyRegistrationStatus::REGISTERED) + ->and($credentials->findForAccount('acct-1'))->toHaveCount(1) + ->and($startedAuthentication->status)->toBe(PasskeyAuthenticationStatus::STARTED) + ->and($finishedAuthentication->status)->toBe(PasskeyAuthenticationStatus::VERIFIED) + ->and($credentials->findByCredentialId('credential-1')?->lastUsedAt)->toBe(1000) + ->and($notifier->notifications())->toHaveCount(1) + ->and(array_map(static fn($event) => $event->type, $audit->events()))->toContain(AuthEventType::PASSKEY_REGISTERED, AuthEventType::PASSKEY_USED); +}); + +it('revokes passkey credentials and reports invalid authentications', function (): void { + $clock = new FrozenClock(1000); + $service = new TestPasskeyService(); + $service->verification = new Infocyph\AuthLayer\Passkey\PasskeyVerificationResult(false, reason: 'bad_signature'); + $credentials = new InMemoryPasskeyCredentialStore($clock); + $audit = new InMemoryAuditEventStore(); + $notifier = new CollectingAuthNotifier(); + $manager = new PasskeyManager($service, $credentials, $audit, $notifier, new TestAuthIdGenerator(), $clock); + + $credential = $manager->finishRegistration(new PasskeyRegistrationResult('pk-reg-1', 'acct-1', 'credential-1', 'public-key')); + $invalid = $manager->finishAuthentication(new PasskeyAuthenticationResult('pk-auth-1', 'credential-1', 'client', 'auth', 'sig')); + $manager->revokeCredential('acct-1', $credential->credential?->credentialId ?? ''); + + expect($invalid->status)->toBe(PasskeyAuthenticationStatus::INVALID) + ->and($credentials->findByCredentialId('credential-1'))->toBeNull() + ->and($notifier->notifications())->toHaveCount(2) + ->and(array_map(static fn($event) => $event->type, $audit->events()))->toContain(AuthEventType::PASSKEY_REMOVED); +}); + +it('starts anonymous authentication and skips usage updates when verification lacks counters', function (): void { + $clock = new FrozenClock(1000); + $service = new TestPasskeyService(); + $service->verification = new Infocyph\AuthLayer\Passkey\PasskeyVerificationResult(true, accountId: 'acct-1'); + $credentials = new InMemoryPasskeyCredentialStore($clock); + $credentials->save(new Infocyph\AuthLayer\Passkey\PasskeyCredential('cred-record-1', 'acct-1', 'credential-1', 'public-key', 1, ['usb'], 1000)); + $manager = new PasskeyManager( + $service, + $credentials, + new InMemoryAuditEventStore(), + new CollectingAuthNotifier(), + new TestAuthIdGenerator(), + $clock, + ); + + $started = $manager->startAuthentication(); + $verified = $manager->finishAuthentication(new PasskeyAuthenticationResult('pk-auth-1', 'credential-1', 'client', 'auth', 'sig')); + + expect($started->status)->toBe(PasskeyAuthenticationStatus::STARTED) + ->and($started->challenge?->accountId)->toBeNull() + ->and($verified->status)->toBe(PasskeyAuthenticationStatus::VERIFIED) + ->and($credentials->findByCredentialId('credential-1')?->lastUsedAt)->toBeNull(); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..3ec251a --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +put('key', 'value', 10); + + expect($store->get('key'))->toBe('value'); + + $clock->tick(11); + + expect($store->get('key', 'fallback'))->toBe('fallback') + ->and($store->pull('key', 'fallback'))->toBe('fallback'); +}); + +it('provides counter TTL behavior for lockout windows', function (): void { + $clock = new FrozenClock(1000); + $counters = new InMemoryCounterStore($clock); + + expect($counters->increment('failures', ttlSeconds: 10))->toBe(1) + ->and($counters->increment('failures', ttlSeconds: 10))->toBe(2); + + $clock->tick(11); + + expect($counters->increment('failures', ttlSeconds: 10))->toBe(1); +}); + +it('expires lockouts when their deadline passes', function (): void { + $clock = new FrozenClock(1000); + $locks = new InMemoryLockoutStore($clock); + + $locks->lock('acct-1', LockoutReason::TOO_MANY_LOGIN_ATTEMPTS, 1010); + + expect($locks->isLocked('acct-1'))->toBeTrue(); + + $clock->tick(11); + + expect($locks->isLocked('acct-1'))->toBeFalse(); +}); + +it('collects notifications and events and can flush them', function (): void { + $notifier = new CollectingAuthNotifier(); + $dispatcher = new CollectingEventDispatcher(); + $audit = new InMemoryAuditEventStore(); + + $notification = new AuthNotification(AuthNotificationType::LOGIN_ALERT, 'acct-1', ['ip' => '127.0.0.1']); + $event = new stdClass(); + + $notifier->send($notification); + $dispatcher->dispatch($event); + $audit->record(new Infocyph\AuthLayer\Audit\AuthEvent('evt-1', AuthEventType::LOGIN_SUCCESS, Infocyph\AuthLayer\Audit\AuthEventSeverity::INFO, 'acct-1', null, null, null, 'corr-1', 1000)); + + expect($notifier->notifications())->toHaveCount(1) + ->and($dispatcher->events())->toHaveCount(1) + ->and($audit->events())->toHaveCount(1); + + $notifier->flush(); + $dispatcher->flush(); + $audit->flush(); + + expect($notifier->notifications())->toBe([]) + ->and($dispatcher->events())->toBe([]) + ->and($audit->events())->toBe([]); +}); + +it('provides null implementations without side effects', function (): void { + $ttl = new NullTtlStore(); + $notifier = new NullAuthNotifier(); + $dispatcher = new NullEventDispatcher(); + + $ttl->put('key', 'value', 10); + $notifier->send(new AuthNotification(AuthNotificationType::LOGIN_ALERT, null)); + $dispatcher->dispatch(new stdClass()); + + expect($ttl->get('key', 'fallback'))->toBe('fallback') + ->and($ttl->pull('key', 'fallback'))->toBe('fallback'); +}); + +it('provides context value coercion helpers', function (): void { + $context = ['session_id' => 'sess-1', 'attempts' => 3, 'empty' => '']; + + expect(ContextValue::stringOrNull($context, 'session_id'))->toBe('sess-1') + ->and(ContextValue::stringOrNull($context, 'empty'))->toBeNull() + ->and(ContextValue::int($context, 'attempts', 0))->toBe(3) + ->and(ContextValue::int($context, 'missing', 7))->toBe(7); +}); + +it('provides permissive password policy and random identifiers', function (): void { + $policy = new AcceptAllPasswordPolicy(); + $ids = new RandomAuthIdGenerator(); + + expect($policy->validate('secret')->valid)->toBeTrue() + ->and($ids->accountId())->toStartWith('acct_') + ->and($ids->sessionId())->toStartWith('sess_') + ->and($ids->permissionId())->toStartWith('perm_'); +}); + +it('mutates accounts in the in-memory account store', function (): void { + $store = new InMemoryAccountStore(); + $account = new Account('acct-1', 'alice@example.com', AccountStatus::PENDING_VERIFICATION, 'hash', ['role' => 'user']); + + $store->save($account); + $store->markVerified('acct-1', 1234); + $store->updatePasswordHash('acct-1', 'hash-2'); + $store->updateMetadata('acct-1', ['role' => 'admin']); + $store->updateStatus('acct-1', AccountStatus::SUSPENDED); + + $stored = $store->findById('acct-1'); + + expect($stored)->not->toBeNull() + ->and($stored?->passwordHash())->toBe('hash-2') + ->and($stored?->metadata())->toBe(['role' => 'admin']) + ->and($stored?->status())->toBe(AccountStatus::SUSPENDED); +}); diff --git a/tests/Support/ModelHelpersTest.php b/tests/Support/ModelHelpersTest.php new file mode 100644 index 0000000..4dc14b6 --- /dev/null +++ b/tests/Support/ModelHelpersTest.php @@ -0,0 +1,87 @@ + 1]); + + expect($account->withMetadata(['b' => 2])->metadata())->toBe(['b' => 2]) + ->and($account->withPasswordHash('hash-2')->passwordHash())->toBe('hash-2') + ->and($account->withStatus(AccountStatus::LOCKED)->status())->toBe(AccountStatus::LOCKED); +}); + +it('supports session and device helper methods', function (): void { + $session = new AuthSession('sess-1', 'acct-1', 'dev-1', 1000, 1000, 1100, 1000, ['ip' => '127.0.0.1']); + $device = new DeviceRecord('dev-1', 'acct-1', 'Laptop', 'fp', false, 1000); + + expect($session->seenAt(1010)->lastSeenAt)->toBe(1010) + ->and($session->isExpiredAt(1200))->toBeTrue() + ->and($device->trusted()->trusted)->toBeTrue() + ->and($device->seenAt(1020)->lastSeenAt)->toBe(1020) + ->and($device->revokedAt(1030)->isRevoked())->toBeTrue(); +}); + +it('supports consumable request helpers', function (): void { + $verification = new EmailVerificationRequest('req-1', 'acct-1', 'alice@example.com', 1000, 1100); + $reset = new PasswordResetRequest('req-2', 'acct-1', 1000, 1100); + + expect($verification->withConsumedAt(1050)->isConsumed())->toBeTrue() + ->and($verification->isExpiredAt(1200))->toBeTrue() + ->and($reset->withConsumedAt(1050)->consumedAt)->toBe(1050) + ->and($reset->isExpiredAt(1200))->toBeTrue(); +}); + +it('supports family token record helpers', function (): void { + $remember = new RememberTokenRecord('dev-1', 'selector-1', 'hash-1', 'rec-1', 'acct-1', 'family-1', 1000, 1100); + $refresh = new RefreshTokenRecord('hash-1', 'client-1', 'dev-1', 'token-1', 'acct-1', 'family-1', 1000, 1100); + + expect($remember->withLastUsedAt(1050)->lastUsedAt)->toBe(1050) + ->and($remember->withRotatedAt(1060)->rotatedAt)->toBe(1060) + ->and($remember->withRevokedAt(1070)->isRevoked())->toBeTrue() + ->and($refresh->withRotatedAt(1060)->rotatedAt)->toBe(1060) + ->and($refresh->withRevokedAt(1070)->isRevoked())->toBeTrue() + ->and($refresh->isExpiredAt(1200))->toBeTrue(); +}); + +it('supports grant, MFA, and passkey helper methods', function (): void { + $grant = new AccessGrant('grant-1', 'principal-1', 'documents:view', 'document', 'doc-1', 1100); + $factor = new MfaFactor('factor-1', 'acct-1', 'totp', 'Phone', false, 1000); + $challenge = new MfaChallenge('challenge-1', 'acct-1', 'factor-1', 'login', 1000, 1100); + $passkeyChallenge = new PasskeyChallenge('pk-1', 'acct-1', 'login', 'challenge', 1000, 1100); + $credential = new PasskeyCredential('cred-1', 'acct-1', 'credential-1', 'public-key', 1, ['usb'], 1000); + + expect($grant->isExpiredAt(1200))->toBeTrue() + ->and((new AccessGrant('grant-2', 'principal-1', 'documents:view', revokedAt: 1200))->isRevoked())->toBeTrue() + ->and($factor->activated()->enabled)->toBeTrue() + ->and($challenge->isExpiredAt(1200))->toBeTrue() + ->and($passkeyChallenge->isExpiredAt(1200))->toBeTrue() + ->and($credential->revokedAt(1300)->isRevoked())->toBeTrue(); +}); + +it('supports authorization decisions and scope values', function (): void { + $allow = AuthorizationDecision::allow('allowed_code', 'Allowed'); + $deny = AuthorizationDecision::deny('denied_code', 'Denied'); + $scope = new AuthScope('tenant-1', 'workspace-1', 'org-1', ['region' => 'us']); + + expect($allow->allowed)->toBeTrue() + ->and($allow->code)->toBe('allowed_code') + ->and($deny->allowed)->toBeFalse() + ->and($scope->tenantId)->toBe('tenant-1') + ->and($scope->metadata)->toBe(['region' => 'us']); +}); diff --git a/tests/Support/ResultHelpersTest.php b/tests/Support/ResultHelpersTest.php new file mode 100644 index 0000000..fb2b3cf --- /dev/null +++ b/tests/Support/ResultHelpersTest.php @@ -0,0 +1,76 @@ +successful())->toBeTrue() + ->and((new PasswordChangeResult(PasswordChangeStatus::CHANGED))->successful())->toBeTrue() + ->and((new PasswordResetResult(PasswordResetStatus::COMPLETED))->successful())->toBeTrue() + ->and((new EmailVerificationResult(EmailVerificationStatus::VERIFIED))->successful())->toBeTrue() + ->and((new RememberMeResult(RememberTokenStatus::VERIFIED))->successful())->toBeTrue() + ->and((new TokenRevocationResult(TokenRevocationStatus::REVOKED, 'family-1'))->successful())->toBeTrue() + ->and((new MfaEnrollmentResult(MfaStatus::ENROLLED))->successful())->toBeTrue() + ->and((new MfaChallengeResult(MfaStatus::VERIFIED))->successful())->toBeTrue() + ->and((new PasskeyRegistrationOutcome(PasskeyRegistrationStatus::REGISTERED))->successful())->toBeTrue() + ->and((new PasskeyAuthenticationOutcome(PasskeyAuthenticationStatus::VERIFIED))->successful())->toBeTrue() + ->and((new DelegationResult(DelegationStatus::GRANTED))->successful())->toBeTrue() + ->and((new DeviceResult(DeviceStatus::REGISTERED))->successful())->toBeTrue() + ->and((new LoginResult(LoginStatus::AUTHENTICATED, $principal))->successful())->toBeTrue() + ->and((new PasswordlessResult(PasswordlessStatus::VERIFIED))->successful())->toBeTrue() + ->and((new LockoutResult(LockoutStatus::LOCKED, 'acct-1'))->successful())->toBeTrue() + ->and((new ImpersonationResult($principal, $session))->successful())->toBeTrue() + ->and((new StepUpResult(false, new StepUpRequirement('billing:update', 900, StepUpMethod::MFA)))->successful())->toBeTrue() + ->and((new TokenAuthResult(TokenType::ACCESS, 'token'))->successful())->toBeTrue() + ->and((new RefreshTokenRotationResult(true))->successful())->toBeTrue(); +}); + +it('reports failed helpers for unsuccessful results', function (): void { + expect((new PasswordChangeResult(PasswordChangeStatus::INVALID_CREDENTIALS))->failed())->toBeTrue() + ->and((new EmailVerificationResult(EmailVerificationStatus::INVALID))->failed())->toBeTrue() + ->and((new PasswordlessResult(PasswordlessStatus::INVALID))->failed())->toBeTrue() + ->and((new StepUpResult(true, new StepUpRequirement('billing:update')))->failed())->toBeTrue() + ->and((new RefreshTokenRotationResult(false))->failed())->toBeTrue(); +});