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, Flash, Result, Session,
mail::{self, Email},
render,
};
use serde::Deserialize;
use crate::{
auth::{OAUTH_PROVIDERS, origin, sign_in, sign_out},
auth_api::throttle,
models::{
auth_token::{self, MAGIC_LINK, valid_minutes},
user,
},
};
pub fn routes() -> Router<Ctx> {
Router::new()
.route("/login", get(new).post(create))
.route("/logout", post(destroy))
.route("/magic_link", get(new_magic_link).post(create_magic_link))
.route("/magic_link/{token}", get(show_magic_link).post(use_magic_link))
}
#[derive(Clone, Default, Deserialize)]
#[serde(default)]
pub struct LoginForm {
pub email: String,
pub password: String,
pub remember_me: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct EmailForm {
pub email: String,
}
#[derive(Template)]
#[template(path = "auth/login.html")]
struct LoginView {
flash: Flash,
alert: Option<&'static str>,
email: String,
oauth_providers: &'static [&'static str],
}
#[derive(Template)]
#[template(path = "auth/magic_link_new.html")]
struct MagicLinkNewView {
flash: Flash,
}
#[derive(Template)]
#[template(path = "auth/magic_link_show.html")]
struct MagicLinkShowView {
token: String,
}
async fn new(flash: Flash) -> Result<Html<String>> {
render(&LoginView { flash, alert: None, email: String::new(), oauth_providers: OAUTH_PROVIDERS })
}
async fn create(
State(ctx): State<Ctx>,
session: Session,
headers: HeaderMap,
Form(form): Form<LoginForm>,
) -> Result<Response> {
throttle(&ctx, &headers, "login").await?;
match user::authenticate(&ctx, &form.email, &form.password).await? {
Some(user) => {
let next = sign_in(&ctx, &session, &headers, &user, form.remember_me.is_some()).await?;
session.flash("notice", "Signed in.")?;
Ok(Redirect::to(&next).into_response())
}
None => {
let alert = Some("Invalid email or password.");
let view = LoginView { flash: Flash::default(), alert, email: form.email, oauth_providers: OAUTH_PROVIDERS };
Ok((StatusCode::UNPROCESSABLE_ENTITY, render(&view)?).into_response())
}
}
}
async fn destroy(State(ctx): State<Ctx>, session: Session) -> Result<Redirect> {
sign_out(&ctx, &session).await?;
session.flash("notice", "Signed out.")?;
Ok(Redirect::to("/"))
}
async fn new_magic_link(flash: Flash) -> Result<Html<String>> {
render(&MagicLinkNewView { flash })
}
async fn create_magic_link(
State(ctx): State<Ctx>,
session: Session,
headers: HeaderMap,
uri: Uri,
Form(form): Form<EmailForm>,
) -> Result<Redirect> {
throttle(&ctx, &headers, "magic_link").await?;
if let Some(user) = user::find_by_email(&ctx, &form.email).await? {
let token = auth_token::issue(&ctx, user.id, MAGIC_LINK).await?;
let link = format!("{}/magic_link/{token}", origin(&uri));
let minutes = valid_minutes(MAGIC_LINK);
let text = format!(
"Open this link within {minutes} minutes to sign in:\n\n{link}\n\nIf you did not ask for it, ignore this email.\n"
);
let html = format!(
"<p><a href=\"{link}\">Sign in</a> (valid {minutes} minutes).</p><p>If you did not ask for it, ignore this email.</p>"
);
mail::send(&ctx, Email::new(&user.email, "Your sign-in link", text).html(html)).await?;
}
session.flash("notice", "If an account exists for that email, a sign-in link is on its way.")?;
Ok(Redirect::to("/login"))
}
async fn show_magic_link(Path(token): Path<String>) -> Result<Html<String>> {
render(&MagicLinkShowView { token })
}
async fn use_magic_link(
State(ctx): State<Ctx>,
session: Session,
headers: HeaderMap,
Path(token): Path<String>,
) -> Result<Redirect> {
let user = match auth_token::consume(&ctx, MAGIC_LINK, &token).await? {
Some(user_id) => {
user::confirm(&ctx, user_id).await?;
user::find(&ctx, user_id).await?
}
None => None,
};
let Some(user) = user else {
session.flash("alert", "That sign-in link is invalid or has expired.")?;
return Ok(Redirect::to("/magic_link"));
};
let next = sign_in(&ctx, &session, &headers, &user, false).await?;
session.flash("notice", "Signed in.")?;
Ok(Redirect::to(&next))
}