Compare commits
13 Commits
1282c2b8b5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
62a7103423
|
|||
|
d028636e43
|
|||
|
7ca7056fac
|
|||
|
ab90cd81f9
|
|||
|
04ca8abce9
|
|||
|
08fc3995e5
|
|||
|
4b2fb25c6d
|
|||
|
17ea0ee46a
|
|||
|
1b5a5125a7
|
|||
|
978c9c4202
|
|||
|
7fc0513efc
|
|||
|
f2a1296454
|
|||
|
d52c4cbe9e
|
35
Cargo.lock
generated
35
Cargo.lock
generated
@@ -2427,7 +2427,7 @@ dependencies = [
|
|||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tower-sessions-redis-store",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3317,6 +3317,25 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "ron"
|
name = "ron"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -4212,6 +4231,20 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ 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"] }
|
tower-sessions-redis-store = { version = "0.16.0", optional = true }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
@@ -40,7 +40,7 @@ server = [
|
|||||||
"dep:dotenvy",
|
"dep:dotenvy",
|
||||||
"dep:fred",
|
"dep:fred",
|
||||||
"dep:pbkdf2",
|
"dep:pbkdf2",
|
||||||
"dep:tokio",
|
"dep:tower-sessions-redis-store",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Disabled until supported
|
# Disabled until supported
|
||||||
|
|||||||
82
src/api/auth.rs
Normal file
82
src/api/auth.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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, create_user}, config::Config, database::DbPool};
|
||||||
|
use crate::util::error::{AuthError, Contextualize, Error, ErrorType};
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/v1/auth/signup", mut auth: Extension<AuthSession>, db_pool: Extension<DbPool>, config: Extension<Config>)]
|
||||||
|
pub async fn signup(credentials: UserCredentials) -> Result<User> {
|
||||||
|
if !config.auth.open_signup {
|
||||||
|
return Err(Error::new_here(ErrorType::Auth(AuthError::Unauthorized)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow signup when already logged in
|
||||||
|
if auth.user.is_some() {
|
||||||
|
return Err(Error::new_here(ErrorType::Auth(AuthError::Unauthorized)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hashed_creds = credentials
|
||||||
|
.try_hash()
|
||||||
|
.map_err(|e| Error::message_here(e.to_string()))
|
||||||
|
.err_context("Error hashing new user credentials")?;
|
||||||
|
|
||||||
|
let mut db_conn = db_pool
|
||||||
|
.get()
|
||||||
|
.await
|
||||||
|
.err_context("Failed to get database pool connection")?;
|
||||||
|
|
||||||
|
let new_user = create_user(&mut db_conn, &hashed_creds)
|
||||||
|
.await
|
||||||
|
.err_context("Error creating user")?;
|
||||||
|
|
||||||
|
// Don't return this to the client, logging in immediately isn't strictly necessary
|
||||||
|
if let Err(e) = auth.login(&new_user).await {
|
||||||
|
tracing::warn!("Failed to log in user after creating: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(new_user.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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")
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
|
pub mod auth;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
//! Various user types. Some types marked server-only to help prevent
|
//! Various user types. Some types marked server-only to help prevent
|
||||||
//! leaking passwords to the frontend
|
//! leaking passwords to the frontend
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Standard informational user type, contains no password information
|
/// 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", derive(Queryable, Selectable, Identifiable))]
|
||||||
#[cfg_attr(feature = "server", diesel(table_name = crate::schema::users,
|
#[cfg_attr(feature = "server", diesel(table_name = crate::schema::users,
|
||||||
check_for_backend(diesel::pg::Pg)))]
|
check_for_backend(diesel::pg::Pg)))]
|
||||||
@@ -13,6 +15,7 @@ pub struct User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Plaintext user credentials, used for login/signup form
|
/// Plaintext user credentials, used for login/signup form
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct UserCredentials {
|
pub struct UserCredentials {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
use axum_login::{AuthUser, AuthnBackend, UserId};
|
use axum_login::{AuthManagerLayer, AuthUser, AuthnBackend, UserId};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel_async::RunQueryDsl;
|
use diesel_async::RunQueryDsl;
|
||||||
|
use tower_sessions_redis_store::RedisStore;
|
||||||
|
|
||||||
use crate::models::user::{DbUser, UserCredentials};
|
use crate::models::user::{DbUser, HashedUserCredentials, UserCredentials};
|
||||||
use crate::server::database::{DbConn, DbPool};
|
use crate::server::{
|
||||||
|
database::{DbConn, DbPool},
|
||||||
|
key_val_store::KeyValPool,
|
||||||
|
};
|
||||||
use crate::util::error::{Contextualize, Error, Result};
|
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 {
|
impl AuthUser for DbUser {
|
||||||
type Id = i32;
|
type Id = i32;
|
||||||
|
|
||||||
@@ -69,6 +76,17 @@ impl AuthnBackend for AuthBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_user(
|
||||||
|
db_conn: &mut DbConn,
|
||||||
|
credentials: &HashedUserCredentials,
|
||||||
|
) -> Result<DbUser> {
|
||||||
|
diesel::insert_into(crate::schema::users::table)
|
||||||
|
.values(credentials)
|
||||||
|
.get_result(db_conn)
|
||||||
|
.await
|
||||||
|
.err_context("Error creating user")
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_user_by_id(db_conn: &mut DbConn, id: i32) -> Result<Option<DbUser>> {
|
pub async fn get_user_by_id(db_conn: &mut DbConn, id: i32) -> Result<Option<DbUser>> {
|
||||||
crate::schema::users::table
|
crate::schema::users::table
|
||||||
.find(id)
|
.find(id)
|
||||||
@@ -89,3 +107,16 @@ pub async fn get_user_by_username(
|
|||||||
.optional()
|
.optional()
|
||||||
.err_context("Error fetching user from database by username")
|
.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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
use tokio::runtime::Runtime;
|
use dioxus::{fullstack::axum::Router, server::axum::Extension};
|
||||||
|
|
||||||
use crate::App;
|
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};
|
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
|
|
||||||
// allowed. Therefore, this function can't be made async, and we must manually create a runtime
|
|
||||||
// for any async setup tasks
|
|
||||||
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 db_pool = database::setup(config.database.connection_uri())
|
let db_pool = database::setup(config.database.connection_uri())
|
||||||
.await
|
.await
|
||||||
.err_context("Failed database setup")?;
|
.err_context("Failed database setup")?;
|
||||||
@@ -31,11 +28,13 @@ pub fn main() -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.err_context("Failed key-value store setup")?;
|
.err_context("Failed key-value store setup")?;
|
||||||
|
|
||||||
Ok::<_, Error>((db_pool, key_val_pool))
|
let auth_layer = build_auth_layer(db_pool.clone(), key_val_pool);
|
||||||
})?;
|
|
||||||
|
|
||||||
tracing::info!("Setup complete, launching web server...");
|
let router = dioxus::server::router(App)
|
||||||
dioxus::launch(App);
|
.layer(Extension(config))
|
||||||
|
.layer(Extension(db_pool))
|
||||||
|
.layer(auth_layer);
|
||||||
|
|
||||||
Err(Error::message_here("Web server exited"))
|
tracing::info!("Setup complete, returning Router...");
|
||||||
|
Ok(router)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,9 @@ impl From<ServerFnError> for Error {
|
|||||||
impl dioxus_fullstack::AsStatusCode for Error {
|
impl dioxus_fullstack::AsStatusCode for Error {
|
||||||
fn as_status_code(&self) -> StatusCode {
|
fn as_status_code(&self) -> StatusCode {
|
||||||
match &self.source {
|
match &self.source {
|
||||||
|
ErrorType::Auth(AuthError::InvalidCredentials | AuthError::Unauthorized) => {
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
}
|
||||||
ErrorType::Database(msg) if *msg == (diesel::result::Error::NotFound).to_string() => {
|
ErrorType::Database(msg) if *msg == (diesel::result::Error::NotFound).to_string() => {
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
}
|
}
|
||||||
@@ -282,6 +285,9 @@ impl<T, E: Into<Error>> Contextualize<Result<T>> for E {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)]
|
#[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)]
|
||||||
pub enum ErrorType {
|
pub enum ErrorType {
|
||||||
|
#[error("Authentication error: {0}")]
|
||||||
|
Auth(AuthError),
|
||||||
|
|
||||||
// Using string to represent Diesel errors, because Diesel's Error type is not `Serialize`,
|
// Using string to represent Diesel errors, because Diesel's Error type is not `Serialize`,
|
||||||
// and Diesel is only available on the server
|
// and Diesel is only available on the server
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
@@ -331,3 +337,15 @@ impl From<fred::error::Error> for Error {
|
|||||||
Error::new_here(ErrorType::KeyValStore(format!("{err}")))
|
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),
|
||||||
|
|
||||||
|
#[error("Unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user