diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a34ce..6a517eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **Username-based password login.** `laranail.authkit.login.identifiers` names the user columns an + identifier is matched against, in order, and `laranail.authkit.login.field` names the request input + it arrives in. Both default to email, so existing forms, API clients and validation are unchanged + until a second identifier is configured. Validation is derived from the list rather than configured + separately, so it cannot describe a mode the lookup does not implement. The application owns the + `username` column, its uniqueness constraint and its registration validation — AuthKit does not own + the users table and ships no migration for it. See `docs/login.md`. Closes + [#65](https://github.com/laranail/authkit/issues/65). - 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. diff --git a/config/laranail/authkit.php b/config/laranail/authkit.php index 9190175..a7417e5 100644 --- a/config/laranail/authkit.php +++ b/config/laranail/authkit.php @@ -7,6 +7,27 @@ 'user_model' => env(key: 'AUTHKIT_USER_MODEL'), + 'login' => [ + /* + * The request input the identifier arrives in. + * + * Existing forms and API clients post `email`, so that stays the default and nothing about + * them changes. Set it to something neutral -- `login` -- when the form accepts either an + * address or a username, so the field name stops claiming to be an address. + */ + 'field' => env(key: 'AUTHKIT_LOGIN_FIELD', default: 'email'), + + /* + * The user columns an identifier is looked up against, tried in this order. + * + * Email only by default, which is the behaviour this package has always had. Add + * 'username' to accept either. The application owns that column, its uniqueness + * constraint and its registration validation -- this package does not own the users + * table, so it ships no migration for it. See docs/login.md. + */ + 'identifiers' => ['email'], + ], + 'rate_limit' => [ 'max_attempts' => (int) env(key: 'AUTHKIT_RATE_LIMIT_MAX_ATTEMPTS', default: 5), 'decay_minutes' => (int) env(key: 'AUTHKIT_RATE_LIMIT_DECAY_MINUTES', default: 1), diff --git a/docs/configuration.md b/docs/configuration.md index ccea2f8..12d2948 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,6 +8,8 @@ php artisan vendor:publish --tag=laranail::authkit-config Set `AUTHKIT_GUARD` to the application's intended guard. The `laranail.authkit.fortify.features` list enables Fortify capabilities individually: `reset-passwords`, `update-profile-information`, `update-passwords`, `email-verification`, and `passkeys`. Removing an item prevents Auth Kit from enabling that capability; remove corresponding application routes and UI too. +The `laranail.authkit.login` block chooses how a user identifies themselves. `field` is the request input the identifier arrives in (`email` by default, `AUTHKIT_LOGIN_FIELD`), and `identifiers` lists the user columns it is matched against, in order (`['email']` by default). See [Login](login.md#signing-in-with-a-username). + Credential throttling is controlled by `AUTHKIT_RATE_LIMIT_MAX_ATTEMPTS` and `AUTHKIT_RATE_LIMIT_DECAY_MINUTES`, defaulting to five attempts per minute. Social credentials, callbacks, and enabled providers belong to [`laranail/authkit-social-login`](https://github.com/laranail/authkit-social-login); see its [social login guide](https://github.com/laranail/authkit-social-login/blob/main/docs/social-login.md). --- diff --git a/docs/login.md b/docs/login.md index 5d02c3b..525ea9b 100644 --- a/docs/login.md +++ b/docs/login.md @@ -21,6 +21,46 @@ app(LoginUser::class)->execute($result->user, 'web'); The default guard is `web`; change it with `AUTHKIT_GUARD` or pass the intended guard explicitly. Use the same guard for credential validation and session login. +## Signing in with a username + +Email is the only identifier out of the box, which is the behaviour this package has always had. +Accept usernames by naming the columns an identifier is matched against: + +```php +// config/laranail/authkit.php +'login' => [ + 'field' => 'login', + 'identifiers' => ['email', 'username'], +], +``` + +`field` is the request input the identifier arrives in. It stays `email` by default so existing +forms and API clients are untouched; rename it once the field no longer only holds an address. +`identifiers` are user columns, tried in the order given, and the first account whose password +also matches is the one signed in. + +Validation follows the list rather than being configured twice. With email alone the identifier is +validated as an address; with anything wider that rule is dropped, because a username is not an +address and validating one as an address rejects every username before the lookup runs. + +Throttling, the generic invalid-credential response, and two-factor behaviour are unchanged — the +limiter already keyed on the submitted identifier rather than on an address specifically. + +### The application owns the username column + +Auth Kit resolves whatever user model the application configures and does not own the users table, +so it ships no migration for one. Adding a `username` column, its uniqueness constraint, and its +registration validation is the application's job. A package that does not own a table must not +migrate it. + +Nothing stops one account's username from equalling another account's address. The password is +checked against each candidate rather than only against the first row found, so the username's +owner can still sign in; enforce uniqueness across both columns if you would rather that +ambiguity could not arise. + +`CheckEmailExists` is deliberately not widened to usernames. It answers whether an address is +registered, and extending it would add a username-enumeration surface that nothing here needs. + --- [← Docs index](../README.md#documentation) diff --git a/src/Actions/AttemptEmailPasswordLogin.php b/src/Actions/AttemptEmailPasswordLogin.php index 9ef5d59..a832081 100644 --- a/src/Actions/AttemptEmailPasswordLogin.php +++ b/src/Actions/AttemptEmailPasswordLogin.php @@ -6,6 +6,9 @@ use Illuminate\Http\Request; use Illuminate\Cache\RateLimiter; +use Illuminate\Contracts\Auth\UserProvider; +use Simtabi\Laranail\AuthKit\Support\AuthKit; +use Illuminate\Contracts\Auth\Authenticatable; use Simtabi\Laranail\AuthKit\Support\AuthResult; use Illuminate\Contracts\Auth\Factory as AuthFactory; use Simtabi\Laranail\AuthKit\Contracts\AttemptEmailPasswordLoginInterface; @@ -19,11 +22,11 @@ public function __construct( public function execute(Request $request, string $guard): AuthResult { - $email = $request->input('email'); - $password = $request->input('password'); + $identifier = (string) $request->input(AuthKit::loginField()); + $password = (string) $request->input('password'); $ip = $request->ip(); - $key = 'login:' . $guard . ':' . mb_strtolower(string: $email) . ':' . ($ip ?? '_'); + $key = 'login:' . $guard . ':' . mb_strtolower(string: $identifier) . ':' . ($ip ?? '_'); $maxAttempts = (int) config(key: 'laranail.authkit.rate_limit.max_attempts', default: 5); $decaySeconds = (int) config(key: 'laranail.authkit.rate_limit.decay_minutes', default: 1) * 60; @@ -31,13 +34,13 @@ public function execute(Request $request, string $guard): AuthResult return AuthResult::throttled(retryAfterSeconds: $this->limiter->availableIn(key: $key)); } - $guardInstance = $this->auth->guard(name: $guard); - $provider = $guardInstance->getProvider(); - $credentials = ['email' => $email, 'password' => $password]; - $user = $provider->retrieveByCredentials($credentials); - $ok = $user !== null && $provider->validateCredentials($user, $credentials); + $user = $this->retrieve( + provider: $this->auth->guard(name: $guard)->getProvider(), + identifier: $identifier, + password: $password, + ); - if (! $ok) { + if ($user === null) { $this->limiter->hit(key: $key, decaySeconds: $decaySeconds); return AuthResult::failed(); @@ -47,4 +50,32 @@ public function execute(Request $request, string $guard): AuthResult return AuthResult::passed(user: $user); } + + /** + * Resolve the identifier against each configured column, in order, and return the first + * account whose password also matches. + * + * One single-column lookup per identifier rather than one `or` query. `EloquentUserProvider` + * would accept a closure credential expressing the `or`, but `DatabaseUserProvider` and any + * provider an application has substituted need not -- and going through the provider at all + * is pointless if the call only works for one of them. + * + * The password is checked per candidate rather than only against the first account found, so + * a username that happens to equal another account's address does not lock its owner out. It + * also means a failed attempt costs the same number of hash comparisons whichever column the + * identifier belongs to. + */ + private function retrieve(UserProvider $provider, string $identifier, string $password): ?Authenticatable + { + foreach (AuthKit::loginIdentifiers() as $column) { + $credentials = [$column => $identifier, 'password' => $password]; + $user = $provider->retrieveByCredentials($credentials); + + if ($user !== null && $provider->validateCredentials($user, $credentials)) { + return $user; + } + } + + return null; + } } diff --git a/src/Http/Controllers/AbstractAttemptEmailPasswordLoginController.php b/src/Http/Controllers/AbstractAttemptEmailPasswordLoginController.php index 714730c..87332dd 100644 --- a/src/Http/Controllers/AbstractAttemptEmailPasswordLoginController.php +++ b/src/Http/Controllers/AbstractAttemptEmailPasswordLoginController.php @@ -55,11 +55,17 @@ protected function passed(Request $request, AuthResult $result): mixed return redirect()->intended(default: AuthKit::afterLoginRedirect()); } + /** + * Repopulate and report the error against the field the identifier was posted in, so a form + * configured for usernames does not lose its input and show its error on an absent `email`. + */ protected function failed(Request $request, AuthResult $result): mixed { + $field = AuthKit::loginField(); + return redirect()->back() - ->withInput(input: $request->only(keys: ['email', 'remember'])) - ->withErrors(provider: ['email' => 'Invalid credentials.']); + ->withInput(input: $request->only(keys: [$field, 'remember'])) + ->withErrors(provider: [$field => 'Invalid credentials.']); } protected function throttled(Request $request, AuthResult $result): mixed diff --git a/src/Http/Requests/AttemptEmailPasswordLoginRequest.php b/src/Http/Requests/AttemptEmailPasswordLoginRequest.php index 84598ac..9b8246a 100644 --- a/src/Http/Requests/AttemptEmailPasswordLoginRequest.php +++ b/src/Http/Requests/AttemptEmailPasswordLoginRequest.php @@ -5,13 +5,23 @@ namespace Simtabi\Laranail\AuthKit\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Simtabi\Laranail\AuthKit\Support\AuthKit; class AttemptEmailPasswordLoginRequest extends FormRequest { + /** + * The rules are derived from the configured identifiers rather than written per mode. + * + * With the default email-only configuration this is the rule set this request has always + * had. Any wider set drops the `email` rule, because a username is not an address and + * validating one as an address rejects every username before the lookup is reached. + */ public function rules(): array { return [ - 'email' => ['required', 'string', 'email'], + AuthKit::loginField() => AuthKit::loginIsEmailOnly() + ? ['required', 'string', 'email'] + : ['required', 'string', 'max:255'], 'password' => ['required', 'string'], 'remember' => ['sometimes', 'boolean'], ]; diff --git a/src/Support/AuthKit.php b/src/Support/AuthKit.php index 27a3fa6..3c51866 100644 --- a/src/Support/AuthKit.php +++ b/src/Support/AuthKit.php @@ -21,6 +21,54 @@ public static function guard(): string return config(key: 'laranail.authkit.guard', default: 'web'); } + /** + * The request input a password login reads its identifier from. + * + * Defaults to `email`, so a form or API client written against this package before usernames + * existed keeps working untouched. + */ + public static function loginField(): string + { + $field = config(key: 'laranail.authkit.login.field', default: 'email'); + + return is_string($field) && $field !== '' ? $field : 'email'; + } + + /** + * The user columns a login identifier is looked up against, in the order they are tried. + * + * Email only by default. The list is the single source for both the lookup and the validation + * rules, so a rule cannot describe a mode the lookup does not implement. + * + * @return list + */ + public static function loginIdentifiers(): array + { + $identifiers = config(key: 'laranail.authkit.login.identifiers', default: ['email']); + + if (! is_array($identifiers)) { + return ['email']; + } + + $identifiers = array_values(array_filter( + $identifiers, + static fn (mixed $column): bool => is_string($column) && $column !== '', + )); + + return $identifiers === [] ? ['email'] : $identifiers; + } + + /** + * Whether the only identifier configured is an email address. + * + * The discriminator for validation: an address can be validated as one, and anything wider + * cannot, because a username is not an address. + */ + public static function loginIsEmailOnly(): bool + { + return self::loginIdentifiers() === ['email']; + } + /** * Let a frontend package own the redirect targets. * diff --git a/tests/Feature/UsernamePasswordLoginTest.php b/tests/Feature/UsernamePasswordLoginTest.php new file mode 100644 index 0000000..cbdd8be --- /dev/null +++ b/tests/Feature/UsernamePasswordLoginTest.php @@ -0,0 +1,199 @@ +server->set('REMOTE_ADDR', $ip); + $request->headers->set('X-Forwarded-For', $ip); + } + + return $request; +} + +function acceptUsernames(): void +{ + config()->set('laranail.authkit.login.identifiers', ['email', 'username']); + config()->set('laranail.authkit.login.field', 'login'); +} + +it('ships email as the only identifier', function (): void { + expect(AuthKit::loginIdentifiers())->toBe(['email']) + ->and(AuthKit::loginField())->toBe('email') + ->and(AuthKit::loginIsEmailOnly())->toBeTrue(); +}); + +it('validates the identifier as an address while email is the only one', function (): void { + $rules = (new AttemptEmailPasswordLoginRequest)->rules(); + + expect($rules)->toHaveKey('email') + ->and($rules['email'])->toBe(['required', 'string', 'email']); +}); + +it('stops validating the identifier as an address once usernames are accepted', function (): void { + acceptUsernames(); + + $rules = (new AttemptEmailPasswordLoginRequest)->rules(); + + expect($rules)->toHaveKey('login') + ->and($rules['login'])->not->toContain('email') + ->and($rules)->not->toHaveKey('email'); +}); + +it('refuses a username while email is the only identifier', function (): void { + $password = Str::random(16); + + User::factory()->create([ + 'username' => 'ada', + 'email' => 'ada@example.com', + 'password' => bcrypt($password), + ]); + + $result = app(AttemptEmailPasswordLogin::class)->execute( + request: identifierRequest(['email' => 'ada', 'password' => $password]), + guard: 'web', + ); + + expect($result->isPassed())->toBeFalse(); +}); + +it('logs a user in by username once configured', function (): void { + acceptUsernames(); + $password = Str::random(16); + + $user = User::factory()->create([ + 'username' => 'ada', + 'email' => 'ada@example.com', + 'password' => bcrypt($password), + ]); + + $result = app(AttemptEmailPasswordLogin::class)->execute( + request: identifierRequest(['login' => 'ada', 'password' => $password]), + guard: 'web', + ); + + expect($result->isPassed())->toBeTrue() + ->and($result->user?->getAuthIdentifier())->toBe($user->getAuthIdentifier()); +}); + +it('still logs a user in by email once usernames are also accepted', function (): void { + acceptUsernames(); + $password = Str::random(16); + + $user = User::factory()->create([ + 'username' => 'ada', + 'email' => 'ada@example.com', + 'password' => bcrypt($password), + ]); + + $result = app(AttemptEmailPasswordLogin::class)->execute( + request: identifierRequest(['login' => 'ada@example.com', 'password' => $password]), + guard: 'web', + ); + + expect($result->isPassed())->toBeTrue() + ->and($result->user?->getAuthIdentifier())->toBe($user->getAuthIdentifier()); +}); + +it('rejects an identifier that matches no account', function (): void { + acceptUsernames(); + + User::factory()->create([ + 'username' => 'ada', + 'email' => 'ada@example.com', + 'password' => bcrypt(Str::random(16)), + ]); + + $result = app(AttemptEmailPasswordLogin::class)->execute( + request: identifierRequest(['login' => 'grace', 'password' => TestCredentials::password()]), + guard: 'web', + ); + + expect($result->isPassed())->toBeFalse() + ->and($result->status)->toBe(AuthStatus::Failed); +}); + +it('rejects a known username with the wrong password', function (): void { + acceptUsernames(); + $password = Str::random(16); + + expect(TestCredentials::password('wrong'))->not->toBe($password); + + User::factory()->create([ + 'username' => 'ada', + 'email' => 'ada@example.com', + 'password' => bcrypt($password), + ]); + + $result = app(AttemptEmailPasswordLogin::class)->execute( + request: identifierRequest(['login' => 'ada', 'password' => TestCredentials::password('wrong')]), + guard: 'web', + ); + + expect($result->isPassed())->toBeFalse(); +}); + +it('throttles repeated failures against a username', function (): void { + acceptUsernames(); + config()->set('laranail.authkit.rate_limit.max_attempts', 1); + + User::factory()->create([ + 'username' => 'ada', + 'email' => 'ada@example.com', + 'password' => bcrypt(Str::random(16)), + ]); + + $action = app(AttemptEmailPasswordLogin::class); + $request = identifierRequest(['login' => 'ada', 'password' => TestCredentials::password('wrong')]); + + $action->execute(request: $request, guard: 'web'); + $result = $action->execute(request: $request, guard: 'web'); + + expect($result->status)->toBe(AuthStatus::Throttled); +}); + +/* + * The identifier spaces can overlap: nothing stops one account's username from being another + * account's address. Checking the password against each candidate rather than only against the + * first row found is what keeps the username's owner able to sign in. + */ +it('signs in the username owner when a username equals another account address', function (): void { + acceptUsernames(); + $owner = Str::random(16); + $other = Str::random(16); + + expect($owner)->not->toBe($other); + + $addressee = User::factory()->create([ + 'username' => 'ada', + 'email' => 'grace@example.com', + 'password' => bcrypt($other), + ]); + + $usernameOwner = User::factory()->create([ + 'username' => 'grace@example.com', + 'email' => 'hopper@example.com', + 'password' => bcrypt($owner), + ]); + + $result = app(AttemptEmailPasswordLogin::class)->execute( + request: identifierRequest(['login' => 'grace@example.com', 'password' => $owner]), + guard: 'web', + ); + + expect($result->isPassed())->toBeTrue() + ->and($result->user?->getAuthIdentifier())->toBe($usernameOwner->getAuthIdentifier()) + ->and($result->user?->getAuthIdentifier())->not->toBe($addressee->getAuthIdentifier()); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index b6da269..02a2e17 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -41,5 +41,6 @@ protected function defineDatabaseMigrations(): void $this->loadMigrationsFrom(dirname(__DIR__) . '/vendor/laravel/fortify/database/migrations'); $this->loadMigrationsFrom(dirname(__DIR__) . '/database/migrations/passkeys'); $this->loadMigrationsFrom(dirname(__DIR__) . '/vendor/laravel/sanctum/database/migrations'); + $this->loadMigrationsFrom(dirname(__DIR__) . '/workbench/database/migrations'); } } diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php index 3898767..7419373 100644 --- a/workbench/app/Models/User.php +++ b/workbench/app/Models/User.php @@ -21,6 +21,7 @@ class User extends Authenticatable implements PasskeyUser protected $fillable = [ 'name', + 'username', 'email', 'password', ]; diff --git a/workbench/database/migrations/0001_01_01_000100_add_username_to_users_table.php b/workbench/database/migrations/0001_01_01_000100_add_username_to_users_table.php new file mode 100644 index 0000000..412dbad --- /dev/null +++ b/workbench/database/migrations/0001_01_01_000100_add_username_to_users_table.php @@ -0,0 +1,34 @@ +string('username')->nullable()->unique()->after('name'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropUnique(['username']); + $table->dropColumn('username'); + }); + } +};