From b029b4b975f736cbc7ac88d282129586f37b261e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= Date: Wed, 10 Jul 2024 17:49:45 +0200 Subject: [PATCH] wip: implementing auth further --- Cargo.lock | 44 ++++ rear_auth/Cargo.toml | 2 + rear_auth/roadmap.md | 2 + rear_auth/src/lib.rs | 4 +- rear_auth/src/main.rs | 2 - rear_auth/src/models.rs | 230 ++++++++++++++++++ .../src/user_admin_repository.rs | 80 +++--- rear_auth/src/users.rs | 154 ------------ rear_auth/src/views.rs | 96 ++++++++ src/admin_examples/mod.rs | 3 +- src/main.rs | 5 +- 11 files changed, 418 insertions(+), 204 deletions(-) create mode 100644 rear_auth/roadmap.md create mode 100644 rear_auth/src/models.rs rename src/admin_examples/user_repository.rs => rear_auth/src/user_admin_repository.rs (69%) delete mode 100644 rear_auth/src/users.rs create mode 100644 rear_auth/src/views.rs diff --git a/Cargo.lock b/Cargo.lock index c30b61a..9dbd8c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayref" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545" + [[package]] name = "arrayvec" version = "0.7.4" @@ -490,6 +496,23 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "axum-messages" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d06050ddcb05eaaffe44c5f73443ea936eadae6a8332dd47b8c8c8cb1af98a" +dependencies = [ + "async-trait", + "axum-core 0.4.3", + "http 1.0.0", + "parking_lot", + "serde", + "serde_json", + "tower", + "tower-sessions-core", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.69" @@ -576,6 +599,19 @@ dependencies = [ "digest", ] +[[package]] +name = "blake3" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cca6d3674597c30ddf2c587bf8d9d65c9a84d2326d941cc79c9842dfe0ef52" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -772,6 +808,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" + [[package]] name = "cookie" version = "0.18.1" @@ -2431,6 +2473,8 @@ dependencies = [ "async-trait", "axum 0.7.4", "axum-login", + "axum-messages", + "blake3", "entity", "log", "password-auth", diff --git a/rear_auth/Cargo.toml b/rear_auth/Cargo.toml index b7c91b4..bdf4ff1 100644 --- a/rear_auth/Cargo.toml +++ b/rear_auth/Cargo.toml @@ -23,6 +23,8 @@ sea-orm = { version = "0.12.10", features = [ "sqlx-postgres", ] } axum-login = "0.15.3" +axum-messages = "0.6.1" async-trait = "0.1.80" password-auth = "1.0.0" thiserror = "1.0.61" +blake3 = "1.5.1" diff --git a/rear_auth/roadmap.md b/rear_auth/roadmap.md new file mode 100644 index 0000000..89c4159 --- /dev/null +++ b/rear_auth/roadmap.md @@ -0,0 +1,2 @@ +Atm. it is not clear to me if rear_auth will really be it's own package, as it needs to interact a lot with rear, or if I split up rear into modules instead. + diff --git a/rear_auth/src/lib.rs b/rear_auth/src/lib.rs index c8435ce..63c6f4b 100644 --- a/rear_auth/src/lib.rs +++ b/rear_auth/src/lib.rs @@ -1 +1,3 @@ -pub mod users; +pub mod models; +pub mod user_admin_repository; +pub mod views; diff --git a/rear_auth/src/main.rs b/rear_auth/src/main.rs index a9c7ed9..4d16e09 100644 --- a/rear_auth/src/main.rs +++ b/rear_auth/src/main.rs @@ -1,3 +1 @@ -mod users; - pub fn main() {} diff --git a/rear_auth/src/models.rs b/rear_auth/src/models.rs new file mode 100644 index 0000000..0509cc7 --- /dev/null +++ b/rear_auth/src/models.rs @@ -0,0 +1,230 @@ +use std::collections::HashSet; + +use async_trait::async_trait; +use axum_login::{AuthUser, AuthzBackend}; +use axum_login::{AuthnBackend, UserId}; +use log::debug; +use password_auth::{generate_hash, is_hash_obsolete, verify_password}; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::task; + +#[derive(Debug, Clone)] +pub struct UserRepository { + pub(crate) connection: DatabaseConnection, +} + +impl UserRepository { + pub fn new(connection: DatabaseConnection) -> Self { + UserRepository { + connection: connection, + } + } + + pub(crate) fn encode_password(password: String) -> String { + // This function will try to avoid re-encoding an encoded password. + // This is why it is not public outside of the crate. + if let Ok(_is_obsolete) = is_hash_obsolete(password.as_str()) { + // Not sure what to do if it is obsolete. + if _is_obsolete { + debug!("UserRepository::encode_password: found obsolete password hash."); + } + return password; + } + // As checking for obsoleteness errored out, we assume this a raw password. + generate_hash(password) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct AuthenticatedUser { + id: i64, + pub username: String, + password: String, + session_token: Vec, // Stores the hash +} + +impl AuthenticatedUser { + fn new(id: i64, username: String, password: String) -> Self { + AuthenticatedUser { + id: id, + username: username, + session_token: blake3::hash(&password.as_bytes()).as_bytes().to_vec(), + password: password, + } + } + + fn set_password(&mut self, new_password: String) { + self.password = new_password; + self.session_token = blake3::hash(self.password.as_bytes()).as_bytes().to_vec(); + } +} + +// Here we've implemented `Debug` manually to avoid accidentally logging the +// password hash. +impl std::fmt::Debug for AuthenticatedUser { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("User") + .field("id", &self.id) + .field("username", &self.username) + .field("password", &"[redacted]") + .finish() + } +} + +impl AuthUser for AuthenticatedUser { + type Id = i64; + + fn id(&self) -> Self::Id { + self.id + } + + fn session_auth_hash(&self) -> &[u8] { + &self.session_token + } +} + +impl From for AuthenticatedUser { + fn from(value: entity::user::Model) -> Self { + AuthenticatedUser::new(value.id, value.username, value.password) + } +} + +// This allows us to extract the authentication fields from forms. We use this +// to authenticate requests with the backend. +#[derive(Debug, Clone, Deserialize)] +pub struct Credentials { + pub username: String, + pub password: String, + pub next: Option, +} + +#[derive(Debug, Error)] +pub struct NotFoundError { + pub details: String, +} + +impl NotFoundError { + pub fn new(details: &str) -> Self { + NotFoundError { + details: details.to_string(), + } + } +} + +impl std::fmt::Display for NotFoundError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Not Found... Error: {}", self.details) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + DbErr(#[from] sea_orm::DbErr), + + #[error("Not Found: {0}")] + NotFound(#[from] NotFoundError), + + #[error(transparent)] + TaskJoin(#[from] task::JoinError), +} + +#[async_trait] +impl AuthnBackend for UserRepository { + type User = AuthenticatedUser; + type Credentials = Credentials; + type Error = Error; + + async fn authenticate( + &self, + creds: Self::Credentials, + ) -> Result, Self::Error> { + let user_found = entity::User::find() + .filter(entity::user::Column::Username.eq(creds.username)) + .one(&self.connection) + .await?; + + let user = if let Some(user) = user_found { + let rear_user: AuthenticatedUser = user.into(); + Some(rear_user) + } else { + None + }; + + // Verifying the password is blocking and potentially slow, so we'll do so via + // `spawn_blocking`. + task::spawn_blocking(|| { + // We're using password-based authentication--this works by comparing our form + // input with an argon2 password hash. + Ok(user.filter(|user| verify_password(creds.password, &user.password).is_ok())) + }) + .await? + } + + async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { + let user_found = entity::User::find_by_id(*user_id) + .one(&self.connection) + .await?; + if let Some(user) = user_found { + let rear_user: AuthenticatedUser = user.into(); + Ok(Some(rear_user)) + } else { + Ok(None) + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct Permission { + pub name: String, +} + +impl From<&str> for Permission { + fn from(name: &str) -> Self { + Permission { + name: name.to_string(), + } + } +} + +impl From for Permission { + fn from(model: entity::permission::Model) -> Self { + Permission { + name: model.codename, + } + } +} + +#[async_trait] +impl AuthzBackend for UserRepository { + type Permission = Permission; + + async fn get_group_permissions( + &self, + user: &Self::User, + ) -> Result, Self::Error> { + let user = entity::User::find_by_id(user.id) + .one(&self.connection) + .await?; + if let Some(user) = user { + let permissions = user + .find_related(entity::Permission) + .all(&self.connection) + .await?; + + Ok(permissions + .into_iter() + .map(|item| Permission::from(item)) + .collect()) + } else { + Ok(HashSet::new()) + } + } +} + +// We use a type alias for convenience. +// +// Note that we've supplied our concrete backend here. +pub type AuthSession = axum_login::AuthSession; diff --git a/src/admin_examples/user_repository.rs b/rear_auth/src/user_admin_repository.rs similarity index 69% rename from src/admin_examples/user_repository.rs rename to rear_auth/src/user_admin_repository.rs index 29e36c0..1925f90 100644 --- a/src/admin_examples/user_repository.rs +++ b/rear_auth/src/user_admin_repository.rs @@ -1,21 +1,11 @@ -use crate::admin::domain::*; -use crate::admin::state::AdminRegistry; use async_trait::async_trait; use log::debug; +use rear::admin::domain::*; +use rear::admin::state::AdminRegistry; use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ModelTrait, Set}; use serde_json::Value; -struct UserRepository { - connection: DatabaseConnection, -} - -impl UserRepository { - pub fn new(connection: DatabaseConnection) -> Self { - UserRepository { - connection: connection, - } - } -} +use crate::models::UserRepository; #[async_trait] impl AdminRepository for UserRepository { @@ -91,41 +81,47 @@ impl AdminRepository for UserRepository { } async fn create(&mut self, model: &RepositoryContext, data: Value) -> Option { - let username = data.get("username").unwrap().as_str().unwrap(); + if let Value::Object(data) = data { + let username = data.get("username").unwrap().as_str().unwrap(); - let mut user = entity::user::ActiveModel { - username: Set(username.to_owned()), - ..Default::default() - }; + let mut user = entity::user::ActiveModel { + username: Set(username.to_owned()), + ..Default::default() + }; - let keys = [ - "password", - "first_name", - "last_name", - "email", - "is_staff", - "is_active", - "is_superuser", - ]; + let keys = [ + "password", + "first_name", + "last_name", + "email", + "is_staff", + "is_active", + "is_superuser", + ]; - for key in &keys { - if let Some(value) = data.get(*key) { - match *key { - "password" => user.password = Set(value.as_str().unwrap().to_owned()), - "first_name" => user.first_name = Set(value.as_str().map(|s| s.to_owned())), - "last_name" => user.last_name = Set(value.as_str().map(|s| s.to_owned())), - "email" => user.email = Set(value.as_str().map(|s| s.to_owned())), - "is_staff" => user.is_staff = Set(value.as_bool().unwrap_or(false)), - "is_active" => user.is_active = Set(value.as_bool().unwrap_or(false)), - "is_superuser" => user.is_superuser = Set(value.as_bool().unwrap_or(false)), - _ => (), + for key in &keys { + if let Some(value) = data.get(*key) { + match *key { + "password" => { + user.password = Set(UserRepository::encode_password( + value.as_str().unwrap().to_owned(), + )) + } + "first_name" => user.first_name = Set(value.as_str().map(|s| s.to_owned())), + "last_name" => user.last_name = Set(value.as_str().map(|s| s.to_owned())), + "email" => user.email = Set(value.as_str().map(|s| s.to_owned())), + "is_staff" => user.is_staff = Set(value.as_bool().unwrap_or(false)), + "is_active" => user.is_active = Set(value.as_bool().unwrap_or(false)), + "is_superuser" => user.is_superuser = Set(value.as_bool().unwrap_or(false)), + _ => (), + } } } - } - if let Ok(user) = user.insert(&self.connection).await { - let id = user.id.to_string(); - return Some(model.build_item(&*id, serde_json::to_value(&user).unwrap())); + if let Ok(user) = user.insert(&self.connection).await { + let id = user.id.to_string(); + return Some(model.build_item(&*id, serde_json::to_value(&user).unwrap())); + } } None } diff --git a/rear_auth/src/users.rs b/rear_auth/src/users.rs deleted file mode 100644 index 994df33..0000000 --- a/rear_auth/src/users.rs +++ /dev/null @@ -1,154 +0,0 @@ -use async_trait::async_trait; -use axum_login::{AuthUser, AuthnBackend, UserId}; -use password_auth::verify_password; -use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use tokio::task; - -#[derive(Debug, Clone)] -pub struct UserDatabase { - connection: DatabaseConnection, -} - -impl UserDatabase { - pub fn new(connection: DatabaseConnection) -> Self { - UserDatabase { - connection: connection, - } - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct User { - id: i64, - pub username: String, - password: String, -} - -// Here we've implemented `Debug` manually to avoid accidentally logging the -// password hash. -impl std::fmt::Debug for User { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("User") - .field("id", &self.id) - .field("username", &self.username) - .field("password", &"[redacted]") - .finish() - } -} - -impl AuthUser for User { - type Id = i64; - - fn id(&self) -> Self::Id { - self.id - } - - fn session_auth_hash(&self) -> &[u8] { - self.password.as_bytes() // We use the password hash as the auth - // hash--what this means - // is when the user changes their password the - // auth session becomes invalid. - } -} - -impl From for User { - fn from(value: entity::user::Model) -> Self { - User { - id: value.id, - username: value.username, - password: "".to_owned(), // TODO: password field. - } - } -} - -// This allows us to extract the authentication fields from forms. We use this -// to authenticate requests with the backend. -#[derive(Debug, Clone, Deserialize)] -pub struct Credentials { - pub username: String, - pub password: String, - pub next: Option, -} - -#[derive(Debug, Error)] -pub struct NotFoundError { - pub details: String, -} - -impl NotFoundError { - pub fn new(details: &str) -> Self { - NotFoundError { - details: details.to_string(), - } - } -} - -impl std::fmt::Display for NotFoundError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Not Found... Error: {}", self.details) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error(transparent)] - DbErr(#[from] sea_orm::DbErr), - - #[error("Not Found: {0}")] - NotFound(#[from] NotFoundError), - - #[error(transparent)] - TaskJoin(#[from] task::JoinError), -} - -#[async_trait] -impl AuthnBackend for UserDatabase { - type User = User; - type Credentials = Credentials; - type Error = Error; - - async fn authenticate( - &self, - creds: Self::Credentials, - ) -> Result, Self::Error> { - let user_found = entity::User::find() - .filter(entity::user::Column::Username.eq(creds.username)) - .one(&self.connection) - .await?; - - let user = if let Some(user) = user_found { - let rear_user: User = user.into(); - Some(rear_user) - } else { - None - }; - - // Verifying the password is blocking and potentially slow, so we'll do so via - // `spawn_blocking`. - task::spawn_blocking(|| { - // We're using password-based authentication--this works by comparing our form - // input with an argon2 password hash. - Ok(user.filter(|user| verify_password(creds.password, &user.password).is_ok())) - }) - .await? - } - - async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { - let user_found = entity::User::find_by_id(*user_id) - .one(&self.connection) - .await?; - if let Some(user) = user_found { - let rear_user: User = user.into(); - Ok(Some(rear_user)) - } else { - Ok(None) - } - } -} - -// We use a type alias for convenience. -// -// Note that we've supplied our concrete backend here. -pub type AuthSession = axum_login::AuthSession; diff --git a/rear_auth/src/views.rs b/rear_auth/src/views.rs new file mode 100644 index 0000000..6c1d96d --- /dev/null +++ b/rear_auth/src/views.rs @@ -0,0 +1,96 @@ +use axum::{ + extract::Query, + http::StatusCode, + response::{IntoResponse, Redirect}, + routing::{get, post}, + Form, Router, +}; +use axum_messages::{Message, Messages}; +use rear::service::templates::Templates; +use serde::{Deserialize, Serialize}; + +use crate::models::{AuthSession, Credentials}; + +#[derive(Serialize)] +pub struct LoginTemplate { + messages: Vec, + next: Option, +} + +// This allows us to extract the "next" field from the query string. We use this +// to redirect after log in. +#[derive(Debug, Deserialize)] +pub struct NextUrl { + next: Option, +} + +pub fn router() -> Router { + Router::new() + .route("/login", post(self::post::login)) + .route("/login", get(self::get::login)) + .route("/logout", get(self::get::logout)) +} + +mod post { + use super::*; + + pub async fn login( + mut auth_session: AuthSession, + messages: Messages, + Form(creds): Form, + ) -> impl IntoResponse { + let user = match auth_session.authenticate(creds.clone()).await { + Ok(Some(user)) => user, + Ok(None) => { + messages.error("Invalid credentials"); + + let mut login_url = "/login".to_string(); + if let Some(next) = creds.next { + login_url = format!("{}?next={}", login_url, next); + }; + + return Redirect::to(&login_url).into_response(); + } + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + if auth_session.login(&user).await.is_err() { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + messages.success(format!("Successfully logged in as {}", user.username)); + + if let Some(ref next) = creds.next { + Redirect::to(next) + } else { + Redirect::to("/") + } + .into_response() + } +} + +mod get { + use super::*; + use axum::extract::State; + + pub async fn login( + messages: Messages, + templates: State, + Query(NextUrl { next }): Query, + ) -> impl IntoResponse { + let context = LoginTemplate { + messages: messages.into_iter().collect(), + next, + }; + templates.render_html("login.html", context) + } + + pub async fn logout(mut auth_session: AuthSession) -> impl IntoResponse { + match auth_session.logout().await { + Ok(_) => Redirect::to("/login").into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + } +} + +// this was taken from the axum_login examples and modified. Redirection might need reverse-routing support. diff --git a/src/admin_examples/mod.rs b/src/admin_examples/mod.rs index 7bc9f7c..11b6b5e 100644 --- a/src/admin_examples/mod.rs +++ b/src/admin_examples/mod.rs @@ -1,4 +1,3 @@ pub mod empty_repository; +pub mod file_repository; pub mod static_repository; -pub mod user_repository; -pub mod file_repository; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 89a5a19..7bad7f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,8 @@ mod howto; mod state; use crate::state::AppState; -use admin_examples::static_repository; -use admin_examples::user_repository; use admin_examples::file_repository; +use admin_examples::static_repository; use axum::{ body::Bytes, extract::MatchedPath, @@ -53,7 +52,7 @@ async fn main() { // Register Admin Apps static_repository::register(&mut admin); - user_repository::register(&mut admin, db_connection); + //user_repository::register(&mut admin, db_connection); file_repository::register(&mut admin, "static/admin"); // Create global Application State.