Login and roles
The optional auth feature logs users in over sessions with Argon2 password hashes, and gives every user exactly one role. It adds no dependencies (ADR 0013).
Roles#
A user's role is one role column, so a user always has exactly one: never none, never two. The app lists its roles highest first and hands them to web::App:
pub const ROLES: &[&str] = &["super-admin", "admin", "user", "blocked"];
pub fn app() -> App {
App { /* config, migrations, seeders, routes */ roles: ROLES }
}
super-admin | Passes every check |
blocked | Fails every check, cannot log in, and is logged out on their next request |
| anyone else | Decided by the policy: auth::allows(user, |user| ...), or role("admin") for that role or above |
Change a role with request.auth().sync_role(db, id, "blocked"): it replaces the role and refuses slugs the app does not list. Blocking is a role change, never a delete.
Routes#
use rustclamp::web::auth::{authenticate, role};
router.group(|web| {
web.middleware(sessions.middleware())
.middleware(csrf())
.middleware(authenticate) // loads request.user()
.get("/login", auth::show)
.post("/login", auth::store)
.post("/logout", auth::destroy)
.group(|admin| admin.middleware(role("admin")).get("/admin/posts", posts::index))
})
Guests on a guarded page are redirected to /login. A logged-in user without the role gets 403, which the router answers as the 404 page and logs with http_status_code=403, so a hidden page is never confirmed to exist.
Logging in#
match request.auth().attempt(request, form.get("email"), form.get("password"))? {
Ok(_user) => Ok(redirect("/admin/posts")),
Err(Refused::Invalid) => /* the same message for unknown email and wrong password */,
Err(Refused::Blocked) => /* the password was right; the account is disabled */,
Err(Refused::Throttled(wait)) => /* LOGIN_PER_MINUTE (5) per email and client */,
}
Behind nginx, set TRUST_PROXY=true: the client is then the forwarded address, so one visitor cannot lock an account out for everyone. A successful login moves the session to a new ID. auth::logout(request), from a POST route, clears the session and moves it again. The session holds only the user's id; authenticate reads the user on every request, so a role change applies at once. Views get public_id, name, email and role, never the id or the password hash.
Registration, verification and password reset#
With the mail feature as well, request.auth() runs the account flows Laravel's Fortify gives you. Links in the mail are built from APP_URL (never from the request) and signed with APP_KEY; the app refuses to start without them.
register(request, name, email, password) | Creates a user and mails a link that confirms the address within 24 hours. An address that already has an account gets the same answer and no mail. The new user logs in after confirming. |
verify(request), resend_verification(request) | The link's route, /email/verify/{user}, and a new link for a logged-in user. The auth::verified guard sends unconfirmed users to /email/verify. |
forgot(request, email) | Mails a reset link valid for 60 minutes, if the address has an account. Every address gets the same answer. |
open_reset, reset_pending, reset | The link moves its token into the session and redirects to a clean /reset-password. Setting the password spends the link and logs out every session of that account. |
confirm(request, password) | For the auth::password_confirmed guard: asks again after 3 hours, then returns to the page that asked. |
Account mail is limited to 3 an hour per client and 1 per 10 minutes per address, and counts against the daily mail cap. The users table needs a nullable email_verified_at.
The first admin#
Create the first admin from the command line; the account is verified and the password is generated and shown once:
cargo run -- user:create [email protected] admin "Ada"
The app owns its users migration: id, public_id, name, email (unique), password, role (default user) and timestamps.