ocre-cli 0.2.0

Command-line tool for Ocre: create, generate, migrate, run and deploy apps.
//! User model: the `users` table. Generated by `ocre g auth`.
//!
//! Emails are stored trimmed and lowercased, so `Ada@Example.com` and
//! `ada@example.com` are one account. Passwords are stored as
//! `ocre::password` digests (PBKDF2); `password_digest` is never serialized,
//! and is empty for users who only sign in with an emailed link or an OAuth
//! provider. `confirmed_at` is set once the user opened the confirmation
//! email (`User::confirmed`).
//!
//! This module is Ocre's equivalent of Loco's `Authenticable` trait: the
//! extractors call `find` (sessions, JWTs) and `api_key::authenticate` (API
//! keys) directly.

use ocre::{Ctx, Error, Query, Result, Validator, params};
use serde::{Deserialize, Serialize};

/// Shortest accepted password.
pub const MIN_PASSWORD_LENGTH: usize = 8;
/// Longest accepted password, to bound the work per request.
pub const MAX_PASSWORD_LENGTH: usize = 128;

/// A row of the `users` table.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct User {
    pub id: i64,
    pub email: String,
    #[serde(skip_serializing)]
    pub password_digest: String,
    /// When the user confirmed their email address; `None` until then.
    pub confirmed_at: Option<String>,
    pub created_at: String,
    pub updated_at: String,
}

impl User {
    /// Whether the user opened the link of the confirmation email.
    pub fn confirmed(&self) -> bool {
        self.confirmed_at.is_some()
    }

    /// Whether the user has a password (OAuth-only users have none).
    pub fn has_password(&self) -> bool {
        !self.password_digest.is_empty()
    }

    // ocre:associations
}

/// Sign-up values, as typed (HTML form or JSON). Missing fields are empty,
/// so they fail validation with a message instead of a 400. No `Debug`: it
/// would print the password in logs.
#[derive(Clone, Default, Deserialize)]
#[serde(default)]
pub struct NewUser {
    pub email: String,
    pub password: String,
}

impl NewUser {
    /// Checks that need no database; `create` adds uniqueness.
    pub fn validate(&self) -> Validator {
        let mut v = Validator::new();
        validate_email(&mut v, &self.email);
        validate_password(&mut v, &self.password);
        v
    }
}

/// `" Ada@Example.COM "` -> `"ada@example.com"`.
pub fn normalize_email(email: &str) -> String {
    email.trim().to_lowercase()
}

/// Email rules, for sign-up and OAuth sign-ups.
pub fn validate_email(v: &mut Validator, email: &str) {
    let email = normalize_email(email);
    if email.is_empty() {
        v.required("email", &email);
    } else {
        v.email("email", &email).max_length("email", &email, 254);
    }
}

/// Password rules, for sign-up and password changes.
pub fn validate_password(v: &mut Validator, password: &str) {
    v.min_length("password", password, MIN_PASSWORD_LENGTH).max_length("password", password, MAX_PASSWORD_LENGTH);
}

pub async fn find(ctx: &Ctx, id: i64) -> Result<Option<User>> {
    ctx.db()?.first("SELECT * FROM users WHERE id = ?1", params![id]).await
}

/// Loads many users in few queries (100 ids per query, D1's limit on
/// parameters): what `belongs_to` preloads of other models call.
pub async fn find_many(ctx: &Ctx, ids: &[i64]) -> Result<Vec<User>> {
    let db = ctx.db()?;
    let mut rows = Vec::with_capacity(ids.len());
    for chunk in ids.chunks(100) {
        rows.extend(Query::<User>::table("users").is_in("id", chunk.iter().copied()).all(&db).await?);
    }
    Ok(rows)
}

pub async fn find_by_email(ctx: &Ctx, email: &str) -> Result<Option<User>> {
    ctx.db()?.first("SELECT * FROM users WHERE email = ?1", params![normalize_email(email)]).await
}

/// Creates the user with a hashed password (one PBKDF2 run).
pub async fn create(ctx: &Ctx, new: NewUser) -> Result<User> {
    let db = ctx.db()?;
    let email = normalize_email(&new.email);
    let mut v = new.validate();
    v.check(
        "email",
        db.exists("SELECT 1 FROM users WHERE email = ?1 LIMIT 1", params![&email]).await?,
        "has already been taken",
    );
    v.finish()?;
    let password_digest = ocre::password::hash(&new.password).await?;
    db.first("INSERT INTO users (email, password_digest) VALUES (?1, ?2) RETURNING *", params![email, password_digest])
        .await?
        .ok_or_else(|| Error::internal("INSERT ... RETURNING returned no row"))
}

/// Creates a user without a password whose email is already confirmed
/// (verified by an OAuth provider).
pub async fn create_confirmed(ctx: &Ctx, email: &str) -> Result<User> {
    let mut v = Validator::new();
    validate_email(&mut v, email);
    v.finish()?;
    ctx.db()?
        .first(
            "INSERT INTO users (email, password_digest, confirmed_at) VALUES (?1, '', datetime('now')) RETURNING *",
            params![normalize_email(email)],
        )
        .await?
        .ok_or_else(|| Error::internal("INSERT ... RETURNING returned no row"))
}

/// The user with this email and password, or `None`. An unknown email (or a
/// user without a password) costs the same PBKDF2 run as a wrong password,
/// so response times do not reveal which emails have accounts.
pub async fn authenticate(ctx: &Ctx, email: &str, password: &str) -> Result<Option<User>> {
    let user = find_by_email(ctx, email).await?.filter(User::has_password);
    let Some(user) = user else {
        ocre::password::hash(password).await?;
        return Ok(None);
    };
    let valid = ocre::password::verify(password, &user.password_digest).await?;
    Ok(valid.then_some(user))
}

/// Replaces the password (password reset). 422 when it breaks the rules.
pub async fn update_password(ctx: &Ctx, id: i64, password: &str) -> Result<()> {
    let mut v = Validator::new();
    validate_password(&mut v, password);
    v.finish()?;
    let password_digest = ocre::password::hash(password).await?;
    ctx.db()?
        .execute(
            "UPDATE users SET password_digest = ?1, updated_at = datetime('now') WHERE id = ?2",
            params![password_digest, id],
        )
        .await?;
    Ok(())
}

/// Marks the email as confirmed (idempotent).
pub async fn confirm(ctx: &Ctx, id: i64) -> Result<()> {
    ctx.db()?
        .execute(
            "UPDATE users SET confirmed_at = COALESCE(confirmed_at, datetime('now')), updated_at = datetime('now') \
             WHERE id = ?1",
            params![id],
        )
        .await?;
    Ok(())
}

/// Deletes the account. Its tokens, API keys and sessions go with it
/// (`ON DELETE CASCADE`; D1 enforces foreign keys).
pub async fn delete(ctx: &Ctx, id: i64) -> Result<()> {
    ctx.db()?.execute("DELETE FROM users WHERE id = ?1", params![id]).await?;
    Ok(())
}

/// Checks a deletion request: the password for users who have one, the
/// email address typed again for the others. One PBKDF2 run at most.
pub async fn deletion_confirmed(user: &User, confirmation: &str) -> Result<bool> {
    if user.has_password() {
        ocre::password::verify(confirmation, &user.password_digest).await
    } else {
        Ok(normalize_email(confirmation) == user.email)
    }
}