//! Password hashing, and who the current request is.
//!
//! Sessions are a stateless signed cookie: the browser carries a signed
//! payload naming the user, and the server verifies the signature rather than
//! looking anything up. [`CurrentUser`] is that payload, extracted from the
//! request.
use actix_session::Session;
use actix_session::SessionExt;
use actix_web::{FromRequest, HttpRequest, dev::Payload, error::ErrorUnauthorized};
use argon2::Argon2;
use argon2::password_hash::{
PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng,
};
use std::future::{Ready, ready};
use crate::config::SESSION_SECRET_VAR_KEY;
use crate::entities::users;
/// The session keys naming the logged-in user. The name rides along with the
/// id so the page shell can address the visitor - their profile, their way
/// out - on every request without a database round trip; nothing here is
/// secret, so carrying it costs nothing.
const USER_ID: &str = "user_id";
const USER_NAME: &str = "user_name";
/// Hashes `password` with argon2id, the result being what goes in the
/// database. Each call draws a fresh random salt.
///
/// The span is at `debug` so it does not clutter the terminal; argon2 is
/// deliberately slow, so it is the first place to look when a login feels
/// sluggish.
#[tracing::instrument(level = "debug", name = "password.hash", skip_all)]
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
Ok(Argon2::default()
.hash_password(password.as_bytes(), &salt)?
.to_string())
}
/// Checks `password` against a stored hash. A stored value that is not a
/// valid hash is a mismatch, not an error: the row is unusable either way.
#[tracing::instrument(level = "debug", name = "password.verify", skip_all)]
pub fn verify_password(password: &str, hash: &str) -> bool {
let Ok(parsed) = PasswordHash::new(hash) else {
return false;
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
/// The user making this request, taken from the session cookie.
///
/// The name comes from the session rather than the database, so the page shell
/// can address the visitor without a lookup; nothing here is secret, so
/// carrying it costs nothing. Callers that need the row itself look it up by
/// [`CurrentUser::id`].
pub struct CurrentUser {
pub id: i64,
pub name: String,
}
impl FromRequest for CurrentUser {
type Error = actix_web::Error;
type Future = Ready<Result<Self, Self::Error>>;
/// Reads the visitor out of the session. Failing here is a `401` rather
/// than an error page, since the only thing the caller can do about it is
/// log in.
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let session = req.get_session();
let id = session.get::<i64>(USER_ID);
let name = session.get::<String>(USER_NAME);
// Both halves are written together, so a session missing one of them
// is not a session of ours worth honouring.
match (id, name) {
(Ok(Some(id)), Ok(Some(name))) => ready(Ok(CurrentUser { id, name })),
_ => ready(Err(ErrorUnauthorized("log in to continue"))),
}
}
}
/// Records `user` as the session's user.
pub fn log_in(session: &Session, user: &users::Model) -> Result<(), actix_web::Error> {
session
.insert(USER_ID, user.id)
.map_err(|_| actix_web::error::ErrorInternalServerError("could not start a session"))?;
session
.insert(USER_NAME, &user.name)
.map_err(|_| actix_web::error::ErrorInternalServerError("could not start a session"))?;
Ok(())
}
/// Ends the session.
pub fn log_out(session: &Session) {
session.clear();
}
/// The name to address the visitor by, when there is one.
///
/// Takes the optional `CurrentUser` extractors hand in, so that the pages that
/// do not need a signed-in visitor can still greet one.
pub fn viewer_name(viewer: Option<&CurrentUser>) -> Option<&str> {
viewer.map(|viewer| viewer.name.as_str())
}
/// The key the session cookie is signed with.
///
/// Taken from [`SESSION_SECRET_VAR`] so that sessions survive a restart and
/// cannot be forged by another instance. Without it - while developing, say -
/// a random key is used and everyone is logged out on restart.
pub fn session_key() -> actix_web::cookie::Key {
match std::env::var(SESSION_SECRET_VAR_KEY) {
Ok(secret) if secret.len() >= 64 => actix_web::cookie::Key::from(secret.as_bytes()),
Ok(_) => {
tracing::warn!(
"{SESSION_SECRET_VAR_KEY} is shorter than 64 bytes; generating a random key instead"
);
actix_web::cookie::Key::generate()
}
Err(_) => {
tracing::warn!(
"{SESSION_SECRET_VAR_KEY} is not set; sessions will not survive a restart"
);
actix_web::cookie::Key::generate()
}
}
}