From b53d66c4dc2f16563551220064da66bcc3ad3c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABlle=20Huisman?= Date: Sat, 14 Mar 2026 15:59:20 +0100 Subject: [PATCH] feat(email): add email method --- Cargo.lock | 95 ++++++++++- Cargo.toml | 2 + examples/axum/Cargo.toml | 3 +- examples/axum/src/main.rs | 54 +++--- examples/dioxus-axum/Cargo.toml | 5 + examples/dioxus-axum/src/main.rs | 40 +++-- examples/leptos-actix/Cargo.toml | 5 + examples/leptos-actix/src/main.rs | 32 ++-- examples/leptos-axum/Cargo.toml | 5 + examples/leptos-axum/src/main.rs | 36 ++-- packages/methods/shield-email/Cargo.toml | 16 +- packages/methods/shield-email/src/actions.rs | 7 + .../shield-email/src/actions/sign_in.rs | 124 ++++++++++++++ .../src/actions/sign_in_callback.rs | 154 ++++++++++++++++++ .../shield-email/src/actions/sign_out.rs | 55 +++++++ packages/methods/shield-email/src/lib.rs | 13 ++ packages/methods/shield-email/src/method.rs | 57 +++++++ packages/methods/shield-email/src/options.rs | 20 +++ packages/methods/shield-email/src/provider.rs | 19 +++ packages/methods/shield-email/src/sender.rs | 42 +++++ packages/methods/shield-email/src/storage.rs | 23 +++ packages/methods/shield-email/src/token.rs | 27 +++ packages/storage/shield-memory/Cargo.toml | 12 +- packages/storage/shield-memory/src/lib.rs | 2 +- .../src/{providers.rs => methods.rs} | 2 + .../shield-memory/src/methods/email.rs | 78 +++++++++ .../src/{providers => methods}/oauth.rs | 0 .../src/{providers => methods}/oidc.rs | 0 packages/storage/shield-memory/src/storage.rs | 6 +- packages/storage/shield-sea-orm/Cargo.toml | 9 +- .../storage/shield-sea-orm/src/methods.rs | 2 + .../shield-sea-orm/src/methods/email.rs | 71 ++++++++ .../core/m20241210_203135_create_user.rs | 4 +- .../shield-bootstrap/src/dioxus/form.rs | 2 +- .../shield-bootstrap/src/dioxus/input.rs | 4 +- 35 files changed, 932 insertions(+), 94 deletions(-) create mode 100644 packages/methods/shield-email/src/actions.rs create mode 100644 packages/methods/shield-email/src/actions/sign_in.rs create mode 100644 packages/methods/shield-email/src/actions/sign_in_callback.rs create mode 100644 packages/methods/shield-email/src/actions/sign_out.rs create mode 100644 packages/methods/shield-email/src/method.rs create mode 100644 packages/methods/shield-email/src/options.rs create mode 100644 packages/methods/shield-email/src/provider.rs create mode 100644 packages/methods/shield-email/src/sender.rs create mode 100644 packages/methods/shield-email/src/storage.rs create mode 100644 packages/methods/shield-email/src/token.rs rename packages/storage/shield-memory/src/{providers.rs => methods.rs} (66%) create mode 100644 packages/storage/shield-memory/src/methods/email.rs rename packages/storage/shield-memory/src/{providers => methods}/oauth.rs (100%) rename packages/storage/shield-memory/src/{providers => methods}/oidc.rs (100%) create mode 100644 packages/storage/shield-sea-orm/src/methods/email.rs diff --git a/Cargo.lock b/Cargo.lock index 08469e7..6f90fb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,7 +263,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -901,6 +901,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "charset" version = "0.1.5" @@ -1268,6 +1279,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.3.0" @@ -1352,7 +1372,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -2562,6 +2582,7 @@ dependencies = [ "js-sys", "libc", "r-efi", + "rand_core 0.10.0", "wasip2", "wasip3", "wasm-bindgen", @@ -3311,6 +3332,15 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -3608,7 +3638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -4031,7 +4061,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.16", "http 1.4.0", @@ -4367,7 +4397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4665,6 +4695,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4703,6 +4744,12 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -5130,7 +5177,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -5674,7 +5721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5685,8 +5732,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ "digest", + "keccak", ] [[package]] @@ -5799,6 +5856,19 @@ dependencies = [ [[package]] name = "shield-email" version = "0.2.2" +dependencies = [ + "async-trait", + "bon", + "chrono", + "hex", + "rand 0.10.0", + "secrecy", + "serde", + "serde_json", + "sha3", + "shield", + "tracing", +] [[package]] name = "shield-examples-axum" @@ -5807,6 +5877,7 @@ dependencies = [ "axum", "shield", "shield-axum", + "shield-email", "shield-memory", "shield-oidc", "time", @@ -5829,6 +5900,7 @@ dependencies = [ "shield-bootstrap", "shield-dioxus", "shield-dioxus-axum", + "shield-email", "shield-memory", "shield-oidc", "tokio", @@ -5849,6 +5921,7 @@ dependencies = [ "leptos_meta", "leptos_router", "shield", + "shield-email", "shield-leptos", "shield-leptos-actix", "shield-memory", @@ -5871,6 +5944,7 @@ dependencies = [ "leptos_router", "shield", "shield-bootstrap", + "shield-email", "shield-leptos", "shield-leptos-axum", "shield-memory", @@ -5953,8 +6027,10 @@ name = "shield-memory" version = "0.2.2" dependencies = [ "async-trait", + "chrono", "serde", "shield", + "shield-email", "shield-oauth", "shield-oidc", "uuid", @@ -6006,6 +6082,7 @@ dependencies = [ "serde", "serde_json", "shield", + "shield-email", "shield-oauth", "shield-oidc", "tokio", @@ -7605,7 +7682,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f55b4c2..fa8f96b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,11 +45,13 @@ dioxus-html = "0.7.0-rc.3" dioxus-server = "0.7.0-rc.3" futures = "0.3.31" http = "1.2.0" +jsonwebtoken = "10.3.0" leptos = "0.8.3" leptos_actix = "0.8.3" leptos_axum = "0.8.3" leptos_meta = "0.8.3" leptos_router = "0.8.3" +rand = "0.10.0" regex = "1.12.2" sea-orm = "1.1.2" sea-orm-migration = "1.1.2" diff --git a/examples/axum/Cargo.toml b/examples/axum/Cargo.toml index 5483ead..381a85e 100644 --- a/examples/axum/Cargo.toml +++ b/examples/axum/Cargo.toml @@ -13,7 +13,8 @@ version.workspace = true axum.workspace = true shield.workspace = true shield-axum = { workspace = true, features = ["utoipa"] } -shield-memory = { workspace = true, features = ["method-oidc"] } +shield-email = { workspace = true, features = ["sender-tracing"] } +shield-memory = { workspace = true, features = ["method-email", "method-oidc"] } shield-oidc = { workspace = true, features = ["native-tls"] } time = "0.3.47" tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/examples/axum/src/main.rs b/examples/axum/src/main.rs index 56e0b48..e266379 100644 --- a/examples/axum/src/main.rs +++ b/examples/axum/src/main.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use axum::{Json, middleware::from_fn, routing::get}; use shield::{Shield, ShieldOptions}; use shield_axum::{AuthRoutes, ShieldLayer, auth_required}; +use shield_email::{EmailMethod, EmailOptions, TracingSender}; use shield_memory::{MemoryStorage, User}; use shield_oidc::{Keycloak, OidcMethod, OidcOptions}; use time::Duration; @@ -34,28 +35,37 @@ async fn main() { let storage = MemoryStorage::new(); let shield = Shield::new( storage.clone(), - vec![Arc::new( - OidcMethod::new(storage) - .with_providers([Keycloak::builder( - "keycloak", - "http://localhost:18080/realms/Shield", - "client1", - ) - .client_secret("xcpQsaGbRILTljPtX4npjmYMBjKrariJ") - .redirect_url(format!( - "http://localhost:{}/api/auth/oidc/sign-in-callback/keycloak", - addr.port() - )) - .build()]) - .with_options( - OidcOptions::builder() - .redirect_origins([ - Url::parse(&format!("http://localhost:{}", addr.port())).unwrap(), - Url::parse("http://localhost:5173").unwrap(), - ]) - .build(), - ), - )], + vec![ + Arc::new(EmailMethod::new( + EmailOptions::builder() + .secret("secret") + .sender(TracingSender) + .build(), + storage.clone(), + )), + Arc::new( + OidcMethod::new(storage) + .with_providers([Keycloak::builder( + "keycloak", + "http://localhost:18080/realms/Shield", + "client1", + ) + .client_secret("xcpQsaGbRILTljPtX4npjmYMBjKrariJ") + .redirect_url(format!( + "http://localhost:{}/api/auth/oidc/sign-in-callback/keycloak", + addr.port() + )) + .build()]) + .with_options( + OidcOptions::builder() + .redirect_origins([ + Url::parse(&format!("http://localhost:{}", addr.port())).unwrap(), + Url::parse("http://localhost:5173").unwrap(), + ]) + .build(), + ), + ), + ], ShieldOptions::default(), ); let shield_layer = ShieldLayer::new(shield.clone()); diff --git a/examples/dioxus-axum/Cargo.toml b/examples/dioxus-axum/Cargo.toml index 3d120dd..ab76ee9 100644 --- a/examples/dioxus-axum/Cargo.toml +++ b/examples/dioxus-axum/Cargo.toml @@ -16,12 +16,14 @@ version.workspace = true server = [ "dep:axum", "dep:shield-dioxus-axum", + "dep:shield-email", "dep:shield-memory", "dep:shield-oidc", "dep:tokio", "dep:tower-sessions", "dioxus/server", "shield-dioxus/server", + "shield-memory/method-email", "shield-memory/method-oidc", ] web = ["dioxus/web"] @@ -33,6 +35,9 @@ shield.workspace = true shield-bootstrap = { workspace = true, features = ["dioxus"] } shield-dioxus.workspace = true shield-dioxus-axum = { workspace = true, optional = true } +shield-email = { workspace = true, features = [ + "sender-tracing", +], optional = true } shield-memory = { workspace = true, optional = true } shield-oidc = { workspace = true, features = ["native-tls"], optional = true } tokio = { workspace = true, features = [ diff --git a/examples/dioxus-axum/src/main.rs b/examples/dioxus-axum/src/main.rs index c435178..cabc9d2 100644 --- a/examples/dioxus-axum/src/main.rs +++ b/examples/dioxus-axum/src/main.rs @@ -25,6 +25,7 @@ async fn main() { use shield::{Shield, ShieldOptions}; use shield_bootstrap::BootstrapDioxusStyle; use shield_dioxus_axum::{AuthRoutes, AxumDioxusIntegration, ShieldLayer}; + use shield_email::{EmailMethod, EmailOptions, TracingSender}; use shield_memory::{MemoryStorage, User}; use shield_oidc::{Keycloak, OidcMethod}; use tokio::net::TcpListener; @@ -45,21 +46,30 @@ async fn main() { let storage = MemoryStorage::new(); let shield = Shield::new( storage.clone(), - vec![Arc::new( - OidcMethod::new(storage).with_providers([Keycloak::builder( - "keycloak", - "http://localhost:18080/realms/Shield", - "client1", - ) - .client_secret("xcpQsaGbRILTljPtX4npjmYMBjKrariJ") - .redirect_url(format!( - "http://localhost:{}/api/auth/oidc/sign-in-callback/keycloak", - dioxus::cli_config::devserver_raw_addr() - .map(|addr| addr.port()) - .unwrap_or_else(|| addr.port()) - )) - .build()]), - )], + vec![ + Arc::new(EmailMethod::new( + EmailOptions::builder() + .secret("secret") + .sender(TracingSender) + .build(), + storage.clone(), + )), + Arc::new( + OidcMethod::new(storage).with_providers([Keycloak::builder( + "keycloak", + "http://localhost:18080/realms/Shield", + "client1", + ) + .client_secret("xcpQsaGbRILTljPtX4npjmYMBjKrariJ") + .redirect_url(format!( + "http://localhost:{}/api/auth/oidc/sign-in-callback/keycloak", + dioxus::cli_config::devserver_raw_addr() + .map(|addr| addr.port()) + .unwrap_or_else(|| addr.port()) + )) + .build()]), + ), + ], ShieldOptions::default(), ); let shield_layer = ShieldLayer::new(shield.clone()); diff --git a/examples/leptos-actix/Cargo.toml b/examples/leptos-actix/Cargo.toml index 0b74ea9..4c2acd1 100644 --- a/examples/leptos-actix/Cargo.toml +++ b/examples/leptos-actix/Cargo.toml @@ -29,12 +29,14 @@ ssr = [ "dep:actix-session", "dep:actix-web", "dep:leptos_actix", + "dep:shield-email", "dep:shield-leptos-actix", "dep:shield-memory", "dep:shield-oidc", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", + "shield-memory/method-email", "shield-memory/method-oidc", ] @@ -51,6 +53,9 @@ leptos_meta.workspace = true leptos_router.workspace = true shield.workspace = true # shield-bootstrap = { workspace = true, features = ["leptos"] } +shield-email = { workspace = true, features = [ + "sender-tracing", +], optional = true } shield-leptos.workspace = true shield-leptos-actix = { workspace = true, optional = true } shield-memory = { workspace = true, optional = true } diff --git a/examples/leptos-actix/src/main.rs b/examples/leptos-actix/src/main.rs index 00a0bd7..9890b8a 100644 --- a/examples/leptos-actix/src/main.rs +++ b/examples/leptos-actix/src/main.rs @@ -9,6 +9,7 @@ async fn main() -> std::io::Result<()> { use leptos::config::get_configuration; use leptos_actix::{LeptosRoutes, generate_route_list}; use shield::{Shield, ShieldOptions}; + use shield_email::{EmailMethod, EmailOptions, TracingSender}; use shield_examples_leptos_actix::app::*; use shield_leptos_actix::{ShieldMiddleware, provide_actix_integration}; use shield_memory::{MemoryStorage, User}; @@ -40,18 +41,27 @@ async fn main() -> std::io::Result<()> { SessionMiddleware::new(CookieSessionStore::default(), session_secret_key.clone()); // Initialize Shield - let shield_storage = MemoryStorage::new(); + let storage = MemoryStorage::new(); let shield = Shield::new( - shield_storage.clone(), - vec![Arc::new( - OidcMethod::new(shield_storage).with_providers([Keycloak::builder( - "keycloak", - "http://localhost:18080/realms/Shield", - "client1", - ) - .client_secret("xcpQsaGbRILTljPtX4npjmYMBjKrariJ") - .build()]), - )], + storage.clone(), + vec![ + Arc::new(EmailMethod::new( + EmailOptions::builder() + .secret("secret") + .sender(TracingSender) + .build(), + storage.clone(), + )), + Arc::new( + OidcMethod::new(storage).with_providers([Keycloak::builder( + "keycloak", + "http://localhost:18080/realms/Shield", + "client1", + ) + .client_secret("xcpQsaGbRILTljPtX4npjmYMBjKrariJ") + .build()]), + ), + ], ShieldOptions::default(), ); let shield_middleware = ShieldMiddleware::new(shield.clone()); diff --git a/examples/leptos-axum/Cargo.toml b/examples/leptos-axum/Cargo.toml index d736d10..fe69b3a 100644 --- a/examples/leptos-axum/Cargo.toml +++ b/examples/leptos-axum/Cargo.toml @@ -27,6 +27,7 @@ hydrate = ["leptos/hydrate"] ssr = [ "dep:axum", "dep:leptos_axum", + "dep:shield-email", "dep:shield-leptos-axum", "dep:shield-memory", "dep:shield-oidc", @@ -35,6 +36,7 @@ ssr = [ "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", + "shield-memory/method-email", "shield-memory/method-oidc", ] @@ -47,6 +49,9 @@ leptos_meta.workspace = true leptos_router.workspace = true shield.workspace = true shield-bootstrap = { workspace = true, features = ["leptos"] } +shield-email = { workspace = true, features = [ + "sender-tracing", +], optional = true } shield-leptos.workspace = true shield-leptos-axum = { workspace = true, optional = true } shield-memory = { workspace = true, optional = true } diff --git a/examples/leptos-axum/src/main.rs b/examples/leptos-axum/src/main.rs index acb9c25..709e8a8 100644 --- a/examples/leptos-axum/src/main.rs +++ b/examples/leptos-axum/src/main.rs @@ -8,6 +8,7 @@ async fn main() { use leptos_axum::{LeptosRoutes, generate_route_list}; use shield::{Shield, ShieldOptions}; use shield_bootstrap::BootstrapLeptosStyle; + use shield_email::{EmailMethod, EmailOptions, TracingSender}; use shield_examples_leptos_axum::app::*; use shield_leptos_axum::{AuthRoutes, ShieldLayer, auth_required, provide_axum_integration}; use shield_memory::{MemoryStorage, User}; @@ -38,19 +39,28 @@ async fn main() { let storage = MemoryStorage::new(); let shield = Shield::new( storage.clone(), - vec![Arc::new( - OidcMethod::new(storage).with_providers([Keycloak::builder( - "keycloak", - "http://localhost:18080/realms/Shield", - "client1", - ) - .client_secret("xcpQsaGbRILTljPtX4npjmYMBjKrariJ") - .redirect_url(format!( - "http://localhost:{}/api/auth/oidc/sign-in-callback/keycloak", - addr.port() - )) - .build()]), - )], + vec![ + Arc::new(EmailMethod::new( + EmailOptions::builder() + .secret("secret") + .sender(TracingSender) + .build(), + storage.clone(), + )), + Arc::new( + OidcMethod::new(storage).with_providers([Keycloak::builder( + "keycloak", + "http://localhost:18080/realms/Shield", + "client1", + ) + .client_secret("xcpQsaGbRILTljPtX4npjmYMBjKrariJ") + .redirect_url(format!( + "http://localhost:{}/api/auth/oidc/sign-in-callback/keycloak", + addr.port() + )) + .build()]), + ), + ], ShieldOptions::default(), ); let shield_layer = ShieldLayer::new(shield.clone()); diff --git a/packages/methods/shield-email/Cargo.toml b/packages/methods/shield-email/Cargo.toml index e8481bb..a4575d1 100644 --- a/packages/methods/shield-email/Cargo.toml +++ b/packages/methods/shield-email/Cargo.toml @@ -8,5 +8,19 @@ license.workspace = true repository.workspace = true version.workspace = true +[features] +default = [] +sender-tracing = ["dep:tracing"] + [dependencies] -# shield.workspace = true +async-trait.workspace = true +bon.workspace = true +chrono.workspace = true +hex = "0.4.3" +rand.workspace = true +secrecy.workspace = true +serde.workspace = true +serde_json.workspace = true +sha3 = "0.10.8" +shield.workspace = true +tracing = { workspace = true, optional = true } diff --git a/packages/methods/shield-email/src/actions.rs b/packages/methods/shield-email/src/actions.rs new file mode 100644 index 0000000..47d587d --- /dev/null +++ b/packages/methods/shield-email/src/actions.rs @@ -0,0 +1,7 @@ +mod sign_in; +mod sign_in_callback; +mod sign_out; + +pub use sign_in::*; +pub use sign_in_callback::*; +pub use sign_out::*; diff --git a/packages/methods/shield-email/src/actions/sign_in.rs b/packages/methods/shield-email/src/actions/sign_in.rs new file mode 100644 index 0000000..5e83030 --- /dev/null +++ b/packages/methods/shield-email/src/actions/sign_in.rs @@ -0,0 +1,124 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use rand::distr::{Alphanumeric, SampleString}; +use serde::Deserialize; +use shield::{ + Action, ActionMethod, Form, Input, InputType, InputTypeEmail, InputTypeSubmit, InputValue, + MethodSession, Request, Response, ResponseType, SessionAction, ShieldError, SignInAction, User, + erased_action, +}; + +use crate::{ + options::EmailOptions, + provider::EmailProvider, + storage::EmailStorage, + token::{CreateEmailAuthToken, hash_token}, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignInData { + // TODO: Validate using Fortifier. + pub email: String, +} + +pub struct EmailSignInAction { + options: EmailOptions, + storage: Arc>, +} + +impl EmailSignInAction { + pub fn new(options: EmailOptions, storage: Arc>) -> Self { + Self { options, storage } + } +} + +#[async_trait] +impl Action for EmailSignInAction { + fn id(&self) -> String { + SignInAction::id() + } + + fn name(&self) -> String { + SignInAction::name() + } + + fn openapi_summary(&self) -> &'static str { + "Sign in with email" + } + + fn openapi_description(&self) -> &'static str { + "Sign in with email." + } + + fn method(&self) -> ActionMethod { + ActionMethod::Post + } + + async fn forms(&self, _provider: EmailProvider) -> Result, ShieldError> { + Ok(vec![Form { + inputs: vec![ + Input { + name: "email".to_owned(), + label: Some("Email address".to_owned()), + r#type: InputType::Email(InputTypeEmail { + autocomplete: Some("email".to_owned()), + placeholder: Some("Email address".to_owned()), + required: Some(true), + ..Default::default() + }), + value: None, + addon_start: None, + addon_end: None, + }, + Input { + name: "submit".to_owned(), + label: None, + r#type: InputType::Submit(InputTypeSubmit::default()), + value: Some(InputValue::String { + value: "Sign in with email".to_owned(), + }), + addon_start: None, + addon_end: None, + }, + ], + }]) + } + + async fn call( + &self, + _provider: EmailProvider, + _session: &MethodSession<()>, + request: Request, + ) -> Result { + let data = serde_json::from_value::(request.form_data) + .map_err(|err| ShieldError::Validation(err.to_string()))?; + + let token = Alphanumeric.sample_string(&mut rand::rng(), 32); + let expires_at = Utc::now() + self.options.expires_in; + + let email_auth_token = self + .storage + .create_email_auth_token(CreateEmailAuthToken { + email: data.email.to_lowercase(), + token: hash_token(&token, &self.options.secret), + expired_at: expires_at.into(), + }) + .await?; + + self.options + .sender + .send( + &email_auth_token.email, + &email_auth_token.token, + email_auth_token.expired_at, + ) + .await?; + + Ok(Response::new(ResponseType::Default).session_action(SessionAction::unauthenticate())) + } +} + +erased_action!(EmailSignInAction, ); diff --git a/packages/methods/shield-email/src/actions/sign_in_callback.rs b/packages/methods/shield-email/src/actions/sign_in_callback.rs new file mode 100644 index 0000000..cb97772 --- /dev/null +++ b/packages/methods/shield-email/src/actions/sign_in_callback.rs @@ -0,0 +1,154 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use serde::Deserialize; +use shield::{ + Action, ActionMethod, CreateEmailAddress, CreateUser, Form, Input, InputType, InputTypeEmail, + InputTypeSubmit, InputValue, MethodSession, Request, Response, ResponseType, SessionAction, + ShieldError, SignInCallbackAction, User, erased_action, +}; + +use crate::{ + options::EmailOptions, provider::EmailProvider, storage::EmailStorage, token::hash_token, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignInCallbackData { + // TODO: Validate using Fortifier. + pub email: String, + pub token: String, +} + +pub struct EmailSignInCallbackAction { + options: EmailOptions, + storage: Arc>, +} + +impl EmailSignInCallbackAction { + pub fn new(options: EmailOptions, storage: Arc>) -> Self { + Self { options, storage } + } +} + +#[async_trait] +impl Action for EmailSignInCallbackAction { + fn id(&self) -> String { + SignInCallbackAction::id() + } + + fn name(&self) -> String { + SignInCallbackAction::name() + } + + fn openapi_summary(&self) -> &'static str { + "Sign in callback for email" + } + + fn openapi_description(&self) -> &'static str { + "Sign in callback for email." + } + + fn method(&self) -> ActionMethod { + ActionMethod::Get + } + + fn condition( + &self, + provider: &EmailProvider, + session: &MethodSession<()>, + ) -> Result { + SignInCallbackAction::condition(provider, session) + } + + async fn forms(&self, _provider: EmailProvider) -> Result, ShieldError> { + Ok(vec![Form { + inputs: vec![ + Input { + name: "email".to_owned(), + label: Some("Email address".to_owned()), + r#type: InputType::Email(InputTypeEmail { + autocomplete: Some("email".to_owned()), + placeholder: Some("Email address".to_owned()), + required: Some(true), + ..Default::default() + }), + value: None, + addon_start: None, + addon_end: None, + }, + Input { + name: "token".to_owned(), + label: Some("Token".to_owned()), + r#type: InputType::Email(InputTypeEmail { + placeholder: Some("Token".to_owned()), + required: Some(true), + ..Default::default() + }), + value: None, + addon_start: None, + addon_end: None, + }, + Input { + name: "submit".to_owned(), + label: None, + r#type: InputType::Submit(InputTypeSubmit::default()), + value: Some(InputValue::String { + value: "Sign in with email".to_owned(), + }), + addon_start: None, + addon_end: None, + }, + ], + }]) + } + + async fn call( + &self, + _provider: EmailProvider, + _session: &MethodSession<()>, + request: Request, + ) -> Result { + let data = serde_json::from_value::(request.form_data) + .map_err(|err| ShieldError::Validation(err.to_string()))?; + + let email_auth_token = self + .storage + .email_auth_token( + &data.email.to_lowercase(), + &hash_token(&data.token, &self.options.secret), + ) + .await? + .ok_or_else(|| { + ShieldError::Validation("Email authentication token not found.".to_owned()) + })?; + + self.storage + .delete_email_auth_token(&email_auth_token.id) + .await?; + + let user = match self.storage.user_by_email(&email_auth_token.email).await? { + Some(user) => user, + None => { + self.storage + .create_user( + CreateUser { name: None }, + CreateEmailAddress { + email: email_auth_token.email, + is_primary: true, + is_verified: true, + verification_token: None, + verification_token_expired_at: None, + verified_at: Some(Utc::now().into()), + }, + ) + .await? + } + }; + + Ok(Response::new(ResponseType::Default).session_action(SessionAction::authenticate(user))) + } +} + +erased_action!(EmailSignInCallbackAction, ); diff --git a/packages/methods/shield-email/src/actions/sign_out.rs b/packages/methods/shield-email/src/actions/sign_out.rs new file mode 100644 index 0000000..899490b --- /dev/null +++ b/packages/methods/shield-email/src/actions/sign_out.rs @@ -0,0 +1,55 @@ +use async_trait::async_trait; +use shield::{ + Action, ActionMethod, Form, MethodSession, Request, Response, ResponseType, SessionAction, + ShieldError, SignOutAction, erased_action, +}; + +use crate::provider::EmailProvider; + +pub struct EmailSignOutAction; + +#[async_trait] +impl Action for EmailSignOutAction { + fn id(&self) -> String { + SignOutAction::id() + } + + fn name(&self) -> String { + SignOutAction::name() + } + + fn openapi_summary(&self) -> &'static str { + "Sign out with OpenID Connect" + } + + fn openapi_description(&self) -> &'static str { + "Sign out with OpenID Connect." + } + + fn method(&self) -> ActionMethod { + ActionMethod::Post + } + + fn condition( + &self, + provider: &EmailProvider, + session: &MethodSession<()>, + ) -> Result { + SignOutAction::condition(provider, session) + } + + async fn forms(&self, provider: EmailProvider) -> Result, ShieldError> { + SignOutAction::forms(provider).await + } + + async fn call( + &self, + _provider: EmailProvider, + _session: &MethodSession<()>, + _request: Request, + ) -> Result { + Ok(Response::new(ResponseType::Default).session_action(SessionAction::Unauthenticate)) + } +} + +erased_action!(EmailSignOutAction); diff --git a/packages/methods/shield-email/src/lib.rs b/packages/methods/shield-email/src/lib.rs index 8b13789..7cb92e6 100644 --- a/packages/methods/shield-email/src/lib.rs +++ b/packages/methods/shield-email/src/lib.rs @@ -1 +1,14 @@ +mod actions; +mod method; +mod options; +mod provider; +mod sender; +mod storage; +mod token; +pub use method::*; +pub use options::*; +pub use provider::*; +pub use sender::*; +pub use storage::*; +pub use token::*; diff --git a/packages/methods/shield-email/src/method.rs b/packages/methods/shield-email/src/method.rs new file mode 100644 index 0000000..d6fe707 --- /dev/null +++ b/packages/methods/shield-email/src/method.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use shield::{Action, Method, ShieldError, User, erased_method}; + +use crate::{ + actions::{EmailSignInAction, EmailSignInCallbackAction, EmailSignOutAction}, + options::EmailOptions, + provider::EmailProvider, + storage::EmailStorage, +}; + +pub const EMAIL_METHOD_ID: &str = "email"; + +pub struct EmailMethod { + options: EmailOptions, + storage: Arc>, +} + +impl EmailMethod { + pub fn new + 'static>(options: EmailOptions, storage: S) -> Self { + Self { + options, + storage: Arc::new(storage), + } + } +} + +#[async_trait] +impl Method for EmailMethod { + type Provider = EmailProvider; + type Session = (); + + fn id(&self) -> String { + EMAIL_METHOD_ID.to_owned() + } + + fn actions(&self) -> Vec>> { + vec![ + Box::new(EmailSignInAction::new( + self.options.clone(), + self.storage.clone(), + )), + Box::new(EmailSignInCallbackAction::new( + self.options.clone(), + self.storage.clone(), + )), + Box::new(EmailSignOutAction), + ] + } + + async fn providers(&self) -> Result, ShieldError> { + Ok(vec![EmailProvider]) + } +} + +erased_method!(EmailMethod, ); diff --git a/packages/methods/shield-email/src/options.rs b/packages/methods/shield-email/src/options.rs new file mode 100644 index 0000000..c4b66f3 --- /dev/null +++ b/packages/methods/shield-email/src/options.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use bon::Builder; +use chrono::TimeDelta; +use secrecy::SecretString; + +use crate::sender::Sender; + +#[derive(Builder, Clone)] +#[builder(state_mod(vis = "pub(crate)"))] +pub struct EmailOptions { + #[builder(into)] + pub(crate) secret: SecretString, + + #[builder(with = |sender: impl Sender + 'static| Arc::new(sender))] + pub(crate) sender: Arc, + + #[builder(default = TimeDelta::minutes(10))] + pub(crate) expires_in: TimeDelta, +} diff --git a/packages/methods/shield-email/src/provider.rs b/packages/methods/shield-email/src/provider.rs new file mode 100644 index 0000000..2c48740 --- /dev/null +++ b/packages/methods/shield-email/src/provider.rs @@ -0,0 +1,19 @@ +use shield::Provider; + +use crate::method::EMAIL_METHOD_ID; + +pub struct EmailProvider; + +impl Provider for EmailProvider { + fn method_id(&self) -> String { + EMAIL_METHOD_ID.to_owned() + } + + fn id(&self) -> Option { + None + } + + fn name(&self) -> String { + "Email".to_owned() + } +} diff --git a/packages/methods/shield-email/src/sender.rs b/packages/methods/shield-email/src/sender.rs new file mode 100644 index 0000000..a160c6e --- /dev/null +++ b/packages/methods/shield-email/src/sender.rs @@ -0,0 +1,42 @@ +use async_trait::async_trait; +use chrono::{DateTime, FixedOffset}; +use shield::ShieldError; + +#[async_trait] +pub trait Sender: Send + Sync { + async fn send( + &self, + email: &str, + token: &str, + expires_at: DateTime, + ) -> Result<(), ShieldError>; +} + +#[cfg(feature = "sender-tracing")] +mod tracing { + use async_trait::async_trait; + use chrono::{DateTime, FixedOffset}; + use shield::ShieldError; + use tracing::info; + + use super::Sender; + + pub struct TracingSender; + + #[async_trait] + impl Sender for TracingSender { + async fn send( + &self, + email: &str, + token: &str, + expires_at: DateTime, + ) -> Result<(), ShieldError> { + info!("Email authentication token for `{email}` expires at `{expires_at}`:\n`{token}`"); + + Ok(()) + } + } +} + +#[cfg(feature = "sender-tracing")] +pub use tracing::*; diff --git a/packages/methods/shield-email/src/storage.rs b/packages/methods/shield-email/src/storage.rs new file mode 100644 index 0000000..e317321 --- /dev/null +++ b/packages/methods/shield-email/src/storage.rs @@ -0,0 +1,23 @@ +use async_trait::async_trait; + +use shield::{Storage, StorageError, User}; + +use crate::token::{CreateEmailAuthToken, EmailAuthToken}; + +#[async_trait] +pub trait EmailStorage: Storage + Sync { + async fn email_auth_token( + &self, + email: &str, + token: &str, + ) -> Result, StorageError>; + + async fn create_email_auth_token( + &self, + email_auth_token: CreateEmailAuthToken, + ) -> Result; + + async fn delete_email_auth_token(&self, email_auth_token_id: &str) -> Result<(), StorageError>; + + async fn delete_expired_email_auth_tokens(&self) -> Result<(), StorageError>; +} diff --git a/packages/methods/shield-email/src/token.rs b/packages/methods/shield-email/src/token.rs new file mode 100644 index 0000000..82fb9f7 --- /dev/null +++ b/packages/methods/shield-email/src/token.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, FixedOffset}; +use secrecy::{ExposeSecret, SecretString}; +use sha3::{Digest, Sha3_256}; + +#[derive(Clone, Debug)] +pub struct EmailAuthToken { + pub id: String, + pub email: String, + pub token: String, + pub expired_at: DateTime, +} + +#[derive(Clone, Debug)] +pub struct CreateEmailAuthToken { + pub email: String, + pub token: String, + pub expired_at: DateTime, +} + +pub(crate) fn hash_token(token: &str, secret: &SecretString) -> String { + hex::encode( + Sha3_256::new() + .chain_update(token) + .chain_update(secret.expose_secret()) + .finalize(), + ) +} diff --git a/packages/storage/shield-memory/Cargo.toml b/packages/storage/shield-memory/Cargo.toml index e962971..27dd36b 100644 --- a/packages/storage/shield-memory/Cargo.toml +++ b/packages/storage/shield-memory/Cargo.toml @@ -11,25 +11,23 @@ version.workspace = true [features] default = [] all-methods = [ - # "method-credentials", - # "method-email", + "method-email", "method-oauth", # "method-webauthn", "method-oidc", ] -# method-credentials = ["dep:shield-credentials"] -# method-email = ["dep:shield-email"] +method-email = ["dep:chrono", "dep:shield-email"] method-oauth = ["dep:shield-oauth"] method-oidc = ["dep:shield-oidc"] +# method-webauthn = ["dep:shield-webauthn"] [dependencies] async-trait.workspace = true +chrono = { workspace = true, optional = true } serde.workspace = true shield.workspace = true -# shield-credentials = { workspace = true, optional = true } -# shield-email = { workspace = true, optional = true } +shield-email = { workspace = true, optional = true } shield-oauth = { workspace = true, optional = true } shield-oidc = { workspace = true, optional = true } # shield-webauthn = { workspace = true, optional = true } uuid = { workspace = true, features = ["v4"] } -# method-webauthn = ["dep:shield-webauthn"] diff --git a/packages/storage/shield-memory/src/lib.rs b/packages/storage/shield-memory/src/lib.rs index d449641..7ede938 100644 --- a/packages/storage/shield-memory/src/lib.rs +++ b/packages/storage/shield-memory/src/lib.rs @@ -1,4 +1,4 @@ -mod providers; +mod methods; mod storage; mod user; diff --git a/packages/storage/shield-memory/src/providers.rs b/packages/storage/shield-memory/src/methods.rs similarity index 66% rename from packages/storage/shield-memory/src/providers.rs rename to packages/storage/shield-memory/src/methods.rs index 6408e6b..10a02e7 100644 --- a/packages/storage/shield-memory/src/providers.rs +++ b/packages/storage/shield-memory/src/methods.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "method-email")] +pub mod email; #[cfg(feature = "method-oauth")] pub mod oauth; #[cfg(feature = "method-oidc")] diff --git a/packages/storage/shield-memory/src/methods/email.rs b/packages/storage/shield-memory/src/methods/email.rs new file mode 100644 index 0000000..0991737 --- /dev/null +++ b/packages/storage/shield-memory/src/methods/email.rs @@ -0,0 +1,78 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::Utc; +use shield::StorageError; +use shield_email::{CreateEmailAuthToken, EmailAuthToken, EmailStorage}; +use uuid::Uuid; + +use crate::{storage::MemoryStorage, user::User}; + +#[derive(Clone, Debug, Default)] +pub struct EmailMemoryStorage { + email_auth_tokens: Arc>>, +} + +#[async_trait] +impl EmailStorage for MemoryStorage { + async fn email_auth_token( + &self, + email: &str, + token: &str, + ) -> Result, StorageError> { + Ok(self + .email + .email_auth_tokens + .lock() + .map_err(|err| StorageError::Engine(err.to_string()))? + .iter() + .find(|email_auth_token| { + email_auth_token.email == email + && email_auth_token.token == token + && email_auth_token.expired_at < Utc::now() + }) + .cloned()) + } + + async fn create_email_auth_token( + &self, + email_auth_token: CreateEmailAuthToken, + ) -> Result { + let email_auth_token = EmailAuthToken { + id: Uuid::new_v4().to_string(), + email: email_auth_token.email, + token: email_auth_token.token, + expired_at: email_auth_token.expired_at, + }; + + self.email + .email_auth_tokens + .lock() + .map_err(|err| StorageError::Engine(err.to_string()))? + .push(email_auth_token.clone()); + + Ok(email_auth_token) + } + + async fn delete_email_auth_token(&self, email_auth_token_id: &str) -> Result<(), StorageError> { + self.email + .email_auth_tokens + .lock() + .map_err(|err| StorageError::Engine(err.to_string()))? + .retain(|email_auth_token| email_auth_token.id != email_auth_token_id); + + Ok(()) + } + + async fn delete_expired_email_auth_tokens(&self) -> Result<(), StorageError> { + let now = Utc::now(); + + self.email + .email_auth_tokens + .lock() + .map_err(|err| StorageError::Engine(err.to_string()))? + .retain(|email_auth_token| email_auth_token.expired_at > now); + + Ok(()) + } +} diff --git a/packages/storage/shield-memory/src/providers/oauth.rs b/packages/storage/shield-memory/src/methods/oauth.rs similarity index 100% rename from packages/storage/shield-memory/src/providers/oauth.rs rename to packages/storage/shield-memory/src/methods/oauth.rs diff --git a/packages/storage/shield-memory/src/providers/oidc.rs b/packages/storage/shield-memory/src/methods/oidc.rs similarity index 100% rename from packages/storage/shield-memory/src/providers/oidc.rs rename to packages/storage/shield-memory/src/methods/oidc.rs diff --git a/packages/storage/shield-memory/src/storage.rs b/packages/storage/shield-memory/src/storage.rs index c7d2bc9..b6745e1 100644 --- a/packages/storage/shield-memory/src/storage.rs +++ b/packages/storage/shield-memory/src/storage.rs @@ -13,10 +13,12 @@ pub const MEMORY_STORAGE_ID: &str = "memory"; #[derive(Clone, Debug, Default)] pub struct MemoryStorage { pub(crate) users: Arc>>, + #[cfg(feature = "method-email")] + pub(crate) email: crate::methods::email::EmailMemoryStorage, #[cfg(feature = "method-oauth")] - pub(crate) oauth: crate::providers::oauth::OauthMemoryStorage, + pub(crate) oauth: crate::methods::oauth::OauthMemoryStorage, #[cfg(feature = "method-oidc")] - pub(crate) oidc: crate::providers::oidc::OidcMemoryStorage, + pub(crate) oidc: crate::methods::oidc::OidcMemoryStorage, } impl MemoryStorage { diff --git a/packages/storage/shield-sea-orm/Cargo.toml b/packages/storage/shield-sea-orm/Cargo.toml index fc5d215..dab6ed9 100644 --- a/packages/storage/shield-sea-orm/Cargo.toml +++ b/packages/storage/shield-sea-orm/Cargo.toml @@ -12,16 +12,12 @@ version.workspace = true default = [] entity = [] all-methods = [ - # "method-credentials", "method-email", "method-oauth", # "method-webauthn", "method-oidc", ] -# method-credentials = ["dep:shield-credentials"] -# method-email = ["dep:shield-email"] -# method-credentials = [] -method-email = [] +method-email = ["dep:shield-email"] method-oauth = ["dep:shield-oauth"] method-oidc = ["dep:shield-oidc"] # method-webauthn = ["dep:shield-webauthn"] @@ -36,8 +32,7 @@ secrecy.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true shield.workspace = true -# shield-credentials = { workspace = true, optional = true } -# shield-email = { workspace = true, optional = true } +shield-email = { workspace = true, optional = true } shield-oauth = { workspace = true, optional = true } shield-oidc = { workspace = true, optional = true } # shield-webauthn = { workspace = true, optional = true } diff --git a/packages/storage/shield-sea-orm/src/methods.rs b/packages/storage/shield-sea-orm/src/methods.rs index 6408e6b..10a02e7 100644 --- a/packages/storage/shield-sea-orm/src/methods.rs +++ b/packages/storage/shield-sea-orm/src/methods.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "method-email")] +pub mod email; #[cfg(feature = "method-oauth")] pub mod oauth; #[cfg(feature = "method-oidc")] diff --git a/packages/storage/shield-sea-orm/src/methods/email.rs b/packages/storage/shield-sea-orm/src/methods/email.rs new file mode 100644 index 0000000..f607666 --- /dev/null +++ b/packages/storage/shield-sea-orm/src/methods/email.rs @@ -0,0 +1,71 @@ +use async_trait::async_trait; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter}; +use shield::StorageError; +use shield_email::{CreateEmailAuthToken, EmailAuthToken, EmailStorage}; + +use crate::{entities::email_auth_token, storage::SeaOrmStorage, user::User}; + +#[async_trait] +impl EmailStorage for SeaOrmStorage { + async fn email_auth_token( + &self, + email: &str, + token: &str, + ) -> Result, StorageError> { + email_auth_token::Entity::find() + .filter(email_auth_token::Column::Email.eq(email)) + .filter(email_auth_token::Column::Token.eq(token)) + .filter(email_auth_token::Column::ExpiredAt.lte(Utc::now())) + .one(&self.database) + .await + .map_err(|err| StorageError::Engine(err.to_string())) + .map(|email_auth_token| email_auth_token.map(EmailAuthToken::from)) + } + + async fn create_email_auth_token( + &self, + email_auth_token: CreateEmailAuthToken, + ) -> Result { + let active_model = email_auth_token::ActiveModel { + email: ActiveValue::Set(email_auth_token.email), + token: ActiveValue::Set(email_auth_token.token), + expired_at: ActiveValue::Set(email_auth_token.expired_at), + ..Default::default() + }; + + active_model + .insert(&self.database) + .await + .map_err(|err| StorageError::Engine(err.to_string())) + .map(EmailAuthToken::from) + } + + async fn delete_email_auth_token(&self, email_auth_token_id: &str) -> Result<(), StorageError> { + email_auth_token::Entity::delete_by_id(Self::parse_uuid(email_auth_token_id)?) + .exec(&self.database) + .await + .map_err(|err| StorageError::Engine(err.to_string())) + .map(|_| ()) + } + + async fn delete_expired_email_auth_tokens(&self) -> Result<(), StorageError> { + email_auth_token::Entity::delete_many() + .filter(email_auth_token::Column::ExpiredAt.lte(Utc::now())) + .exec(&self.database) + .await + .map_err(|err| StorageError::Engine(err.to_string())) + .map(|_| ()) + } +} + +impl From for EmailAuthToken { + fn from(value: email_auth_token::Model) -> Self { + EmailAuthToken { + id: value.id.to_string(), + email: value.email, + token: value.token, + expired_at: value.expired_at, + } + } +} diff --git a/packages/storage/shield-sea-orm/src/migrations/core/m20241210_203135_create_user.rs b/packages/storage/shield-sea-orm/src/migrations/core/m20241210_203135_create_user.rs index 29c8a11..eba756d 100644 --- a/packages/storage/shield-sea-orm/src/migrations/core/m20241210_203135_create_user.rs +++ b/packages/storage/shield-sea-orm/src/migrations/core/m20241210_203135_create_user.rs @@ -56,7 +56,7 @@ impl MigrationTrait for Migration { .not_null() .default(false), ) - .col(ColumnDef::new(EmailAddress::VerificationToken).string_len(32)) + .col(ColumnDef::new(EmailAddress::VerificationToken).string_len(64)) .col( ColumnDef::new(EmailAddress::VerificationTokenExpiredAt) .timestamp_with_time_zone(), @@ -107,7 +107,7 @@ impl MigrationTrait for Migration { .not_null() .default(false), ) - .col(ColumnDef::new(EmailAddress::VerificationToken).string_len(32)) + .col(ColumnDef::new(EmailAddress::VerificationToken).string_len(64)) .col( ColumnDef::new(EmailAddress::VerificationTokenExpiredAt) .timestamp_with_time_zone(), diff --git a/packages/styles/shield-bootstrap/src/dioxus/form.rs b/packages/styles/shield-bootstrap/src/dioxus/form.rs index 37dc948..ea9a86c 100644 --- a/packages/styles/shield-bootstrap/src/dioxus/form.rs +++ b/packages/styles/shield-bootstrap/src/dioxus/form.rs @@ -49,7 +49,7 @@ pub fn Form(props: FormProps) -> Element { // TODO: Handle error. if let Ok(response) = result { match response { - ResponseType::Default => todo!("default response"), + ResponseType::Default => {}, ResponseType::Redirect(to) => { navigator.push(to); }, diff --git a/packages/styles/shield-bootstrap/src/dioxus/input.rs b/packages/styles/shield-bootstrap/src/dioxus/input.rs index c2043e8..6c800f1 100644 --- a/packages/styles/shield-bootstrap/src/dioxus/input.rs +++ b/packages/styles/shield-bootstrap/src/dioxus/input.rs @@ -27,8 +27,8 @@ pub fn FormInput(props: FormInputProps) -> Element { name: props.input.name, type: props.input.r#type.as_str(), value: props.input.value.map(|value| match value { - InputValue::Origin => todo!("origin"), - InputValue::Query {key} => todo!("query parameter `{key}`"), + InputValue::Origin => "TODO: origin".to_owned(), + InputValue::Query {key} => format!("TODO: query param {key}"), InputValue::String { value } => value.clone(), }), placeholder: props.input.label,