ocre-cli 0.2.0

Command-line tool for Ocre: create, generate, migrate, run and deploy apps.
//! Password reset by emailed link (HTML). Generated by `ocre g auth`.

use askama::Template;
use axum::{
    Form, Router,
    extract::{Path, State},
    http::{HeaderMap, StatusCode, Uri},
    response::{Html, IntoResponse, Redirect, Response},
    routing::{get, post},
};
use ocre::{
    Ctx, Error, FieldError, Flash, Result, Session, Validator,
    mail::{self, Email},
    render,
};
use serde::Deserialize;

use crate::{
    auth::{origin, sign_out},
    auth_api::throttle,
    models::{
        auth_token::{self, PASSWORD_RESET, valid_minutes},
        user,
    },
    sessions::EmailForm,
};

pub fn routes() -> Router<Ctx> {
    Router::new()
        .route("/passwords/new", get(new))
        .route("/passwords", post(create))
        .route("/passwords/{token}", get(edit).post(update))
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct PasswordForm {
    pub password: String,
    pub password_confirmation: String,
}

#[derive(Template)]
#[template(path = "auth/password_new.html")]
struct NewView {
    flash: Flash,
}

#[derive(Template)]
#[template(path = "auth/password_edit.html")]
struct EditView {
    token: String,
    errors: Vec<FieldError>,
}

async fn new(flash: Flash) -> Result<Html<String>> {
    render(&NewView { flash })
}

/// Emails a reset link. Same answer whether or not the email has an account.
async fn create(
    State(ctx): State<Ctx>,
    session: Session,
    headers: HeaderMap,
    uri: Uri,
    Form(form): Form<EmailForm>,
) -> Result<Redirect> {
    throttle(&ctx, &headers, "password_reset").await?;
    if let Some(user) = user::find_by_email(&ctx, &form.email).await? {
        let token = auth_token::issue(&ctx, user.id, PASSWORD_RESET).await?;
        let link = format!("{}/passwords/{token}", origin(&uri));
        let minutes = valid_minutes(PASSWORD_RESET);
        let text = format!(
            "Open this link within {minutes} minutes to choose a new password:\n\n{link}\n\nIf you did not ask for it, ignore this email.\n"
        );
        let html = format!(
            "<p><a href=\"{link}\">Choose a new password</a> (valid {minutes} minutes).</p><p>If you did not ask for it, ignore this email.</p>"
        );
        mail::send(&ctx, Email::new(&user.email, "Reset your password", text).html(html)).await?;
    }
    session.flash("notice", "If an account exists for that email, password reset instructions are on their way.")?;
    Ok(Redirect::to("/login"))
}

async fn edit(State(ctx): State<Ctx>, session: Session, Path(token): Path<String>) -> Result<Response> {
    if auth_token::peek(&ctx, PASSWORD_RESET, &token).await?.is_none() {
        return invalid_link(&session);
    }
    Ok(render(&EditView { token, errors: vec![] })?.into_response())
}

async fn update(
    State(ctx): State<Ctx>,
    session: Session,
    Path(token): Path<String>,
    Form(form): Form<PasswordForm>,
) -> Result<Response> {
    let mut v = Validator::new();
    user::validate_password(&mut v, &form.password);
    v.check("password_confirmation", form.password != form.password_confirmation, "doesn't match Password");
    match v.finish() {
        Ok(()) => {}
        Err(Error::Invalid(errors)) => {
            let page = render(&EditView { token, errors })?;
            return Ok((StatusCode::UNPROCESSABLE_ENTITY, page).into_response());
        }
        Err(err) => return Err(err),
    }
    let Some(user_id) = auth_token::consume(&ctx, PASSWORD_RESET, &token).await? else {
        return invalid_link(&session);
    };
    user::update_password(&ctx, user_id, &form.password).await?;
    // The reset link was opened from the mailbox: the address is confirmed too.
    user::confirm(&ctx, user_id).await?;
    sign_out(&ctx, &session).await?;
    session.flash("notice", "Password updated. Log in with your new password.")?;
    Ok(Redirect::to("/login").into_response())
}

fn invalid_link(session: &Session) -> Result<Response> {
    session.flash("alert", "That password reset link is invalid or has expired.")?;
    Ok(Redirect::to("/passwords/new").into_response())
}