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

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