From 8c86dd6cda09c97d525d500336c3674ad3a54ec0 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 24 Aug 2025 17:04:27 -0400 Subject: [PATCH] add: login --- .gitignore | 2 - app/.gitignore | 1 + app/public/style.css | 9 +- app/public/tokens.js | 24 +++++ app/templates_src/index.lisp | 4 - app/templates_src/login.lisp | 109 ++++++++++++++++++++ app/templates_src/root.lisp | 33 +++++- src/config.rs | 2 +- src/routes/api/auth.rs | 191 +++++++++++++++++++++++++++++++++++ src/routes/api/mod.rs | 13 ++- src/routes/mod.rs | 5 +- src/routes/pages/misc.rs | 35 +++++-- src/routes/pages/mod.rs | 4 +- 13 files changed, 407 insertions(+), 25 deletions(-) create mode 100644 app/public/tokens.js create mode 100644 app/templates_src/login.lisp create mode 100644 src/routes/api/auth.rs diff --git a/.gitignore b/.gitignore index 7961184..2f7896d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1 @@ target/ -tetratto.toml -app.toml diff --git a/app/.gitignore b/app/.gitignore index 53bfc0b..5b117d6 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -5,3 +5,4 @@ templates_build/ public/favicon.svg .env app.toml +tetratto.toml diff --git a/app/public/style.css b/app/public/style.css index 2f2b3b4..8abc8ce 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -345,7 +345,7 @@ input { input:not([type="checkbox"]):focus { outline: solid 2px var(--color-primary); - box-shadow: 0 0 0 4px oklch(87% 0.065 274.039 / 25%); + box-shadow: 0 0 0 4px oklch(70.5% 0.213 47.604 / 25%); background: var(--color-super-raised); } @@ -450,19 +450,14 @@ code:not(pre *):not(.dark *) { svg.icon { stroke: currentColor; - fill: currentColor; width: 18px; height: 1em; } -svg.icon.filled { +.filled svg.icon { fill: currentColor; } -.no_fill svg.icon { - fill: transparent; -} - button svg { pointer-events: none; } diff --git a/app/public/tokens.js b/app/public/tokens.js new file mode 100644 index 0000000..5f15681 --- /dev/null +++ b/app/public/tokens.js @@ -0,0 +1,24 @@ +globalThis.LOGIN_ACCOUNT_TOKENS = JSON.parse( + window.localStorage.getItem("login_account_tokens") || "[]", +); + +function save_login_account_tokens() { + window.localStorage.setItem( + "login_account_tokens", + JSON.stringify(LOGIN_ACCOUNT_TOKENS), + ); +} + +function user_logout() { + if (!confirm("Are you sure you would like to do this?")) { + return; + } + + fetch("/api/v1/auth/logout", { method: "POST" }) + .then((res) => res.json()) + .then((res) => { + if (res.ok) { + window.location.href = "/"; + } + }); +} diff --git a/app/templates_src/index.lisp b/app/templates_src/index.lisp index 8a45f31..41155c2 100644 --- a/app/templates_src/index.lisp +++ b/app/templates_src/index.lisp @@ -1,10 +1,6 @@ (text "{% extends \"root.lisp\" %} {% block head %}") (title (text "{{ name }}")) - -(meta ("property" "og:title") ("content" "{{ name }}")) -(meta ("property" "twitter:title") ("content" "{{ name }}")) -(link ("rel" "icon") ("href" "/public/favicon.svg")) (text "{% endblock %} {% block body %}") (div ("class" "card") diff --git a/app/templates_src/login.lisp b/app/templates_src/login.lisp new file mode 100644 index 0000000..47e51e8 --- /dev/null +++ b/app/templates_src/login.lisp @@ -0,0 +1,109 @@ +(text "{% extends \"root.lisp\" %} {% block head %}") +(title + (text "Login — {{ name }}")) +(text "{% endblock %} {% block body %}") +(div + ("class" "card") + (h4 (text "Login with Tetratto")) + + (form + ("class" "flex flex_col gap_4") + ("onsubmit" "login(event)") + (div + ("id" "flow_1") + ("style" "display: contents") + (div + ("class" "flex flex_col gap_1") + (label ("for" "username") (b (text "Username"))) + (input + ("class" "surface") + ("type" "text") + ("placeholder" "username") + ("required" "") + ("name" "username") + ("id" "username"))) + (div + ("class" "flex flex_col gap_1") + (label ("for" "username") (b (text "Password"))) + (input + ("class" "surface") + ("type" "password") + ("placeholder" "password") + ("required" "") + ("name" "password") + ("id" "password")))) + + (div + ("id" "flow_2") + ("style" "display: none") + (div + ("class" "flex flex_col gap_1") + (label ("for" "totp") (b (text "TOTP code"))) + (input + ("class" "surface") + ("type" "text") + ("placeholder" "totp code") + ("name" "totp") + ("id" "totp")))) + + (button + ("class" "button surface") + (text "{{ icon \"arrow-right\" }}") + (text "Continue"))) + + (hr ("class" "margin")) + (a ("href" "{{ config.service_hosts.tetratto }}/auth/register") (text "I don't have a Tetratto account"))) + +(script + (text "let flow_page = 1; + + function next_page() { + document.getElementById(`flow_${flow_page}`).style.display = \"none\"; + flow_page += 1; + document.getElementById(`flow_${flow_page}`).style.display = \"contents\"; + } + + async function login(e) { + e.preventDefault(); + + if (flow_page === 1) { + // check if we need TOTP + const res = await ( + await fetch( + `/api/v1/auth/user/${e.target.username.value}/check_totp`, + ) + ).json(); + + if (res.ok && res.payload) { + // user exists AND totp is required + return next_page(); + } + } + + fetch(\"/api/v1/auth/login\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + username: e.target.username.value, + password: e.target.password.value, + totp: e.target.totp.value, + }), + }) + .then((res) => res.json()) + .then(async (res) => { + show_message(res.message, res.ok); + if (res.ok) { + // update tokens + LOGIN_ACCOUNT_TOKENS[e.target.username.value] = res.message; + save_login_account_tokens(); + + // redirect + setTimeout(() => { + window.location.href = \"/app\"; + }, 150); + } + }); + }")) +(text "{% endblock %}") diff --git a/app/templates_src/root.lisp b/app/templates_src/root.lisp index 1a53805..42c42ce 100644 --- a/app/templates_src/root.lisp +++ b/app/templates_src/root.lisp @@ -15,7 +15,12 @@ (meta ("property" "og:type") ("content" "website")) (meta ("property" "og:site_name") ("content" "{{ name }}")) + (meta ("property" "og:title") ("content" "{{ name }}")) + (meta ("property" "twitter:title") ("content" "{{ name }}")) + (link ("rel" "icon") ("href" "/public/favicon.svg")) + (script ("src" "/public/app.js?v={{ build_code }}") ("defer")) + (script ("src" "/public/tokens.js?v={{ build_code }}") ("defer")) (text "{% block head %}{% endblock %}")) @@ -42,6 +47,30 @@ ("class" "button") ("href" "https://trisua.com/t/malachite") (text "source")) + (hr) + (text "{% if not user -%}") + (a + ("class" "button") + ("href" "/login") + (text "login")) + (a + ("class" "button") + ("href" "{{ config.service_hosts.tetratto }}/auth/register") + (text "sign up")) + (text "{%- else -%}") + (a + ("class" "button") + ("href" "/app") + (text "app")) + (a + ("class" "button") + ("href" "{{ config.service_hosts.tetratto }}/settings") + (text "settings")) + (button + ("class" "button red") + ("onclick" "user_logout()") + (text "logout")) + (text "{%- endif %}") (text "{% block dropdown %}{% endblock %}"))) (a ("class" "button camo") ("href" "/") (b (text "{{ name }}")))) @@ -51,14 +80,14 @@ ; theme switches (button - ("class" "button camo fade") + ("class" "button camo fade filled") ("id" "switch_light") ("title" "Switch theme") ("onclick" "set_theme('Dark')") (text "{{ icon \"sun\" }}")) (button - ("class" "button camo fade hidden") + ("class" "button camo fade filled hidden") ("id" "switch_dark") ("title" "Switch theme") ("onclick" "set_theme('Light')") diff --git a/src/config.rs b/src/config.rs index 9d98e68..a3b7aa1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,7 +36,7 @@ fn default_name() -> String { } fn default_theme_color() -> String { - "#cd5700".to_string() + "#f97316".to_string() } fn default_real_ip_header() -> String { diff --git a/src/routes/api/auth.rs b/src/routes/api/auth.rs new file mode 100644 index 0000000..276ea38 --- /dev/null +++ b/src/routes/api/auth.rs @@ -0,0 +1,191 @@ +use crate::{State, get_user_from_token}; +use axum::{ + Extension, Json, + extract::{Path, Query}, + http::{HeaderMap, HeaderValue}, + response::{IntoResponse, Redirect}, +}; +use axum_extra::extract::CookieJar; +use serde::Deserialize; +use tetratto_core::model::{ApiReturn, Error, addr::RemoteAddr, auth::User}; +use tetratto_shared::hash::hash; + +#[derive(Deserialize)] +pub struct LoginProps { + pub username: String, + pub password: String, + #[serde(default)] + pub totp: String, +} + +/// `/api/v1/auth/login` +pub async fn login_request( + headers: HeaderMap, + // jar: CookieJar, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + // let user = get_user_from_token!(jar, data); + + // if user.is_some() { + // return (None, Json(Error::AlreadyAuthenticated.into())); + // } + + // get real ip + let real_ip = headers + .get(data.2.0.0.security.real_ip_header.to_owned()) + .unwrap_or(&HeaderValue::from_static("")) + .to_str() + .unwrap_or("") + .to_string(); + + // check for ip ban + if data + .2 + .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str())) + .await + .is_ok() + { + return (None, Json(Error::NotAllowed.into())); + } + + // verify password + let user = match data + .2 + .get_user_by_username_no_cache(&props.username.to_lowercase()) + .await + { + Ok(ua) => ua, + Err(_) => return (None, Json(Error::IncorrectPassword.into())), + }; + + if !user.check_password(props.password) { + return (None, Json(Error::IncorrectPassword.into())); + } + + // verify totp code + if !data.2.check_totp(&user, &props.totp) { + return (None, Json(Error::NotAllowed.into())); + } + + // update tokens + let mut new_tokens = user.tokens.clone(); + let (unhashed_token_id, token) = User::create_token(&real_ip); + new_tokens.push(token); + + if let Err(e) = data.2.update_user_tokens(user.id, new_tokens).await { + return (None, Json(e.into())); + } + + // ... + ( + Some([( + "Set-Cookie", + format!( + "__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}", + unhashed_token_id, + 60 * 60 * 24 * 365 + ), + )]), + Json(ApiReturn { + ok: true, + message: unhashed_token_id, + payload: (), + }), + ) +} + +/// `/api/v1/auth/logout` +pub async fn logout_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data.2) { + Some(ua) => ua, + None => return (None, Json(Error::NotAllowed.into())), + }; + + // update tokens + let token = jar + .get("__Secure-atto-token") + .unwrap() + .to_string() + .replace("__Secure-atto-token=", ""); + + let mut new_tokens = user.tokens.clone(); + new_tokens.remove( + new_tokens + .iter() + .position(|t| t.1 == hash(token.to_string())) + .unwrap(), + ); + + if let Err(e) = data.2.update_user_tokens(user.id, new_tokens).await { + return (None, Json(e.into())); + } + + // ... + ( + Some([( + "Set-Cookie", + format!( + "__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=0", + "refresh", + ), + )]), + Json(ApiReturn { + ok: true, + message: "Goodbye!".to_string(), + payload: Some(user.username.clone()), + }), + ) +} + +#[derive(Deserialize)] +pub struct SetTokenQuery { + #[serde(default)] + pub token: String, +} + +/// Set the current user token. +pub async fn set_token_request(Query(props): Query) -> impl IntoResponse { + ( + { + let mut headers = HeaderMap::new(); + + headers.insert( + "Set-Cookie", + format!( + "__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}", + props.token, + 60* 60 * 24 * 365 + ) + .parse() + .unwrap(), + ); + + headers + }, + Redirect::to("/"), + ) +} + +/// Check if the given user has TOTP enabled. +pub async fn check_totp_request( + Path(username): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match data.2.get_user_by_username(&username).await { + Ok(u) => u, + Err(e) => return Json(e.into()), + }; + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(!user.totp.is_empty()), + }) +} diff --git a/src/routes/api/mod.rs b/src/routes/api/mod.rs index bca74a0..e4e911f 100644 --- a/src/routes/api/mod.rs +++ b/src/routes/api/mod.rs @@ -1,13 +1,24 @@ +pub mod auth; pub mod chats; pub mod messages; -use axum::routing::{Router, delete, post, put}; +use axum::routing::{Router, delete, get, post, put}; pub fn routes() -> Router { Router::new() + // auth + .route("/auth/login", post(auth::login_request)) + .route("/auth/logout", post(auth::logout_request)) + .route("/auth/set_token", get(auth::set_token_request)) + .route( + "/auth/user/{username}/check_totp", + get(auth::check_totp_request), + ) + // chats .route("/chats", post(chats::create_request)) .route("/chats/{id}/leave", post(chats::leave_request)) .route("/chats/{id}/info", post(chats::update_info_request)) + // messages .route("/messages", post(messages::create_request)) .route("/messages/{id}", delete(messages::delete_request)) .route("/messages/{id}", put(messages::update_content_request)) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 36bf69e..1d694b8 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,6 +1,7 @@ use crate::config::Config; use axum::{Router, routing::get_service}; use tera::Context; +use tetratto_core::model::auth::User; pub mod api; pub mod pages; @@ -16,10 +17,12 @@ pub fn routes() -> Router { .nest("/api/v1", api::routes()) } -pub fn default_context(config: &Config, build_code: &str) -> Context { +pub fn default_context(config: &Config, build_code: &str, user: &Option) -> Context { let mut ctx = Context::new(); ctx.insert("name", &config.name); ctx.insert("theme_color", &config.theme_color); ctx.insert("build_code", &build_code); + ctx.insert("user", &user); + ctx.insert("config", &config); ctx } diff --git a/src/routes/pages/misc.rs b/src/routes/pages/misc.rs index 6a9f015..afca4a1 100644 --- a/src/routes/pages/misc.rs +++ b/src/routes/pages/misc.rs @@ -1,14 +1,19 @@ -use crate::{State, routes::default_context}; +use crate::{State, get_user_from_token, routes::default_context}; use axum::{ Extension, response::{Html, IntoResponse}, }; +use axum_extra::extract::CookieJar; use tetratto_core::model::Error; -pub async fn not_found_request(Extension(data): Extension) -> impl IntoResponse { +pub async fn not_found_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { let (ref data, ref tera, ref build_code) = *data.read().await; + let user = get_user_from_token!(jar, data.2); - let mut ctx = default_context(&data.0.0, &build_code); + let mut ctx = default_context(&data.0.0, &build_code, &user); ctx.insert( "error", &Error::GeneralNotFound("page".to_string()).to_string(), @@ -16,10 +21,28 @@ pub async fn not_found_request(Extension(data): Extension) -> impl IntoRe return Html(tera.render("error.lisp", &ctx).unwrap()); } -pub async fn index_request(Extension(data): Extension) -> impl IntoResponse { +pub async fn index_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { let (ref data, ref tera, ref build_code) = *data.read().await; + let user = get_user_from_token!(jar, data.2); + Html( - tera.render("index.lisp", &default_context(&data.0.0, &build_code)) - .unwrap(), + tera.render( + "index.lisp", + &default_context(&data.0.0, &build_code, &user), + ) + .unwrap(), + ) +} + +pub async fn login_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let (ref data, ref tera, ref build_code) = *data.read().await; + let user = get_user_from_token!(jar, data.2); + + Html( + tera.render( + "login.lisp", + &default_context(&data.0.0, &build_code, &user), + ) + .unwrap(), ) } diff --git a/src/routes/pages/mod.rs b/src/routes/pages/mod.rs index c1af820..6fbfa5b 100644 --- a/src/routes/pages/mod.rs +++ b/src/routes/pages/mod.rs @@ -3,5 +3,7 @@ pub mod misc; use axum::routing::{Router, get}; pub fn routes() -> Router { - Router::new().route("/", get(misc::index_request)) + Router::new() + .route("/", get(misc::index_request)) + .route("/login", get(misc::login_request)) }