🗝
summary refs log tree commit diff
path: root/src/server/login.rs
diff options
context:
space:
mode:
authormia <mia@mia.jetzt>2024-04-16 19:05:41 -0700
committermia <mia@mia.jetzt>2024-04-16 19:05:41 -0700
commit796b2cafc798a7faa80a007002831a4c40635fe8 (patch)
treed8e68590524f4adab7ff8ff6e2cb3dfbb0c64b37 /src/server/login.rs
downloaddissociate-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.rs208
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())
+}