ocre-cli 0.2.0

Command-line tool for Ocre: create, generate, migrate, run and deploy apps.
//! Signed-in sessions: the `user_sessions` table. Generated by
//! `ocre g auth --db-sessions`.
//!
//! The session cookie carries a random token; the table keeps only its
//! SHA-256 digest, with the IP address, the browser and the last activity.
//! Deleting a row signs that device out.

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

use crate::models::user::User;

/// A session, as listed on /account/sessions.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UserSession {
    pub id: i64,
    pub user_id: i64,
    pub ip_address: Option<String>,
    pub user_agent: Option<String>,
    /// Updated at most once an hour, to save D1 writes.
    pub last_seen_at: String,
    pub expires_at: String,
    pub created_at: String,
    /// Whether this is the session of the request listing them.
    #[serde(deserialize_with = "ocre::bool_from_sql")]
    pub current: bool,
}

/// Values for a new session.
pub struct NewUserSession {
    pub user_id: i64,
    pub ip_address: Option<String>,
    pub user_agent: Option<String>,
    /// Seconds before the session expires.
    pub seconds: i64,
}

/// Records a session and returns its token, for the session cookie. Expired
/// sessions of the user are deleted on the way (no cron needed).
pub async fn start(ctx: &Ctx, new: NewUserSession) -> Result<String> {
    let token = ocre::token::generate();
    let db = ctx.db()?;
    db.execute(
        "DELETE FROM user_sessions WHERE user_id = ?1 AND expires_at <= datetime('now')",
        params![new.user_id],
    )
    .await?;
    // Browsers send long User-Agent strings; 255 characters are enough to recognize one.
    let user_agent = new.user_agent.map(|agent| agent.chars().take(255).collect::<String>());
    db.execute(
        "INSERT INTO user_sessions (user_id, digest, ip_address, user_agent, expires_at) \
         VALUES (?1, ?2, ?3, ?4, datetime('now', ?5))",
        params![new.user_id, ocre::token::digest(&token), new.ip_address, user_agent, format!("+{} seconds", new.seconds)],
    )
    .await?;
    Ok(token)
}

/// The user of a live session, recording its activity at most once an hour.
pub async fn authenticate(ctx: &Ctx, token: &str) -> Result<Option<User>> {
    #[derive(Deserialize)]
    struct Row {
        session_id: i64,
        #[serde(deserialize_with = "ocre::bool_from_sql")]
        stale: bool,
        #[serde(flatten)]
        user: User,
    }
    let db = ctx.db()?;
    let row: Option<Row> = db
        .first(
            "SELECT users.*, user_sessions.id AS session_id, \
             (user_sessions.last_seen_at < datetime('now', '-1 hour')) AS stale \
             FROM user_sessions JOIN users ON users.id = user_sessions.user_id \
             WHERE user_sessions.digest = ?1 AND user_sessions.expires_at > datetime('now')",
            params![ocre::token::digest(token)],
        )
        .await?;
    let Some(row) = row else { return Ok(None) };
    if row.stale {
        db.execute("UPDATE user_sessions SET last_seen_at = datetime('now') WHERE id = ?1", params![row.session_id])
            .await?;
    }
    Ok(Some(row.user))
}

/// The user's live sessions, most recent first; `token` marks the current one.
pub async fn for_user(ctx: &Ctx, user_id: i64, token: &str) -> Result<Vec<UserSession>> {
    ctx.db()?
        .all(
            "SELECT id, user_id, ip_address, user_agent, last_seen_at, expires_at, created_at, digest = ?2 AS current \
             FROM user_sessions WHERE user_id = ?1 AND expires_at > datetime('now') ORDER BY last_seen_at DESC, id DESC",
            params![user_id, ocre::token::digest(token)],
        )
        .await
}

/// Ends the session of this token (sign out).
pub async fn end(ctx: &Ctx, token: &str) -> Result<()> {
    ctx.db()?.execute("DELETE FROM user_sessions WHERE digest = ?1", params![ocre::token::digest(token)]).await?;
    Ok(())
}

/// Ends one of the user's sessions; `false` when the user has no such session.
pub async fn revoke(ctx: &Ctx, user_id: i64, id: i64) -> Result<bool> {
    Ok(ctx.db()?.execute("DELETE FROM user_sessions WHERE id = ?1 AND user_id = ?2", params![id, user_id]).await? > 0)
}

/// Ends every session of the user but the one of `token`; returns how many.
pub async fn revoke_others(ctx: &Ctx, user_id: i64, token: &str) -> Result<usize> {
    ctx.db()?
        .execute(
            "DELETE FROM user_sessions WHERE user_id = ?1 AND digest != ?2",
            params![user_id, ocre::token::digest(token)],
        )
        .await
}