Compare commits

...

7 Commits

Author SHA1 Message Date
04ca8abce9 Add login and logout endpoints
All checks were successful
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / tailwind-build (push) Successful in 11s
Push Workflows / clippy (push) Successful in 43s
Push Workflows / test (push) Successful in 1m4s
Push Workflows / docs (push) Successful in 1m8s
Push Workflows / build (push) Successful in 1m30s
Push Workflows / nix-build (push) Successful in 5m27s
2026-06-27 22:18:10 -04:00
08fc3995e5 Add auth layer to router 2026-06-27 22:17:27 -04:00
4b2fb25c6d Create function to build auth layer 2026-06-27 22:17:07 -04:00
17ea0ee46a Add AuthError 2026-06-27 22:14:40 -04:00
1b5a5125a7 Derive Ser/De on User and UserCredentials 2026-06-27 22:14:10 -04:00
978c9c4202 Add tower-sessions-redis-store 2026-06-27 22:01:25 -04:00
7fc0513efc Attach db_pool to router as Extension 2026-06-27 18:54:32 -04:00
8 changed files with 136 additions and 9 deletions

34
Cargo.lock generated
View File

@@ -2427,6 +2427,7 @@ dependencies = [
"rand 0.10.1",
"serde",
"thiserror 2.0.18",
"tower-sessions-redis-store",
"tracing",
]
@@ -3316,6 +3317,25 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "ron"
version = "0.12.1"
@@ -4211,6 +4231,20 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-sessions-redis-store"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e15b774f3d46625a27a8ac1238ecd73c8bd50013244e2de004026e161aad728"
dependencies = [
"async-trait",
"fred",
"rmp-serde",
"thiserror 2.0.18",
"time",
"tower-sessions-core",
]
[[package]]
name = "tracing"
version = "0.1.44"

View File

@@ -24,6 +24,7 @@ pbkdf2 = { version = "0.13.0", optional = true, features = ["getrandom", "phc"]
rand = "0.10.1"
serde = { version = "1.0.228", features = ["derive"] }
thiserror = "2.0.18"
tower-sessions-redis-store = { version = "0.16.0", optional = true }
tracing = "0.1.44"
[features]
@@ -39,6 +40,7 @@ server = [
"dep:dotenvy",
"dep:fred",
"dep:pbkdf2",
"dep:tower-sessions-redis-store",
]
# Disabled until supported

49
src/api/auth.rs Normal file
View File

@@ -0,0 +1,49 @@
use dioxus::prelude::*;
use crate::models::user::{User, UserCredentials};
use crate::util::error::Result;
cfg_if::cfg_if! {
if #[cfg(feature = "server")] {
use dioxus::server::axum::Extension;
use crate::server::auth::AuthSession;
use crate::util::error::{AuthError, Contextualize, Error, ErrorType};
}
}
#[post("/api/v1/auth/login", mut auth: Extension<AuthSession>)]
pub async fn login(credentials: UserCredentials) -> Result<User> {
let db_user = match auth.authenticate(credentials).await {
Ok(Some(db_user)) => Ok(db_user),
Ok(None) => Err(Error::new_here(ErrorType::Auth(
AuthError::InvalidCredentials,
))),
Err(axum_login::Error::Session(e)) => Err(Error::new_here(ErrorType::Auth(
AuthError::Error(format!("Session error: {e}")),
))),
Err(axum_login::Error::Backend(e)) => Err(e),
}
.err_context("Error authenticating")?;
auth.login(&db_user)
.await
.map_err(|e| Error::new_here(ErrorType::Auth(AuthError::Error(e.to_string()))))
.err_context("Error logging in")?;
Ok(db_user.into())
}
#[post("/api/v1/auth/logout", mut auth: Extension<AuthSession>)]
pub async fn logout() -> Result<()> {
match auth.logout().await {
Ok(_) => Ok(()),
Err(axum_login::Error::Session(e)) => Err(Error::new_here(ErrorType::Auth(
AuthError::Error(format!("Session error: {e}")),
))),
Err(axum_login::Error::Backend(e)) => Err(e),
}
.err_context("Error logging out")
}

View File

@@ -1 +1 @@
pub mod auth;

View File

@@ -1,8 +1,10 @@
//! Various user types. Some types marked server-only to help prevent
//! leaking passwords to the frontend
use serde::{Deserialize, Serialize};
/// Standard informational user type, contains no password information
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(feature = "server", derive(Queryable, Selectable, Identifiable))]
#[cfg_attr(feature = "server", diesel(table_name = crate::schema::users,
check_for_backend(diesel::pg::Pg)))]
@@ -13,6 +15,7 @@ pub struct User {
}
/// Plaintext user credentials, used for login/signup form
#[derive(Deserialize, Serialize)]
pub struct UserCredentials {
pub username: String,
pub password: String,

View File

@@ -1,11 +1,18 @@
use axum_login::{AuthUser, AuthnBackend, UserId};
use axum_login::{AuthManagerLayer, AuthUser, AuthnBackend, UserId};
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use tower_sessions_redis_store::RedisStore;
use crate::models::user::{DbUser, UserCredentials};
use crate::server::database::{DbConn, DbPool};
use crate::server::{
database::{DbConn, DbPool},
key_val_store::KeyValPool,
};
use crate::util::error::{Contextualize, Error, Result};
pub type AuthLayer = AuthManagerLayer<AuthBackend, RedisStore<KeyValPool>>;
pub type AuthSession = axum_login::AuthSession<AuthBackend>;
impl AuthUser for DbUser {
type Id = i32;
@@ -89,3 +96,16 @@ pub async fn get_user_by_username(
.optional()
.err_context("Error fetching user from database by username")
}
/// Create the authentication middleware layer
pub fn build_auth_layer(db_pool: DbPool, key_val_pool: KeyValPool) -> AuthLayer {
use axum_login::{AuthManagerLayerBuilder, tower_sessions::SessionManagerLayer};
use tower_sessions_redis_store::RedisStore;
let auth_session_store = RedisStore::new(key_val_pool);
let session_layer = SessionManagerLayer::new(auth_session_store);
let auth_backend = AuthBackend { db_pool };
AuthManagerLayerBuilder::new(auth_backend, session_layer).build()
}

View File

@@ -1,7 +1,7 @@
use dioxus::fullstack::axum::Router;
use dioxus::{fullstack::axum::Router, server::axum::Extension};
use crate::App;
use crate::server::{config, database, key_val_store};
use crate::server::{auth::build_auth_layer, config, database, key_val_store};
use crate::util::error::{Contextualize, Error, Result};
pub fn main() -> Result<std::convert::Infallible> {
@@ -20,14 +20,20 @@ async fn router_setup() -> Result<Router> {
.map_err(|e| Error::message_here(e.to_string()))
.err_context("Failed to load config")?;
let _db_pool = database::setup(config.database.connection_uri())
let db_pool = database::setup(config.database.connection_uri())
.await
.err_context("Failed database setup")?;
let _key_val_pool = key_val_store::setup(&config.key_val_store.connection_uri())
let key_val_pool = key_val_store::setup(&config.key_val_store.connection_uri())
.await
.err_context("Failed key-value store setup")?;
let auth_layer = build_auth_layer(db_pool.clone(), key_val_pool);
let router = dioxus::server::router(App)
.layer(Extension(db_pool))
.layer(auth_layer);
tracing::info!("Setup complete, returning Router...");
Ok(dioxus::server::router(App))
Ok(router)
}

View File

@@ -239,6 +239,7 @@ impl From<ServerFnError> for Error {
impl dioxus_fullstack::AsStatusCode for Error {
fn as_status_code(&self) -> StatusCode {
match &self.source {
ErrorType::Auth(AuthError::InvalidCredentials) => StatusCode::UNAUTHORIZED,
ErrorType::Database(msg) if *msg == (diesel::result::Error::NotFound).to_string() => {
StatusCode::NOT_FOUND
}
@@ -282,6 +283,9 @@ impl<T, E: Into<Error>> Contextualize<Result<T>> for E {
#[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)]
pub enum ErrorType {
#[error("Authentication error: {0}")]
Auth(AuthError),
// Using string to represent Diesel errors, because Diesel's Error type is not `Serialize`,
// and Diesel is only available on the server
#[error("Database error: {0}")]
@@ -331,3 +335,12 @@ impl From<fred::error::Error> for Error {
Error::new_here(ErrorType::KeyValStore(format!("{err}")))
}
}
#[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)]
pub enum AuthError {
#[error("Invalid credentials")]
InvalidCredentials,
#[error("{0}")]
Error(String),
}