🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! 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 std::future::{Ready, ready};

use actix_session::{Session, SessionExt};
use actix_web::dev::Payload;
use actix_web::error::ErrorUnauthorized;
use actix_web::{FromRequest, HttpRequest};
use argon2::Argon2;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};

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()
        }
    }
}