diff options
author | mia <mia@mia.jetzt> | 2024-04-16 19:05:41 -0700 |
---|---|---|
committer | mia <mia@mia.jetzt> | 2024-04-16 19:05:41 -0700 |
commit | 796b2cafc798a7faa80a007002831a4c40635fe8 (patch) | |
tree | d8e68590524f4adab7ff8ff6e2cb3dfbb0c64b37 /src/server/login.rs | |
download | dissociate-0.1.0.tar.gz dissociate-0.1.0.zip |
initial commit v0.1.0
Diffstat (limited to 'src/server/login.rs')
-rw-r--r-- | src/server/login.rs | 208 |
1 files changed, 208 insertions, 0 deletions
diff --git a/src/server/login.rs b/src/server/login.rs new file mode 100644 index 0000000..f9cee70 --- /dev/null +++ b/src/server/login.rs @@ -0,0 +1,208 @@ +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<ApiState>) -> Router<ApiState> { + 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<Store>, + State(CookieDomain(cookie_domain)): State<CookieDomain>, + Form(form): Form<FormData>, +) -> Result<Response, Response> { + // 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::Strict); + 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<String>, State(store): State<Store>) -> 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<String>, + State(store): State<Store>, + State(CookieDomain(cookie_domain)): State<CookieDomain>, + Form(form): Form<FormData>, +) -> Result<Response, Response> { + 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', "<br>")), + "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::Strict); + cookie.set_secure(true); + if let Some(cookie_domain) = cookie_domain { + cookie.set_domain(cookie_domain); + } + + Ok((jar.add(cookie), Redirect::to("/")).into_response()) +} |