From 173f4559bd7489fccb223586e0e34fa38189002a Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 13 Nov 2026 07:27:01 -0500 Subject: [PATCH 1/3] Accept a username as a password-login identifier Password login read the `email` input and looked the account up on the `email` column, so an account that signs in with a username could not sign in at all. Name the columns instead. `laranail.authkit.login.identifiers` lists the user columns an identifier is matched against, in order, and `login.field` names the request input it arrives in. Both default to email, so a form or API client written before this keeps working untouched. Validation is derived from that same list rather than configured beside it: with email alone the identifier is still validated as an address, and any wider set drops that rule, because a username is not an address and validating one as an address rejects every username before the lookup runs. The lookup is one single-column query per identifier rather than one `or` query. EloquentUserProvider would take a closure credential expressing the `or`, but DatabaseUserProvider and any provider an application has substituted need not -- and going through the provider is pointless if the call only works for one of them. The password is checked per candidate, so a username that happens to equal another account's address does not lock its owner out. --- config/laranail/authkit.php | 21 ++++++++ src/Actions/AttemptEmailPasswordLogin.php | 49 +++++++++++++++---- ...actAttemptEmailPasswordLoginController.php | 10 +++- .../AttemptEmailPasswordLoginRequest.php | 12 ++++- src/Support/AuthKit.php | 48 ++++++++++++++++++ 5 files changed, 128 insertions(+), 12 deletions(-) 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/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. * From 67f99f82ee2b8e79e0e481242c2cc8163989abe3 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 13 Nov 2026 07:27:01 -0500 Subject: [PATCH 2/3] Cover username login, and pin email-only as the default Ten cases: the four the issue asks for -- email login, username login, an identifier matching no account, and throttling -- plus the wrong-password case, the derived validation rules in both modes, and the collision where one account's username equals another's address. Two of them exist to pin the default rather than the feature: email is the only identifier shipped, and a username is refused until one is configured. Widening the default fails them. The `username` column lives in the workbench, not in a package migration, which is the claim the docs make made executable: AuthKit does not own the users table, so if it ever started depending on a column it does not create, the test harness would be the only place that column existed. Proven red three ways before being relied on: hardcoding the lookup back to the email column fails 2, always validating the identifier as an address fails 1, and widening the shipped default fails 3. --- tests/Feature/UsernamePasswordLoginTest.php | 199 ++++++++++++++++++ tests/TestCase.php | 1 + workbench/app/Models/User.php | 1 + ..._01_000100_add_username_to_users_table.php | 34 +++ 4 files changed, 235 insertions(+) create mode 100644 tests/Feature/UsernamePasswordLoginTest.php create mode 100644 workbench/database/migrations/0001_01_01_000100_add_username_to_users_table.php 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'); + }); + } +}; From ce27272dda0043a417a11758f8445be771761cd3 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 13 Nov 2026 07:27:02 -0500 Subject: [PATCH 3/3] Document username login and who owns the column Records the answer the issue asks for: the username column, its uniqueness constraint and its registration validation belong to the application, because AuthKit resolves whatever user model is configured and does not own the users table. Also states the two things a reader would otherwise have to infer -- that identifier spaces may overlap and which account wins, and that CheckEmailExists is deliberately not widened, since answering it for usernames would add an enumeration surface nothing asks for. --- CHANGELOG.md | 8 ++++++++ docs/configuration.md | 2 ++ docs/login.md | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) 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/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)