Lesson 1’s scavenger hunt found passwords stored in plaintext. Lesson 3
pointedly refused to seed them. This is the lesson that pays those setups
off: registration, sign-in, sign-out, and the auth extractor that will gate
order history — plus the return of j2ee/j2ee, finally stored the way a
password should be. Checkpoint:
lesson-08.
The whole password policy, two functions
argon2 is the port’s eighth and final planned dependency, and src/auth.rs
is everything the app knows about passwords:
pub fn hash_password(password: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|_| AppError::Hashing)
}
pub fn verify(stored_hash: &str, password: &str) -> bool {
PasswordHash::new(stored_hash)
.map(|parsed| Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
.unwrap_or(false)
}
What you get for those few lines: argon2id, a random salt per password, and
the PHC string format — $argon2id$v=19$m=19456,t=2,p=1$… — which stores
the algorithm and its parameters inside the hash, so the column documents
itself and can be re-hashed forward when parameters age. Note verify
returns a plain bool: a wrong password, a corrupt column, and an
unparseable hash are all just “no.” Sign-in has no use for the distinction,
and APIs that offer unused distinctions grow bugs in the gaps.
Hashing is deliberately slow — tens of milliseconds, that’s the security
property. At course scale, calling it inline is fine; a high-traffic
service wraps these calls in tokio::task::spawn_blocking, and now you
know why.
The demo users return
Migration 0003_signon_seed.sql inserts j2ee and ACID with argon2
hashes, generated by a twelve-line example binary you’ll keep around:
cargo run --example mkhash -- j2ee
Run the app and watch the migration machinery from lesson 3 earn its keep:
your existing jpetstore.db gets exactly the one new migration applied, no
rebuild, no reset. Then peek at the column — sqlite3 jpetstore.db 'SELECT * FROM signon;' — and compare it against what lesson 1’s scavenger
hunt found in the original. Same users, same demo passwords, but the column
is now useless to whoever steals it.
Registration is a transaction
The original’s registration writes three tables — signon, account,
profile — and so does ours, atomically:
let mut tx = pool.begin().await?;
sqlx::query!("INSERT INTO signon …").execute(&mut *tx).await?;
sqlx::query!("INSERT INTO account …").execute(&mut *tx).await?;
sqlx::query!("INSERT INTO profile …").execute(&mut *tx).await?;
tx.commit().await
Any failure before commit rolls back all three inserts — lesson 10 leans
on this same machinery for orders. And notice what’s not here: no
“check if username exists” query. The signon primary key makes duplicates
impossible, the first insert trips a unique violation, and the handler
matches on it:
Err(err) if db::account::is_unique_violation(&err) => {
// re-render the form: "That username is taken."
}
Check-then-insert is a race you lose on a bad day; the database was always the arbiter of uniqueness, so let it arbitrate.
One structural detail worth copying into your own projects: NewAccount
carries the ten profile fields and deliberately not the password. The
password travels alone, from form to hash to database, and never sits on a
struct that might get debug-logged.
Sign-in, and the id swap
let ok = stored.as_deref().is_some_and(|hash| auth::verify(hash, &form.password));
if !ok {
// "Wrong username or password." — one message for both cases
}
session.cycle_id().await?;
session.insert(USER_KEY, &form.username).await?;
Two security decisions live in these lines. The single error message means
the form never confirms which usernames exist. And cycle_id issues a
fresh session id at the privilege boundary — whatever id the browser
carried while anonymous is worthless after sign-in, which closes session
fixation. The part students usually find surprising: the session data
survives the id swap. Your cart, added anonymously, is still there after
you sign in. That’s the “cart survives login” behavior real shops have, and
it fell out of doing the security right.
Sign-out is the blunt instrument: session.flush() — everything goes,
cart included, exactly like the original’s signoff() invalidating the
whole HttpSession.
Proof by extractor
The account page is gated by its own argument list:
pub struct AuthUser(pub String);
pub async fn account(AuthUser(username): AuthUser) -> AppResult<Html<String>> {
Ok(Html(AccountTemplate { username }.render()?))
}
AuthUser implements FromRequestParts: it pulls the session, looks for
the signed-in username, and rejects with a redirect to /signin if there
isn’t one. A handler that takes an AuthUser cannot run for an anonymous
visitor — the type system enforces what Spring Security does with filter
chains and annotations. This is lesson 4’s parse-don’t-validate one more
time, aimed at authentication: an AuthUser value is proof the check
happened, and lesson 10’s order pages will simply ask for one.
Build it
| File | Action | What goes in it |
|---|---|---|
Cargo.toml |
generated | cargo add argon2 --features std |
src/auth.rs |
write | The two functions shown above, plus tests: roundtrip, wrong password, garbage hashes (including the original’s 'j2ee' column contents) |
examples/mkhash.rs |
write | argon2 + SaltString::generate + println — twelve lines |
migrations/0003_signon_seed.sql |
write | Two INSERTs with hashes you generated via mkhash |
src/domain/account.rs |
write | NewAccount — ten fields, no password |
src/db/account.rs |
write | password_hash, the three-insert transaction create, is_unique_violation; tests: seeded j2ee verifies, register-then-read-back, duplicate is a unique violation |
src/web/account.rs |
write | AuthUser + FromRequestParts shown above; signin form/POST, register form/POST, signout, the gated account page |
src/web/error.rs |
modify | A Hashing variant, mapped to 500 |
src/web/mod.rs, src/main.rs, src/domain/mod.rs, src/db/mod.rs |
modify | mod auth;, module declarations, five new routes |
templates/signin.html, register.html, account.html |
write | Forms with an optional error line; account shows the username and a sign-out button |
templates/base.html |
modify | An Account link in the header |
.sqlx/ |
generated | cargo sqlx prepare |
Done when: anonymous /account answers 303 to /signin; a wrong
password re-renders the form with the message; j2ee/j2ee lands on
“Signed in as j2ee”; an item added before sign-in is still in the cart
after; sign-out re-gates the account page; and cargo test shows 20
passing.
Answer key: lesson-07...lesson-08.
Challenge: don’t leak who exists
The error message is the same for unknown users and wrong passwords, but
the timing isn’t: an unknown user skips the ~50ms argon2 verification, so
a stopwatch can still enumerate usernames. Close the gap — when the user
doesn’t exist, verify the password against a throwaway hash anyway and
discard the result. Generate one dummy hash at startup, not per-request,
and convince yourself with time curl before and after.
Next lesson: checkout. The forms, the confirmation page — and the reason there is no credit card column for them to write to.