From 5eaafe775e22a371b83a1b2742f14d8b3d1b2074 Mon Sep 17 00:00:00 2001 From: Phillip Assmann Date: Fri, 23 Oct 2026 16:23:51 +0200 Subject: [PATCH] refactor!: move auth state, auth middleware and the admin app into their own modules Prepares the auth refactor by moving auth-related modules: * the authentication state (`Auth`) moves to `cot::auth::state`, * the authentication middleware moves to `cot::middleware::auth`, * the admin app moves to `cot::admin::app`, * `cot::auth::db` is renamed to `cot::auth::user_pass` (including its migrations), as it will hold the username/password backend. BREAKING CHANGE: `cot::auth::db` is now `cot::auth::user_pass`. --- cot-cli/src/project_template/src/main.rs | 2 +- cot/src/admin.rs | 460 +----------------- cot/src/admin/app.rs | 460 ++++++++++++++++++ cot/src/auth.rs | 406 +++------------- cot/src/auth/state.rs | 285 +++++++++++ cot/src/auth/{db.rs => user_pass.rs} | 28 +- cot/src/auth/{db => user_pass}/migrations.rs | 0 .../migrations/m_0001_initial.rs | 35 +- cot/src/config.rs | 5 +- cot/src/db/migrations/graph_export.rs | 2 +- cot/src/middleware.rs | 191 +------- cot/src/middleware/auth.rs | 203 ++++++++ cot/src/project.rs | 2 +- cot/src/request/extractors.rs | 14 - cot/src/test.rs | 4 +- cot/tests/admin.rs | 2 +- cot/tests/auth.rs | 2 +- cot/tests/db_testing/migrations.rs | 2 +- cot/tests/ui/unimplemented_admin_model.stderr | 2 +- cot/tests/ui/unimplemented_db_model.stderr | 2 +- cot/tests/ui/unimplemented_form.stderr | 4 +- docs/admin-panel.md | 4 +- docs/configuration.md | 2 +- examples/admin/src/main.rs | 2 +- examples/todo-list/src/main.rs | 2 +- 25 files changed, 1084 insertions(+), 1037 deletions(-) create mode 100644 cot/src/admin/app.rs create mode 100644 cot/src/auth/state.rs rename cot/src/auth/{db.rs => user_pass.rs} (97%) rename cot/src/auth/{db => user_pass}/migrations.rs (100%) rename cot/src/auth/{db => user_pass}/migrations/m_0001_initial.rs (67%) create mode 100644 cot/src/middleware/auth.rs diff --git a/cot-cli/src/project_template/src/main.rs b/cot-cli/src/project_template/src/main.rs index d22127abc..950a72a7a 100644 --- a/cot-cli/src/project_template/src/main.rs +++ b/cot-cli/src/project_template/src/main.rs @@ -1,6 +1,6 @@ mod migrations; -use cot::auth::db::DatabaseUserApp; +use cot::auth::user_pass::DatabaseUserApp; use cot::cli::CliMetadata; use cot::db::migrations::SyncDynMigration; use cot::html::Html; diff --git a/cot/src/admin.rs b/cot/src/admin.rs index 5ebd50957..a5d3a6727 100644 --- a/cot/src/admin.rs +++ b/cot/src/admin.rs @@ -3,148 +3,25 @@ //! This module provides an administration panel for managing models //! registered in the application, straight from the web interface. +mod app; + use std::any::Any; use std::marker::PhantomData; +pub use app::AdminApp; use async_trait::async_trait; -use bytes::Bytes; /// Implements the [`AdminModel`] trait for a struct. /// /// This is a simple method for adding a database model to the admin panel. /// Note that in order for this derive macro to work, the structure /// **must** implement [`Model`](crate::db::Model) and -/// [`Form`] traits. These can also be derived using the `#[model]` and -/// `#[derive(Form)]` attributes. +/// [`Form`](crate::form::Form) traits. These can also be derived using the +/// `#[model]` and `#[derive(Form)]` attributes. pub use cot_macros::AdminModel; use derive_more::Debug; -use serde::Deserialize; - -use crate::auth::Auth; -use crate::common_types::Password; -use crate::error::NotFound; -use crate::form::{ - Form, FormContext, FormErrorTarget, FormField, FormFieldValidationError, FormResult, -}; -use crate::html::Html; -use crate::request::extractors::{FromRequestHead, Path, StaticFiles, UrlQuery}; -use crate::request::{Request, RequestExt, RequestHead}; -use crate::response::{IntoResponse, Response}; -use crate::router::{Router, Urls}; -use crate::static_files::StaticFile; -use crate::{App, Error, Method, RequestHandler, Template, reverse_redirect}; - -struct AdminAuthenticated(H, PhantomData T>); - -impl + Send + Sync> AdminAuthenticated { - #[must_use] - fn new(handler: H) -> Self { - Self(handler, PhantomData) - } -} - -impl + Send + Sync> RequestHandler for AdminAuthenticated { - async fn handle(&self, mut request: Request) -> crate::Result { - let auth: Auth = request.extract_from_head().await?; - if !auth.user().is_authenticated() { - return Ok(reverse_redirect!(request, "login")?); - } - self.0.handle(request).await - } -} - -#[derive(Debug, FromRequestHead)] -struct BaseContext { - urls: Urls, - static_files: StaticFiles, -} - -async fn index( - base_context: BaseContext, - AdminModelManagers(managers): AdminModelManagers, -) -> crate::Result { - #[derive(Debug, Template)] - #[template(path = "admin/model_list.html")] - struct ModelListTemplate<'a> { - ctx: &'a BaseContext, - #[debug("..")] - model_managers: Vec>, - } - - let template = ModelListTemplate { - ctx: &base_context, - model_managers: managers, - }; - Ok(Html::new(template.render()?)) -} - -#[derive(Debug, Form)] -struct LoginForm { - username: String, - password: Password, -} - -async fn login( - base_context: BaseContext, - auth: Auth, - mut request: Request, -) -> crate::Result { - #[derive(Debug, Template)] - #[template(path = "admin/login.html")] - struct LoginTemplate<'a> { - ctx: &'a BaseContext, - form: ::Context, - } - - let login_form_context = if request.method() == Method::GET { - LoginForm::build_context(&mut request).await? - } else if request.method() == Method::POST { - let login_form = LoginForm::from_request(&mut request).await?; - match login_form { - FormResult::Ok(login_form) => { - if authenticate(&auth, login_form).await? { - return Ok(reverse_redirect!(base_context.urls, "index")?); - } - - let mut context = LoginForm::build_context(&mut request).await?; - context.add_error( - FormErrorTarget::Form, - FormFieldValidationError::from_static("Invalid username or password."), - ); - context - } - FormResult::ValidationError(context) => context, - } - } else { - panic!("Unexpected request method"); - }; - - let template = LoginTemplate { - ctx: &base_context, - form: login_form_context, - }; - Html::new(template.render()?).into_response() -} - -async fn authenticate(auth: &Auth, login_form: LoginForm) -> crate::Result { - #[cfg(feature = "db")] - let user = auth - .authenticate(&crate::auth::db::DatabaseUserCredentials::new( - login_form.username, - Password::new(login_form.password.into_string()), - )) - .await?; - - #[cfg(not(feature = "db"))] - let user: Option> = None; - - if let Some(user) = user { - auth.login(user).await?; - Ok(true) - } else { - Ok(false) - } -} +use crate::form::FormContext; +use crate::request::Request; /// Struct representing the pagination of objects. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -154,15 +31,6 @@ pub struct Pagination { } impl Pagination { - fn new(limit: u64, page: u64) -> Self { - assert!(page > 0, "Page number must be greater than 0"); - - Self { - limit, - offset: (page - 1) * limit, - } - } - /// Returns the limit of objects per page. #[must_use] pub fn limit(&self) -> u64 { @@ -176,225 +44,6 @@ impl Pagination { } } -#[derive(Debug, Deserialize)] -struct PaginationParams { - page: Option, - page_size: Option, -} - -async fn view_model( - base_context: BaseContext, - managers: AdminModelManagers, - Path(model_name): Path, - UrlQuery(pagination_params): UrlQuery, - request: Request, -) -> crate::Result { - #[derive(Debug, Template)] - #[template(path = "admin/model.html")] - struct ModelTemplate<'a> { - ctx: &'a BaseContext, - #[debug("..")] - model: &'a dyn AdminModelManager, - #[debug("..")] - objects: Vec>, - page: u64, - page_size: &'a u64, - total_object_counts: u64, - total_pages: u64, - } - - const DEFAULT_PAGE_SIZE: u64 = 10; - - let manager = get_manager(managers, &model_name)?; - - let page = pagination_params.page.unwrap_or(1); - let page_size = pagination_params.page_size.unwrap_or(DEFAULT_PAGE_SIZE); - - let total_object_counts = manager.get_total_object_counts(&request).await?; - let total_pages = total_object_counts.div_ceil(page_size); - - if (page == 0 || page > total_pages) && total_pages > 0 { - return Err(Error::from(NotFound::with_message(format!( - "page {page} not found" - )))); - } - - let pagination = Pagination::new(page_size, page); - - let objects = manager.get_objects(&request, pagination).await?; - - let template = ModelTemplate { - ctx: &base_context, - model: &*manager, - objects, - page, - page_size: &page_size, - total_object_counts, - total_pages, - }; - - Html::new(template.render()?).into_response() -} - -async fn create_model_instance( - base_context: BaseContext, - managers: AdminModelManagers, - Path(model_name): Path, - request: Request, -) -> cot::Result { - edit_model_instance_impl(base_context, managers, request, &model_name, None).await -} - -async fn edit_model_instance( - base_context: BaseContext, - managers: AdminModelManagers, - Path((model_name, object_id)): Path<(String, String)>, - request: Request, -) -> cot::Result { - edit_model_instance_impl( - base_context, - managers, - request, - &model_name, - Some(&object_id), - ) - .await -} - -async fn edit_model_instance_impl( - base_context: BaseContext, - managers: AdminModelManagers, - mut request: Request, - model_name: &str, - object_id: Option<&str>, -) -> cot::Result { - #[derive(Debug, Template)] - #[template(path = "admin/model_edit.html")] - struct ModelEditTemplate<'a> { - ctx: &'a BaseContext, - #[debug("..")] - model: &'a dyn AdminModelManager, - form_context: Box, - is_edit: bool, - } - - let manager = get_manager(managers, model_name)?; - - let form_context = if request.method() == Method::POST { - let form_context = manager.save_from_request(&mut request, object_id).await?; - - if let Some(form_context) = form_context { - form_context - } else { - return Ok(reverse_redirect!( - base_context.urls, - "view_model", - model_name = manager.url_name() - )?); - } - } else if let Some(object_id) = object_id { - let object = get_object(&mut request, &*manager, object_id).await?; - - manager.form_context_from_object(object).await - } else { - manager.form_context() - }; - - let template = ModelEditTemplate { - ctx: &base_context, - model: &*manager, - form_context, - is_edit: object_id.is_some(), - }; - - Html::new(template.render()?).into_response() -} - -async fn remove_model_instance( - base_context: BaseContext, - managers: AdminModelManagers, - Path((model_name, object_id)): Path<(String, String)>, - mut request: Request, -) -> cot::Result { - #[derive(Debug, Template)] - #[template(path = "admin/model_remove.html")] - struct ModelRemoveTemplate<'a> { - ctx: &'a BaseContext, - #[debug("..")] - model: &'a dyn AdminModelManager, - #[debug("..")] - object: &'a dyn AdminModel, - } - - let manager = get_manager(managers, &model_name)?; - let object = get_object(&mut request, &*manager, &object_id).await?; - - if request.method() == Method::POST { - manager.remove_by_id(&mut request, &object_id).await?; - - Ok(reverse_redirect!( - base_context.urls, - "view_model", - model_name = manager.url_name() - )?) - } else { - let template = ModelRemoveTemplate { - ctx: &base_context, - model: &*manager, - object: &*object, - }; - - Html::new(template.render()?).into_response() - } -} - -async fn get_object( - request: &mut Request, - manager: &dyn AdminModelManager, - object_id: &str, -) -> Result, Error> { - manager - .get_object_by_id(request, object_id) - .await? - .ok_or_else(|| { - Error::from(NotFound::with_message(format!( - "Object with ID `{}` not found in model `{}`", - object_id, - manager.name() - ))) - }) -} - -fn get_manager( - AdminModelManagers(model_managers): AdminModelManagers, - model_name: &str, -) -> cot::Result> { - model_managers - .into_iter() - .find(|manager| manager.url_name() == model_name) - .ok_or_else(|| { - Error::from(NotFound::with_message(format!( - "Model `{model_name}` not found" - ))) - }) -} - -#[repr(transparent)] -struct AdminModelManagers(Vec>); - -impl FromRequestHead for AdminModelManagers { - #[expect(clippy::unused_async_trait_impl)] - async fn from_request_head(head: &RequestHead) -> cot::Result { - let managers = head - .context() - .apps() - .iter() - .flat_map(|app| app.admin_model_managers()) - .collect(); - Ok(Self(managers)) - } -} - /// A trait for adding admin models to the app. /// /// This exposes an API over [`AdminModel`] that is dyn-compatible and @@ -619,98 +268,3 @@ pub trait AdminModel: Any + Send + 'static { where Self: Sized; } - -/// The admin app. -/// -/// # Examples -/// -/// ``` -/// use cot::admin::AdminApp; -/// use cot::project::RegisterAppsContext; -/// use cot::{AppBuilder, Project}; -/// -/// struct MyProject; -/// impl Project for MyProject { -/// fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { -/// apps.register_with_views(AdminApp::new(), "/admin"); -/// } -/// } -/// ``` -#[derive(Debug, Copy, Clone)] -pub struct AdminApp; - -impl Default for AdminApp { - fn default() -> Self { - Self::new() - } -} - -impl AdminApp { - /// Creates an admin app instance. - /// - /// # Examples - /// - /// ``` - /// use cot::admin::AdminApp; - /// use cot::project::RegisterAppsContext; - /// use cot::{AppBuilder, Project}; - /// - /// struct MyProject; - /// impl Project for MyProject { - /// fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { - /// apps.register_with_views(AdminApp::new(), "/admin"); - /// } - /// } - /// ``` - #[must_use] - pub fn new() -> Self { - Self {} - } -} - -impl App for AdminApp { - fn name(&self) -> &'static str { - "cot_admin" - } - - fn router(&self) -> Router { - Router::with_urls([ - crate::router::Route::with_handler_and_name( - "/", - AdminAuthenticated::new(index), - "index", - ), - crate::router::Route::with_handler_and_name("/login/", login, "login"), - crate::router::Route::with_handler_and_name( - "/{model_name}/", - AdminAuthenticated::new(view_model), - "view_model", - ), - crate::router::Route::with_handler_and_name( - "/{model_name}/create/", - AdminAuthenticated::new(create_model_instance), - "create_model_instance", - ), - crate::router::Route::with_handler_and_name( - "/{model_name}/{pk}/edit/", - AdminAuthenticated::new(edit_model_instance), - "edit_model_instance", - ), - crate::router::Route::with_handler_and_name( - "/{model_name}/{pk}/remove/", - AdminAuthenticated::new(remove_model_instance), - "remove_model_instance", - ), - ]) - } - - fn static_files(&self) -> Vec { - vec![StaticFile::new( - "admin/admin.css", - Bytes::from_static(include_bytes!(concat!( - env!("OUT_DIR"), - "/static/admin/admin.css" - ))), - )] - } -} diff --git a/cot/src/admin/app.rs b/cot/src/admin/app.rs new file mode 100644 index 000000000..35cc12057 --- /dev/null +++ b/cot/src/admin/app.rs @@ -0,0 +1,460 @@ +//! The admin panel app and its views. + +use std::marker::PhantomData; + +use bytes::Bytes; +use derive_more::Debug; +use serde::Deserialize; + +use super::{AdminModel, AdminModelManager, Pagination}; +use crate::auth::Auth; +use crate::common_types::Password; +use crate::error::NotFound; +use crate::form::{ + Form, FormContext, FormErrorTarget, FormField, FormFieldValidationError, FormResult, +}; +use crate::html::Html; +use crate::request::extractors::{FromRequestHead, Path, StaticFiles, UrlQuery}; +use crate::request::{Request, RequestExt, RequestHead}; +use crate::response::{IntoResponse, Response}; +use crate::router::{Router, Urls}; +use crate::static_files::StaticFile; +use crate::{App, Error, Method, RequestHandler, Template, reverse_redirect}; + +struct AdminAuthenticated(H, PhantomData T>); + +impl + Send + Sync> AdminAuthenticated { + #[must_use] + fn new(handler: H) -> Self { + Self(handler, PhantomData) + } +} + +impl + Send + Sync> RequestHandler for AdminAuthenticated { + async fn handle(&self, mut request: Request) -> crate::Result { + let auth: Auth = request.extract_from_head().await?; + if !auth.user().is_authenticated() { + return Ok(reverse_redirect!(request, "login")?); + } + + self.0.handle(request).await + } +} + +#[derive(Debug, FromRequestHead)] +struct BaseContext { + urls: Urls, + static_files: StaticFiles, +} + +async fn index( + base_context: BaseContext, + AdminModelManagers(managers): AdminModelManagers, +) -> crate::Result { + #[derive(Debug, Template)] + #[template(path = "admin/model_list.html")] + struct ModelListTemplate<'a> { + ctx: &'a BaseContext, + #[debug("..")] + model_managers: Vec>, + } + + let template = ModelListTemplate { + ctx: &base_context, + model_managers: managers, + }; + Ok(Html::new(template.render()?)) +} + +#[derive(Debug, Form)] +struct LoginForm { + username: String, + password: Password, +} + +async fn login( + base_context: BaseContext, + auth: Auth, + mut request: Request, +) -> crate::Result { + #[derive(Debug, Template)] + #[template(path = "admin/login.html")] + struct LoginTemplate<'a> { + ctx: &'a BaseContext, + form: ::Context, + } + + let login_form_context = if request.method() == Method::GET { + LoginForm::build_context(&mut request).await? + } else if request.method() == Method::POST { + let login_form = LoginForm::from_request(&mut request).await?; + match login_form { + FormResult::Ok(login_form) => { + if authenticate(&auth, login_form).await? { + return Ok(reverse_redirect!(base_context.urls, "index")?); + } + + let mut context = LoginForm::build_context(&mut request).await?; + context.add_error( + FormErrorTarget::Form, + FormFieldValidationError::from_static("Invalid username or password."), + ); + context + } + FormResult::ValidationError(context) => context, + } + } else { + panic!("Unexpected request method"); + }; + + let template = LoginTemplate { + ctx: &base_context, + form: login_form_context, + }; + Html::new(template.render()?).into_response() +} + +async fn authenticate(auth: &Auth, login_form: LoginForm) -> crate::Result { + #[cfg(feature = "db")] + let user = auth + .authenticate(&crate::auth::user_pass::DatabaseUserCredentials::new( + login_form.username, + Password::new(login_form.password.into_string()), + )) + .await?; + + #[cfg(not(feature = "db"))] + let user: Option> = None; + + if let Some(user) = user { + auth.login(user).await?; + Ok(true) + } else { + Ok(false) + } +} + +impl Pagination { + fn new(limit: u64, page: u64) -> Self { + assert!(page > 0, "Page number must be greater than 0"); + + Self { + limit, + offset: (page - 1) * limit, + } + } +} + +#[derive(Debug, Deserialize)] +struct PaginationParams { + page: Option, + page_size: Option, +} + +async fn view_model( + base_context: BaseContext, + managers: AdminModelManagers, + Path(model_name): Path, + UrlQuery(pagination_params): UrlQuery, + request: Request, +) -> crate::Result { + #[derive(Debug, Template)] + #[template(path = "admin/model.html")] + struct ModelTemplate<'a> { + ctx: &'a BaseContext, + #[debug("..")] + model: &'a dyn AdminModelManager, + #[debug("..")] + objects: Vec>, + page: u64, + page_size: &'a u64, + total_object_counts: u64, + total_pages: u64, + } + + const DEFAULT_PAGE_SIZE: u64 = 10; + + let manager = get_manager(managers, &model_name)?; + + let page = pagination_params.page.unwrap_or(1); + let page_size = pagination_params.page_size.unwrap_or(DEFAULT_PAGE_SIZE); + + let total_object_counts = manager.get_total_object_counts(&request).await?; + let total_pages = total_object_counts.div_ceil(page_size); + + if (page == 0 || page > total_pages) && total_pages > 0 { + return Err(Error::from(NotFound::with_message(format!( + "page {page} not found" + )))); + } + + let pagination = Pagination::new(page_size, page); + + let objects = manager.get_objects(&request, pagination).await?; + + let template = ModelTemplate { + ctx: &base_context, + model: &*manager, + objects, + page, + page_size: &page_size, + total_object_counts, + total_pages, + }; + + Html::new(template.render()?).into_response() +} + +async fn create_model_instance( + base_context: BaseContext, + managers: AdminModelManagers, + Path(model_name): Path, + request: Request, +) -> cot::Result { + edit_model_instance_impl(base_context, managers, request, &model_name, None).await +} + +async fn edit_model_instance( + base_context: BaseContext, + managers: AdminModelManagers, + Path((model_name, object_id)): Path<(String, String)>, + request: Request, +) -> cot::Result { + edit_model_instance_impl( + base_context, + managers, + request, + &model_name, + Some(&object_id), + ) + .await +} + +async fn edit_model_instance_impl( + base_context: BaseContext, + managers: AdminModelManagers, + mut request: Request, + model_name: &str, + object_id: Option<&str>, +) -> cot::Result { + #[derive(Debug, Template)] + #[template(path = "admin/model_edit.html")] + struct ModelEditTemplate<'a> { + ctx: &'a BaseContext, + #[debug("..")] + model: &'a dyn AdminModelManager, + form_context: Box, + is_edit: bool, + } + + let manager = get_manager(managers, model_name)?; + + let form_context = if request.method() == Method::POST { + let form_context = manager.save_from_request(&mut request, object_id).await?; + + if let Some(form_context) = form_context { + form_context + } else { + return Ok(reverse_redirect!( + base_context.urls, + "view_model", + model_name = manager.url_name() + )?); + } + } else if let Some(object_id) = object_id { + let object = get_object(&mut request, &*manager, object_id).await?; + + manager.form_context_from_object(object).await + } else { + manager.form_context() + }; + + let template = ModelEditTemplate { + ctx: &base_context, + model: &*manager, + form_context, + is_edit: object_id.is_some(), + }; + + Html::new(template.render()?).into_response() +} + +async fn remove_model_instance( + base_context: BaseContext, + managers: AdminModelManagers, + Path((model_name, object_id)): Path<(String, String)>, + mut request: Request, +) -> cot::Result { + #[derive(Debug, Template)] + #[template(path = "admin/model_remove.html")] + struct ModelRemoveTemplate<'a> { + ctx: &'a BaseContext, + #[debug("..")] + model: &'a dyn AdminModelManager, + #[debug("..")] + object: &'a dyn AdminModel, + } + + let manager = get_manager(managers, &model_name)?; + let object = get_object(&mut request, &*manager, &object_id).await?; + + if request.method() == Method::POST { + manager.remove_by_id(&mut request, &object_id).await?; + + Ok(reverse_redirect!( + base_context.urls, + "view_model", + model_name = manager.url_name() + )?) + } else { + let template = ModelRemoveTemplate { + ctx: &base_context, + model: &*manager, + object: &*object, + }; + + Html::new(template.render()?).into_response() + } +} + +async fn get_object( + request: &mut Request, + manager: &dyn AdminModelManager, + object_id: &str, +) -> Result, Error> { + manager + .get_object_by_id(request, object_id) + .await? + .ok_or_else(|| { + Error::from(NotFound::with_message(format!( + "Object with ID `{}` not found in model `{}`", + object_id, + manager.name() + ))) + }) +} + +fn get_manager( + AdminModelManagers(model_managers): AdminModelManagers, + model_name: &str, +) -> cot::Result> { + model_managers + .into_iter() + .find(|manager| manager.url_name() == model_name) + .ok_or_else(|| { + Error::from(NotFound::with_message(format!( + "Model `{model_name}` not found" + ))) + }) +} + +#[repr(transparent)] +struct AdminModelManagers(Vec>); + +impl FromRequestHead for AdminModelManagers { + #[expect(clippy::unused_async_trait_impl)] + async fn from_request_head(head: &RequestHead) -> cot::Result { + let managers = head + .context() + .apps() + .iter() + .flat_map(|app| app.admin_model_managers()) + .collect(); + Ok(Self(managers)) + } +} + +/// The admin app. +/// +/// # Examples +/// +/// ``` +/// use cot::admin::AdminApp; +/// use cot::project::RegisterAppsContext; +/// use cot::{AppBuilder, Project}; +/// +/// struct MyProject; +/// impl Project for MyProject { +/// fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { +/// apps.register_with_views(AdminApp::new(), "/admin"); +/// } +/// } +/// ``` +#[derive(Debug, Copy, Clone)] +pub struct AdminApp; + +impl Default for AdminApp { + fn default() -> Self { + Self::new() + } +} + +impl AdminApp { + /// Creates an admin app instance. + /// + /// # Examples + /// + /// ``` + /// use cot::admin::AdminApp; + /// use cot::project::RegisterAppsContext; + /// use cot::{AppBuilder, Project}; + /// + /// struct MyProject; + /// impl Project for MyProject { + /// fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { + /// apps.register_with_views(AdminApp::new(), "/admin"); + /// } + /// } + /// ``` + #[must_use] + pub fn new() -> Self { + Self {} + } +} + +impl App for AdminApp { + fn name(&self) -> &'static str { + "cot_admin" + } + + fn router(&self) -> Router { + Router::with_urls([ + crate::router::Route::with_handler_and_name( + "/", + AdminAuthenticated::new(index), + "index", + ), + crate::router::Route::with_handler_and_name("/login/", login, "login"), + crate::router::Route::with_handler_and_name( + "/{model_name}/", + AdminAuthenticated::new(view_model), + "view_model", + ), + crate::router::Route::with_handler_and_name( + "/{model_name}/create/", + AdminAuthenticated::new(create_model_instance), + "create_model_instance", + ), + crate::router::Route::with_handler_and_name( + "/{model_name}/{pk}/edit/", + AdminAuthenticated::new(edit_model_instance), + "edit_model_instance", + ), + crate::router::Route::with_handler_and_name( + "/{model_name}/{pk}/remove/", + AdminAuthenticated::new(remove_model_instance), + "remove_model_instance", + ), + ]) + } + + fn static_files(&self) -> Vec { + vec![StaticFile::new( + "admin/admin.css", + Bytes::from_static(include_bytes!(concat!( + env!("OUT_DIR"), + "/static/admin/admin.css" + ))), + )] + } +} diff --git a/cot/src/auth.rs b/cot/src/auth.rs index a536dac2c..a97e4d66f 100644 --- a/cot/src/auth.rs +++ b/cot/src/auth.rs @@ -6,12 +6,13 @@ //! //! For the default way to store users in the database, see the [`db`] module. +mod state; #[cfg(feature = "db")] -pub mod db; +pub mod user_pass; use std::any::Any; use std::borrow::Cow; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::Arc; /// backwards compatible shim for form Password type. use async_trait::async_trait; @@ -22,16 +23,11 @@ use derive_more::with_trait::Debug; use mockall::automock; use password_auth::VerifyError; use serde::{Deserialize, Serialize}; +pub use state::Auth; use subtle::ConstantTimeEq; use thiserror::Error; use crate::config::SecretKey; -#[cfg(feature = "db")] -use crate::db::{ - ColumnType, DatabaseField, DbValue, FromDbValue, SqlxValueRef, TextField, ToDbValue, -}; -use crate::request::{Request, RequestExt}; -use crate::session::Session; const ERROR_PREFIX: &str = "failed to authenticate user:"; @@ -87,8 +83,8 @@ pub type Result = std::result::Result; /// /// A concrete instance of a `User` object is returned by a backend that /// implements the [`AuthBackend`] trait. The default backend is the -/// [`DatabaseUserBackend`](db::DatabaseUserBackend), which stores user data in -/// the database using Cot ORM. +/// [`DatabaseUserBackend`](user_pass::DatabaseUserBackend), which stores user +/// data in the database using Cot ORM. #[cfg_attr(test, automock)] pub trait User { /// Returns the user's ID. @@ -159,7 +155,7 @@ pub trait User { /// This is used to verify that the session hash stored in the session /// object is valid. If the session hash is not valid, the user is /// logged out. For instance, - /// [`DatabaseUser`](db::DatabaseUser) implements this method + /// [`DatabaseUser`](user_pass::DatabaseUser) implements this method /// to generate a session hash using the user's password hash. /// Therefore, when a user changes their password, the session hash is /// also changed, and all their sessions are invalidated. @@ -653,345 +649,88 @@ impl Debug for PasswordHash { const MAX_PASSWORD_HASH_LENGTH: u32 = 128; #[cfg(feature = "db")] -impl DatabaseField for PasswordHash { - const TYPE: ColumnType = ColumnType::String(MAX_PASSWORD_HASH_LENGTH); -} - -#[cfg(feature = "db")] -impl FromDbValue for PasswordHash { - #[cfg(feature = "sqlite")] - fn from_sqlite(value: crate::db::impl_sqlite::SqliteValueRef<'_>) -> cot::db::Result { - PasswordHash::new(value.get::()?).map_err(cot::db::DatabaseError::value_decode) - } - - #[cfg(feature = "postgres")] - fn from_postgres( - value: crate::db::impl_postgres::PostgresValueRef<'_>, - ) -> cot::db::Result { - PasswordHash::new(value.get::()?).map_err(cot::db::DatabaseError::value_decode) - } - - #[cfg(feature = "mysql")] - fn from_mysql(value: crate::db::impl_mysql::MySqlValueRef<'_>) -> crate::db::Result - where - Self: Sized, - { - PasswordHash::new(value.get::()?).map_err(cot::db::DatabaseError::value_decode) - } -} - -#[cfg(feature = "db")] -impl ToDbValue for PasswordHash { - fn to_db_value(&self) -> DbValue { - self.0.clone().into() - } -} - -#[cfg(feature = "db")] -impl FromDbValue for Option { - #[cfg(feature = "sqlite")] - fn from_sqlite(value: crate::db::impl_sqlite::SqliteValueRef<'_>) -> cot::db::Result { - value - .get::>() - .map(|op_str| op_str.map(PasswordHash::new))? - .transpose() - .map_err(cot::db::DatabaseError::value_decode) - } +mod db_impls { + use super::{MAX_PASSWORD_HASH_LENGTH, PasswordHash}; + use crate::db::{ + ColumnType, DatabaseField, DbValue, FromDbValue, SqlxValueRef, TextField, ToDbValue, + }; - #[cfg(feature = "postgres")] - fn from_postgres( - value: crate::db::impl_postgres::PostgresValueRef<'_>, - ) -> cot::db::Result { - value - .get::>() - .map(|op_str| op_str.map(PasswordHash::new))? - .transpose() - .map_err(cot::db::DatabaseError::value_decode) + impl DatabaseField for PasswordHash { + const TYPE: ColumnType = ColumnType::String(MAX_PASSWORD_HASH_LENGTH); } - #[cfg(feature = "mysql")] - fn from_mysql(value: crate::db::impl_mysql::MySqlValueRef<'_>) -> crate::db::Result - where - Self: Sized, - { - value - .get::>() - .map(|op_str| op_str.map(PasswordHash::new))? - .transpose() - .map_err(cot::db::DatabaseError::value_decode) - } -} - -#[cfg(feature = "db")] -impl ToDbValue for Option { - fn to_db_value(&self) -> DbValue { - match self { - Some(hash) => hash.to_db_value(), - None => >::None.to_db_value(), + impl FromDbValue for PasswordHash { + #[cfg(feature = "sqlite")] + fn from_sqlite(value: crate::db::impl_sqlite::SqliteValueRef<'_>) -> cot::db::Result { + PasswordHash::new(value.get::()?).map_err(cot::db::DatabaseError::value_decode) } - } -} - -#[cfg(feature = "db")] -impl TextField for PasswordHash {} - -/// Authentication helper structure. -/// -/// This is an object that provides methods to sign users in and out, by using -/// the authentication backend defined in the project configuration. It is -/// constructed by [`AuthMiddleware`](crate::middleware::AuthMiddleware) and -/// included in every [`Request`] object as long as the middleware is active. -/// -/// # Security -/// -/// This is a wrapper around a reference-counted inner object, which contains -/// the actual user data. It's safe and cheap to clone it and authenticate the -/// user, as the change will be reflected in all clones. -#[derive(Debug, Clone)] -pub struct Auth { - inner: Arc, -} - -impl Auth { - // only currently used in TestRequestBuilder if database is enabled - #[cfg(all(feature = "db", feature = "test"))] - pub(crate) async fn new( - session: Session, - backend: Arc, - secret_key: SecretKey, - fallback_secret_keys: &[SecretKey], - ) -> cot::Result { - let inner = AuthInner::new(session, backend, secret_key, fallback_secret_keys).await?; - Ok(Self { - inner: Arc::new(inner), - }) - } - - pub(crate) async fn from_request(request: &mut Request) -> cot::Result { - let inner = AuthInner::from_request(request).await?; - Ok(Self { - inner: Arc::new(inner), - }) - } - - /// Returns the current user. - /// - /// This uses the auth backend configured in - /// [`ProjectConfig::auth_backend`](crate::config::ProjectConfig::auth_backend). - /// If the user is not authenticated, the [`AnonymousUser`] object is - /// returned. - #[must_use] - pub fn user(&self) -> Arc { - self.inner.user() - } - /// Authenticates a user with the given credentials. - /// - /// This uses the auth backend configured in - /// [`ProjectConfig::auth_backend`](crate::config::ProjectConfig::auth_backend). - /// If the authentication is successful, the user object is returned. If the - /// authentication fails, [`None`] is returned. - /// - /// Note that this doesn't log the user in, it only checks if the - /// credentials are valid and returns the user object. To log the user - /// in the current session, use the [`login`](Self::login) method. - /// - /// # Errors - /// - /// Returns an error if the [`AuthBackend`] accepts the credentials but - /// fails to fetch the user object. - pub async fn authenticate( - &self, - credentials: &(dyn Any + Send + Sync), - ) -> Result>> { - self.inner.authenticate(credentials).await - } - - /// Logs in a user. - /// - /// This logs in the user in the current session. The user object is stored - /// in the session object and can be accessed using the [`user`](Self::user) - /// method. - /// - /// # Errors - /// - /// Returns an error if the user object cannot be stored in the session - /// object. - pub async fn login(&self, user: Box) -> Result<()> { - self.inner.login(user).await - } - - /// Logs out the current user. - /// - /// This removes the user object from the session object and logs the user - /// out. Subsequent calls to [`user`](Self::user) will return the - /// [`AnonymousUser`] object, unless a user is logged in again. - /// - /// # Errors - /// - /// Returns an error if the user object cannot be removed from the session - /// object. - pub async fn logout(&self) -> Result<()> { - self.inner.logout().await - } -} - -#[derive(Debug)] -struct AuthInner { - session: Session, - #[debug("..")] - backend: Arc, - secret_key: SecretKey, - // Using a standard Mutex object instead of an async version - it's unlikely this will ever be - // accessed from multiple threads, since it's tied to a single request. The mutex is used - // mostly to allow the `Auth` object to be cloned and passed around while maintaining the - // reference to the same `AuthInner` object with a mutable `user`. - #[debug("..")] - user: Mutex, -} - -impl AuthInner { - async fn new( - session: Session, - backend: Arc, - secret_key: SecretKey, - fallback_secret_keys: &[SecretKey], - ) -> cot::Result { - #[expect(trivial_casts)] // cast to Arc - let user = get_user_with_saved_id(&session, &*backend, &secret_key, fallback_secret_keys) - .await? - .map_or_else( - || Arc::new(AnonymousUser) as Arc, - Arc::from, - ); - - Ok(Self { - session, - backend, - secret_key, - user: Mutex::new(UserWrapper(user)), - }) - } - - async fn from_request(request: &mut Request) -> cot::Result { - let config = request.context().config(); - - let session = Session::from_request(request).clone(); - let backend = request.context().auth_backend().clone(); - let secret_key = config.secret_key.clone(); - - Self::new(session, backend, secret_key, &config.fallback_secret_keys).await - } - - fn user(&self) -> Arc { - Arc::clone(&self.user_lock().0) - } - - async fn authenticate( - &self, - credentials: &(dyn Any + Send + Sync), - ) -> Result>> { - self.backend.authenticate(credentials).await - } - - async fn login(&self, user: Box) -> Result<()> { - // Mitigate the session fixation attack by changing the session ID: - // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#renew-the-session-id-after-any-privilege-level-change - self.session.cycle_id().await?; - - if let Some(user_id) = user.id() { - self.session.insert(USER_ID_SESSION_KEY, user_id).await?; - } - let secret_key = &self.secret_key; - if let Some(session_auth_hash) = user.session_auth_hash(secret_key) { - self.session - .insert(SESSION_HASH_SESSION_KEY, session_auth_hash.as_bytes()) - .await?; + #[cfg(feature = "postgres")] + fn from_postgres( + value: crate::db::impl_postgres::PostgresValueRef<'_>, + ) -> cot::db::Result { + PasswordHash::new(value.get::()?).map_err(cot::db::DatabaseError::value_decode) } - *self.user_lock() = UserWrapper(Arc::from(user)); - Ok(()) - } - - async fn logout(&self) -> Result<()> { - self.session.flush().await?; - *self.user_lock() = UserWrapper(Arc::new(AnonymousUser)); - - Ok(()) - } - - fn user_lock(&self) -> MutexGuard<'_, UserWrapper> { - self.user.lock().unwrap_or_else(|poison_error| { - // We don't have any invariants about the structure of the - // UserWrapper object, so we can safely clear the - // poison. - self.user.clear_poison(); - poison_error.into_inner() - }) + #[cfg(feature = "mysql")] + fn from_mysql(value: crate::db::impl_mysql::MySqlValueRef<'_>) -> crate::db::Result + where + Self: Sized, + { + PasswordHash::new(value.get::()?).map_err(cot::db::DatabaseError::value_decode) + } } -} -const USER_ID_SESSION_KEY: &str = "__cot_auth_user_id"; -const SESSION_HASH_SESSION_KEY: &str = "__cot_auth_session_hash"; - -async fn get_user_with_saved_id( - session: &Session, - auth_backend: &dyn AuthBackend, - secret_key: &SecretKey, - fallback_secret_keys: &[SecretKey], -) -> Result>> { - let Some(user_id) = session.get::(USER_ID_SESSION_KEY).await? else { - return Ok(None); - }; - - let Some(user) = auth_backend.get_by_id(user_id).await? else { - return Ok(None); - }; - - if session_auth_hash_valid(&*user, session, secret_key, fallback_secret_keys).await? { - Ok(Some(user)) - } else { - Ok(None) + impl ToDbValue for PasswordHash { + fn to_db_value(&self) -> DbValue { + self.0.clone().into() + } } -} -async fn session_auth_hash_valid( - user: &(dyn User + Send + Sync), - session: &Session, - secret_key: &SecretKey, - fallback_secret_keys: &[SecretKey], -) -> Result { - let Some(user_hash) = user.session_auth_hash(secret_key) else { - return Ok(true); - }; + impl FromDbValue for Option { + #[cfg(feature = "sqlite")] + fn from_sqlite(value: crate::db::impl_sqlite::SqliteValueRef<'_>) -> cot::db::Result { + value + .get::>() + .map(|op_str| op_str.map(PasswordHash::new))? + .transpose() + .map_err(cot::db::DatabaseError::value_decode) + } - let stored_hash = session - .get::>(SESSION_HASH_SESSION_KEY) - .await? - .expect("Session hash should be present in the session object"); - let stored_hash = SessionAuthHash::new(&stored_hash); + #[cfg(feature = "postgres")] + fn from_postgres( + value: crate::db::impl_postgres::PostgresValueRef<'_>, + ) -> cot::db::Result { + value + .get::>() + .map(|op_str| op_str.map(PasswordHash::new))? + .transpose() + .map_err(cot::db::DatabaseError::value_decode) + } - if user_hash == stored_hash { - return Ok(true); + #[cfg(feature = "mysql")] + fn from_mysql(value: crate::db::impl_mysql::MySqlValueRef<'_>) -> crate::db::Result + where + Self: Sized, + { + value + .get::>() + .map(|op_str| op_str.map(PasswordHash::new))? + .transpose() + .map_err(cot::db::DatabaseError::value_decode) + } } - // If the primary secret key doesn't match, try the fallback keys (in other - // words, check if the session hash was generated with an old secret key) - // and update the session hash with the new key if a match is found. - for fallback_key in fallback_secret_keys { - let user_hash_fallback = user - .session_auth_hash(fallback_key) - .expect("User should have a session hash for each secret key"); - if user_hash_fallback == stored_hash { - session - .insert(SESSION_HASH_SESSION_KEY, user_hash.as_bytes()) - .await?; - - return Ok(true); + impl ToDbValue for Option { + fn to_db_value(&self) -> DbValue { + match self { + Some(hash) => hash.to_db_value(), + None => >::None.to_db_value(), + } } } - Ok(false) + impl TextField for PasswordHash {} } /// An authentication backend. @@ -1077,9 +816,12 @@ mod tests { use mockall::predicate::eq; + use super::state::USER_ID_SESSION_KEY; use super::*; use crate::common_types::Password; use crate::config::ProjectConfig; + use crate::request::Request; + use crate::session::Session; use crate::test::TestRequestBuilder; struct MockAuthBackend { diff --git a/cot/src/auth/state.rs b/cot/src/auth/state.rs new file mode 100644 index 000000000..d16460e98 --- /dev/null +++ b/cot/src/auth/state.rs @@ -0,0 +1,285 @@ +//! The authentication state of a request. + +use std::any::Any; +use std::sync::{Arc, Mutex, MutexGuard}; + +use derive_more::with_trait::Debug; + +use super::{AnonymousUser, AuthBackend, Result, SessionAuthHash, User, UserId, UserWrapper}; +use crate::config::SecretKey; +use crate::request::extractors::FromRequestHead; +use crate::request::{Request, RequestExt, RequestHead}; +use crate::session::Session; + +/// Authentication helper structure. +/// +/// This is an object that provides methods to sign users in and out, by using +/// the authentication backend defined in the project configuration. It is +/// constructed by [`AuthMiddleware`](crate::middleware::AuthMiddleware) and +/// included in every [`Request`] object as long as the middleware is active. +/// +/// # Security +/// +/// This is a wrapper around a reference-counted inner object, which contains +/// the actual user data. It's safe and cheap to clone it and authenticate the +/// user, as the change will be reflected in all clones. +#[derive(Debug, Clone)] +pub struct Auth { + inner: Arc, +} + +impl Auth { + // only currently used in TestRequestBuilder if database is enabled + #[cfg(all(feature = "db", feature = "test"))] + pub(crate) async fn new( + session: Session, + backend: Arc, + secret_key: SecretKey, + fallback_secret_keys: &[SecretKey], + ) -> cot::Result { + let inner = AuthInner::new(session, backend, secret_key, fallback_secret_keys).await?; + Ok(Self { + inner: Arc::new(inner), + }) + } + + pub(crate) async fn from_request(request: &mut Request) -> cot::Result { + let inner = AuthInner::from_request(request).await?; + Ok(Self { + inner: Arc::new(inner), + }) + } + + /// Returns the current user. + /// + /// This uses the auth backend configured in + /// [`ProjectConfig::auth_backend`](crate::config::ProjectConfig::auth_backend). + /// If the user is not authenticated, the [`AnonymousUser`] object is + /// returned. + #[must_use] + pub fn user(&self) -> Arc { + self.inner.user() + } + + /// Authenticates a user with the given credentials. + /// + /// This uses the auth backend configured in + /// [`ProjectConfig::auth_backend`](crate::config::ProjectConfig::auth_backend). + /// If the authentication is successful, the user object is returned. If the + /// authentication fails, [`None`] is returned. + /// + /// Note that this doesn't log the user in, it only checks if the + /// credentials are valid and returns the user object. To log the user + /// in the current session, use the [`login`](Self::login) method. + /// + /// # Errors + /// + /// Returns an error if the [`AuthBackend`] accepts the credentials but + /// fails to fetch the user object. + pub async fn authenticate( + &self, + credentials: &(dyn Any + Send + Sync), + ) -> Result>> { + self.inner.authenticate(credentials).await + } + + /// Logs in a user. + /// + /// This logs in the user in the current session. The user object is stored + /// in the session object and can be accessed using the [`user`](Self::user) + /// method. + /// + /// # Errors + /// + /// Returns an error if the user object cannot be stored in the session + /// object. + pub async fn login(&self, user: Box) -> Result<()> { + self.inner.login(user).await + } + + /// Logs out the current user. + /// + /// This removes the user object from the session object and logs the user + /// out. Subsequent calls to [`user`](Self::user) will return the + /// [`AnonymousUser`] object, unless a user is logged in again. + /// + /// # Errors + /// + /// Returns an error if the user object cannot be removed from the session + /// object. + pub async fn logout(&self) -> Result<()> { + self.inner.logout().await + } +} + +#[derive(Debug)] +struct AuthInner { + session: Session, + #[debug("..")] + backend: Arc, + secret_key: SecretKey, + // Using a standard Mutex object instead of an async version - it's unlikely + // this will ever be accessed from multiple threads, since it's tied to a + // single request. The mutex is used mostly to allow the `Auth` object to be + // cloned and passed around while maintaining the reference to the same + // `AuthInner` object with a mutable `user`. + #[debug("..")] + user: Mutex, +} + +impl AuthInner { + async fn new( + session: Session, + backend: Arc, + secret_key: SecretKey, + fallback_secret_keys: &[SecretKey], + ) -> cot::Result { + #[expect(trivial_casts)] // cast to Arc + let user = get_user_with_saved_id(&session, &*backend, &secret_key, fallback_secret_keys) + .await? + .map_or_else( + || Arc::new(AnonymousUser) as Arc, + Arc::from, + ); + + Ok(Self { + session, + backend, + secret_key, + user: Mutex::new(UserWrapper(user)), + }) + } + + async fn from_request(request: &mut Request) -> cot::Result { + let config = request.context().config(); + + let session = Session::from_request(request).clone(); + let backend = request.context().auth_backend().clone(); + let secret_key = config.secret_key.clone(); + + Self::new(session, backend, secret_key, &config.fallback_secret_keys).await + } + + fn user(&self) -> Arc { + Arc::clone(&self.user_lock().0) + } + + async fn authenticate( + &self, + credentials: &(dyn Any + Send + Sync), + ) -> Result>> { + self.backend.authenticate(credentials).await + } + + async fn login(&self, user: Box) -> Result<()> { + // Mitigate the session fixation attack by changing the session ID: + // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#renew-the-session-id-after-any-privilege-level-change + self.session.cycle_id().await?; + + if let Some(user_id) = user.id() { + self.session.insert(USER_ID_SESSION_KEY, user_id).await?; + } + let secret_key = &self.secret_key; + if let Some(session_auth_hash) = user.session_auth_hash(secret_key) { + self.session + .insert(SESSION_HASH_SESSION_KEY, session_auth_hash.as_bytes()) + .await?; + } + *self.user_lock() = UserWrapper(Arc::from(user)); + + Ok(()) + } + + async fn logout(&self) -> Result<()> { + self.session.flush().await?; + *self.user_lock() = UserWrapper(Arc::new(AnonymousUser)); + + Ok(()) + } + + fn user_lock(&self) -> MutexGuard<'_, UserWrapper> { + self.user.lock().unwrap_or_else(|poison_error| { + // We don't have any invariants about the structure of the + // UserWrapper object, so we can safely clear the + // poison. + self.user.clear_poison(); + poison_error.into_inner() + }) + } +} + +pub(super) const USER_ID_SESSION_KEY: &str = "__cot_auth_user_id"; +const SESSION_HASH_SESSION_KEY: &str = "__cot_auth_session_hash"; + +async fn get_user_with_saved_id( + session: &Session, + auth_backend: &dyn AuthBackend, + secret_key: &SecretKey, + fallback_secret_keys: &[SecretKey], +) -> Result>> { + let Some(user_id) = session.get::(USER_ID_SESSION_KEY).await? else { + return Ok(None); + }; + + let Some(user) = auth_backend.get_by_id(user_id).await? else { + return Ok(None); + }; + + if session_auth_hash_valid(&*user, session, secret_key, fallback_secret_keys).await? { + Ok(Some(user)) + } else { + Ok(None) + } +} + +async fn session_auth_hash_valid( + user: &(dyn User + Send + Sync), + session: &Session, + secret_key: &SecretKey, + fallback_secret_keys: &[SecretKey], +) -> Result { + let Some(user_hash) = user.session_auth_hash(secret_key) else { + return Ok(true); + }; + + let stored_hash = session + .get::>(SESSION_HASH_SESSION_KEY) + .await? + .expect("Session hash should be present in the session object"); + let stored_hash = SessionAuthHash::new(&stored_hash); + + if user_hash == stored_hash { + return Ok(true); + } + + // If the primary secret key doesn't match, try the fallback keys (in other + // words, check if the session hash was generated with an old secret key) + // and update the session hash with the new key if a match is found. + for fallback_key in fallback_secret_keys { + let user_hash_fallback = user + .session_auth_hash(fallback_key) + .expect("User should have a session hash for each secret key"); + if user_hash_fallback == stored_hash { + session + .insert(SESSION_HASH_SESSION_KEY, user_hash.as_bytes()) + .await?; + + return Ok(true); + } + } + + Ok(false) +} + +impl FromRequestHead for Auth { + #[expect(clippy::unused_async_trait_impl)] + async fn from_request_head(head: &RequestHead) -> cot::Result { + let auth = head + .extensions + .get::() + .expect("AuthMiddleware not enabled for the route/project") + .clone(); + + Ok(auth) + } +} diff --git a/cot/src/auth/db.rs b/cot/src/auth/user_pass.rs similarity index 97% rename from cot/src/auth/db.rs rename to cot/src/auth/user_pass.rs index e3f3b09d8..150831aab 100644 --- a/cot/src/auth/db.rs +++ b/cot/src/auth/user_pass.rs @@ -8,8 +8,8 @@ use std::borrow::Cow; use std::fmt::{Display, Formatter}; use async_trait::async_trait; -// Importing `Auto` from `cot` instead of `crate` so that the migration generator -// can figure out it's an autogenerated field +// Importing `Auto` from `cot` instead of `crate` so that the migration +// generator can figure out it's an autogenerated field use cot::db::Auto; use cot_macros::AdminModel; use thiserror::Error; @@ -73,7 +73,7 @@ impl DatabaseUser { /// # Example /// /// ``` - /// use cot::auth::db::DatabaseUser; + /// use cot::auth::user_pass::DatabaseUser; /// use cot::common_types::Password; /// use cot::db::Database; /// use cot::html::Html; @@ -134,7 +134,7 @@ impl DatabaseUser { /// # Example /// /// ``` - /// use cot::auth::db::{DatabaseUser, DatabaseUserCredentials}; + /// use cot::auth::user_pass::{DatabaseUser, DatabaseUserCredentials}; /// use cot::common_types::Password; /// use cot::db::{Database, LimitedString, Model}; /// use cot::html::Html; @@ -176,7 +176,7 @@ impl DatabaseUser { /// # Example /// /// ``` - /// use cot::auth::db::{DatabaseUser, DatabaseUserCredentials}; + /// use cot::auth::user_pass::{DatabaseUser, DatabaseUserCredentials}; /// use cot::common_types::Password; /// use cot::db::{Database, Model}; /// use cot::html::Html; @@ -225,7 +225,7 @@ impl DatabaseUser { /// /// ``` /// use cot::auth::UserId; - /// use cot::auth::db::DatabaseUser; + /// use cot::auth::user_pass::DatabaseUser; /// use cot::common_types::Password; /// use cot::db::Database; /// use cot::html::Html; @@ -270,7 +270,7 @@ impl DatabaseUser { /// /// ``` /// use cot::auth::UserId; - /// use cot::auth::db::DatabaseUser; + /// use cot::auth::user_pass::DatabaseUser; /// use cot::common_types::Password; /// use cot::db::Database; /// use cot::html::Html; @@ -363,7 +363,7 @@ impl DatabaseUser { /// /// ``` /// use cot::auth::UserId; - /// use cot::auth::db::DatabaseUser; + /// use cot::auth::user_pass::DatabaseUser; /// use cot::common_types::Password; /// use cot::db::Database; /// use cot::html::Html; @@ -406,7 +406,7 @@ impl DatabaseUser { /// /// ``` /// use cot::auth::UserId; - /// use cot::auth::db::DatabaseUser; + /// use cot::auth::user_pass::DatabaseUser; /// use cot::common_types::Password; /// use cot::db::Database; /// use cot::html::Html; @@ -493,7 +493,7 @@ impl DatabaseUserCredentials { /// # Example /// /// ``` - /// use cot::auth::db::DatabaseUserCredentials; + /// use cot::auth::user_pass::DatabaseUserCredentials; /// use cot::common_types::Password; /// /// let credentials = @@ -509,7 +509,7 @@ impl DatabaseUserCredentials { /// # Example /// /// ``` - /// use cot::auth::db::DatabaseUserCredentials; + /// use cot::auth::user_pass::DatabaseUserCredentials; /// use cot::common_types::Password; /// /// let credentials = @@ -526,7 +526,7 @@ impl DatabaseUserCredentials { /// # Example /// /// ``` - /// use cot::auth::db::DatabaseUserCredentials; + /// use cot::auth::user_pass::DatabaseUserCredentials; /// use cot::common_types::Password; /// /// let credentials = @@ -560,7 +560,7 @@ impl DatabaseUserBackend { /// use std::sync::Arc; /// /// use cot::auth::AuthBackend; - /// use cot::auth::db::DatabaseUserBackend; + /// use cot::auth::user_pass::DatabaseUserBackend; /// use cot::config::ProjectConfig; /// use cot::project::{AuthBackendContext, WithApps}; /// use cot::{Project, ProjectContext}; @@ -623,7 +623,7 @@ impl DatabaseUserApp { /// # Example /// /// ```no_run - /// use cot::auth::db::DatabaseUserApp; + /// use cot::auth::user_pass::DatabaseUserApp; /// use cot::config::{DatabaseConfig, ProjectConfig}; /// use cot::project::RegisterAppsContext; /// use cot::{App, AppBuilder, Project}; diff --git a/cot/src/auth/db/migrations.rs b/cot/src/auth/user_pass/migrations.rs similarity index 100% rename from cot/src/auth/db/migrations.rs rename to cot/src/auth/user_pass/migrations.rs diff --git a/cot/src/auth/db/migrations/m_0001_initial.rs b/cot/src/auth/user_pass/migrations/m_0001_initial.rs similarity index 67% rename from cot/src/auth/db/migrations/m_0001_initial.rs rename to cot/src/auth/user_pass/migrations/m_0001_initial.rs index 8277d8ca1..e4f672f85 100644 --- a/cot/src/auth/db/migrations/m_0001_initial.rs +++ b/cot/src/auth/user_pass/migrations/m_0001_initial.rs @@ -6,43 +6,42 @@ impl ::cot::db::migrations::Migration for Migration { const APP_NAME: &'static str = "cot"; const MIGRATION_NAME: &'static str = "m_0001_initial"; const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[]; - const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[ - ::cot::db::migrations::Operation::create_model() - .table_name(::cot::db::Identifier::new("cot__database_user")) - .fields( - &[ - ::cot::db::migrations::Field::new( + const OPERATIONS: &'static [::cot::db::migrations::Operation] = + &[ + ::cot::db::migrations::Operation::create_model() + .table_name(::cot::db::Identifier::new("cot__database_user")) + .fields( + &[ + ::cot::db::migrations::Field::new( ::cot::db::Identifier::new("id"), as ::cot::db::DatabaseField>::TYPE, ) .auto() .primary_key() - .set_null( - as ::cot::db::DatabaseField>::NULLABLE, - ), - ::cot::db::migrations::Field::new( + .set_null( as ::cot::db::DatabaseField>::NULLABLE), + ::cot::db::migrations::Field::new( ::cot::db::Identifier::new("username"), as ::cot::db::DatabaseField>::TYPE, ) .set_null( as ::cot::db::DatabaseField>::NULLABLE, ) .unique(), - ::cot::db::migrations::Field::new( + ::cot::db::migrations::Field::new( ::cot::db::Identifier::new("password"), ::TYPE, ) .set_null( ::NULLABLE, ), - ], - ) - .build(), - ]; + ], + ) + .build(), + ]; } #[derive(::core::fmt::Debug)] @@ -51,6 +50,6 @@ struct _DatabaseUser { #[model(primary_key)] id: cot::db::Auto, #[model(unique)] - username: crate::db::LimitedString<{ crate::auth::db::MAX_USERNAME_LENGTH }>, + username: crate::db::LimitedString<{ crate::auth::user_pass::MAX_USERNAME_LENGTH }>, password: crate::auth::PasswordHash, } diff --git a/cot/src/config.rs b/cot/src/config.rs index 84cfbf72c..8e34d817f 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -472,8 +472,9 @@ pub enum AuthBackendConfig { /// Database authentication backend. /// /// This enables - /// [`DatabaseUserBackend`](struct@cot::auth::db::DatabaseUserBackend) - /// to be used as the authentication backend. + /// [`DatabaseUserBackend`](struct@ + /// cot::auth::user_pass::DatabaseUserBackend) to be used as the + /// authentication backend. #[cfg(feature = "db")] Database, } diff --git a/cot/src/db/migrations/graph_export.rs b/cot/src/db/migrations/graph_export.rs index ddd08d4cb..cd4bc3f89 100644 --- a/cot/src/db/migrations/graph_export.rs +++ b/cot/src/db/migrations/graph_export.rs @@ -139,7 +139,7 @@ fn group_by_app<'a>(nodes: &[Node<'a>]) -> Vec<(&'a str, Vec)> { #[cfg(test)] mod tests { - use cot::auth::db::DatabaseUserApp; + use cot::auth::user_pass::DatabaseUserApp; use cot::db::migrations::{ Field, Migration, MigrationDependency, MigrationEngine, Operation, SyncDynMigration, wrap_migrations, diff --git a/cot/src/middleware.rs b/cot/src/middleware.rs index 5e7a2dc70..a16dc22d8 100644 --- a/cot/src/middleware.rs +++ b/cot/src/middleware.rs @@ -9,18 +9,14 @@ use std::fmt::Debug; use std::sync::Arc; use std::task::{Context, Poll}; -use futures_core::future::BoxFuture; use tower::Service; use tower_sessions::service::PlaintextCookie; use tower_sessions::{SessionManagerLayer, SessionStore}; -use crate::Error; #[cfg(feature = "cache")] use crate::config::CacheType; use crate::config::{Expiry, SameSite, SessionStoreTypeConfig}; use crate::project::MiddlewareContext; -use crate::request::Request; -use crate::response::Response; use crate::session::store::SessionStoreWrapper; #[cfg(all(feature = "db", feature = "json"))] use crate::session::store::db::DbStore; @@ -30,11 +26,13 @@ use crate::session::store::memory::MemoryStore; #[cfg(feature = "redis")] use crate::session::store::redis::RedisStore; +mod auth; #[cfg(feature = "live-reload")] mod live_reload; mod trailing_slash; -/// Middleware that converts any error type to [`Error`]. +pub use auth::{AuthMiddleware, AuthService}; +/// Middleware that converts any error type to [`Error`](crate::Error). /// /// This is useful for converting a response from a middleware that is /// compatible with the `tower` crate to a response that is compatible with @@ -65,7 +63,7 @@ mod trailing_slash; /// ``` pub use cot_core::middleware::IntoCotErrorLayer; /// Middleware that converts any `http::Response` generic type -/// to a [`Response`]. +/// to a [`Response`](crate::response::Response). /// /// This is useful for converting a response from a middleware that is /// compatible with the `tower` crate to a response that is compatible with @@ -447,128 +445,17 @@ where } } -/// A middleware that provides authentication functionality. -/// -/// This middleware is used to authenticate requests and add the authenticated -/// user to the request extensions. This adds the [`crate::auth::Auth`] object -/// to the request which can be accessed by the request handlers. -/// -/// This middleware requires [`SessionMiddleware`]. Since each -/// [`RootHandlerBuilder::middleware`] call wraps the existing handler, register -/// `SessionMiddleware` after `AuthMiddleware` so session handling runs first. -/// -/// # Examples -/// -/// ``` -/// use cot::middleware::{AuthMiddleware, SessionMiddleware}; -/// use cot::project::{MiddlewareContext, RootHandler, RootHandlerBuilder}; -/// use cot::{Project, ProjectContext}; -/// -/// struct MyProject; -/// impl Project for MyProject { -/// fn middlewares( -/// &self, -/// handler: RootHandlerBuilder, -/// context: &MiddlewareContext, -/// ) -> RootHandler { -/// handler -/// .middleware(AuthMiddleware::new()) -/// .middleware(SessionMiddleware::from_context(context)) -/// .build() -/// } -/// } -/// ``` -#[derive(Debug, Copy, Clone)] -pub struct AuthMiddleware; - -impl AuthMiddleware { - /// Create a new [`AuthMiddleware`]. - /// - /// # Examples - /// - /// ``` - /// use cot::middleware::AuthMiddleware; - /// - /// let middleware = AuthMiddleware::new(); - /// ``` - #[must_use] - pub fn new() -> Self { - Self {} - } -} - -impl Default for AuthMiddleware { - fn default() -> Self { - Self::new() - } -} - -impl tower::Layer for AuthMiddleware { - type Service = AuthService; - - fn layer(&self, inner: S) -> Self::Service { - AuthService::new(inner) - } -} - -/// Service that adds [`crate::auth::Auth`] to the request. -/// -/// Used by [`AuthMiddleware`]. -#[derive(Debug, Clone)] -pub struct AuthService { - inner: S, -} - -impl AuthService { - fn new(inner: S) -> Self { - Self { inner } - } -} - -impl Service for AuthService -where - S: Service + Clone + Send + 'static, - S::Future: Send, -{ - type Response = S::Response; - type Error = Error; - type Future = BoxFuture<'static, Result>; - - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner.poll_ready(cx) - } - - fn call(&mut self, mut req: Request) -> Self::Future { - // The inner service may panic until ready, so it's important to clone - // it here and used the version that is ready. This is a common pattern - // when using `tower::Service`. - // - // https://docs.rs/tower/latest/tower/trait.Service.html#be-careful-when-cloning-inner-services - let clone = self.inner.clone(); - let mut inner = std::mem::replace(&mut self.inner, clone); - - Box::pin(async move { - let auth = crate::auth::Auth::from_request(&mut req).await?; - req.extensions_mut().insert(auth); - - inner.call(req).await - }) - } -} - // TODO: add Cot ORM-based session store #[cfg(test)] mod tests { use std::env; use std::path::PathBuf; - use std::sync::Arc; use http::Request; use tower::{Layer, Service, ServiceExt}; use super::*; - use crate::auth::Auth; use crate::config::{ CacheUrl, DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, SessionStoreTypeConfig, @@ -648,76 +535,6 @@ mod tests { assert!(!cookie_value.contains("Secure;")); } - #[cot::test] - async fn auth_middleware_adds_auth() { - let svc = tower::service_fn(|req: Request| async move { - let auth = req - .extensions() - .get::() - .expect("Auth should be present"); - - assert!(!auth.user().is_authenticated()); - - Ok::<_, Error>(Response::new(Body::empty())) - }); - - let mut svc = AuthMiddleware::new().layer(svc); - - let request = TestRequestBuilder::get("/").with_session().build(); - - svc.ready().await.unwrap().call(request).await.unwrap(); - } - - #[cot::test] - #[should_panic( - expected = "Session extension missing. Add SessionMiddleware, and if using AuthMiddleware, register SessionMiddleware after AuthMiddleware." - )] - async fn auth_middleware_requires_session() { - let svc = tower::service_fn(|_req: Request| async move { - Ok::<_, Error>(Response::new(Body::empty())) - }); - - let mut svc = AuthMiddleware::new().layer(svc); - - let request = TestRequestBuilder::get("/").build(); - - // Should fail because Auth middleware requires session - let _result = svc.ready().await.unwrap().call(request).await; - } - - #[cot::test] - async fn auth_service_cloning() { - let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let counter_clone = counter.clone(); - - let svc = tower::service_fn(move |req: Request| { - let counter = counter_clone.clone(); - async move { - counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - - assert!(req.extensions().get::().is_some()); - - Ok::<_, Error>(Response::new(Body::empty())) - } - }); - - let mut svc = AuthMiddleware::new().layer(svc); - let svc = svc.ready().await.unwrap(); - - // Send multiple requests to test service cloning - let request1 = TestRequestBuilder::get("/").with_session().build(); - let request2 = TestRequestBuilder::get("/").with_session().build(); - - // Process requests concurrently - let (res1, res2) = tokio::join!(svc.clone().call(request1), svc.call(request2)); - - assert!(res1.is_ok()); - assert!(res2.is_ok()); - - // Counter should have been incremented twice - assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 2); - } - async fn create_svc_and_call_with_req(context: &ProjectContext) { let store = SessionMiddleware::from_context(context); let svc = tower::service_fn(|req: Request| async move { diff --git a/cot/src/middleware/auth.rs b/cot/src/middleware/auth.rs new file mode 100644 index 000000000..de7207bde --- /dev/null +++ b/cot/src/middleware/auth.rs @@ -0,0 +1,203 @@ +//! The authentication middleware. + +use std::task::{Context, Poll}; + +use futures_core::future::BoxFuture; +use tower::Service; + +use crate::Error; +use crate::request::Request; +use crate::response::Response; + +/// A middleware that provides authentication functionality. +/// +/// This middleware is used to authenticate requests and add the authenticated +/// user to the request extensions. This adds the [`crate::auth::Auth`] object +/// to the request which can be accessed by the request handlers. +/// +/// This middleware requires [`SessionMiddleware`](super::SessionMiddleware). +/// Since each +/// [`RootHandlerBuilder::middleware`](crate::project::RootHandlerBuilder::middleware) call wraps the existing handler, register +/// `SessionMiddleware` after `AuthMiddleware` so session handling runs first. +/// +/// # Examples +/// +/// ``` +/// use cot::middleware::{AuthMiddleware, SessionMiddleware}; +/// use cot::project::{MiddlewareContext, RootHandler, RootHandlerBuilder}; +/// use cot::{Project, ProjectContext}; +/// +/// struct MyProject; +/// impl Project for MyProject { +/// fn middlewares( +/// &self, +/// handler: RootHandlerBuilder, +/// context: &MiddlewareContext, +/// ) -> RootHandler { +/// handler +/// .middleware(AuthMiddleware::new()) +/// .middleware(SessionMiddleware::from_context(context)) +/// .build() +/// } +/// } +/// ``` +#[derive(Debug, Copy, Clone)] +pub struct AuthMiddleware; + +impl AuthMiddleware { + /// Create a new [`AuthMiddleware`]. + /// + /// # Examples + /// + /// ``` + /// use cot::middleware::AuthMiddleware; + /// + /// let middleware = AuthMiddleware::new(); + /// ``` + #[must_use] + pub fn new() -> Self { + Self {} + } +} + +impl Default for AuthMiddleware { + fn default() -> Self { + Self::new() + } +} + +impl tower::Layer for AuthMiddleware { + type Service = AuthService; + + fn layer(&self, inner: S) -> Self::Service { + AuthService::new(inner) + } +} + +/// Service that adds [`crate::auth::Auth`] to the request. +/// +/// Used by [`AuthMiddleware`]. +#[derive(Debug, Clone)] +pub struct AuthService { + inner: S, +} + +impl AuthService { + fn new(inner: S) -> Self { + Self { inner } + } +} + +impl Service for AuthService +where + S: Service + Clone + Send + 'static, + S::Future: Send, +{ + type Response = S::Response; + type Error = Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: Request) -> Self::Future { + // The inner service may panic until ready, so it's important to clone + // it here and used the version that is ready. This is a common pattern + // when using `tower::Service`. + // + // https://docs.rs/tower/latest/tower/trait.Service.html#be-careful-when-cloning-inner-services + let clone = self.inner.clone(); + let mut inner = std::mem::replace(&mut self.inner, clone); + + Box::pin(async move { + let auth = crate::auth::Auth::from_request(&mut req).await?; + req.extensions_mut().insert(auth); + + inner.call(req).await + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use http::Request; + use tower::{Layer, Service, ServiceExt}; + + use super::*; + use crate::Body; + use crate::auth::Auth; + use crate::test::TestRequestBuilder; + + #[cot::test] + async fn auth_middleware_adds_auth() { + let svc = tower::service_fn(|req: Request| async move { + let auth = req + .extensions() + .get::() + .expect("Auth should be present"); + + assert!(!auth.user().is_authenticated()); + + Ok::<_, Error>(Response::new(Body::empty())) + }); + + let mut svc = AuthMiddleware::new().layer(svc); + + let request = TestRequestBuilder::get("/").with_session().build(); + + svc.ready().await.unwrap().call(request).await.unwrap(); + } + + #[cot::test] + #[should_panic( + expected = "Session extension missing. Add SessionMiddleware, and if using AuthMiddleware, register SessionMiddleware after AuthMiddleware." + )] + async fn auth_middleware_requires_session() { + let svc = tower::service_fn(|_req: Request| async move { + Ok::<_, Error>(Response::new(Body::empty())) + }); + + let mut svc = AuthMiddleware::new().layer(svc); + + let request = TestRequestBuilder::get("/").build(); + + // Should fail because Auth middleware requires session + let _result = svc.ready().await.unwrap().call(request).await; + } + + #[cot::test] + async fn auth_service_cloning() { + let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter_clone = counter.clone(); + + let svc = tower::service_fn(move |req: Request| { + let counter = counter_clone.clone(); + async move { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + assert!(req.extensions().get::().is_some()); + + Ok::<_, Error>(Response::new(Body::empty())) + } + }); + + let mut svc = AuthMiddleware::new().layer(svc); + let svc = svc.ready().await.unwrap(); + + // Send multiple requests to test service cloning + let request1 = TestRequestBuilder::get("/").with_session().build(); + let request2 = TestRequestBuilder::get("/").with_session().build(); + + // Process requests concurrently + let (res1, res2) = tokio::join!(svc.clone().call(request1), svc.call(request2)); + + assert!(res1.is_ok()); + assert!(res2.is_ok()); + + // Counter should have been incremented twice + assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 2); + } +} diff --git a/cot/src/project.rs b/cot/src/project.rs index 58d6923ae..f140bb485 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -45,7 +45,7 @@ use tracing::{error, info, trace}; use crate::admin::AdminModelManager; #[cfg(feature = "db")] -use crate::auth::db::DatabaseUserBackend; +use crate::auth::user_pass::DatabaseUserBackend; use crate::auth::{AuthBackend, NoAuthBackend}; #[cfg(feature = "cache")] use crate::cache::Cache; diff --git a/cot/src/request/extractors.rs b/cot/src/request/extractors.rs index 3b53dffc1..2aa4d947a 100644 --- a/cot/src/request/extractors.rs +++ b/cot/src/request/extractors.rs @@ -73,7 +73,6 @@ pub use cot_core::request::extractors::{Path, UrlQuery}; pub use self::client_ip::ClientIpAddr; use crate::Body; -use crate::auth::Auth; use crate::form::{Form, FormResult}; use crate::request::{Request, RequestExt, RequestHead}; use crate::router::Urls; @@ -285,19 +284,6 @@ impl FromRequestHead for Session { } } -impl FromRequestHead for Auth { - #[expect(clippy::unused_async_trait_impl)] - async fn from_request_head(head: &RequestHead) -> cot::Result { - let auth = head - .extensions - .get::() - .expect("AuthMiddleware not enabled for the route/project") - .clone(); - - Ok(auth) - } -} - #[cfg(test)] mod tests { use cot_core::Method; diff --git a/cot/src/test.rs b/cot/src/test.rs index 34f2eef4c..5de12fcac 100644 --- a/cot/src/test.rs +++ b/cot/src/test.rs @@ -22,7 +22,7 @@ use tower::Service; use tower_sessions::MemoryStore; #[cfg(feature = "db")] -use crate::auth::db::DatabaseUserBackend; +use crate::auth::user_pass::DatabaseUserBackend; use crate::auth::{Auth, AuthBackend, NoAuthBackend, User, UserId}; #[cfg(feature = "cache")] use crate::cache::Cache; @@ -1132,7 +1132,7 @@ impl TestDatabase { /// ``` #[cfg(feature = "db")] pub fn with_auth(&mut self) -> &mut Self { - self.add_migrations(cot::auth::db::migrations::MIGRATIONS.to_vec()); + self.add_migrations(cot::auth::user_pass::migrations::MIGRATIONS.to_vec()); self } diff --git a/cot/tests/admin.rs b/cot/tests/admin.rs index afe7b3cd3..a8f6bcad7 100644 --- a/cot/tests/admin.rs +++ b/cot/tests/admin.rs @@ -2,7 +2,7 @@ use std::error::Error; use async_trait::async_trait; use cot::admin::AdminApp; -use cot::auth::db::{DatabaseUser, DatabaseUserApp}; +use cot::auth::user_pass::{DatabaseUser, DatabaseUserApp}; use cot::cli::CliMetadata; use cot::config::{ AuthBackendConfig, DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, diff --git a/cot/tests/auth.rs b/cot/tests/auth.rs index 12bacb304..6b862c0de 100644 --- a/cot/tests/auth.rs +++ b/cot/tests/auth.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use cot::auth::Auth; -use cot::auth::db::{DatabaseUser, DatabaseUserCredentials}; +use cot::auth::user_pass::{DatabaseUser, DatabaseUserCredentials}; use cot::common_types::Password; use cot::request::RequestExt; use cot::test::{TestDatabase, TestRequestBuilder}; diff --git a/cot/tests/db_testing/migrations.rs b/cot/tests/db_testing/migrations.rs index 286861ebb..b6f09b151 100644 --- a/cot/tests/db_testing/migrations.rs +++ b/cot/tests/db_testing/migrations.rs @@ -1,5 +1,5 @@ use cot::App; -use cot::auth::db::DatabaseUserApp; +use cot::auth::user_pass::DatabaseUserApp; use cot::db::migrations::{ Field, Migration, MigrationDependency, MigrationEngine, Operation, SyncDynMigration, wrap_migrations, diff --git a/cot/tests/ui/unimplemented_admin_model.stderr b/cot/tests/ui/unimplemented_admin_model.stderr index 3d4c08f00..0e7d5c563 100644 --- a/cot/tests/ui/unimplemented_admin_model.stderr +++ b/cot/tests/ui/unimplemented_admin_model.stderr @@ -11,7 +11,7 @@ help: the trait `AdminModel` is not implemented for `Model` | ^^^^^^^^^^^^ = note: add #[derive(cot::admin::AdminModel)] to the struct to automatically derive the trait help: the trait `AdminModel` is implemented for `DatabaseUser` - --> src/auth/db.rs + --> src/auth/user_pass.rs | | #[derive(Debug, Clone, Form, AdminModel)] | ^^^^^^^^^^ diff --git a/cot/tests/ui/unimplemented_db_model.stderr b/cot/tests/ui/unimplemented_db_model.stderr index 8f9dc0643..5e5410b16 100644 --- a/cot/tests/ui/unimplemented_db_model.stderr +++ b/cot/tests/ui/unimplemented_db_model.stderr @@ -16,7 +16,7 @@ help: the following other types implement trait `cot::db::Model` | #[model] | ^^^^^^^^ `cot::session::db::Session` | - ::: src/auth/db.rs + ::: src/auth/user_pass.rs | | #[model] | ^^^^^^^^ `DatabaseUser` diff --git a/cot/tests/ui/unimplemented_form.stderr b/cot/tests/ui/unimplemented_form.stderr index d279a82b2..5da5c6f6d 100644 --- a/cot/tests/ui/unimplemented_form.stderr +++ b/cot/tests/ui/unimplemented_form.stderr @@ -11,7 +11,7 @@ help: the trait `cot::form::Form` is not implemented for `MyForm` | ^^^^^^^^^^^^^ = note: add #[derive(cot::form::Form)] to the struct to automatically derive the trait help: the trait `cot::form::Form` is implemented for `DatabaseUser` - --> src/auth/db.rs + --> src/auth/user_pass.rs | | #[derive(Debug, Clone, Form, AdminModel)] | ^^^^ @@ -30,7 +30,7 @@ help: the trait `cot::form::Form` is not implemented for `MyForm` | ^^^^^^^^^^^^^ = note: add #[derive(cot::form::Form)] to the struct to automatically derive the trait help: the trait `cot::form::Form` is implemented for `DatabaseUser` - --> src/auth/db.rs + --> src/auth/user_pass.rs | | #[derive(Debug, Clone, Form, AdminModel)] | ^^^^ diff --git a/docs/admin-panel.md b/docs/admin-panel.md index 8f62e070f..4d3f288bc 100644 --- a/docs/admin-panel.md +++ b/docs/admin-panel.md @@ -10,7 +10,7 @@ First, add the admin app and the dependencies required to your project in `src/m ```rust use cot::admin::AdminApp; -use cot::auth::db::{DatabaseUser, DatabaseUserApp}; +use cot::auth::user_pass::{DatabaseUser, DatabaseUserApp}; use cot::middleware::SessionMiddleware; use cot::project::{MiddlewareContext, RegisterAppsContext, RootHandler, RootHandlerBuilder}; use cot::static_files::StaticFilesMiddleware; @@ -48,7 +48,7 @@ By default, the admin interface uses Cot's authentication system. Therefore, you ```rust use async_trait::async_trait; // cargo add async-trait use cot::ProjectContext; -use cot::auth::db::DatabaseUser; +use cot::auth::user_pass::DatabaseUser; use cot::common_types::Password; use std::env; # struct MyApp; diff --git a/docs/configuration.md b/docs/configuration.md index 075f0d897..bd3e66f99 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,7 +41,7 @@ No authentication backend. This enables [`NoAuthBackend`](struct@cot::auth::NoAu ### `type = "database"` -Database authentication backend. This enables [`DatabaseUserBackend`](struct@cot::auth::db::DatabaseUserBackend) to be used as the authentication backend. +Database authentication backend. This enables [`DatabaseUserBackend`](struct@cot::auth::user_pass::DatabaseUserBackend) to be used as the authentication backend. ## `[database]` diff --git a/examples/admin/src/main.rs b/examples/admin/src/main.rs index 77af4fb97..34337d36d 100644 --- a/examples/admin/src/main.rs +++ b/examples/admin/src/main.rs @@ -5,7 +5,7 @@ use std::time::Duration; use async_trait::async_trait; use cot::admin::{AdminApp, AdminModel, AdminModelManager, DefaultAdminModelManager}; -use cot::auth::db::{DatabaseUser, DatabaseUserApp}; +use cot::auth::user_pass::{DatabaseUser, DatabaseUserApp}; use cot::cli::CliMetadata; use cot::config::{ AuthBackendConfig, DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, diff --git a/examples/todo-list/src/main.rs b/examples/todo-list/src/main.rs index e2a2ba953..d37730188 100644 --- a/examples/todo-list/src/main.rs +++ b/examples/todo-list/src/main.rs @@ -1,6 +1,6 @@ mod migrations; -use cot::auth::db::DatabaseUserApp; +use cot::auth::user_pass::DatabaseUserApp; use cot::cli::CliMetadata; use cot::config::{DatabaseConfig, ProjectConfig}; use cot::db::migrations::SyncDynMigration;