Security
Resetting passwords
A user who forgot their password asks for a reset link by email, and sets a new password on the page it opens. The
Password facade does the work, and the starter kits come with the pages.
Introduction#
Every starter kit lets a user reset their password: the kits with pages have a "Forgot password" page and a page to set the new password, and the API kit has the two endpoints (see Starter kits). This page explains what they do, so you can change them or write your own.
Model preparation#
The user model must be able to give its email address and to receive the reset mail. A model built on
AuthenticatableModel, as a new app's User is, does both: getEmailForPasswordReset() returns its email
attribute, and sendPasswordResetNotification(token) sends it the ResetPassword notification by mail.
Database preparation#
The reset tokens are stored in the password_reset_tokens table, which a new app's first migration creates: one row
per email address, with the token and when it was made. A new token for an address replaces the one before it.
Configuring trusted hosts#
Routing#
Requesting the password reset link#
The password reset link request form#
The kits with pages show the form at /forgot-password, the password.request route, behind the guest middleware.
It asks for the email address and posts it to the same URI.
Handling the form submission#
Password.sendResetLink() takes the credentials that find the user, the email address here, and sends the user a
reset link:
import { __, back, Password, request } from '@orieljs/framework'import { Controller } from '../Controller'export class PasswordResetLinkController extends Controller { /** Handle an incoming password reset link request. */ async store() { await request().validate({ email: 'required|email', }) await Password.sendResetLink(request().only('email')) return back().with('status', __('A reset link will be sent if the account exists.')) }}It returns a status, which __() turns into a message:
| Status | Message |
|---|---|
passwords.sent | "We have emailed your password reset link." |
passwords.user | "We can't find a user with that email address." |
passwords.throttled | "Please wait before retrying." |
The kits answer with the same message whatever the status, as above, so the form can't be used to find out who has an account.
A new link for an address is refused while the last one is younger than throttle seconds, 60 in a new app, so the
form can't be used to flood a user's inbox. With a function as the second argument, sendResetLink() calls it with
the user and the token instead of sending the mail.
Resetting the password#
The password reset form#
The link in the mail opens /reset-password/, the password.reset route. The page there asks for the
new password, twice, and posts it with the token and the email address to /reset-password, the password.store
route.
Handling the form submission#
Password.reset() checks the token against the user's, then calls your function with the user and the new password,
and deletes the token. Your function saves the password, hashed:
import { randomBytes } from 'node:crypto'import { __, event, Hash, Password, PasswordReset, redirect, request, Rule, ValidationException } from '@orieljs/framework'import type { User } from '../../../Models/User'import { Controller } from '../Controller'export class NewPasswordController extends Controller { /** Handle an incoming new password request. */ async store() { await request().validate({ token: 'required', email: 'required|email', password: ['required', 'confirmed', Rule.password()], }) const status = await Password.reset( request().only('email', 'password', 'password_confirmation', 'token'), async (user, password) => { await (user as User) .forceFill({ password: await Hash.make(password), remember_token: randomBytes(30).toString('hex'), }) .save() await event(new PasswordReset(user)) }, ) if (status === 'passwords.reset') { return redirect().route('login').with('status', __(status)) } // An unknown email answers like a bad token, so the reset can't reveal whether an account exists. throw ValidationException.withMessages({ email: [__(status === 'passwords.user' ? 'passwords.token' : status)] }) }}reset() returns passwords.reset ("Your password has been reset."), passwords.token ("This password reset token is
invalid.") for a wrong or expired token, or passwords.user when no user has the address. A token lasts expire
minutes, 60 in a new app. The new remember_token signs the user out of the browsers where they chose "Remember me".
In the Live kit, the two pages are the ForgotPassword and ResetPassword components, which do the same in their
actions. The API kit's endpoints answer JSON, and its reset also revokes every API token of the user.
A model with the hashed cast on password can take the plain password in the callback, in place of
Hash.make().
Deleting expired tokens#
An expired token can't be used, but its row stays until the user asks for another link. Laravel's
auth:clear-resets command isn't in Oriel; the token repository's deleteExpired() deletes them, so schedule it in
routes/ if you want the table kept small:
import { Password, Schedule } from '@orieljs/framework'Schedule.call(() => Password.broker().tokens.deleteExpired()) .everyFifteenMinutes() .name('clear-password-resets')Customization#
The broker's settings are under passwords in config/:
const auth = { passwords: { users: { provider: 'users', table: 'password_reset_tokens', expire: 60, throttle: 60 } },}expire is in minutes and throttle in seconds. Password.broker(name) uses another entry of passwords, with its
own table or user provider.
Reset link customization#
ResetPassword.createUrlUsing() builds the link yourself, in a service provider's boot(). This one starts it with
APP_URL, whatever host the request came to:
import { config, ResetPassword, ServiceProvider } from '@orieljs/framework'export class AppServiceProvider extends ServiceProvider { override boot(): void { ResetPassword.createUrlUsing((user, token) => { const email = encodeURIComponent(user.getEmailForPasswordReset()) return `${String(config('app.url'))}/reset-password/${token}?email=${email}` }) }}A client app on another domain, such as one using the API kit, points the link at its own page the same way.
Reset email customization#
ResetPassword.toMailUsing() builds the mail yourself: return a MailMessage with your own lines, or a
mailable. Its function receives the user and the token:
import { MailMessage, ResetPassword, ServiceProvider, url } from '@orieljs/framework'export class AppServiceProvider extends ServiceProvider { override boot(): void { ResetPassword.toMailUsing((user, token) => new MailMessage() .subject('Reset your password') .line('Someone asked to reset the password of your account.') .action('Choose a new password', url(`/reset-password/${token}`, { email: user.getEmailForPasswordReset() })), ) }}