use std::time::Duration; use argon2::{Argon2, PasswordVerifier}; use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Redirect, Response}, routing::get, Form, Router, }; use axum_extra::extract::{ cookie::{Cookie, SameSite}, CookieJar, }; use maud::{html, PreEscaped}; use tap::Tap; use crate::server::store::Store; use super::{render_html, ApiState, CookieDomain, MakeError, MakeErrorMessage}; pub fn bind(app: Router) -> Router { app.route("/login", get(handle_login_get).post(handle_login_post)) .route("/logout", get(logout)) .route( "/invite/:invite", get(handle_invite_get).post(handle_invite_post), ) } fn make_form(status: StatusCode, error: Option<&str>, submit: &str) -> Response { render_html( html!(title {"login"}), html! { @if let Some(error) = error { p { "error: " (PreEscaped(error)) } } form action="" method="post" { label for="name" { "name:" } input type="text" id="name" name="name" {} label for="password" { "password:" } input type="password" id="password" name="password" {} input type="submit" value=(submit) {} } }, ) .tap_mut(|response| *response.status_mut() = status) } #[axum::debug_handler] async fn handle_login_get() -> Response { make_form(StatusCode::OK, None, "login") } #[derive(serde::Deserialize)] struct FormData { name: String, password: String, } #[axum::debug_handler(state = ApiState)] async fn handle_login_post( jar: CookieJar, State(store): State, State(CookieDomain(cookie_domain)): State, Form(form): Form, ) -> Result { // read lock scope { let account = store.get_account(&form.name).await.ok_or_else(|| { make_form(StatusCode::FORBIDDEN, Some("invalid credentials"), "login") })?; Argon2::default() .verify_password(form.password.as_bytes(), &account.password.parsed()) .ok() .ok_or_else(|| { make_form(StatusCode::FORBIDDEN, Some("invalid credentials"), "login") })?; } let token = store .create_token(&form.name) .await .make_error()? .error_message( StatusCode::INTERNAL_SERVER_ERROR, "account disappeared while generating token", )?; let mut cookie = Cookie::new("dissociate-token", token); cookie.set_http_only(true); cookie.set_max_age(Duration::from_secs(60 * 60 * 24 * 29).try_into().ok()); cookie.set_path("/"); cookie.set_same_site(SameSite::None); cookie.set_secure(true); if let Some(cookie_domain) = cookie_domain { cookie.set_domain(cookie_domain); } Ok((jar.add(cookie), Redirect::to("/")).into_response()) } #[axum::debug_handler] async fn logout(jar: CookieJar) -> Response { ( jar.remove(Cookie::from("dissociate-token")), Redirect::to("/login"), ) .into_response() } fn invite_error() -> Response { render_html( html!(), html! { h1 { "unknown invite" } a href="/" { "return home" } }, ) .tap_mut(|response| *response.status_mut() = StatusCode::NOT_FOUND) } #[axum::debug_handler] async fn handle_invite_get(Path(invite): Path, State(store): State) -> Response { if !store.check_invite(&invite).await { return invite_error(); } make_form(StatusCode::OK, None, "create account") } const NAME_ERROR: &str = "invalid name requirements: must not be empty must not exceed 32 characters must not contain control characters"; #[axum::debug_handler(state = ApiState)] async fn handle_invite_post( jar: CookieJar, Path(invite): Path, State(store): State, State(CookieDomain(cookie_domain)): State, Form(form): Form, ) -> Result { if form.name.to_ascii_lowercase() == "empty" || form.password.to_ascii_lowercase() == "empty" { return Err(make_form( StatusCode::BAD_REQUEST, Some("are you jokester?"), "create account", )); } if form.name.is_empty() || form.name.len() > 32 || form.name.chars().any(|ch| ch.is_control()) { return Err(make_form( StatusCode::BAD_REQUEST, Some(&NAME_ERROR.replace('\n', "
")), "create account", )); } if form.password.is_empty() { return Err(make_form( StatusCode::BAD_REQUEST, Some("password cannot be empty"), "create account", )); } if store.get_account(&form.name).await.is_some() { return Err(make_form( StatusCode::CONFLICT, Some("name taken"), "create account", )); } if !store.use_invite(&invite).await.make_error()? { return Err(invite_error()); } if !store .create_account(&form.name, &form.password) .await .make_error()? { return Err(make_form( StatusCode::CONFLICT, Some("name taken"), "create account", )); } let token = store .create_token(&form.name) .await .make_error()? .error_message( StatusCode::INTERNAL_SERVER_ERROR, "account disappeared while generating token", )?; let mut cookie = Cookie::new("dissociate-token", token); cookie.set_http_only(true); cookie.set_max_age(Duration::from_secs(60 * 60 * 24 * 29).try_into().ok()); cookie.set_path("/"); cookie.set_same_site(SameSite::None); cookie.set_secure(true); if let Some(cookie_domain) = cookie_domain { cookie.set_domain(cookie_domain); } Ok((jar.add(cookie), Redirect::to("/")).into_response()) }