From 52b70d4ee964990038296f507364753d7cb20bf3 Mon Sep 17 00:00:00 2001 From: jakubmanczak Date: Mon, 23 Feb 2026 02:17:49 +0100 Subject: [PATCH] a whole lot of preem User/Session/Auth work --- .gitignore | 5 + Cargo.lock | 110 +++++++++++++- Cargo.toml | 7 +- src/database/migrations/2026-02-20--01.sql | 8 +- src/database/mod.rs | 56 +++++++ src/main.rs | 4 +- src/users/auth/implementation.rs | 152 +++++++++++++++++++ src/users/auth/mod.rs | 58 +++++++ src/users/handle.rs | 2 +- src/users/mod.rs | 166 ++++++++++++++++++++- src/users/sessions.rs | 153 ++++++++++++++++++- src/users/setup.rs | 31 ++++ 12 files changed, 744 insertions(+), 8 deletions(-) create mode 100644 src/users/auth/implementation.rs create mode 100644 src/users/auth/mod.rs create mode 100644 src/users/setup.rs diff --git a/.gitignore b/.gitignore index 71c4f75..de3bb7f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ /target +.DS_Store *.db +*.db-shm +*.db-wal *.db3 +*.db3-shm +*.db3-wal *.env diff --git a/Cargo.lock b/Cargo.lock index 1f27008..7d3411b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,6 +126,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + [[package]] name = "base64" version = "0.22.1" @@ -239,6 +245,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -447,6 +454,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -798,14 +816,19 @@ version = "0.1.0" dependencies = [ "argon2", "axum", + "base32", + "base64", "chrono", "chrono-tz", "dotenvy", "maud", - "rand", + "rand 0.10.0", + "rand 0.8.5", "rusqlite", "serde", "serde_json", + "sha2", + "strum", "thiserror", "tokio", "tower", @@ -904,6 +927,15 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -950,6 +982,17 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.0" @@ -961,11 +1004,24 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -1099,6 +1155,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1161,6 +1228,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1709,6 +1797,26 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 786f1a9..ca0a5cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,14 +6,19 @@ edition = "2024" [dependencies] argon2 = "0.5.3" axum = "0.8.8" -chrono = "0.4.43" +base32 = "0.5.1" +base64 = "0.22.1" +chrono = { version = "0.4.43", features = ["serde"] } chrono-tz = "0.10.4" dotenvy = "0.15.7" maud = { version = "0.27.0", features = ["axum"] } rand = "0.10.0" +rand08 = { version = "0.8.5", package = "rand" } rusqlite = { version = "0.38.0", features = ["bundled", "chrono", "uuid"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +sha2 = "0.10.9" +strum = { version = "0.27.0", features = ["derive"] } thiserror = "2.0.18" tokio = { version = "1.49.0", features = ["full"] } tower = { version = "0.5.3", features = ["full"] } diff --git a/src/database/migrations/2026-02-20--01.sql b/src/database/migrations/2026-02-20--01.sql index 45ca43a..9c62387 100644 --- a/src/database/migrations/2026-02-20--01.sql +++ b/src/database/migrations/2026-02-20--01.sql @@ -6,13 +6,17 @@ CREATE TABLE users ( ); CREATE TABLE sessions ( id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes - token TEXT NOT NULL UNIQUE, + token BLOB NOT NULL UNIQUE, user_id BLOB NOT NULL REFERENCES users(id), -- UUIDv7 bytes (userID) - issued TEXT NOT NULL, -- RFC3339 into DateTime expiry TEXT NOT NULL, -- RFC3339 into DateTime revoked INTEGER NOT NULL DEFAULT 0, -- bool (int 0 or int 1) revoked_at TEXT DEFAULT NULL, -- RFC3339 into DateTime revoked_by BLOB DEFAULT NULL REFERENCES users(id) -- UUIDv7 bytes (userID) + + CHECK( + (revoked = 0 AND revoked_at IS NULL AND revoked_by IS NULL) OR + (revoked = 1 AND revoked_at IS NOT NULL AND revoked_by IS NOT NULL) + ) ); CREATE INDEX sessions_by_userid ON sessions(user_id); diff --git a/src/database/mod.rs b/src/database/mod.rs index 8b13789..32a492b 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1 +1,57 @@ +use std::{env, error::Error, sync::LazyLock}; +use rusqlite::{Connection, OptionalExtension}; + +macro_rules! migration { + ($name:literal) => { + ($name, include_str!(concat!("./migrations/", $name, ".sql"))) + }; +} +const MIGRATIONS: &[(&str, &str)] = &[migration!("2026-02-20--01")]; + +pub static DB_URL: LazyLock = + LazyLock::new(|| env::var("DATABASE_URL").expect("DATABASE_URL is not set")); + +const PERSISTENT_PRAGMAS: &[&str] = &["PRAGMA journal_mode = WAL"]; +const CONNECTION_PRAGMAS: &[&str] = &["PRAGMA foreign_keys = ON", "PRAGMA busy_timeout = 5000"]; +const TABLE_MIGRATIONS: &str = r#" +CREATE TABLE IF NOT EXISTS migrations ( + id TEXT PRIMARY KEY, + time INTEGER DEFAULT (unixepoch()) +); +"#; + +pub fn conn() -> Result { + let conn = Connection::open(&*DB_URL)?; + for pragma in CONNECTION_PRAGMAS { + conn.query_row(*pragma, (), |_| Ok(())).optional()?; + } + Ok(conn) +} + +pub fn migrations() -> Result<(), Box> { + let conn = Connection::open(&*DB_URL)?; + for pragma in PERSISTENT_PRAGMAS { + conn.query_row(*pragma, (), |_| Ok(()))?; + } + conn.execute(TABLE_MIGRATIONS, ())?; + + let mut changes = false; + for (key, sql) in MIGRATIONS { + let mut statement = conn.prepare("SELECT id, time FROM migrations WHERE id = ?1")?; + let query = statement.query_one([key], |_| Ok(())).optional()?; + if query.is_some() { + continue; + } + changes = true; + println!("Applying migration {key}..."); + + conn.execute_batch(sql)?; + conn.execute("INSERT INTO migrations(id) VALUES (?1)", &[key])?; + } + + if changes { + println!("Migrations applied.") + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 30908b2..013fb12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,9 @@ mod tags; mod users; fn main() -> Result<(), Box> { - println!("Hello, world!"); + dotenvy::dotenv()?; + database::migrations()?; + users::setup::initialise_reserved_users_if_needed()?; Ok(()) } diff --git a/src/users/auth/implementation.rs b/src/users/auth/implementation.rs new file mode 100644 index 0000000..e2f02f6 --- /dev/null +++ b/src/users/auth/implementation.rs @@ -0,0 +1,152 @@ +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; +use axum::http::{ + HeaderMap, + header::{AUTHORIZATION, COOKIE}, +}; +use base64::{Engine, prelude::BASE64_STANDARD}; +use rusqlite::OptionalExtension; +use uuid::Uuid; + +use crate::{ + database, + users::{ + User, + auth::{AuthError, COOKIE_NAME, TokenSize, UserAuthenticate, UserPasswordHashing}, + sessions::Session, + }, +}; + +impl TokenSize { + pub fn bytes(&self) -> usize { + match self { + TokenSize::Char8 => 5, + TokenSize::Char16 => 10, + TokenSize::Char32 => 20, + TokenSize::Char64 => 40, + } + } +} + +impl UserPasswordHashing for User { + fn hash_password(passw: &str) -> Result { + use rand08::rngs::OsRng as ArgonOsRng; + let argon = Argon2::default(); + let passw = passw.as_bytes(); + let salt = SaltString::generate(&mut ArgonOsRng); + + Ok(argon.hash_password(passw, &salt)?.to_string()) + } + fn match_hash_password(passw: &str, hash: &str) -> Result { + let argon = Argon2::default(); + let passw = passw.as_bytes(); + let hash = PasswordHash::try_from(hash)?; + Ok(argon.verify_password(passw, &hash).is_ok()) + } +} + +impl From for AuthError { + fn from(err: argon2::password_hash::Error) -> Self { + AuthError::PassHashError(err) + } +} + +enum AuthScheme<'a> { + Basic(&'a str), + Bearer(&'a str), + None, +} + +impl<'a> AuthScheme<'a> { + fn from_header(header: &'a str) -> Self { + if let Some(credentials) = header + .strip_prefix("Basic ") + .or_else(|| header.strip_prefix("basic ")) + { + AuthScheme::Basic(credentials) + } else if let Some(token) = header + .strip_prefix("Bearer ") + .or_else(|| header.strip_prefix("bearer ")) + { + AuthScheme::Bearer(token) + } else { + AuthScheme::None + } + } +} + +impl UserAuthenticate for User { + fn authenticate(headers: &HeaderMap) -> Result, AuthError> { + let mut auth_values = Vec::new(); + for auth_header in headers.get_all(AUTHORIZATION).iter() { + if let Ok(s) = auth_header.to_str() { + auth_values.push(s.to_string()); + } + } + for cookie_header in headers.get_all(COOKIE).iter() { + if let Ok(cookies) = cookie_header.to_str() { + for cookie in cookies.split(';') { + let cookie = cookie.trim(); + if let Some(value) = cookie.strip_prefix(&format!("{}=", COOKIE_NAME)) { + auth_values.push(format!("Bearer {}", value)); + } + } + } + } + + let mut basic_auth: Option<&str> = None; + let mut bearer_auth: Option<&str> = None; + for header in &auth_values { + let header = header.trim(); + match AuthScheme::from_header(header) { + AuthScheme::Basic(creds) => { + if basic_auth.is_none() { + basic_auth = Some(creds); + } + } + AuthScheme::Bearer(token) => { + if bearer_auth.is_none() { + bearer_auth = Some(token); + } + } + AuthScheme::None => {} + } + } + + match (basic_auth, bearer_auth) { + (Some(creds), _) => authenticate_basic(creds), + (None, Some(token)) => authenticate_bearer(token), + _ => Ok(None), + } + } +} + +fn authenticate_basic(credentials: &str) -> Result, AuthError> { + let decoded = BASE64_STANDARD.decode(credentials)?; + let credentials_str = String::from_utf8(decoded)?; + + let Some((username, password)) = credentials_str.split_once(':') else { + return Err(AuthError::InvalidFormat); + }; + let conn = database::conn()?; + let user: Option<(Uuid, Option)> = conn + .prepare("SELECT id, password FROM users WHERE handle = ?1")? + .query_row([username], |r| Ok((r.get(0)?, r.get(1)?))) + .optional()?; + + match user { + Some((id, Some(passhash))) => match User::match_hash_password(password, &passhash)? { + true => Ok(Some(User::get_by_id(id)?)), + false => Err(AuthError::InvalidCredentials), + }, + _ => Err(AuthError::InvalidCredentials), + } +} + +fn authenticate_bearer(token: &str) -> Result, AuthError> { + let mut s = Session::get_by_token(token)?; + if s.is_expired_or_revoked() { + return Err(AuthError::InvalidCredentials); + } + s.prolong()?; + Ok(Some(User::get_by_id(s.user_id)?)) +} diff --git a/src/users/auth/mod.rs b/src/users/auth/mod.rs new file mode 100644 index 0000000..662a65f --- /dev/null +++ b/src/users/auth/mod.rs @@ -0,0 +1,58 @@ +use axum::http::HeaderMap; +use rand08::{RngCore, rngs::OsRng}; + +use crate::users::{User, UserError, sessions::SessionError}; + +mod implementation; + +pub const COOKIE_NAME: &str = "mnemohash"; + +pub trait UserAuthenticate { + fn authenticate(headers: &HeaderMap) -> Result, AuthError>; +} +pub trait UserPasswordHashing { + /// Returns the hashed password as a String + fn hash_password(passw: &str) -> Result; + /// Returns whether the password matches the hash + fn match_hash_password(passw: &str, hash: &str) -> Result; +} + +#[derive(thiserror::Error, Debug)] +pub enum AuthError { + #[error("Invalid credentials")] + InvalidCredentials, + #[error("Session error: {0}")] + SessionError(#[from] SessionError), + #[error("User error: {0}")] + UserError(#[from] UserError), + #[error("Invalid authorization header format")] + InvalidFormat, + #[error("Invalid base64 encoding")] + InvalidBase64(#[from] base64::DecodeError), + #[error("Invalid UTF-8 in credentials")] + InvalidUtf8(#[from] std::string::FromUtf8Error), + #[error("Database error: {0}")] + DatabaseError(#[from] rusqlite::Error), + #[error("Argon2 passhash error: {0}")] + PassHashError(argon2::password_hash::Error), +} + +#[derive(Debug, Clone, Copy)] +#[allow(unused)] +pub enum TokenSize { + /// 5 bytes = 8 chars + Char8, + /// 10 bytes = 16 chars + Char16, + /// 20 bytes = 32 chars + Char32, + /// 40 bytes = 64 chars + Char64, +} + +pub fn generate_token(len: TokenSize) -> String { + let mut bytes = vec![0u8; len.bytes()]; + let mut rng = OsRng; + rng.try_fill_bytes(&mut bytes).unwrap(); + base32::encode(base32::Alphabet::Crockford, &bytes) +} diff --git a/src/users/handle.rs b/src/users/handle.rs index af11473..e75f6d7 100644 --- a/src/users/handle.rs +++ b/src/users/handle.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; #[serde(try_from = "String")] pub struct UserHandle(String); -#[derive(Debug, thiserror::Error, Clone, PartialEq)] +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Serialize)] pub enum UserHandleError { #[error("Handle is too short - must be 3 or more characters.")] HandleTooShort, diff --git a/src/users/mod.rs b/src/users/mod.rs index 41df237..39a7437 100644 --- a/src/users/mod.rs +++ b/src/users/mod.rs @@ -1,14 +1,178 @@ +use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::users::handle::UserHandle; +use crate::{ + database::{self}, + users::{ + auth::{AuthError, UserPasswordHashing}, + handle::{UserHandle, UserHandleError}, + }, +}; pub mod auth; pub mod handle; pub mod sessions; +pub mod setup; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct User { pub id: Uuid, pub handle: UserHandle, } + +#[derive(Debug, thiserror::Error)] +pub enum UserError { + #[error("UserHandleError: {0}")] + UserHandleError(#[from] UserHandleError), + #[error("No user found with ID {0}")] + NoUserWithId(Uuid), + #[error("No user found with handle {0}")] + NoUserWithHandle(UserHandle), + #[error("Database error: {0}")] + DatabaseError(String), + #[error("Argon2 passhash error: {0}")] + PassHashError(argon2::password_hash::Error), +} +impl From for UserError { + fn from(error: rusqlite::Error) -> Self { + UserError::DatabaseError(error.to_string()) + } +} +impl From for UserError { + fn from(err: argon2::password_hash::Error) -> Self { + UserError::PassHashError(err) + } +} + +impl User { + pub fn get_by_id(id: Uuid) -> Result { + let res = database::conn()? + .prepare("SELECT handle FROM users WHERE id = ?1")? + .query_one((&id,), |r| { + Ok(User { + id, + handle: r.get(0)?, + }) + }) + .optional()?; + match res { + Some(u) => Ok(u), + None => Err(UserError::NoUserWithId(id)), + } + } + pub fn get_by_handle(handle: UserHandle) -> Result { + let res = database::conn()? + .prepare("SELECT id FROM users WHERE handle = ?1")? + .query_one((&handle,), |r| { + Ok(User { + id: r.get(0)?, + handle: handle.clone(), + }) + }) + .optional()?; + match res { + Some(u) => Ok(u), + None => Err(UserError::NoUserWithHandle(handle)), + } + } +} + +// DANGEROUS: AUTH +impl User { + pub fn set_password(&mut self, passw: Option<&str>) -> Result<(), UserError> { + let conn = database::conn()?; + match passw { + None => { + conn.prepare("UPDATE users SET password = NULL WHERE id = ?1")? + .execute((self.id,))?; + Ok(()) + } + Some(passw) => { + let hashed = User::hash_password(passw)?; + conn.prepare("UPDATE users SET password = ?1 WHERE id = ?2")? + .execute((hashed, self.id))?; + Ok(()) + } + } + } +} + +// RESERVED USERS IMPL +impl User { + /// Constructs and pushes an infradmin to database + /// + /// An infradmin is the user account made for controlling + /// Mnemosyne top-down. The infrastructure admin has permission + /// to do everything and probably should not be used as a regular account + /// due to the ramifications of compromise. But it could be used for that, + /// and have its name changed. + pub fn create_infradmin() -> Result { + let mut u = User { + id: Uuid::max(), + handle: UserHandle::new("Infradmin")?, + }; + database::conn()? + .prepare("INSERT INTO users(id, handle) VALUES (?1, ?2)")? + .execute((&u.id, &u.handle))?; + u.regenerate_infradmin_password()?; + + Ok(u) + } + + /// Checks if the User is an infradmin + /// + /// An infradmin is the user account made for controlling + /// Mnemosyne top-down. The infrastructure admin has permission + /// to do everything and probably should not be used as a regular account + /// due to the ramifications of compromise. But it could be used for that, + /// and have its name changed. + pub fn is_infradmin(&self) -> bool { + self.id == Uuid::max() + } + + /// Regenerates the infradmin password + /// + /// An infradmin is the user account made for controlling + /// Mnemosyne top-down. The infrastructure admin has permission + /// to do everything and probably should not be used as a regular account + /// due to the ramifications of compromise. But it could be used for that, + /// and have its name changed. + pub fn regenerate_infradmin_password(&mut self) -> Result<(), UserError> { + let passw = auth::generate_token(auth::TokenSize::Char16); + self.set_password(Some(&passw))?; + println!("[USERS] The infradmin account password has been (re)generated."); + println!("[USERS] Handle: {}", self.handle.as_str()); + println!("[USERS] Password: {}", passw); + println!("[USERS] The infradmin is urged to change this password to a secure one.\n"); + Ok(()) + } + + /// Constructs and pushes a systemuser to database + /// + /// A systemuser is used for internal blame representation + /// for actions performed by Mnemosyne internally. + /// It shall not be available for log-in. + /// It should not have its name changed, and should be protected from that. + pub fn create_systemuser() -> Result { + let u = User { + id: Uuid::nil(), + handle: UserHandle::new("Mnenosyne")?, + }; + database::conn()? + .prepare("INSERT INTO users(id, handle) VALUES (?1, ?2)")? + .execute((&u.id, &u.handle))?; + + Ok(u) + } + + /// Checks if the User is a systemuser + /// + /// A systemuser is used for internal blame representation + /// for actions performed by Mnemosyne internally. + /// It shall not be available for log-in. + /// It should not have its name changed, and should be protected from that. + pub fn is_systemuser(&self) -> bool { + self.id == Uuid::nil() + } +} diff --git a/src/users/sessions.rs b/src/users/sessions.rs index e19ab6e..368795c 100644 --- a/src/users/sessions.rs +++ b/src/users/sessions.rs @@ -1 +1,152 @@ -pub struct Session; +use chrono::{DateTime, Duration, Utc}; +use rusqlite::OptionalExtension; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{ + database, + users::{User, auth}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: Uuid, + pub user_id: Uuid, + pub expiry: DateTime, + #[serde(flatten)] + pub status: SessionStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize, strum::EnumIs)] +#[serde(tag = "revoked")] +pub enum SessionStatus { + #[serde(rename = "false")] + Active, + #[serde(rename = "true")] + Revoked { + revoked_at: DateTime, + revoked_by: Uuid, + }, +} + +#[derive(Debug, thiserror::Error, Serialize)] +pub enum SessionError { + #[error("Database error: {0}")] + DatabaseError(String), + #[error("No session found with id: {0}")] + NoSessionWithId(Uuid), + #[error("No session found with token: {0}")] + NoSessionWithToken(String), +} +impl From for SessionError { + fn from(error: rusqlite::Error) -> Self { + SessionError::DatabaseError(error.to_string()) + } +} + +impl Session { + pub fn get_by_id(id: Uuid) -> Result { + let res = database::conn()? + .prepare("SELECT user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE id = ?1")? + .query_one((&id,), |r| Ok(Session { + id: id, + user_id: r.get(0)?, + expiry: r.get(1)?, + status: match r.get::<_, bool>(2)? { + false => SessionStatus::Active, + true => { + SessionStatus::Revoked { revoked_at: r.get(3)?, revoked_by: r.get(4)? } + } + } + })).optional()?; + + match res { + Some(s) => Ok(s), + None => Err(SessionError::NoSessionWithId(id)), + } + } + pub fn get_by_token(token: &str) -> Result { + let hashed = Sha256::digest(token.as_bytes()).to_vec(); + let res = database::conn()? + .prepare("SELECT id, user_id, expiry, revoked, revoked_at, revoked_by FROM sessions WHERE token = ?1")? + .query_one((hashed,), |r| Ok(Session { + id: r.get(0)?, + user_id: r.get(1)?, + expiry: r.get(2)?, + status: match r.get::<_, bool>(3)? { + false => SessionStatus::Active, + true => { + SessionStatus::Revoked { revoked_at: r.get(4)?, revoked_by: r.get(5)? } + } + } + })).optional()?; + + match res { + Some(s) => Ok(s), + None => Err(SessionError::NoSessionWithToken(token.to_string())), + } + } + pub fn new_for_user(user: &User) -> Result<(Session, String), SessionError> { + let id = Uuid::now_v7(); + let token = auth::generate_token(auth::TokenSize::Char64); + let hashed = Sha256::digest(token.as_bytes()).to_vec(); + let expiry = Utc::now() + Session::DEFAULT_PROLONGATION; + + database::conn()? + .prepare("INSERT INTO sessions VALUES (?1, ?2, ?3, ?4)")? + .execute((&id, &hashed, user.id, expiry))?; + let s = Session { + id, + user_id: user.id, + expiry, + status: SessionStatus::Active, + }; + Ok((s, token)) + } + + const DEFAULT_PROLONGATION: Duration = Duration::days(14); + const PROLONGATION_THRESHOLD: Duration = Duration::hours(2); + pub fn prolong(&mut self) -> Result<(), SessionError> { + if self.expiry - Session::DEFAULT_PROLONGATION + Session::PROLONGATION_THRESHOLD + > Utc::now() + { + return Ok(()); + } + + let expiry = Utc::now() + Session::DEFAULT_PROLONGATION; + database::conn()? + .prepare("UPDATE sessions SET expiry = ?1 WHERE id = ?2")? + .execute((&expiry, &self.id))?; + self.expiry = expiry; + Ok(()) + } + + pub fn revoke(&mut self, actor: Option<&User>) -> Result<(), SessionError> { + let now = Utc::now(); + let id = actor.map(|u| u.id).unwrap_or(Uuid::nil()); + database::conn()? + .prepare( + "UPDATE sessions SET revoked = ?1, revoked_at = ?2, revoked_by = ?3 WHERE id = ?4", + )? + .execute((&true, &now, &id, &self.id))?; + self.status = SessionStatus::Revoked { + revoked_at: now, + revoked_by: id, + }; + Ok(()) + } + + pub fn issued(&self) -> DateTime { + // unwrapping here since we use UUIDv7 + // and since we assume we're not in 10k CE + let timestamp = self.id.get_timestamp().unwrap().to_unix(); + DateTime::from_timestamp_secs(timestamp.0 as i64).unwrap() + } + pub fn is_expired_or_revoked(&self) -> bool { + self.is_expired() || self.status.is_revoked() + } + pub fn is_expired(&self) -> bool { + self.expiry <= Utc::now() + } +} diff --git a/src/users/setup.rs b/src/users/setup.rs new file mode 100644 index 0000000..b5f28d0 --- /dev/null +++ b/src/users/setup.rs @@ -0,0 +1,31 @@ +use rusqlite::OptionalExtension; +use uuid::Uuid; + +use crate::{ + database, + users::{User, UserError}, +}; + +pub fn initialise_reserved_users_if_needed() -> Result<(), UserError> { + let conn = database::conn()?; + + if conn + .prepare("SELECT handle FROM users WHERE id = ?1")? + .query_one((&Uuid::nil(),), |_| Ok(())) + .optional()? + .is_none() + { + User::create_systemuser()?; + } + + if conn + .prepare("SELECT handle FROM users WHERE id = ?1")? + .query_one((&Uuid::max(),), |_| Ok(())) + .optional()? + .is_none() + { + User::create_infradmin()?; + } + + Ok(()) +}