sum bullshit

This commit is contained in:
CanadianBaconBoi 2026-08-20 19:08:36 +02:00
parent f4b83d14ec
commit 8f5bc07070
40 changed files with 1733 additions and 318 deletions

12
.idea/dataSources.xml Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="postgres@localhost" uuid="e563e508-3d63-4d4f-94a7-2f2722a9bc0d">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/postgres</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

882
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,24 +11,44 @@ edition = "2024"
microtld-web = {path = "web"} microtld-web = {path = "web"}
microtld-backend = {path = "backend"} microtld-backend = {path = "backend"}
tokio = {version = "1.53.1", features = ["full"]} tokio = {version = "1.53", features = ["full"]}
lmrc-cloudflare = {version = "0.3.16"} lmrc-cloudflare = {version = "0.3"}
anyhow = {version = "1.0.104"} anyhow = {version = "1.0"}
serde = {version = "1.0.229", features = ["derive"]} serde = {version = "1.0", features = ["derive"]}
derive_more = {version = "2.1.1", default-features = false, features = ["display"]} derive_more = {version = "2.1", default-features = false, features = ["display"]}
axum = {version = "0.8.9", features = ["macros"]} axum = {version = "0.8", features = ["macros"]}
axum-htmx = {version = "0.8.1"} axum-htmx = {version = "0.8"}
axum-login = {version = "0.18.0"} fred = {version = "10.1"}
tower-sessions = {version = "0.15.0"} tracing = {version = "0.1"}
fred = {version = "10.1.0"} reqwest = {version = "0.13"}
tracing = {version = "0.1.44"} maud = {version = "0.27", features = ["axum"]}
reqwest = {version = "0.13.4"}
maud = {version = "0.27.0", features = ["axum"]}
sqlx = {version = "0.9.0", features = ["runtime-tokio", "mysql"] }
async-trait = {version = "0.1.91"} async-trait = {version = "0.1.91"}
tracing-subscriber = "0.3.23" tracing-subscriber = "0.3.23"
base64 = "0.23.1"
sha2 = "0.11"
subtle = "2.6"
# Auth
tower-sessions = {version = "0.14"}
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
tower-csrf = "0.1"
argon2 = "0.5"
sqlx = { version = "0.8", features = [
"runtime-tokio",
"tls-rustls-ring-webpki",
"postgres",
"macros",
"migrate",
"uuid",
"time",
"json",
] }
time = "0.3"
uuid = { version = "1", features = ["v4", "serde"] }
tower_governor = "0.8"
# TODO: DIE :) # TODO: DIE :)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -11,20 +11,34 @@ anyhow = {workspace = true}
axum = {workspace = true } axum = {workspace = true }
axum-htmx = {workspace = true } axum-htmx = {workspace = true }
axum-login = {workspace = true }
tower-sessions = {workspace = true }
fred = {workspace = true } fred = {workspace = true }
tracing = {workspace = true } tracing = {workspace = true }
reqwest = {workspace = true } reqwest = {workspace = true }
maud = {workspace = true } maud = {workspace = true }
sqlx = {workspace = true}
tracing-subscriber = {workspace = true, features = ["env-filter"]} tracing-subscriber = {workspace = true, features = ["env-filter"]}
serde = {workspace = true} serde = {workspace = true}
async-trait = {workspace = true}
lightningcss = "1.0.0-alpha.72" lightningcss = "1.0.0-alpha.72"
inventory = "0.3.24" inventory = "0.3.24"
tower = "0.5"
tower-http = { version = "0.7.0", features = ["trace", "compression-gzip"] } tower-http = { version = "0.7.0", features = ["trace", "compression-gzip"] }
rust-embed = "8" base64 = {workspace = true}
mime_guess = "2" sha2 = {workspace = true}
subtle = {workspace = true}
#Auth
tower-sessions = {workspace = true}
tower-sessions-sqlx-store = {workspace = true}
tower-csrf = {workspace = true}
argon2 = {workspace = true}
sqlx = {workspace = true}
time = {workspace = true}
uuid = {workspace = true}
tower_governor = {workspace = true}
tower = "0.5.3"
rust-embed = {version = "8.12.0", features = ["debug-embed"]}
mime_guess = "2"
urlencoding = "2.1.3"
[profile.release]
debug = true

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -0,0 +1,3 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z" transform="scale(64)" fill="#f3f3f3"/>
</svg>

After

Width:  |  Height:  |  Size: 968 B

5
web/build.rs Normal file
View File

@ -0,0 +1,5 @@
// generated by `sqlx migrate build-script`
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
}

View File

@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE users;

View File

@ -0,0 +1,11 @@
-- Add up migration script here
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
email_confirmed_at TIMESTAMPTZ,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
two_factor_secret TEXT
);

View File

@ -0,0 +1,3 @@
-- Add down migration script here
DROP INDEX idx_confirmations_user_id;
DROP TABLE confirmations;

View File

@ -0,0 +1,12 @@
-- Add up migration script here
CREATE TABLE confirmations (
identifier BYTEA PRIMARY KEY,
verifier_hash BYTEA NOT NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
action_type TEXT NOT NULL,
details JSONB,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_confirmations_user_id ON confirmations(user_id);

View File

@ -1,22 +1,87 @@
use crate::web::content::style::CssFragment;
use super::*; use super::*;
inventory::submit! {
CssFragment(r#"
footer {
display: flex;
flex-direction: column;
align-items: end;
justify-content: center;
gap: 1rem;
padding: 1rem;
background: var(--footer-bg);
> .upper {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 1rem;
width: 100%;
> .accessibility {
display: flex;
.skip-link {
position: absolute;
top: -10000px;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus {
position: fixed;
top: 10px;
left: 10px;
width: auto;
height: auto;
padding: 10px 20px;
background-color: #fff;
color: #000;
border: 2px solid #000;
z-index: 9999;
}
}
}
> .lower {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 1rem;
width: 100%;
}
}
"#
)
}
pub fn footer() -> Markup { pub fn footer() -> Markup {
html! { html! {
footer { footer {
div class="accessibility" { div class="upper" {
a href="#main-content" { "Skip to main content" } div class="decoration" {
a href="#footer" { "Skip to footer" } img src="/assets/img/blinkiesCafe-eX.gif";
a href="#navigation" { "Skip to navigation" } img src="/assets/img/blinkiesCafe-gn.gif";
img src="/assets/img/blinkiesCafe-HY.gif";
}
div class="accessibility" {
nav aria-label="Skip links" {
a.skip-link href="#main-content" tabindex="0" { "Skip to main content" }
a.skip-link href="#footer" tabindex="1" { "Skip to footer" }
a.skip-link href="#navigation" tabindex="2" { "Skip to navigation" }
}
}
} }
div class="decoration" { div class="lower" {
img src="/static/img/banner-begay.gif"; div class="notice" {
img src="/static/img/banner-docrime.gif"; span {
img src="/static/img/banner-hate.gif"; "Cookies are used to store your session."
} "We do not use them to track you."
div class="notice" { }
span {
"Cookies are used to store your session."
"We do not use them to track you."
} }
} }
} }

View File

@ -1,23 +1,69 @@
use crate::web::content::style::CssFragment;
use super::*; use super::*;
inventory::submit! {
CssFragment(r#"
.nav-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.5rem;
background: var(--nav-bg);
> .spacer {
flex-grow: 1;
}
> .link {
color: var(--nav-link);
text-decoration: none;
border: none;
padding: 0;
background: none;
cursor: pointer;
font: inherit;
> img {
max-height: 1.5rem;
}
}
> .link.__active {
font-weight: 600;
color: var(--nav-link-active);
}
> .title {
color: var(--nav-link);
font-size: 1.5rem;
font-weight: 600;
color: var(--nav-link);
}
}
"#)
}
pub fn header(is_auth: bool) -> Markup { pub fn header(is_auth: bool) -> Markup {
html! { html! {
nav class="navigation" { nav.nav-bar {
ul { a.title href="/" { "MicroTLD" }
li { a href="/" { "Home" } }
li { a href="/regdomain" { "Register a subdomain" }} a.link.__active href="/" { "Home" }
li { a href="/status" { "Status" }} a.link href="/regdomain" { "Register a subdomain" }
div class="spacer" {} a.link href="/status" { "Status" }
@if is_auth { div class="spacer" {}
li { a href="/logout" class="auth-shown" hx-get="/auth/logoutprompt" hx-target="#popup" hx-swap="innerHTML" { "Logout" }} @if is_auth {
} @else { button.link href="/logout" class="auth-shown" hx-get="/auth/logoutprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Logout" }
li { a href="/login" class="auth-hidden" hx-get="/auth/loginprompt" hx-target="#popup" hx-swap="innerHTML" { "Login" }} } @else {
li { a href="/register" class="auth-hidden" hx-get="/auth/registerprompt" hx-target="#popup" hx-swap="innerHTML" { "Register" }} button.link href="/login" class="auth-hidden" hx-get="/auth/loginprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Login" }
} button.link href="/register" class="auth-hidden" hx-get="/auth/registerprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Register" }
li { a href="https://github.com/microtld/microtld" {
img src="/static/img/github-mark.svg";
}}
} }
a.link href="https://github.com/microtld/microtld" {img src="/assets/img/github-mark.svg";}
} }
} }
} }

View File

@ -2,8 +2,97 @@ use maud::DOCTYPE;
use crate::web::content::partials::footer::footer; use crate::web::content::partials::footer::footer;
use crate::web::content::partials::head::head; use crate::web::content::partials::head::head;
use crate::web::content::partials::header::header; use crate::web::content::partials::header::header;
use crate::web::content::style::CssFragment;
use super::*; use super::*;
inventory::submit! {
CssFragment(r#"
.main-content {
padding: 1rem;
display: flex;
flex-direction: column;
flex-grow: 1;
}
/* Style the built-in backdrop provided by the Popover API */
#modal::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
#modal {
/* Underlay covers entire screen. */
top:0px;
bottom: 0px;
left:0px;
right:0px;
background-color: color-mix(in srgb, var(--bg) 50%, transparent);
border: 1px solid var(--border);
z-index: 1000;
color: var(--text);
justify-content: center;
flex-direction:column;
align-items:center;
> #modal-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
> form {
display: flex;
flex-direction: column;
> label {
padding-top: 1rem;
}
> button {
margin-top: 1rem;
}
}
}
> .modal-close {
z-index: 1001;
background: rgba(200, 20, 20, 0.8);
color: var(--text);
font-weight: 600;
border: none;
margin-top: 1rem;
cursor: pointer;
font: inherit;
}
> .modal-close:hover {
background: rgba(200, 20, 20, 0.6);
transition: background 0.2s ease-in-out;
}
padding: 1rem 3rem;
}
#modal:popover-open {
display:flex;
}
"#
)
}
pub fn show_error(message: &str) -> Markup {
html! {
h1 { (message) }
}
}
pub fn page(page_title: &str, page_slug: &str, is_auth: bool, content: Markup) -> Markup { pub fn page(page_title: &str, page_slug: &str, is_auth: bool, content: Markup) -> Markup {
html! { html! {
(DOCTYPE) (DOCTYPE)
@ -11,9 +100,14 @@ pub fn page(page_title: &str, page_slug: &str, is_auth: bool, content: Markup) -
(head(page_title, page_slug)) (head(page_title, page_slug))
(header(is_auth)) (header(is_auth))
body { body {
div class="main-content" { div class="main-content" tabindex="-1" {
(content) (content)
} }
div popover id="modal" {
div id="modal-content" {}
button class="modal-close" popovertarget="modal" popovertargetaction="hide" { "Close" }
}
} }
(footer()) (footer())
} }

View File

@ -1,3 +1,35 @@
html { :root {
background-color: #0e0e0e; --text: #f3f3f3;
--text-muted: #cecece;
--bg: #0e0e0e;
--border: #5f2f5f;
--accent: #c11292;
--nav-bg: #1e1e1e;
--nav-link: #f3f3f3;
--nav-link-active: #ce9ece;
}
html {
height: 100%;
width: 100%;
scroll-behavior: smooth;
}
body {
font-family: 'Roboto', system-ui, -apple-system, sans-serif;
color: var(--text);
background: var(--bg);
margin: 0;
line-height: 1.6;
background-color: #0e0e0e;
font-size: 16px;
padding: 0;
overflow-x: hidden;
overflow-y: auto;
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
} }

View File

@ -1,3 +1,3 @@
pub(crate) mod content; pub(crate) mod content;
pub(crate) mod htmx; pub(crate) mod htmx;
pub(crate) mod service; pub(crate) mod service;

View File

@ -0,0 +1,106 @@
use argon2::password_hash::rand_core::{OsRng, RngCore};
use sha2::{Digest, Sha256};
use sqlx::{Error, PgPool};
use sqlx::types::Json;
use time::{OffsetDateTime, Duration};
use uuid::Uuid;
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct Confirmation {
identifier: [u8; 16],
#[sqlx(skip)]
verifier: Option<[u8; 16]>,
pub verifier_hash: [u8; 32],
pub user_id: Uuid,
pub action_type: String,
details: Option<Json<String>>,
expires_at: OffsetDateTime,
created_at: OffsetDateTime,
}
impl Confirmation {
pub fn new(user_id: Uuid, action_type: String, expiry_duration: Duration) -> Confirmation {
let mut rng = OsRng::default();
let mut identifier = [0; 16];
rng.fill_bytes(&mut identifier);
let mut verifier = [0; 16];
rng.fill_bytes(&mut verifier);
let mut hasher = Sha256::default();
Digest::update(&mut hasher, &verifier);
let verifier_hash = hasher.finalize().0;
let now = OffsetDateTime::now_utc();
Confirmation {
identifier,
verifier: Some(verifier),
verifier_hash,
user_id,
action_type,
details: None,
expires_at: now + expiry_duration,
created_at: now,
}
}
/// for use in http://localhost:3000/auth/confirm?token=<token>
pub fn get_url_token(&self) -> anyhow::Result<String> {
Ok(base64::encode([self.identifier.as_ref(), self.verifier.ok_or_else(||anyhow::anyhow!("Cannot construct url token from non-original confirmation"))?.as_ref()].concat()))
}
pub fn decode_url_token(token: &str) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
let decoded_token = base64::decode(token)?;
let (identifier, verifier) = decoded_token.split_at(16);
Ok((identifier.to_vec(), verifier.to_vec()))
}
pub async fn submit(self, db: &PgPool) -> anyhow::Result<Self> {
let result = sqlx::query_as::<_, Confirmation>(
r#"
INSERT INTO confirmations (identifier, verifier_hash, user_id, action_type, details, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
"#,
)
.bind(&self.identifier)
.bind(&self.verifier_hash)
.bind(&self.user_id)
.bind(&self.action_type)
.bind(&self.details)
.bind(&self.expires_at)
.bind(&self.created_at)
.fetch_optional(db)
.await;
match result {
Ok(c) => {
match c {
None => {
Err(anyhow::anyhow!("Confirmation didn't make it to the database."))
}
Some(c) => {
Ok(c)
}
}
},
Err(Error::Database(db_err)) if db_err.is_unique_violation() => {
Err(anyhow::anyhow!("Confirmation already exists"))
}
Err(e) => {
Err(anyhow::anyhow!("Database error: {}", e))
}
}
}
pub fn is_expired(&self) -> bool {
self.expires_at < OffsetDateTime::now_utc()
}
pub fn is_valid(&self, verifier: &[u8; 16]) -> bool {
self.verifier.as_ref().map_or(false, |v| v == verifier)
}
}

View File

@ -0,0 +1,20 @@
pub struct EmailHandler {
}
impl EmailHandler {
pub fn send_confirmation_email(&self, email: String, confirmation_token: String) {
}
pub fn send_password_reset_email(&self, email: String, reset_token: String) {
}
pub fn send_attempted_registration_email(&self, email: String) {
}
pub fn send_attempted_login_email(&self, email: String) {
}
pub fn send_password_changed_email(&self, email: String) {
}
}

View File

@ -0,0 +1,59 @@
use std::sync::Arc;
use argon2::{
password_hash::{
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
},
Algorithm, Argon2, Params, Version,
};
use tokio::task;
// fn build_hasher() -> Argon2<'static> {
// let params = Params::new(
// 64 * 1024, // 64 MiB memory cost
// 2, // 2 iterations
// 1, // 1 degree of parallelism
// None, // default output length (32 bytes)
// )
// .expect("valid argon2 params");
//
// Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
// }
fn build_hasher_with_pepper(pepper: &[u8]) -> Argon2<'_> {
let params = Params::new(64 * 1024, 2, 1, None).expect("valid argon2 params");
Argon2::new_with_secret(pepper, Algorithm::Argon2id, Version::V0x13, params)
.expect("valid argon2 secret")
}
pub fn hash_password(password: &str, pepper: &[u8]) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let hash = build_hasher_with_pepper(pepper).hash_password(password.as_bytes(), &salt)?;
Ok(hash.to_string())
}
pub fn verify_password(
password: &str,
stored_hash: &str,
pepper: &[u8]
) -> Result<(), argon2::password_hash::Error> {
let parsed = PasswordHash::new(stored_hash)?;
build_hasher_with_pepper(pepper).verify_password(password.as_bytes(), &parsed)
}
pub async fn hash_password_async(password: String, pepper: Arc<[u8]>) -> Result<String, anyhow::Error> {
task::spawn_blocking(move || hash_password(&password, pepper.as_ref()))
.await?
.map_err(|e| anyhow::anyhow!("Failed to hash password: {}", e))
}
pub async fn verify_password_async(
password: String,
stored_hash: String,
pepper: Arc<[u8]>
) -> Result<(), anyhow::Error> {
task::spawn_blocking(move || verify_password(&password, &stored_hash, pepper.as_ref()))
.await?
.map_err(|e|anyhow::anyhow!("Failed to verify password: {}", e))
}

View File

@ -1,32 +1,5 @@
use std::fmt::{Display, Formatter}; pub mod user;
use axum_login::{AuthnBackend, UserId}; pub mod hash;
use crate::web::service::WebService; pub mod session;
pub mod confirmation;
mod user; pub mod email;
impl AuthnBackend for WebService {
type User = user::User;
type Credentials = user::Credentials;
type Error = AuthError;
async fn authenticate(&self, creds: Self::Credentials) -> Result<Option<Self::User>, Self::Error> {
todo!()
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
todo!()
}
}
#[derive(Debug)]
pub struct AuthError {
username: String,
reason: String,
}
impl Display for AuthError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "AuthError for user {}: {}", self.username, self.reason)
}
}
impl std::error::Error for AuthError {}

View File

@ -0,0 +1,21 @@
use sqlx::{PgPool};
use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::{PostgresStore};
use time::Duration;
pub async fn session_layer(pool: PgPool) -> SessionManagerLayer<PostgresStore> {
let store = PostgresStore::new(pool);
store.migrate().await.expect("session table migration failed");
// Clean up expired sessions every 60 seconds
tokio::task::spawn(
store
.clone()
.continuously_delete_expired(tokio::time::Duration::from_secs(60)),
);
SessionManagerLayer::new(store)
.with_secure(false)//todo: set to true
.with_expiry(Expiry::OnInactivity(Duration::hours(24)))
}

View File

@ -1,5 +1,9 @@
use axum_login::AuthUser;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Error, PgPool};
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
use crate::web::service::auth::confirmation::Confirmation;
use crate::web::service::auth::email::EmailHandler;
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
pub struct Credentials { pub struct Credentials {
@ -7,24 +11,63 @@ pub struct Credentials {
pub password: String, pub password: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User { pub struct User {
pub id: u64, pub id: Uuid,
pub username: String, pub username: String,
pub email: String, pub email: String,
pub email_confirmed_at: Option<OffsetDateTime>,
pub password_hash: String, pub password_hash: String,
pub two_factor_enabled: bool, pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
pub two_factor_secret: Option<String>, pub two_factor_secret: Option<String>,
} }
impl AuthUser for User { impl User {
type Id = u64; pub async fn register(username: String, email: String, password_hash: String, db: &PgPool) -> Result<Self, RegistrationError> {
// ON CONFLICT DO NOTHING prevents errors on duplicate email
// without revealing whether the email already exists
let result = sqlx::query_as::<_, User>(
r#"
INSERT INTO users (username, email, password_hash)
VALUES ($1, $2, $3)
ON CONFLICT (email) DO NOTHING
RETURNING *
"#,
)
.bind(&username)
.bind(&email)
.bind(&password_hash)
.fetch_optional(db)
.await;
fn id(&self) -> u64 { match result {
self.id Ok(u) => {
} if let Some(u) = u {
let email_handler = EmailHandler{};
fn session_auth_hash(&self) -> &[u8] { let confirmation = Confirmation::new(u.id, "email_confirmation".into(), Duration::hours(24));
self.password_hash.as_bytes() let confirmation_token = confirmation.get_url_token().map_err(|e|RegistrationError::AnyhowError(e))?;
println!("http://localhost:3000/auth/confirm?token={}", urlencoding::encode(&confirmation_token));
email_handler.send_confirmation_email(email, confirmation_token);
confirmation.submit(db).await.map_err(|e|RegistrationError::AnyhowError(e))?;
Ok(u)
} else {
Err(RegistrationError::EmailExists)
}
}
Err(Error::Database(db_err)) if db_err.is_unique_violation() => {
Err(RegistrationError::UsernameExists)
}
Err(e) => {
Err(RegistrationError::DatabaseError(e))
}
}
} }
} }
pub enum RegistrationError {
UsernameExists,
EmailExists,
DatabaseError(Error),
AnyhowError(anyhow::Error),
}

View File

@ -0,0 +1,40 @@
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
use sqlx::PgPool;
use tower_sessions::Session;
use uuid::Uuid;
use crate::web::service::AppState;
use crate::web::service::auth::user::User;
pub struct AuthUser(pub User);
impl FromRequestParts<AppState> for AuthUser {
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let session = Session::from_request_parts(parts, state)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let user_id: Uuid = session
.get("user_id")
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(AuthUser(user))
}
}

View File

@ -1 +1,2 @@
pub mod dns; pub mod dns;
mod authuser;

View File

@ -2,7 +2,9 @@ pub mod auth;
pub mod routes; pub mod routes;
pub mod extractors; pub mod extractors;
use sqlx::Pool; use std::sync::Arc;
use sqlx::{PgPool, Pool};
use sqlx::postgres::PgPoolOptions;
use tokio::signal; use tokio::signal;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use microtld_backend::MicroTld; use microtld_backend::MicroTld;
@ -15,7 +17,7 @@ pub struct WebService {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AppState { pub struct AppState {
db: Pool<sqlx::mysql::MySql>, db: PgPool,
config: AppConfig, config: AppConfig,
micro_tld: MicroTld micro_tld: MicroTld
} }
@ -24,6 +26,7 @@ pub struct AppState {
pub struct AppConfig { pub struct AppConfig {
database_url: String, database_url: String,
listen_url: String, listen_url: String,
pepper: Arc<[u8]>
} }
impl WebService { impl WebService {
@ -31,12 +34,22 @@ impl WebService {
let config = AppConfig { let config = AppConfig {
database_url, database_url,
listen_url, listen_url,
pepper: Arc::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]),
}; };
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&config.database_url)
.await?;
sqlx::migrate!()
.run(&pool)
.await?;
let state = AppState { let state = AppState {
db: Pool::connect(&config.database_url).await?, db: pool,
config, micro_tld, config, micro_tld,
}; };
let router = routes::apply_routes(axum::Router::new()).with_state(state.clone()); let router = routes::apply_routes(axum::Router::new(), state.db.clone()).await.with_state(state.clone());
Ok(WebService {router, state}) Ok(WebService {router, state})
} }

View File

@ -0,0 +1,95 @@
use axum::extract::{Query, State};
use axum::response::IntoResponse;
use serde::Deserialize;
use sha2::Digest;
use subtle::ConstantTimeEq;
use time::OffsetDateTime;
use tower_sessions::Session;
use crate::web::service::AppState;
use crate::web::service::auth::confirmation::Confirmation;
use crate::web::service::auth::user::User;
use crate::web::service::routes::Route;
#[derive(Deserialize, Default)]
pub(crate) struct Confirm {
token: String
}
impl Route<'_, Query<Confirm>> for Confirm {
const PATH: &'static str = "/confirm";
async fn process(session: Session, state: State<AppState>, query: Query<Confirm>) -> impl IntoResponse {
if session.is_empty().await {
return "Not logged in".into_response();
}
let (identifier, verifier) = Confirmation::decode_url_token(&query.token).unwrap();
let verifier_hash = sha2::Sha256::digest(&verifier).0;
let confirmation: Option<Confirmation> = sqlx::query_as("SELECT * FROM confirmations WHERE identifier = $1")
.bind(&identifier)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
match confirmation {
Some(confirmation) => {
if confirmation.action_type != "email_confirmation" {
return "Invalid confirmation type".into_response();
}
if confirmation.is_expired() {
return "Confirmation expired".into_response();
}
match verifier_hash.ct_eq(&confirmation.verifier_hash).unwrap_u8() {
1 => {
let user: Option<User> = sqlx::query_as("SELECT * FROM users WHERE id = $1")
.bind(confirmation.user_id)
.fetch_optional(&state.db)
.await.unwrap_or(None);
match user {
Some(mut user) => {
user.email_confirmed_at = Some(OffsetDateTime::now_utc());
match sqlx::query("UPDATE users SET email_confirmed_at = $1 WHERE id = $2")
.bind(&user.email_confirmed_at)
.bind(&user.id)
.execute(&state.db)
.await {
Ok(_) => {
match sqlx::query("DELETE FROM confirmations WHERE identifier = $1")
.bind(&identifier)
.execute(&state.db)
.await {
Ok(_) => {
"Email confirmed".into_response()
}
Err(e) => {
"Error confirming email".into_response()
}
}
},
Err(e) => {
"Error confirming email".into_response()
}
}
},
None => {
"User not found".into_response()
}
}
}
_ => {
"Invalid token".into_response()
}
}
}
None => {
"Confirmation not found".into_response()
}
}
}
}

View File

@ -1,9 +1,13 @@
use axum::extract::{Form, State}; use axum::extract::{Form, State};
use axum::response::IntoResponse; use axum::http::{HeaderValue, StatusCode};
use reqwest::StatusCode; use axum::response::{IntoResponse, Redirect};
use serde::Deserialize; use serde::Deserialize;
use tower_sessions::Session;
use crate::web::content::partials::page::show_error;
use crate::web::service::AppState; use crate::web::service::AppState;
use crate::web::service::auth::user::User;
use crate::web::service::routes::Route; use crate::web::service::routes::Route;
use crate::web::service::auth::hash::{hash_password_async, verify_password_async};
#[derive(Deserialize, Default)] #[derive(Deserialize, Default)]
pub(crate) struct Login { pub(crate) struct Login {
@ -14,8 +18,41 @@ pub(crate) struct Login {
impl Route<'_, Form<Login>> for Login { impl Route<'_, Form<Login>> for Login {
const PATH: &'static str = "/login"; const PATH: &'static str = "/login";
async fn get_content(state: State<AppState>, Form(login_data): Form<Login>) -> impl IntoResponse { async fn process(session: Session, state: State<AppState>, Form(form): Form<Login>) -> impl IntoResponse {
todo!("implement login"); let user: Option<User> = sqlx::query_as("SELECT * FROM users WHERE username = $1")
StatusCode::OK .bind(&form.username)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
let Some(user) = user else {
println!("User not found");
// Run a dummy hash to prevent timing-based user enumeration
let _ = hash_password_async("dummy-password".to_string(), state.config.pepper.clone()).await;
return show_error("Invalid email or password").into_response();
};
if verify_password_async(form.password, user.password_hash.clone(), state.config.pepper.clone())
.await
.is_err()
{
println!("Password verification failed");
return show_error("Invalid email or password").into_response();
}
// Prevent session fixation: generate a new session ID, preserving data
session.cycle_id().await.expect("failed to cycle session ID");
// Store user identity in the session
session
.insert("user_id", user.id)
.await
.expect("failed to insert session data");
// Validate redirect target if using a ?next= parameter.
// Only allow relative paths. Reject absolute URLs to prevent open redirects.
let mut response = StatusCode::OK.into_response();
response.headers_mut().insert("HX-Redirect", HeaderValue::from_str("/").unwrap());
response
} }
} }

View File

@ -1,6 +1,7 @@
use axum::extract::State; use axum::extract::State;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use maud::html; use maud::html;
use tower_sessions::Session;
use crate::web::service::AppState; use crate::web::service::AppState;
use crate::web::service::routes::Route; use crate::web::service::routes::Route;
@ -9,12 +10,19 @@ pub(crate) struct LoginPrompt;
impl Route<'_, ()> for LoginPrompt { impl Route<'_, ()> for LoginPrompt {
const PATH: &'static str = "/loginprompt"; const PATH: &'static str = "/loginprompt";
async fn get_content(_state: State<AppState>, _query: ()) -> impl IntoResponse { async fn process(_session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
html! { html! {
form { h1 { "Login" }
form hx-post="/auth/login" hx-target="#modal-content" hx-swap="innerHTML" {
label for="username" { "Username" }
input type="text" name="username" placeholder="Username" {} input type="text" name="username" placeholder="Username" {}
input type="password" name="password" placeholder="" {}
input type="submit" value="Login" {} label for="password" { "Password" }
input type="password" name="password" id="password"
required minlength="10" maxlength="128"
autocomplete="new-password";
button type="submit" { "Login" }
} }
} }
} }

View File

@ -1,21 +1,20 @@
use axum::extract::{Form, State}; use axum::extract::{Form, State};
use axum::response::IntoResponse; use axum::http::{HeaderValue, Response, StatusCode};
use reqwest::StatusCode; use axum::response::{IntoResponse, Redirect};
use serde::Deserialize; use serde::Deserialize;
use tower_sessions::Session;
use crate::web::service::AppState; use crate::web::service::AppState;
use crate::web::service::routes::Route; use crate::web::service::routes::Route;
#[derive(Deserialize, Default)] pub(crate) struct Logout;
pub(crate) struct Logout {
username: String,
session_id: String
}
impl Route<'_, Form<Logout>> for Logout { impl Route<'_, ()> for Logout {
const PATH: &'static str = "/logout"; const PATH: &'static str = "/logout";
async fn get_content(state: State<AppState>, Form(query): Form<Logout>) -> impl IntoResponse { async fn process(session: Session, state: State<AppState>, _: ()) -> impl IntoResponse {
todo!("implement logout"); session.flush().await.expect("failed to flush session");
StatusCode::OK let mut response = StatusCode::OK.into_response();
response.headers_mut().insert("HX-Redirect", HeaderValue::from_str("/").unwrap());
response
} }
} }

View File

@ -1,6 +1,7 @@
use axum::extract::State; use axum::extract::State;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use maud::html; use maud::html;
use tower_sessions::Session;
use crate::web::service::AppState; use crate::web::service::AppState;
use crate::web::service::routes::Route; use crate::web::service::routes::Route;
@ -9,11 +10,12 @@ pub(crate) struct LogoutPrompt;
impl Route<'_, ()> for LogoutPrompt { impl Route<'_, ()> for LogoutPrompt {
const PATH: &'static str = "/logoutprompt"; const PATH: &'static str = "/logoutprompt";
async fn get_content(_state: State<AppState>, _query: ()) -> impl IntoResponse { async fn process(_session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
html! { html! {
p { "Are you sure you want to logout?" } h1 { "Logout" }
form { form hx-post="/auth/logout" hx-target="#modal-content" hx-swap="innerHTML" {
input type="submit" value="Logout" {} p { "Are you sure you want to logout?" }
button type="submit" { "Logout" }
} }
} }
} }

View File

@ -4,6 +4,7 @@ pub mod logoutprompt;
pub mod login; pub mod login;
pub mod register; pub mod register;
pub mod logout; pub mod logout;
mod confirm;
pub(crate) const PATH: &'static str = "/auth"; pub(crate) const PATH: &'static str = "/auth";
@ -12,4 +13,5 @@ pub(crate) use registerprompt::RegisterPrompt;
pub(crate) use logoutprompt::LogoutPrompt; pub(crate) use logoutprompt::LogoutPrompt;
pub(crate) use login::Login; pub(crate) use login::Login;
pub(crate) use register::Register; pub(crate) use register::Register;
pub(crate) use logout::Logout; pub(crate) use logout::Logout;
pub(crate) use confirm::Confirm;

View File

@ -1,23 +1,75 @@
use axum::extract::{Form, State}; use axum::extract::{Form, State};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use reqwest::StatusCode; use maud::html;
use serde::Deserialize; use serde::Deserialize;
use tower_sessions::Session;
use crate::web::content::partials::page::show_error;
use crate::web::service::AppState; use crate::web::service::AppState;
use crate::web::service::routes::Route; use crate::web::service::routes::Route;
use crate::web::service::auth::hash::hash_password_async;
use crate::web::service::auth::user::{RegistrationError, User};
#[derive(Deserialize, Default)] #[derive(Deserialize, Default, Debug)]
pub(crate) struct Register { pub(crate) struct Register {
username: String, username: String,
email: String, email: String,
password: String, password: String,
password_confirm: String, password_confirmation: String,
} }
impl Route<'_, Form<Register>> for Register { impl Route<'_, Form<Register>> for Register {
const PATH: &'static str = "/register"; const PATH: &'static str = "/register";
async fn get_content(state: State<AppState>, Form(query): Form<Register>) -> impl IntoResponse { async fn process(_session: Session, State(state): State<AppState>, Form(form): Form<Register>) -> impl IntoResponse {
todo!("implement register"); if form.password != form.password_confirmation {
StatusCode::OK return show_error("Passwords do not match").into_response();
}
if form.password.len() < 10 || form.password.len() > 128 {
return show_error("Password must be 10 to 128 characters").into_response();
}
let password_hash = match hash_password_async(form.password, state.config.pepper.clone()).await {
Ok(hash) => hash,
Err(_) => return show_error("Registration failed").into_response(),
};
match User::register(form.username, form.email, password_hash, &state.db).await {
Ok(_) => {
html! {
h1 { "Check your email" }
p { "If this email can be used for an account, you will receive further instructions." }
}.into_response()
},
Err(error) => {
match error {
RegistrationError::UsernameExists => {
html! {
h1 { "Username taken" }
p { "This username has already been taken. Please choose another." }
}.into_response()
},
RegistrationError::EmailExists => {
html! {
h1 { "Check your email" }
p { "If this email can be used for an account, you will receive further instructions." }
}.into_response()
}
RegistrationError::DatabaseError(e) => {
eprintln!("Database error: {}", e);
html! {
h1 { "An error occurred" }
p { "Please try again later." }
}.into_response()
}
RegistrationError::AnyhowError(e) => {
eprintln!("Anyhow error: {}", e);
html! {
h1 { "An error occurred" }
p { "Please try again later." }
}.into_response()
}
}
}
}
} }
} }

View File

@ -1,6 +1,7 @@
use axum::extract::State; use axum::extract::State;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use maud::html; use maud::html;
use tower_sessions::Session;
use crate::web::service::AppState; use crate::web::service::AppState;
use crate::web::service::routes::Route; use crate::web::service::routes::Route;
@ -9,14 +10,27 @@ pub(crate) struct RegisterPrompt;
impl Route<'_, ()> for RegisterPrompt { impl Route<'_, ()> for RegisterPrompt {
const PATH: &'static str = "/registerprompt"; const PATH: &'static str = "/registerprompt";
async fn get_content(_state: State<AppState>, _query: ()) -> impl IntoResponse { async fn process(_session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
html! { html! {
form { h1 { "Create an account" }
form hx-post="/auth/register" hx-target="#modal-content" hx-swap="innerHTML" {
label for="username" { "Username" }
input type="text" name="username" placeholder="Username" {} input type="text" name="username" placeholder="Username" {}
input type="email" name="email" placeholder="Email" {}
input type="password" name="password" placeholder="" {} label for="email" { "Email" }
input type="password" name="password_confirm" placeholder="Confirm password" {} input type="email" name="email" id="email" required;
input type="submit" value="Register" {}
label for="password" { "Password" }
input type="password" name="password" id="password"
required minlength="10" maxlength="128"
autocomplete="new-password";
label for="password_confirmation" { "Confirm password" }
input type="password" name="password_confirmation"
id="password_confirmation" required
autocomplete="new-password";
button type="submit" { "Register" }
} }
} }
} }

View File

@ -1,47 +1,99 @@
mod root; mod root;
mod auth; mod auth;
use axum::extract::State; use std::sync::Arc;
use axum::http::header; use std::time::Instant;
use axum::response::IntoResponse; use axum::extract::{Request, State};
use axum::Router; use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::{middleware, Router};
use axum::error_handling::HandleErrorLayer;
use axum::routing::{get, post}; use axum::routing::{get, post};
use sqlx::{PgPool};
use tower_csrf::{CrossOriginProtectionLayer, ProtectionError};
use tower_governor::governor::GovernorConfig;
use tower_http::compression::CompressionLayer; use tower_http::compression::CompressionLayer;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tower_sessions::Session;
use crate::web::content::assets::static_handler; use crate::web::content::assets::static_handler;
use crate::web::content::style; use crate::web::content::style;
use crate::web::service::auth::session::session_layer;
use crate::web::service::{ use crate::web::service::{
AppState AppState
}; };
use tower_governor::GovernorLayer;
pub(crate) trait Route<'a, AD> { pub(crate) trait Route<'a, AD> {
const PATH: &'static str; const PATH: &'static str;
const NAME: &'static str = "MicroTLD"; const NAME: &'static str = "MicroTLD";
async fn get_content(state: State<AppState>, query: AD) -> impl IntoResponse { async fn process(session: Session, state: State<AppState>, query: AD) -> impl IntoResponse {
"You should fill out get_content for this route." "You should fill out get_content for this route."
} }
} }
fn auth_routes() -> Router<AppState> { async fn my_custom_middleware(
Router::new() req: Request,
.route(auth::LoginPrompt::PATH, get(auth::LoginPrompt::get_content)) next: Next,
.route(auth::LogoutPrompt::PATH, get(auth::LogoutPrompt::get_content)) ) -> Response {
.route(auth::RegisterPrompt::PATH, get(auth::RegisterPrompt::get_content)) // Perform actions BEFORE the route handler runs
.route(auth::Login::PATH, post(auth::Login::get_content)) let start_time = Instant::now();
.route(auth::Logout::PATH, post(auth::Logout::get_content)) let path = req.uri().path().to_string();
.route(auth::Register::PATH, post(auth::Register::get_content))
// Pass the request to the rest of the application
let response = next.run(req).await;
// Perform actions AFTER the route handler runs
let duration = start_time.elapsed();
println!("Request to '{}' took {:?}", path, duration);
response
} }
pub fn apply_routes(router: axum::Router<AppState>) -> Router<AppState> {
fn auth_routes() -> Router<AppState> {
let governor_config = GovernorConfig::default(); // 1 request per 500ms per IP
let governor_layer = GovernorLayer::new(Arc::new(governor_config));
Router::new()
.layer(governor_layer)
.route(auth::LoginPrompt::PATH, get(auth::LoginPrompt::process))
.route(auth::LogoutPrompt::PATH, get(auth::LogoutPrompt::process))
.route(auth::RegisterPrompt::PATH, get(auth::RegisterPrompt::process))
.route(auth::Login::PATH, post(auth::Login::process))
.route(auth::Logout::PATH, post(auth::Logout::process))
.route(auth::Register::PATH, post(auth::Register::process))
.route(auth::Confirm::PATH, get(auth::Confirm::process))
}
pub async fn apply_routes(router: axum::Router<AppState>, pool: PgPool) -> Router<AppState> {
let css = style::stylesheet(); let css = style::stylesheet();
router.route(root::Root::PATH, get(root::Root::get_content)) let csrf_layer = tower::ServiceBuilder::new()
.layer(HandleErrorLayer::new(
|error: Box<dyn std::error::Error + Send + Sync>| async move {
if error.downcast_ref::<ProtectionError>().is_some() {
(StatusCode::FORBIDDEN, "Cross-origin request blocked").into_response()
} else {
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
},
))
.layer(CrossOriginProtectionLayer::default());
router
.route(root::Root::PATH, get(root::Root::process))
.route(&css.route, get(css_handler)) .route(&css.route, get(css_handler))
.nest(auth::PATH, auth_routes()) .nest(auth::PATH, auth_routes())
.route("/assets/{*path}", get(static_handler)) .route("/assets/{*path}", get(static_handler))
.layer(csrf_layer)
.layer(session_layer(pool).await)
.layer(CompressionLayer::new()) .layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.route_layer(middleware::from_fn(my_custom_middleware))
} }
async fn css_handler() -> impl IntoResponse { async fn css_handler() -> impl IntoResponse {

View File

@ -1,6 +1,7 @@
use axum::extract::State; use axum::extract::State;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use maud::html; use maud::html;
use tower_sessions::Session;
use crate::web::content::partials::page::page; use crate::web::content::partials::page::page;
use crate::web::service::AppState; use crate::web::service::AppState;
use crate::web::service::routes::Route; use crate::web::service::routes::Route;
@ -10,8 +11,8 @@ pub(crate) struct Root;
impl Route<'_, ()> for Root { impl Route<'_, ()> for Root {
const PATH: &'static str = "/"; const PATH: &'static str = "/";
async fn get_content(State(_app_state): State<AppState>, _query: ()) -> impl IntoResponse { async fn process(session: Session, State(_app_state): State<AppState>, _query: ()) -> impl IntoResponse {
page(Self::NAME, Self::PATH, false, html! { page(Self::NAME, Self::PATH, !session.is_empty().await, html! {
"Hello, world!" "Hello, world!"
}) })
} }