ocre-cli 0.2.0

Command-line tool for Ocre: create, generate, migrate, run and deploy apps.
//! Who is signed in, for HTML pages. Generated by `ocre g auth`.
//!
//! - `CurrentUser(user): CurrentUser` in a handler requires a signed-in user;
//!   other visitors are redirected to /login, then back to the page.
//! - `ConfirmedUser(user): ConfirmedUser` also requires a confirmed email.
//! - `OptionalUser(user): OptionalUser` gives `Option<User>` (e.g. a menu).
//! - `sign_in` / `sign_out` change the session; the login, sign-up,
//!   magic-link and OAuth handlers call them.
//!
//! The session (an encrypted cookie) holds only `user_id` and its expiry:
//! two weeks, in a cookie that survives browser restarts only when "Remember
//! me" was ticked. Nothing is stored on the server, so a session cannot be
//! revoked before it expires except by rotating SECRET_KEY_BASE (everyone
//! is signed out); `ocre g auth --db-sessions` tracks sessions in D1 instead.
//! JSON clients use `BearerUser` from src/auth_api.rs.

// Extractors and helpers for the app's own pages; not all are used yet.
#![allow(dead_code)]

use axum::{
    extract::FromRequestParts,
    http::{HeaderMap, Method, Uri, request::Parts},
    response::{IntoResponse, Redirect, Response},
};
use ocre::{Ctx, Error, Result, Session, security::url_from};

use crate::models::user::{self, User};

/// Session key holding the signed-in user's id.
pub const USER_ID: &str = "user_id";
/// Session key holding the page to return to after logging in.
const RETURN_TO: &str = "return_to";
/// How long a sign-in lasts, remembered or not.
pub const SESSION_SECONDS: i64 = 14 * 24 * 3600;
/// OAuth providers offered on the login page (`ocre g auth --oauth github`).
pub const OAUTH_PROVIDERS: &[&str] = &[];

/// The signed-in user. Visitors are redirected to /login.
pub struct CurrentUser(pub User);

/// The signed-in user, if any.
pub struct OptionalUser(pub Option<User>);

/// The signed-in user with a confirmed email. Visitors are redirected to
/// /login, unconfirmed users to /account (which offers a new email).
pub struct ConfirmedUser(pub User);

impl FromRequestParts<Ctx> for OptionalUser {
    type Rejection = Error;

    async fn from_request_parts(parts: &mut Parts, ctx: &Ctx) -> Result<Self> {
        let session = Session::from_request_parts(parts, ctx).await?;
        match session.get::<i64>(USER_ID)? {
            Some(id) => Ok(Self(user::find(ctx, id).await?)),
            None => Ok(Self(None)),
        }
    }
}

impl FromRequestParts<Ctx> for CurrentUser {
    type Rejection = Response;

    async fn from_request_parts(parts: &mut Parts, ctx: &Ctx) -> std::result::Result<Self, Response> {
        match OptionalUser::from_request_parts(parts, ctx).await {
            Ok(OptionalUser(Some(user))) => Ok(Self(user)),
            Ok(OptionalUser(None)) => Err(to_login(parts, ctx).await.unwrap_or_else(IntoResponse::into_response)),
            Err(err) => Err(err.into_response()),
        }
    }
}

impl FromRequestParts<Ctx> for ConfirmedUser {
    type Rejection = Response;

    async fn from_request_parts(parts: &mut Parts, ctx: &Ctx) -> std::result::Result<Self, Response> {
        let CurrentUser(user) = CurrentUser::from_request_parts(parts, ctx).await?;
        if user.confirmed() {
            return Ok(Self(user));
        }
        let session = Session::from_request_parts(parts, ctx).await.map_err(IntoResponse::into_response)?;
        session.flash("alert", "Please confirm your email address first.").map_err(IntoResponse::into_response)?;
        Err(Redirect::to("/account").into_response())
    }
}

/// Redirects to /login, remembering the page for GET requests (a form
/// submission cannot be replayed after a redirect).
async fn to_login(parts: &mut Parts, ctx: &Ctx) -> Result<Response> {
    let session = Session::from_request_parts(parts, ctx).await?;
    if parts.method == Method::GET {
        session.insert(RETURN_TO, parts.uri.path_and_query().map_or("/", |path| path.as_str()))?;
    }
    session.flash("alert", "Please log in to continue.")?;
    Ok(Redirect::to("/login").into_response())
}

/// Signs `user` in and returns where to go next: the page that asked for a
/// login, or `/`. The session is emptied first (pending flash messages
/// stay), so nothing from before the login carries over (session fixation).
/// `remember` keeps the cookie across browser restarts. `ctx` and `headers`
/// are used by the `--db-sessions` variant of this file, which records the
/// session in D1.
pub async fn sign_in(_ctx: &Ctx, session: &Session, _headers: &HeaderMap, user: &User, remember: bool) -> Result<String> {
    // Only paths of this app: never an open redirect.
    let return_to = session.get::<String>(RETURN_TO)?.and_then(|path| url_from(&Uri::default(), &path));
    session.clear()?;
    session.insert(USER_ID, user.id)?;
    if remember { session.remember_for(SESSION_SECONDS)? } else { session.expire_in(SESSION_SECONDS)? }
    Ok(return_to.unwrap_or_else(|| "/".to_owned()))
}

/// Signs out: empties the session.
pub async fn sign_out(_ctx: &Ctx, session: &Session) -> Result<()> {
    session.clear()
}

/// `https://app.example.com`: scheme and host of the current request, for
/// links in emails. Cloudflare routes requests by host name, so this is always
/// one of the app's own hosts (list them in ALLOWED_HOSTS to be sure).
pub fn origin(uri: &Uri) -> String {
    let scheme = uri.scheme_str().unwrap_or("https");
    let host = uri.authority().map_or("localhost", |authority| authority.as_str());
    format!("{scheme}://{host}")
}