add: login

This commit is contained in:
trisua 2025-08-24 17:04:27 -04:00
parent ce9ce4f635
commit 8c86dd6cda
13 changed files with 407 additions and 25 deletions

2
.gitignore vendored
View file

@ -1,3 +1 @@
target/
tetratto.toml
app.toml

1
app/.gitignore vendored
View file

@ -5,3 +5,4 @@ templates_build/
public/favicon.svg
.env
app.toml
tetratto.toml

View file

@ -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;
}

24
app/public/tokens.js Normal file
View file

@ -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 = "/";
}
});
}

View file

@ -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")

View file

@ -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 %}")

View file

@ -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')")

View file

@ -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 {

191
src/routes/api/auth.rs Normal file
View file

@ -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<State>,
Json(props): Json<LoginProps>,
) -> 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<State>,
) -> 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<SetTokenQuery>) -> 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<String>,
Extension(data): Extension<State>,
) -> 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()),
})
}

View file

@ -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))

View file

@ -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<User>) -> 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
}

View file

@ -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<State>) -> impl IntoResponse {
pub async fn not_found_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> 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<State>) -> impl IntoRe
return Html(tera.render("error.lisp", &ctx).unwrap());
}
pub async fn index_request(Extension(data): Extension<State>) -> impl IntoResponse {
pub async fn index_request(jar: CookieJar, Extension(data): Extension<State>) -> 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<State>) -> 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(),
)
}

View file

@ -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))
}