Compare commits

...

10 Commits

Author SHA1 Message Date
f2a1296454 Move server setup into router_setup
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 8s
Push Workflows / docs (push) Successful in 33s
Push Workflows / clippy (push) Successful in 31s
Push Workflows / test (push) Successful in 57s
Push Workflows / build (push) Successful in 1m25s
Push Workflows / nix-build (push) Successful in 5m17s
Remove tokio (dioxus::serve provides a runtime)
2026-06-27 18:51:44 -04:00
d52c4cbe9e Add open_signup config flag
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 7s
Push Workflows / clippy (push) Successful in 18s
Push Workflows / docs (push) Successful in 22s
Push Workflows / test (push) Successful in 26s
Push Workflows / build (push) Successful in 49s
Push Workflows / nix-build (push) Successful in 5m11s
2026-06-27 17:33:23 -04:00
1282c2b8b5 Implement axum-login
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / tailwind-build (push) Successful in 7s
Push Workflows / docs (push) Successful in 40s
Push Workflows / clippy (push) Successful in 46s
Push Workflows / test (push) Successful in 1m2s
Push Workflows / build (push) Successful in 1m54s
Push Workflows / nix-build (push) Successful in 5m20s
2026-06-27 17:25:46 -04:00
f7f4fd2813 Add password check function 2026-06-27 17:23:02 -04:00
46ce08e02f Add HashedPassword::auth_hash 2026-06-27 17:22:35 -04:00
8a049edeeb Add axum-login 2026-06-27 17:21:52 -04:00
0b7e25c792 Add database::DbConn type 2026-06-27 17:07:42 -04:00
554ae23175 Remove unnecessary error conversion 2026-06-27 17:07:27 -04:00
87aa18b7cc Allow error::Result type to use other error
Convenience since this Result will shadow std::result::Result when
imported
2026-06-27 17:06:24 -04:00
86291f1eb5 Convert deadpool::PoolError into Database error 2026-06-27 17:06:02 -04:00
10 changed files with 239 additions and 33 deletions

76
Cargo.lock generated
View File

@@ -194,6 +194,25 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-login"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "964ea6eb764a227baa8c3368e45c94d23b6863cc7b880c6c9e341c143c5a5ff7"
dependencies = [
"axum",
"form_urlencoded",
"serde",
"subtle",
"thiserror 2.0.18",
"tower-cookies",
"tower-layer",
"tower-service",
"tower-sessions",
"tracing",
"urlencoding",
]
[[package]] [[package]]
name = "axum-macros" name = "axum-macros"
version = "0.5.1" version = "0.5.1"
@@ -743,6 +762,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [ dependencies = [
"powerfmt", "powerfmt",
"serde_core",
] ]
[[package]] [[package]]
@@ -2391,6 +2411,7 @@ dependencies = [
name = "libretunes" name = "libretunes"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum-login",
"cfg-if", "cfg-if",
"chrono", "chrono",
"config", "config",
@@ -2406,7 +2427,6 @@ dependencies = [
"rand 0.10.1", "rand 0.10.1",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio",
"tracing", "tracing",
] ]
@@ -2435,6 +2455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [ dependencies = [
"scopeguard", "scopeguard",
"serde",
] ]
[[package]] [[package]]
@@ -4098,6 +4119,22 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "tower-cookies"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
dependencies = [
"axum-core",
"cookie",
"futures-util",
"http",
"parking_lot",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.6.11" version = "0.6.11"
@@ -4137,6 +4174,43 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower-sessions"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3"
dependencies = [
"async-trait",
"http",
"time",
"tokio",
"tower-cookies",
"tower-layer",
"tower-service",
"tower-sessions-core",
"tracing",
]
[[package]]
name = "tower-sessions-core"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b"
dependencies = [
"async-trait",
"base64",
"futures",
"http",
"parking_lot",
"rand 0.8.6",
"serde",
"serde_json",
"thiserror 2.0.18",
"time",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"

View File

@@ -9,6 +9,7 @@ edition = "2024"
build = "src/build.rs" build = "src/build.rs"
[dependencies] [dependencies]
axum-login = { version = "0.18.0", optional = true }
cfg-if = "1.0.4" cfg-if = "1.0.4"
chrono = { version = "0.4.45", features = ["serde"] } chrono = { version = "0.4.45", features = ["serde"] }
config = { version = "0.15.24", optional = true } config = { version = "0.15.24", optional = true }
@@ -23,7 +24,6 @@ pbkdf2 = { version = "0.13.0", optional = true, features = ["getrandom", "phc"]
rand = "0.10.1" rand = "0.10.1"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.3", optional = true, features = ["rt-multi-thread"] }
tracing = "0.1.44" tracing = "0.1.44"
[features] [features]
@@ -31,6 +31,7 @@ default = ["web"]
web = ["dioxus/web"] web = ["dioxus/web"]
server = [ server = [
"dioxus/server", "dioxus/server",
"dep:axum-login",
"dep:config", "dep:config",
"dep:diesel", "dep:diesel",
"dep:diesel-async", "dep:diesel-async",
@@ -38,7 +39,6 @@ server = [
"dep:dotenvy", "dep:dotenvy",
"dep:fred", "dep:fred",
"dep:pbkdf2", "dep:pbkdf2",
"dep:tokio",
] ]
# Disabled until supported # Disabled until supported

View File

@@ -28,9 +28,8 @@ fn main() {
fn main() -> std::process::ExitCode { fn main() -> std::process::ExitCode {
tracing_setup(); tracing_setup();
if let Err(e) = server::main() { let Err(e) = server::main();
tracing::error!("Server main failed:\n{e}"); tracing::error!("Server main failed:\n{e}");
}
std::process::ExitCode::FAILURE std::process::ExitCode::FAILURE
} }

View File

@@ -28,13 +28,45 @@ use diesel::{
serialize::ToSql, serialize::ToSql,
sql_types, sql_types,
}; };
use pbkdf2::{PasswordHasher, Pbkdf2}; use pbkdf2::{
PasswordHasher, PasswordVerifier, Pbkdf2, password_hash::Error::PasswordInvalid,
phc::PasswordHash,
};
use crate::util::error::{Error, Result};
/// Newtype for a `String`-represented hashed password /// Newtype for a `String`-represented hashed password
#[derive(Clone, Debug, AsExpression, FromSqlRow)] #[derive(Clone, Debug, AsExpression, FromSqlRow)]
#[diesel(sql_type = sql_types::Text)] #[diesel(sql_type = sql_types::Text)]
pub struct HashedPassword(String); pub struct HashedPassword(String);
impl HashedPassword {
/// Check a password attempt against this hashed password
///
/// # Returns
///
/// `Ok(true)` for a correct password
/// `Ok(false)` for an incorrect password
/// `Err` for a hashing error
pub fn check(&self, password_attempt: String) -> Result<bool> {
let pw_hash = PasswordHash::new(&self.0)
.map_err(|e| Error::message_here(format!("Error parsing `HashedPassword`: {e}")))?;
match Pbkdf2::default().verify_password(password_attempt.as_bytes(), &pw_hash) {
Ok(()) => Ok(true),
Err(PasswordInvalid) => Ok(false),
Err(e) => Err(Error::message_here(format!(
"Error comparing password attempt against hash: {e}"
))),
}
}
/// Returns the "session auth hash" for `axum-login`, just the hashed password as bytes
pub fn auth_hash(&self) -> &[u8] {
self.0.as_bytes()
}
}
impl<DB> FromSql<diesel::sql_types::Text, DB> for HashedPassword impl<DB> FromSql<diesel::sql_types::Text, DB> for HashedPassword
where where
DB: diesel::backend::Backend, DB: diesel::backend::Backend,

91
src/server/auth.rs Normal file
View File

@@ -0,0 +1,91 @@
use axum_login::{AuthUser, AuthnBackend, UserId};
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use crate::models::user::{DbUser, UserCredentials};
use crate::server::database::{DbConn, DbPool};
use crate::util::error::{Contextualize, Error, Result};
impl AuthUser for DbUser {
type Id = i32;
fn id(&self) -> Self::Id {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
self.hashed_password.auth_hash()
}
}
#[derive(Clone)]
pub struct AuthBackend {
pub db_pool: DbPool,
}
impl AuthnBackend for AuthBackend {
type User = DbUser;
type Credentials = UserCredentials;
type Error = Error;
async fn authenticate(
&self,
attempt_creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let mut db_conn = self
.db_pool
.get()
.await
.err_context("Failed to get database pool connection")?;
let user = get_user_by_username(&mut db_conn, attempt_creds.username)
.await
.err_context("Error fetching user for authentication check")?;
let Some(user) = user else { return Ok(None) };
let password_result = user
.hashed_password
.check(attempt_creds.password)
.err_context("Error checking user password attempt")?;
if password_result {
Ok(Some(user))
} else {
Ok(None)
}
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let mut db_conn = self
.db_pool
.get()
.await
.err_context("Failed to get database pool connection")?;
get_user_by_id(&mut db_conn, *user_id)
.await
.err_context("Failed fetching user for session")
}
}
pub async fn get_user_by_id(db_conn: &mut DbConn, id: i32) -> Result<Option<DbUser>> {
crate::schema::users::table
.find(id)
.first(db_conn)
.await
.optional()
.err_context("Error fetching user from database by id")
}
pub async fn get_user_by_username(
db_conn: &mut DbConn,
username: String,
) -> Result<Option<DbUser>> {
crate::schema::users::table
.filter(crate::schema::users::username.eq(username))
.first(db_conn)
.await
.optional()
.err_context("Error fetching user from database by username")
}

View File

@@ -1,5 +1,10 @@
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct AuthConfig {
pub open_signup: bool,
}
/// Build a connection URI from parts /// Build a connection URI from parts
fn format_uri( fn format_uri(
scheme: &str, scheme: &str,
@@ -134,6 +139,7 @@ impl KeyValStoreConfig {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
/// Top-level application configuration /// Top-level application configuration
pub struct Config { pub struct Config {
pub auth: AuthConfig,
pub database: DatabaseConfig, pub database: DatabaseConfig,
pub key_val_store: KeyValStoreConfig, pub key_val_store: KeyValStoreConfig,
} }
@@ -146,6 +152,7 @@ pub fn load_config() -> Result<Config, config::ConfigError> {
config::Config::builder() config::Config::builder()
.set_default("server.port", 8080)? .set_default("server.port", 8080)?
.set_default("auth.open_signup", false)?
.add_source(File::with_name(&format!("/etc/{pkg_name}/config")).required(false)) .add_source(File::with_name(&format!("/etc/{pkg_name}/config")).required(false))
.add_source(File::with_name(&format!("/etc/{pkg_name}")).required(false)) .add_source(File::with_name(&format!("/etc/{pkg_name}")).required(false))
.add_source(File::with_name("config").required(false)) .add_source(File::with_name("config").required(false))

View File

@@ -9,6 +9,7 @@ use crate::util::error::{Contextualize, Error, ErrorType};
pub const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!(); pub const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
pub type DbPool = Pool<AsyncPgConnection>; pub type DbPool = Pool<AsyncPgConnection>;
pub type DbConn = AsyncPgConnection;
/// Connect to the database using the given URI, and perform migrations /// Connect to the database using the given URI, and perform migrations
pub async fn setup<S: Into<String>>(database_uri: S) -> Result<DbPool, Error> { pub async fn setup<S: Into<String>>(database_uri: S) -> Result<DbPool, Error> {
@@ -26,7 +27,6 @@ pub async fn setup<S: Into<String>>(database_uri: S) -> Result<DbPool, Error> {
let migration_conn = pool let migration_conn = pool
.get() .get()
.await .await
.map_err(|e| ErrorType::Database(e.to_string()))
.err_context("Failed to get connection to database")?; .err_context("Failed to get connection to database")?;
tracing::debug!("Running migrations..."); tracing::debug!("Running migrations...");

View File

@@ -1,41 +1,33 @@
use tokio::runtime::Runtime; use dioxus::fullstack::axum::Router;
use crate::App; use crate::App;
use crate::server::{config, database, key_val_store}; use crate::server::{config, database, key_val_store};
use crate::util::error::{Contextualize, Error, Result}; use crate::util::error::{Contextualize, Error, Result};
pub fn main() -> Result<()> { pub fn main() -> Result<std::convert::Infallible> {
if let Err(e) = dotenvy::dotenv() { if let Err(e) = dotenvy::dotenv() {
tracing::warn!("Error reading .env: {e}"); tracing::warn!("Error reading .env: {e}");
} }
// `Ok(...?)` is because `dioxus::serve` expects an `anyhow::Result`
dioxus::serve(async move || Ok(router_setup().await?));
}
/// Set up the axum Router
async fn router_setup() -> Result<Router> {
tracing::debug!("Loading configuration..."); tracing::debug!("Loading configuration...");
let config = config::load_config() let config = config::load_config()
.map_err(|e| Error::message_here(e.to_string())) .map_err(|e| Error::message_here(e.to_string()))
.err_context("Failed to load config")?; .err_context("Failed to load config")?;
// `dioxus::launch` creates its own runtime, and starting a runtime inside of a runtime isn't let _db_pool = database::setup(config.database.connection_uri())
// allowed. Therefore, this function can't be made async, and we must manually create a runtime .await
// for any async setup tasks .err_context("Failed database setup")?;
tracing::debug!("Starting setup runtime...");
let setup_rt = Runtime::new()
.map_err(|e| Error::message_here(e.to_string()))
.err_context("Failed to create tokio runtime for server setup")?;
let (_db_pool, _key_val_pool) = setup_rt.block_on(async { let _key_val_pool = key_val_store::setup(&config.key_val_store.connection_uri())
let db_pool = database::setup(config.database.connection_uri()) .await
.await .err_context("Failed key-value store setup")?;
.err_context("Failed database setup")?;
let key_val_pool = key_val_store::setup(&config.key_val_store.connection_uri()) tracing::info!("Setup complete, returning Router...");
.await Ok(dioxus::server::router(App))
.err_context("Failed key-value store setup")?;
Ok::<_, Error>((db_pool, key_val_pool))
})?;
tracing::info!("Setup complete, launching web server...");
dioxus::launch(App);
Err(Error::message_here("Web server exited"))
} }

View File

@@ -1,3 +1,4 @@
pub mod auth;
pub mod config; pub mod config;
pub mod database; pub mod database;
pub mod key_val_store; pub mod key_val_store;

View File

@@ -43,7 +43,7 @@ impl fmt::Display for ErrorLocation {
} }
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, Clone, Deserialize, Serialize, thiserror::Error)] #[derive(Debug, Clone, Deserialize, Serialize, thiserror::Error)]
pub struct Error { pub struct Error {
@@ -314,6 +314,16 @@ impl From<diesel::result::Error> for Error {
} }
} }
// This would capture any `deapool::PoolError` and treat it as a database error
// but we're only using `deadpool` for our database, so it's fine
#[cfg(feature = "server")]
impl From<diesel_async::pooled_connection::deadpool::PoolError> for Error {
#[track_caller]
fn from(err: diesel_async::pooled_connection::deadpool::PoolError) -> Self {
Error::new_here(ErrorType::Database(format!("{err}")))
}
}
#[cfg(feature = "server")] #[cfg(feature = "server")]
impl From<fred::error::Error> for Error { impl From<fred::error::Error> for Error {
#[track_caller] #[track_caller]