a whole lot of preem User/Session/Auth work

This commit is contained in:
2026-02-23 02:17:49 +01:00
parent 7a62819d9c
commit 52b70d4ee9
12 changed files with 744 additions and 8 deletions

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

View File

@@ -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,

View File

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

View File

@@ -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
View 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(())
}