diff --git a/Cargo.lock b/Cargo.lock index c93c6c08..507d735f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1989,6 +1989,7 @@ dependencies = [ "log", "regex", "rwf", + "rwf-auth", "serde_json", "tar", "time", @@ -2691,6 +2692,7 @@ version = "0.1.0" dependencies = [ "argon2", "rwf", + "rwf-auth", "time", ] diff --git a/examples/users/Cargo.toml b/examples/users/Cargo.toml index c36eec7b..cd4d5d26 100644 --- a/examples/users/Cargo.toml +++ b/examples/users/Cargo.toml @@ -7,3 +7,4 @@ edition = "2021" rwf = { path = "../../rwf" } time = "0.3" argon2 = "0.5" +rwf-auth = { path = "../../rwf-auth" } diff --git a/examples/users/src/main.rs b/examples/users/src/main.rs index 5d45d048..1ecd158b 100644 --- a/examples/users/src/main.rs +++ b/examples/users/src/main.rs @@ -7,6 +7,7 @@ mod models; #[tokio::main] async fn main() { Logger::init(); + rwf_auth::migrate().await.expect("rwf-auth migrations"); let signup: SignupController = SignupController::new("templates/signup.html").redirect("/profile"); diff --git a/rwf-auth/Cargo.toml b/rwf-auth/Cargo.toml index 264f6d13..7da7f644 100644 --- a/rwf-auth/Cargo.toml +++ b/rwf-auth/Cargo.toml @@ -2,6 +2,7 @@ name = "rwf-auth" version = "0.1.0" edition = "2021" +include = ["migrations/", "src/", "static/", "templates/"] [dependencies] rwf = { path = "../rwf", version = "0.2.1" } diff --git a/rwf-auth/migrations/1733765331409957000_rwf_auth_users.down.sql b/rwf-auth/migrations/1733765331409957000_rwf_auth_users.down.sql new file mode 100644 index 00000000..f400692c --- /dev/null +++ b/rwf-auth/migrations/1733765331409957000_rwf_auth_users.down.sql @@ -0,0 +1 @@ +DROP TABLE rwf_auth_users; diff --git a/rwf-auth/migrations/1733765331409957000_rwf_auth_users.up.sql b/rwf-auth/migrations/1733765331409957000_rwf_auth_users.up.sql new file mode 100644 index 00000000..0519a7c0 --- /dev/null +++ b/rwf-auth/migrations/1733765331409957000_rwf_auth_users.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE rwf_auth_users ( + id BIGSERIAL PRIMARY KEY, + identifier VARCHAR NOT NULL UNIQUE, + password VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW () +); + +CREATE INDEX ON rwf_auth_users USING btree (created_at); diff --git a/rwf-auth/src/controllers/mod.rs b/rwf-auth/src/controllers/mod.rs index 322af0fe..ec0c9899 100644 --- a/rwf-auth/src/controllers/mod.rs +++ b/rwf-auth/src/controllers/mod.rs @@ -1,3 +1,5 @@ // This file is automatically generated by rwf-cli. // Manual modifications to this file will not be preserved. -pub mod password; \ No newline at end of file +pub mod password; + +pub use password::{Password, PasswordController}; diff --git a/rwf-auth/src/controllers/password.rs b/rwf-auth/src/controllers/password.rs index 40ffa1f0..09dfdc16 100644 --- a/rwf-auth/src/controllers/password.rs +++ b/rwf-auth/src/controllers/password.rs @@ -1,3 +1,14 @@ +//! Password authentication controller. +//! +//! Create an account if one doesn't exist. If one exists, attempt to log in. +//! +//! ### Errors +//! +//! The following errors are set in the template: +//! +//! - `error_form`: Any of the required fields are missing. +//! - `error_password`: Account exists and the password is incorrect. +//! - `error_password2`: Passwords do not match on account creation. Only set if `` is present in the form. use std::marker::PhantomData; use rwf::{ @@ -5,26 +16,33 @@ use rwf::{ prelude::*, }; +use crate::models::User; + +/// Account creation and login form. #[derive(macros::Form)] -struct PasswordForm { +pub struct PasswordForm { identifier: String, password: String, + password2: Option, } -/// Errors passed to the template. +/// Password errors. +/// +/// These are passed to the template in the context. #[derive(macros::Context, Default)] pub struct Errors { - /// Something was wrong with the identifier. - pub error_identifier: bool, + /// Form has missing fields. + pub error_form: bool, /// Password was incorrect or the user didn't exist. pub error_password: bool, + /// Passwords do not match. + pub error_password2: bool, } impl Errors { fn form() -> Self { let mut ctx = Self::default(); - ctx.error_identifier = true; - ctx.error_password = true; + ctx.error_form = true; ctx } @@ -33,8 +51,16 @@ impl Errors { ctx.error_password = true; ctx } + + fn wrong_password_match() -> Self { + let mut ctx = Self::default(); + ctx.error_password2 = true; + ctx + } } +/// Generic password authentication controller. Can be used with any model +/// which implements the [`rwf::model::UserModel`] trait. #[derive(Default)] pub struct Password { template_path: String, @@ -43,6 +69,7 @@ pub struct Password { } impl Password { + /// Create controller with the specified template path. pub fn template(template_path: &str) -> Self { Self { template_path: template_path.to_owned(), @@ -51,6 +78,7 @@ impl Password { } } + /// Redirect to the specified URL on successful authentication. pub fn redirect(mut self, redirect_url: &str) -> Self { self.redirect_url = redirect_url.to_owned(); self @@ -79,6 +107,15 @@ impl PageController for Password { return Ok(Response::new().html(tpl.render(Errors::form())?).code(400)); }; + // If second password passed in, make sure they match. + if let Some(ref password2) = form.password2 { + if password2 != &form.password { + return Ok(Response::new() + .html(tpl.render(Errors::wrong_password_match())?) + .code(400)); + } + } + let user = match T::create_user(&form.identifier, &form.password).await { Ok(user) => user, Err(UserError::UserExists) => { @@ -99,3 +136,6 @@ impl PageController for Password { Ok(request.login_user(&user)?.redirect(&self.redirect_url)) } } + +/// Password controller implemented for the [`rwf_auth::models::User`] model. +pub type PasswordController = Password; diff --git a/rwf-auth/src/lib.rs b/rwf-auth/src/lib.rs index 3619c212..08dfe017 100644 --- a/rwf-auth/src/lib.rs +++ b/rwf-auth/src/lib.rs @@ -1,2 +1,12 @@ +use std::path::PathBuf; + +use rwf::model::{Error, Migrations}; + pub mod controllers; pub mod models; + +/// Run `rwf-auth` migrations. +pub async fn migrate() -> Result { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + rwf::model::migrate(Some(path)).await +} diff --git a/rwf-auth/src/models/mod.rs b/rwf-auth/src/models/mod.rs index e69de29b..4e311908 100644 --- a/rwf-auth/src/models/mod.rs +++ b/rwf-auth/src/models/mod.rs @@ -0,0 +1,10 @@ +use rwf::prelude::*; + +#[derive(Clone, macros::Model, macros::UserModel, Debug)] +#[table_name("rwf_auth_users")] +pub struct User { + id: Option, + identifier: String, + password: String, + created_at: OffsetDateTime, +} diff --git a/rwf-cli/Cargo.toml b/rwf-cli/Cargo.toml index 862b9d85..09c53e31 100644 --- a/rwf-cli/Cargo.toml +++ b/rwf-cli/Cargo.toml @@ -11,6 +11,7 @@ readme = "README.md" [dependencies] clap = { version = "4.5.18", features = ["derive"] } rwf = { path = "../rwf", version = "0.2" } +rwf-auth = { path = "../rwf-auth", version = "0.1.0" } tokio = { version = "1", features = ["full"] } log = "0.4" time = "0.3" diff --git a/rwf-cli/src/migrate.rs b/rwf-cli/src/migrate.rs index d9738137..e5595771 100644 --- a/rwf-cli/src/migrate.rs +++ b/rwf-cli/src/migrate.rs @@ -5,10 +5,20 @@ use time::OffsetDateTime; use regex::Regex; use tokio::fs::{create_dir, File}; -use crate::logging::created; +use crate::{logging::created, util::package_info}; pub async fn migrate(version: Option) { - let migrations = Migrations::sync().await.expect("failed to sync migrations"); + let info = package_info().await.expect("couldn't get package info"); + + if info.rwf_auth { + rwf_auth::migrate() + .await + .expect("rwf-auth migrations failed to apply"); + } + + let migrations = Migrations::sync(None) + .await + .expect("failed to sync migrations"); migrations .apply(Direction::Up, version) @@ -17,7 +27,9 @@ pub async fn migrate(version: Option) { } pub async fn revert(version: Option) { - let migrations = Migrations::sync().await.expect("failed to sync migrations"); + let migrations = Migrations::sync(None) + .await + .expect("failed to sync migrations"); let version = if let Some(version) = version { Some(version) } else { diff --git a/rwf-cli/src/setup.rs b/rwf-cli/src/setup.rs index de233c92..8d9e3f4d 100644 --- a/rwf-cli/src/setup.rs +++ b/rwf-cli/src/setup.rs @@ -71,15 +71,6 @@ pub async fn setup() { } // Add rwf dependencies - Command::new("cargo") - .arg("add") - .arg("tokio@1") - .arg("--features") - .arg("full") - .status() - .await - .unwrap(); - Command::new("cargo") .arg("add") .arg("rwf") diff --git a/rwf-cli/src/util.rs b/rwf-cli/src/util.rs index 8f2001ba..e44d6bfb 100644 --- a/rwf-cli/src/util.rs +++ b/rwf-cli/src/util.rs @@ -8,6 +8,7 @@ pub struct PackageInfo { #[allow(dead_code)] pub version: String, pub target_dir: String, + pub rwf_auth: bool, } async fn cargo_toml() -> Result> { @@ -32,6 +33,16 @@ pub async fn package_info() -> Result Result TokenStream { if let Some(attr) = input.attrs.first() { match attr.meta { Meta::List(ref attrs) => { - let attrs = syn::parse2::(attrs.tokens.clone()).unwrap(); - - let identifier = attrs.identifier.to_string(); - let password = attrs.password.to_string(); - - return quote! { - impl rwf::model::UserModel for #ident { - fn identifier_column() -> &'static str { - #identifier - } - - fn password_column() -> &'static str { - #password + if let Ok(attrs) = syn::parse2::(attrs.tokens.clone()) { + let identifier = attrs.identifier.to_string(); + let password = attrs.password.to_string(); + + return quote! { + impl rwf::model::UserModel for #ident { + fn identifier_column() -> &'static str { + #identifier + } + + fn password_column() -> &'static str { + #password + } } } + .into(); } - .into(); } _ => (), diff --git a/rwf-tests/src/main.rs b/rwf-tests/src/main.rs index 7ad34dba..cab43c61 100644 --- a/rwf-tests/src/main.rs +++ b/rwf-tests/src/main.rs @@ -225,7 +225,7 @@ async fn main() -> Result<(), Box> { .init(); rollback().await?; - migrate().await?; + migrate(None).await?; let pool = Pool::from_env(); let mut conn = pool.get().await?; diff --git a/rwf/src/model/migrations/mod.rs b/rwf/src/model/migrations/mod.rs index 049e8398..fbbd503d 100644 --- a/rwf/src/model/migrations/mod.rs +++ b/rwf/src/model/migrations/mod.rs @@ -20,6 +20,7 @@ use tracing::{error, info}; /// may not be applied yet. pub struct Migrations { migrations: Vec, + root_path: Option, } static RE: Lazy = @@ -99,8 +100,15 @@ impl MigrationFile { } impl Migrations { - fn root_path() -> Result { - let path = PathBuf::from(current_dir()?.join(Path::new("migrations"))); + fn root_path(path: Option) -> Result { + let path = PathBuf::from( + if let Some(path) = path { + path + } else { + current_dir()? + } + .join(Path::new("migrations")), + ); if !path.is_dir() { info!(r#"No migrations available, skipping"#); @@ -112,19 +120,22 @@ impl Migrations { } } - async fn load() -> Result { + async fn load(root_path: Option) -> Result { let mut conn = get_connection().await?; let migrations = Migration::all().fetch_all(&mut conn).await?; - Ok(Self { migrations }) + Ok(Self { + migrations, + root_path, + }) } /// Read the `"migrations"` folder and sync all migrations /// to the `"rwf_migrations"` table in the database. This does not /// actually apply the migrations, only makes sure the entries in the folder /// match the database table. - pub async fn sync() -> Result { - let checks = if let Ok(root_path) = Self::root_path() { + pub async fn sync(root_path: Option) -> Result { + let checks = if let Ok(root_path) = Self::root_path(root_path.clone()) { let mut checks = HashMap::new(); let mut dir_entries = read_dir(root_path).await?; @@ -199,7 +210,10 @@ impl Migrations { conn.commit().await?; - Ok(Self { migrations }) + Ok(Self { + migrations, + root_path, + }) } /// Apply the migrations, making changes to the database schema. @@ -240,7 +254,7 @@ impl Migrations { migration.name() ); - let path = Self::root_path()?.join(migration.path(direction)); + let path = Self::root_path(self.root_path.clone())?.join(migration.path(direction)); let sql = read_to_string(path).await?; let queries = sql @@ -291,7 +305,7 @@ impl Migrations { .await?; } - Self::load().await + Self::load(self.root_path).await } /// Get a list of all migrations currently found in the `"migrations"` folder. @@ -301,25 +315,42 @@ impl Migrations { /// Execute all migrations in the up direction. pub async fn migrate() -> Result { - Migrations::sync().await?.apply(Direction::Up, None).await + Migrations::sync(None) + .await? + .apply(Direction::Up, None) + .await } /// Execute all migrations in the down direction. **This will effectively /// destroy all tables and data in your database.** pub async fn flush() -> Result { - Migrations::sync().await?.apply(Direction::Down, None).await + Migrations::sync(None) + .await? + .apply(Direction::Down, None) + .await } } /// Execute all migrations in the up direction. -pub async fn migrate() -> Result { - Migrations::sync().await?.apply(Direction::Up, None).await +/// +/// # Arguments +/// +/// - `root_path`: Folder where the `migrations` folder is located. +/// +pub async fn migrate(root_path: Option) -> Result { + Migrations::sync(root_path) + .await? + .apply(Direction::Up, None) + .await } /// Execute all migrations in the down direction. **This will effectively /// destroy all tables and data in your database.** pub async fn rollback() -> Result { - Migrations::sync().await?.apply(Direction::Down, None).await + Migrations::sync(None) + .await? + .apply(Direction::Down, None) + .await } #[cfg(test)]