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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions config/laranail/authkit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
Expand Down
40 changes: 40 additions & 0 deletions docs/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
49 changes: 40 additions & 9 deletions src/Actions/AttemptEmailPasswordLogin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,25 +22,25 @@ 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;

if ($this->limiter->tooManyAttempts(key: $key, maxAttempts: $maxAttempts)) {
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();
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/Http/Requests/AttemptEmailPasswordLoginRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
];
Expand Down
48 changes: 48 additions & 0 deletions src/Support/AuthKit.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
*/
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.
*
Expand Down
Loading
Loading