generated from t/malachite
add: login
This commit is contained in:
parent
ce9ce4f635
commit
8c86dd6cda
13 changed files with 407 additions and 25 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,3 +1 @@
|
|||
target/
|
||||
tetratto.toml
|
||||
app.toml
|
||||
|
|
1
app/.gitignore
vendored
1
app/.gitignore
vendored
|
@ -5,3 +5,4 @@ templates_build/
|
|||
public/favicon.svg
|
||||
.env
|
||||
app.toml
|
||||
tetratto.toml
|
||||
|
|
|
@ -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
24
app/public/tokens.js
Normal 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 = "/";
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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")
|
||||
|
|
109
app/templates_src/login.lisp
Normal file
109
app/templates_src/login.lisp
Normal 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 %}")
|
|
@ -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')")
|
||||
|
|
|
@ -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
191
src/routes/api/auth.rs
Normal 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()),
|
||||
})
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue