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