a whole lot of preem User/Session/Auth work
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,4 +1,9 @@
|
|||||||
/target
|
/target
|
||||||
|
.DS_Store
|
||||||
*.db
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
*.db3
|
*.db3
|
||||||
|
*.db3-shm
|
||||||
|
*.db3-wal
|
||||||
*.env
|
*.env
|
||||||
|
|||||||
110
Cargo.lock
generated
110
Cargo.lock
generated
@@ -126,6 +126,12 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base32"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -239,6 +245,7 @@ dependencies = [
|
|||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"serde",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@@ -447,6 +454,17 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -798,14 +816,19 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base32",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"maud",
|
"maud",
|
||||||
"rand",
|
"rand 0.10.0",
|
||||||
|
"rand 0.8.5",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"strum",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -904,6 +927,15 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
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]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -950,6 +982,17 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
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]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -961,11 +1004,24 @@ dependencies = [
|
|||||||
"rand_core 0.10.0",
|
"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]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
@@ -1099,6 +1155,17 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1161,6 +1228,27 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -1709,6 +1797,26 @@ dependencies = [
|
|||||||
"wasmparser",
|
"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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
argon2 = "0.5.3"
|
argon2 = "0.5.3"
|
||||||
axum = "0.8.8"
|
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"
|
chrono-tz = "0.10.4"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
maud = { version = "0.27.0", features = ["axum"] }
|
maud = { version = "0.27.0", features = ["axum"] }
|
||||||
rand = "0.10.0"
|
rand = "0.10.0"
|
||||||
|
rand08 = { version = "0.8.5", package = "rand" }
|
||||||
rusqlite = { version = "0.38.0", features = ["bundled", "chrono", "uuid"] }
|
rusqlite = { version = "0.38.0", features = ["bundled", "chrono", "uuid"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
sha2 = "0.10.9"
|
||||||
|
strum = { version = "0.27.0", features = ["derive"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.49.0", features = ["full"] }
|
tokio = { version = "1.49.0", features = ["full"] }
|
||||||
tower = { version = "0.5.3", features = ["full"] }
|
tower = { version = "0.5.3", features = ["full"] }
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ CREATE TABLE users (
|
|||||||
);
|
);
|
||||||
CREATE TABLE sessions (
|
CREATE TABLE sessions (
|
||||||
id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes
|
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)
|
user_id BLOB NOT NULL REFERENCES users(id), -- UUIDv7 bytes (userID)
|
||||||
issued TEXT NOT NULL, -- RFC3339 into DateTime<Utc>
|
|
||||||
expiry TEXT NOT NULL, -- RFC3339 into DateTime<Utc>
|
expiry TEXT NOT NULL, -- RFC3339 into DateTime<Utc>
|
||||||
revoked INTEGER NOT NULL DEFAULT 0, -- bool (int 0 or int 1)
|
revoked INTEGER NOT NULL DEFAULT 0, -- bool (int 0 or int 1)
|
||||||
revoked_at TEXT DEFAULT NULL, -- RFC3339 into DateTime<Utc>
|
revoked_at TEXT DEFAULT NULL, -- RFC3339 into DateTime<Utc>
|
||||||
revoked_by BLOB DEFAULT NULL REFERENCES users(id) -- UUIDv7 bytes (userID)
|
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);
|
CREATE INDEX sessions_by_userid ON sessions(user_id);
|
||||||
|
|
||||||
|
|||||||
@@ -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<String> =
|
||||||
|
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<Connection, rusqlite::Error> {
|
||||||
|
let conn = Connection::open(&*DB_URL)?;
|
||||||
|
for pragma in CONNECTION_PRAGMAS {
|
||||||
|
conn.query_row(*pragma, (), |_| Ok(())).optional()?;
|
||||||
|
}
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn migrations() -> Result<(), Box<dyn Error>> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ mod tags;
|
|||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
println!("Hello, world!");
|
dotenvy::dotenv()?;
|
||||||
|
database::migrations()?;
|
||||||
|
users::setup::initialise_reserved_users_if_needed()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
152
src/users/auth/implementation.rs
Normal file
152
src/users/auth/implementation.rs
Normal file
@@ -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<String, argon2::password_hash::Error> {
|
||||||
|
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<bool, argon2::password_hash::Error> {
|
||||||
|
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<argon2::password_hash::Error> 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<Option<User>, 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<Option<User>, 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<String>)> = 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<Option<User>, 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)?))
|
||||||
|
}
|
||||||
58
src/users/auth/mod.rs
Normal file
58
src/users/auth/mod.rs
Normal file
@@ -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<Option<User>, AuthError>;
|
||||||
|
}
|
||||||
|
pub trait UserPasswordHashing {
|
||||||
|
/// Returns the hashed password as a String
|
||||||
|
fn hash_password(passw: &str) -> Result<String, argon2::password_hash::Error>;
|
||||||
|
/// Returns whether the password matches the hash
|
||||||
|
fn match_hash_password(passw: &str, hash: &str) -> Result<bool, argon2::password_hash::Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[serde(try_from = "String")]
|
#[serde(try_from = "String")]
|
||||||
pub struct UserHandle(String);
|
pub struct UserHandle(String);
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
|
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub enum UserHandleError {
|
pub enum UserHandleError {
|
||||||
#[error("Handle is too short - must be 3 or more characters.")]
|
#[error("Handle is too short - must be 3 or more characters.")]
|
||||||
HandleTooShort,
|
HandleTooShort,
|
||||||
|
|||||||
166
src/users/mod.rs
166
src/users/mod.rs
@@ -1,14 +1,178 @@
|
|||||||
|
use rusqlite::OptionalExtension;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::users::handle::UserHandle;
|
use crate::{
|
||||||
|
database::{self},
|
||||||
|
users::{
|
||||||
|
auth::{AuthError, UserPasswordHashing},
|
||||||
|
handle::{UserHandle, UserHandleError},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod handle;
|
pub mod handle;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
pub mod setup;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub handle: UserHandle,
|
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<rusqlite::Error> for UserError {
|
||||||
|
fn from(error: rusqlite::Error) -> Self {
|
||||||
|
UserError::DatabaseError(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<argon2::password_hash::Error> for UserError {
|
||||||
|
fn from(err: argon2::password_hash::Error) -> Self {
|
||||||
|
UserError::PassHashError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
pub fn get_by_id(id: Uuid) -> Result<User, UserError> {
|
||||||
|
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<User, UserError> {
|
||||||
|
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<User, UserError> {
|
||||||
|
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<User, UserError> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<Utc>,
|
||||||
|
#[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<Utc>,
|
||||||
|
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<rusqlite::Error> for SessionError {
|
||||||
|
fn from(error: rusqlite::Error) -> Self {
|
||||||
|
SessionError::DatabaseError(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn get_by_id(id: Uuid) -> Result<Session, SessionError> {
|
||||||
|
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<Session, SessionError> {
|
||||||
|
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<Utc> {
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
31
src/users/setup.rs
Normal file
31
src/users/setup.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user