Web

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-adminPasses every check
blockedFails every check, cannot log in, and is logged out on their next request
anyone elseDecided 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, resetThe 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.