trail / auth
Users, passwords, passkeys, + backup codes for trail
Requires
- php: >=8.4
- ext-openssl: *
- ext-sodium: *
- trail/db: ^0.4.0
- trail/framework: ^0.4.0
- trail/mail: ^0.4.0
- trail/sessions: ^0.4.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A trail framework addon for handling users + auth
install
composer require trail/auth
trail/trail db::migrate
Pulls in trail/db, trail/sessions, and trail/mail. PHP needs argon2 support (most builds have it), openssl, and sodium
Make sure you set the site url cause mail links and passkeys both need it
// app.php
'url' => 'https://example.com',
whats included
Pages under /auth: signup, signin, signout, forgot, reset, verify, recover (backup codes), confirm, password, and account (email, passkeys, backup codes, and devices).
All of them are named routes (auth.signin, auth.account, ...), so Route::url('auth.signin') works and the paths can change with config
custom paths
A prefix will go in front of any baked in route. The paths option will allow you to customise the top level routes, and the account prefix. Everything else stays as is
'auth' => [
// top level
'prefix' => '',
'paths' => [
'signin' => '/login',
'signup' => '/join/signup',
'account' => '/settings',
],
],
oidc and passkeys live under the prefix. Changing the prefix moves the oidc callback ({prefix}/oidc/{issuer}/callback) so you'll have to update it on the provider end if you do
protecting pages
use Trail\Auth\Gates\RequireUser;
use Trail\Auth\Gates\RequireFresh;
Route::get('/dashboard', Dashboard::class)->gate(RequireUser::class);
// signed in within last 15 minutes
Route::post('/delete-everything', Nuke::class)->gate(RequireUser::class, RequireFresh::class);
Whos asking comes from Visitor. Like session, ask for it in handle()
use Trail\Auth\Users\Visitor;
public function handle(Visitor $visitor): Response
{
if ($visitor->isGuest()) { ... }
$visitor->user->id;
$visitor->user->username;
$visitor->user->email; // verified only
}
Everything else is Users. findMany can take a bunch of IDs for stuff
$users->find(5);
$users->findMany([5, 9, 12]); // [5 => User, 9 => User, etc]
usernames and emails
'auth' => [
'login' => ['username', 'email'], // either or both
],
- whatever's in
loginis required on sign up. Email is optional otherwise - usernames can't have
@so the login knows which is which automatically - usernames are lowercase
a-z 0-9 . _ -, 3 to 32 long - emails are always verified before they're added. Until the link is clicked it's just pending
- email only sites sign people in after they verify. Username sites sign them in right away
Skip the verify mail if you don't need it
'autoVerify' => static fn (string $email): bool => str_ends_with($email, '@club.test'),
Verify and reset links go out throug trail/mail. Set app.mail.dsn and app.mail.from (see mail readme). Without a dsn dev mode logs mail, and prod logs a warning
alarms
Theres security alerts for a few different things. It sends to the verified email on the account:
- a sign in from a device it hasn't seen before (browser + network)
- password changed or reset, passkey added, oidc login connected, or a backup code used
- email change whic is sent to the old address
'alarms' => false, // no alarms
passwords
argon2id rehashed on sign in whenever settings change. Each hash already has its own random salt
A pepper is a per site secret on top of that. Lives in .env only
'pepper' => App::env('AUTH_PEPPER'),
Rotate by moving the old one over. People get rehashed to new one as they sign in
'pepper' => App::env('AUTH_PEPPER'),
'oldPeppers' => [App::env('AUTH_PEPPER_OLD')],
Lose the pepper and every password is dies.
Backup codes use the pepper too. old peppers keep old codes working
passkeys
Added from account page. Soon as someone adds one the account is set to passkey only. Meaning the password is deleted and they get 10 backup codes instead
- lost the passkey? you use a backup code (burn on use), which tuirns off passkeys, logs you in, and has you set a new password
- passkey users can switch back to a password on purpose from account page
- passkey users cant use email resets. Backup codes only
- last working passkey cant be removed (unless an oidc is connected)
- more than one passkey is fine
By default the rp id is the host in app.url. Change that for subdomains
'passkeys' => [
'name' => 'A Site',
'rpId' => 'example.com',
'origins' => ['https://example.com', 'https://www.example.com'],
// skip additional pin/faceID/fingerprint step
'verifyUser' => false,
],
// or turn them off
'passkeys' => false,
verifyUser off prevents the really annoying thing where when you try and use a passkey (like with a password manager), it makes you type in the system password EVERY TIME. It made me mad. Its new
OIDC sign in
You can also use an OIDC (PocketID, Authentik, Authelia, Keycloak, etc) to sign in/register, etc. Create a public client on it (it uses PKCE) with this callback
https://example.com/auth/oidc/{key}/callback
'providers' => [
'pocketid' => [
'name' => 'Pocket ID', // button text
'issuer' => App::env('OIDC_POCKETID_ISSUER'), // https://id.example.com
'clientId' => App::env('OIDC_POCKETID_CLIENT_ID'),
'icon' => '/images/pocketid.svg', // optional
// regiser with OIDC
'signup' => true,
// link to account with matching email
'trustEmail' => false,
],
],
Here's a few notes of what happens with what and when
- connecting an OIDC replaces the password, making it the only login option (like passkeys)
- passkeys + OIDC can work together/at the same time
- switchingback to password turns passkeys off + disconnects oidc
- you cant remove your last login method
- email resets dont happen for accounts without passwords
Other bits
- sign in, sign up, and confirm pages get a new button per oidc
- account page can disconnect oidc
- logins are matched to the oidc's user id
- matching email only links when
trustEmailis on and the oidc says it's verified - new accounts take the oidc's username if it's free / valid, and the email if verified
- needs
allow_url_fopen. Discovery + keys cache instorage/cache/auth
Or you can just ignore passwords altogether like an adult:
// oidc signup only if false
'passwordSignup' => false,
// oidc + passkeys signin only
'passwords' => false,
passwordSignupoff with no oidc setup means registrations are closedpasswordsoff means you'll need oidc, passkey, or a backup code to get in- existing password accounts cant signin once
passwordsare off. connect oidc first
all the config stuff
'auth' => [
// default routes. false + define your own using same names
'routes' => true,
'prefix' => '/auth',
// change routes
'paths' => [],
// landing after signing in/out
'home' => '/',
// parent layout for pages
'layout' => 'auth/_layout',
'login' => ['username', 'email'],
'usernames' => '/^[a-z0-9_.-]{3,32}$/D',
'reserved' => ['admin', 'root'],
'passwordLength' => 10,
'pepper' => App::env('AUTH_PEPPER'),
'oldPeppers' => [],
// password_hash() options
'hashOptions' => [],
// seconds a sign in counts as fresh
'fresh' => 900,
// [limit, window]
'throttle' => [
'signin' => [10, '15 minutes'],
],
'autoVerify' => null,
// security mail
'alarms' => true,
// inside create transaction. use Closed to refuse. issuer is null for password sign ups
'checkSignUp' => static function (User $user, Request $request, ?Issuer $issuer = null): void { ... },
// extra sign up fields here
'onSignUp' => static function (User $user, Request $request, ?Issuer $issuer = null): void { ... },
'passkeys' => [],
// oidc
'providers' => [],
'passwordSignup' => true,
'passwords' => true,
],
throttle
Every limiter has a named rule that can be customised per site. You can set how many attempts, and within what timeframe. It uses seconds or anything strtotime reads like like '1 hour' or '2 days' and the like
A successful signin, backup code, or confirm resets the count
'throttle' => [
'signin' => [3, '1 hour'],
'signup' => [3, '1 day'],
'confirm' => [3, '2 days'],
],
Below is a quick table of the defaults and what they're called
| name | what is it | default |
|---|---|---|
| signin | password signins per user | 10 per 15 minutes |
| failedIpAttempts | failed signins, passkeys, and backup codes per ip | 50 per 15 minutes |
| signup | sign ups per ip | 10 per 15 minutes |
| confirm | password rechecks per user | 10 per 15 minutes |
| recover | backup code attempts per user | 10 per 15 minutes |
| forgot | reset requests per ip | 10 per 15 minutes |
| resetMail | reset emails per user | 3 per 15 minutes |
| verifyMail | verify emails per user | 3 per 15 minutes |
Apps/addons can add their own rules like this:
Config::set('app.auth.throttle.invites', Config::get('app.auth.throttle.invites') ?? [5, '1 day']);
if (!$throttle->take('invites', Throttle::address($request->ip))) {
// too many
}
templates
Templates live in auth/, and emails in auth/_email/. Make a new file with the same name (any language) in your local templates folder and it overrides the default
Auth pages extend auth/_layout to get unified stuff like styles, sitename, etc. Change the layout config to move that or point it at something else
'layout' => '_layouts/_base',
Passkey buttons are data- attributes and a tiny script at /auth/passkey.js
<?php if (Auth::hasPasskeys()): ?>
<script type="module" src="<?= e(App::url('auth.passkeys.script')) ?>"></script>
<?php endif ?>
cli
trail/trail auth::add --username=alex --email=alex@example.com # prints a set password link
trail/trail auth::reset alex # reset link
trail/trail auth::prune # dead links, old attempts, expired sessions
security bits
- sign in swaps the session token. sign out destroys it
- there is a throttle for attempts. ipv6 uses /64
- if using a load balancer or cdn, set
app.proxiesto prevent shared throttle - reset and verify links need to actually be clicked to work, and are capped per user with
verifyMailandresetMailthrottles - password changes, resets, recoveries, and switching to passkeys sign out every other device
- auth pages send
Cache-Control: no-storeso freeze and CDNs never keep them - oidc uses pkce
- rules live in services and handlers' public methods (
SignIn::password(),RemovePasskey::remove(),PasswordForm::save(),Passkeys::isLastWayIn())
not done yet
- authenticator app 2fa (might skip)