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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/code-style.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Code style

on:
pull_request:
branches: [main]
# No path filter: this workflow gates merges as a required status check, and a
# required check that never reports (a docs-only pull request) blocks it for good.
workflow_dispatch:

concurrency:
group: code-style-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

# The laranail siblings resolve through git rather than Packagist, so the install
# hits the GitHub API to enumerate their refs. The token is the built-in one; no
# secret to configure.
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GITHUB_TOKEN }}"}}'

jobs:
pint:
name: Pint
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- uses: actions/checkout@v7

- name: Set up PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: '8.5'
coverage: none

- name: Resolve composer cache directory
id: composer-cache
shell: bash
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"

- name: Restore composer cache
uses: actions/cache@v6
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: composer-${{ runner.os }}-8.5-${{ hashFiles('**/composer.json') }}
restore-keys: composer-${{ runner.os }}-8.5-

- name: Drop cached laranail archives
# laranail/* resolve through a single MOVING v0.1.0 tag, so composer's dist
# cache is keyed on a name whose contents change underneath it. A restored
# archive is then silently stale.
shell: bash
run: rm -rf "$(composer config cache-files-dir)/laranail"

- name: Install dependencies
run: composer update --prefer-stable --prefer-dist --no-interaction --no-progress

- name: Check formatting
# The repository's own lint script: vendor/bin/laranail-pint --test, which runs
# Pint against the shared laranail/package-tools config and never rewrites files.
run: composer pint
32 changes: 25 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,28 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- The two-factor middleware is registered under the vendor-scoped alias `laranail-authkit-two-factor`
(`AuthKitServiceProvider::TWO_FACTOR_MIDDLEWARE`). `NamingConventionTest` asserts it, and that no
other bare alias is registered, against the live router.

- **Opt-in TOTP two-factor authentication for API clients.** Login can return a short-lived MFA challenge; clients can verify TOTP or recovery codes, manage enrollment and recovery codes, and protect routes with the `two-factor` middleware. Verified API tokens receive a `two-factor:verified` ability. The `two_factor_method` field defaults to `none` and is ready for future methods.

- A `NamingConventionTest` that asserts the public names against the **live registries** on a booted
application, rather than the provider source, so the guard survives a refactor.

### Changed

- Pull requests run `composer pint` (Pint with the shared laranail config, check-only) in a new
*Code style* workflow.

- The `vcs` repository entries for `laranail/captcha`, `console`, `db-tools` and `enumerator` are gone. Nothing in this package's
`require` or `require-dev` closure pulls them in (`composer why` finds none of them installed),
so they only told Composer to clone repositories it never used. The Packagist exclusion for
`laranail/*` stays.

- `composer.json` `authors` email is `opensource@simtabi.com`, the community metadata address,
replacing `hello@simtabi.com`.

- **Breaking. Social login moved to `laranail/authkit-social-login`.** Fifteen classes, the `socials`
migration, its factory and the `social` config block left this package. See
[UPGRADING.md](UPGRADING.md); `rector-migrate-social.php` codemods the class renames.
Expand Down Expand Up @@ -60,19 +78,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`dev-main`. A `dev-` constraint in `require` propagates dev stability to every consumer,
and the org convention states no laranail package carries one.

### Deprecated

### Added
- **The bare `two-factor` middleware alias.** Middleware aliases share one flat map, so a bare name
collides with any application or package alias of the same name. It still enforces the same
checks, through `DeprecatedTwoFactorAlias`, and logs one warning per process naming
`laranail-authkit-two-factor`. The earliest release that could remove it is the next minor after 0.1.

- A `NamingConventionTest` that asserts the public names against the **live registries** on a booted
application, rather than the provider source, so the guard survives a refactor.
### Removed

- `composer.lock` is no longer tracked. A library's lock records a resolution consumers never use.

### Fixed

- The user-model exception named the old package.

### Removed

- `composer.lock` is no longer tracked. A library's lock records a resolution consumers never use.

[Unreleased]: https://github.com/laranail/authkit/compare/v0.1.0...HEAD
18 changes: 1 addition & 17 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"authors": [
{
"name": "Simtabi",
"email": "hello@simtabi.com"
"email": "opensource@simtabi.com"
}
],
"require": {
Expand Down Expand Up @@ -52,26 +52,10 @@
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/laranail/console.git"
},
{
"type": "vcs",
"url": "https://github.com/laranail/enumerator.git"
},
{
"type": "vcs",
"url": "https://github.com/laranail/package-tools.git"
},
{
"type": "vcs",
"url": "https://github.com/laranail/captcha.git"
},
{
"type": "vcs",
"url": "https://github.com/laranail/db-tools.git"
},
{
"type": "composer",
"url": "https://repo.packagist.org",
Expand Down
11 changes: 8 additions & 3 deletions docs/two-factor-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,25 @@ The migration adds `two_factor_method` (`none` or `totp`, default `none`), the e

The preset provides account setup at `/auth/user/two-factor` and gates password sign-in with an authenticator or recovery-code challenge for accounts whose method is `totp`. Setup secrets remain inactive until a valid code confirms enrollment. Recovery codes are shown once and each can be used once.

Apply the `two-factor` middleware to routes that must require an enabled factor and successful verification in the current browser session:
Apply the `laranail-authkit-two-factor` middleware to routes that must require an enabled factor and successful verification in the current browser session:

```php
Route::middleware(['auth', 'two-factor'])->group(function () {
Route::middleware(['auth', 'laranail-authkit-two-factor'])->group(function () {
// Sensitive routes
});
```

> The bare `two-factor` alias is a deprecated alias of `laranail-authkit-two-factor`. It enforces
> the same checks and logs one warning per process naming the replacement. Middleware aliases live
> in one flat map, so a bare name collides with any other package or application alias of the same
> name. The earliest release that could remove it is the next minor after 0.1.

## API flow

When the core setting is enabled and the account method is `totp`, `POST /api/auth/login` returns HTTP 202 with `status: mfa_required`, a short-lived `challenge_token`, and `expires_in`. Submit that token with the user's TOTP or recovery code to `POST /api/auth/two-factor/challenge`; only a successful challenge returns a Sanctum bearer token. Failed attempts are throttled, and challenges expire after five minutes by default.

Authenticated clients can inspect `GET /api/auth/user/two-factor`, start enrollment with `POST /api/auth/user/two-factor` (send the current `password`), confirm with `POST /api/auth/user/two-factor/confirm`, disable with `POST /api/auth/user/two-factor/disable`, and replace recovery codes with `POST /api/auth/user/two-factor/recovery-codes`. Enrollment confirmation returns the recovery codes once; disable and recovery-code replacement require a current TOTP or unused recovery code.

API tokens issued after the challenge carry the `two-factor:verified` ability. The `two-factor` middleware checks this exact ability (not wildcard ability matching) and confirms that TOTP remains enabled. Accounts with method `none` retain the existing password-only login response.
API tokens issued after the challenge carry the `two-factor:verified` ability. The `laranail-authkit-two-factor` middleware checks this exact ability (not wildcard ability matching) and confirms that TOTP remains enabled. Accounts with method `none` retain the existing password-only login response.

TOTP verification delegates to Fortify's provider, which applies its configured verification window and replay cache. Keep the application encryption key secure and stable because it protects stored TOTP and recovery secrets.
51 changes: 51 additions & 0 deletions src/Http/Middleware/DeprecatedTwoFactorAlias.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\AuthKit\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

/**
* What the bare `two-factor` middleware alias resolves to.
*
* Laravel keeps middleware aliases in one flat map, so a bare `two-factor` registered by this
* package silently replaces an application's or another package's alias of the same name (or is
* replaced by it). The scoped alias is `laranail-authkit-two-factor`. This class keeps routes that
* still name the bare alias working: it logs one warning per process naming the replacement and
* then enforces exactly what the scoped alias enforces.
*
* @deprecated Use the `laranail-authkit-two-factor` middleware alias. The bare `two-factor` alias
* may be removed in the next minor after 0.1.
*/
final class DeprecatedTwoFactorAlias
{
public const string ALIAS = 'two-factor';

private static bool $warned = false;

public function __construct(private readonly RequireTwoFactorAuthentication $middleware) {}

/** @internal Lets a test observe the once-per-process warning again. */
public static function resetWarning(): void
{
self::$warned = false;
}

public function handle(Request $request, Closure $next): Response
{
if (! self::$warned) {
self::$warned = true;

Log::warning(
'The "two-factor" middleware alias is deprecated; use "laranail-authkit-two-factor" instead. '
. 'The bare alias may be removed in the next minor after 0.1.',
);
}

return $this->middleware->handle($request, $next);
}
}
16 changes: 14 additions & 2 deletions src/Providers/AuthKitServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@

class AuthKitServiceProvider extends PackageServiceProvider
{
/** The vendor-scoped middleware alias that requires verified two-factor authentication. */
public const string TWO_FACTOR_MIDDLEWARE = 'laranail-authkit-two-factor';

public function configurePackage(Package $package): void
{
$package
Expand Down Expand Up @@ -68,11 +71,20 @@ public function packageBooted(): void
// laranail.authkit.api.enabled to false.
$this->loadRoutesFrom($this->packagePath('routes/api.php'));

$this->app->make('router')->aliasMiddleware(
'two-factor',
$router = $this->app->make('router');

$router->aliasMiddleware(
self::TWO_FACTOR_MIDDLEWARE,
\Simtabi\Laranail\AuthKit\Http\Middleware\RequireTwoFactorAuthentication::class,
);

// Deprecated bare alias, kept so routes written against it keep enforcing two-factor.
// It logs once per process naming the scoped alias above.
$router->aliasMiddleware(
\Simtabi\Laranail\AuthKit\Http\Middleware\DeprecatedTwoFactorAlias::ALIAS,
\Simtabi\Laranail\AuthKit\Http\Middleware\DeprecatedTwoFactorAlias::class,
);

$this->configureFortify();
}

Expand Down
50 changes: 50 additions & 0 deletions tests/Feature/NamingConventionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

declare(strict_types=1);

use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Simtabi\Laranail\AuthKit\Providers\AuthKitServiceProvider;
use Simtabi\Laranail\AuthKit\Http\Middleware\DeprecatedTwoFactorAlias;
use Simtabi\Laranail\AuthKit\Http\Middleware\RequireTwoFactorAuthentication;

/**
* The core registers no views, translations or routes — but it does own publish tags and a
Expand Down Expand Up @@ -31,3 +36,48 @@
expect(config($bare))->toBeNull();
}
});

it('registers the two-factor middleware under the vendor-scoped alias', function (): void {
$aliases = app('router')->getMiddleware();

expect($aliases)->toHaveKey(AuthKitServiceProvider::TWO_FACTOR_MIDDLEWARE)
->and($aliases[AuthKitServiceProvider::TWO_FACTOR_MIDDLEWARE])->toBe(RequireTwoFactorAuthentication::class)
->and(AuthKitServiceProvider::TWO_FACTOR_MIDDLEWARE)->toBe('laranail-authkit-two-factor');
});

it('registers no bare middleware alias other than the deprecated two-factor one', function (): void {
$ours = array_filter(
app('router')->getMiddleware(),
fn (string $class): bool => str_starts_with($class, 'Simtabi\\Laranail\\AuthKit\\'),
);

// Non-vacuity: the scan must see this package's aliases at all.
expect($ours)->not->toBeEmpty();

$bare = array_filter(
array_keys($ours),
fn (string $alias): bool => ! str_starts_with($alias, 'laranail-authkit') && $alias !== DeprecatedTwoFactorAlias::ALIAS,
);

expect(array_values($bare))->toBe([]);
});

it('keeps the deprecated bare two-factor alias enforcing, and warns once', function (): void {
expect(app('router')->getMiddleware()[DeprecatedTwoFactorAlias::ALIAS] ?? null)
->toBe(DeprecatedTwoFactorAlias::class);

DeprecatedTwoFactorAlias::resetWarning();
Log::spy();

Route::middleware(DeprecatedTwoFactorAlias::ALIAS)->get('/__two-factor-bare', fn () => 'ok');
Route::middleware(AuthKitServiceProvider::TWO_FACTOR_MIDDLEWARE)->get('/__two-factor-scoped', fn () => 'ok');

// A guest is refused through both spellings: the bare alias is not a bypass.
$this->get('/__two-factor-bare')->assertStatus(401);
$this->get('/__two-factor-bare')->assertStatus(401);
$this->get('/__two-factor-scoped')->assertStatus(401);

Log::shouldHaveReceived('warning')
->once()
->withArgs(fn (string $message): bool => str_contains($message, 'laranail-authkit-two-factor'));
});
Loading