diff --git a/Cargo.lock b/Cargo.lock index e04b255..0ca5eee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,17 @@ dependencies = [ "syn 1.0.107", ] +[[package]] +name = "actix-request-identifier" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f620de7c806297b88cf39da5ef4293a59f7dc219a9838433e83238e53a9c7057" +dependencies = [ + "actix-web", + "futures", + "uuid", +] + [[package]] name = "actix-router" version = "0.5.1" @@ -1210,6 +1221,7 @@ checksum = "13e2792b0ff0340399d58445b88fd9770e3489eff258a4cbc1523418f12abf84" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -1232,6 +1244,17 @@ version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec90ff4d0fe1f57d600049061dc6bb68ed03c7d2fbd697274c41805dcb3f8608" +[[package]] +name = "futures-executor" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8de0a35a6ab97ec8869e32a2473f4b1324459e14c29275d14b10cb1fd19b50e" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-intrusive" version = "0.4.2" @@ -1264,6 +1287,17 @@ dependencies = [ "waker-fn", ] +[[package]] +name = "futures-macro" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a73af87da33b5acf53acfebdc339fe592ecf5357ac7c0a7734ab9d8c876a70" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.107", +] + [[package]] name = "futures-sink" version = "0.3.26" @@ -1285,6 +1319,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -3336,6 +3371,7 @@ dependencies = [ name = "trifid-api" version = "0.1.0" dependencies = [ + "actix-request-identifier", "actix-web", "hex", "log", @@ -3467,6 +3503,7 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79" dependencies = [ + "getrandom 0.2.8", "serde", ] diff --git a/trifid-api/Cargo.toml b/trifid-api/Cargo.toml index c351acc..ab1f364 100644 --- a/trifid-api/Cargo.toml +++ b/trifid-api/Cargo.toml @@ -6,7 +6,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -actix-web = "4" # Web framework +actix-web = "4" # Web framework +actix-request-identifier = "4" # Web framework serde = { version = "1", features = ["derive"] } # Serialization and deserialization diff --git a/trifid-api/src/config.rs b/trifid-api/src/config.rs index fdb00c5..536a5d8 100644 --- a/trifid-api/src/config.rs +++ b/trifid-api/src/config.rs @@ -25,7 +25,8 @@ pub static CONFIG: Lazy = Lazy::new(|| { #[derive(Serialize, Debug, Deserialize)] pub struct TrifidConfig { pub database: TrifidConfigDatabase, - pub server: TrifidConfigServer + pub server: TrifidConfigServer, + pub tokens: TrifidConfigTokens } #[derive(Serialize, Deserialize, Debug)] @@ -53,8 +54,15 @@ pub struct TrifidConfigServer { pub bind: SocketAddr } +#[derive(Serialize, Deserialize, Debug)] +pub struct TrifidConfigTokens { + #[serde(default = "magic_link_expiry_time")] + pub magic_link_expiry_time_seconds: u64 +} + fn max_connections_default() -> u32 { 100 } fn min_connections_default() -> u32 { 5 } fn time_defaults() -> u64 { 8 } fn sqlx_logging_default() -> bool { true } -fn socketaddr_8080() -> SocketAddr { SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::from([0, 0, 0, 0]), 8080)) } \ No newline at end of file +fn socketaddr_8080() -> SocketAddr { SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::from([0, 0, 0, 0]), 8080)) } +fn magic_link_expiry_time() -> u64 { 3600 } \ No newline at end of file diff --git a/trifid-api/src/main.rs b/trifid-api/src/main.rs index 388d5f2..59694e7 100644 --- a/trifid-api/src/main.rs +++ b/trifid-api/src/main.rs @@ -1,5 +1,6 @@ use std::error::Error; use std::time::Duration; +use actix_request_identifier::RequestIdentifier; use actix_web::{App, HttpResponse, HttpServer, post, web::{Data, Json, JsonConfig}}; use log::{error, info, Level}; use sea_orm::{ConnectOptions, Database, DatabaseConnection}; @@ -7,10 +8,17 @@ use serde::{Serialize, Deserialize}; use trifid_api_migration::{Migrator, MigratorTrait}; use crate::config::CONFIG; use crate::error::{APIError, APIErrorsResponse}; +use crate::tokens::random_id_no_id; pub mod config; pub mod routes; pub mod error; +pub mod tokens; +pub mod timers; + +pub struct AppState { + pub conn: DatabaseConnection +} #[actix_web::main] async fn main() -> Result<(), Box> { @@ -33,7 +41,9 @@ async fn main() -> Result<(), Box> { info!("Performing database migration..."); Migrator::up(&db, None).await?; - let data = Data::new(db); + let data = Data::new(AppState { + conn: db + }); HttpServer::new(move || { App::new() @@ -49,6 +59,7 @@ async fn main() -> Result<(), Box> { }) ).into() })) + .wrap(RequestIdentifier::with_generator(random_id_no_id)) }).bind(CONFIG.server.bind)?.run().await?; Ok(()) diff --git a/trifid-api/src/routes/auth/magic_link.rs b/trifid-api/src/routes/auth/magic_link.rs new file mode 100644 index 0000000..6c9bd4c --- /dev/null +++ b/trifid-api/src/routes/auth/magic_link.rs @@ -0,0 +1,90 @@ +use actix_web::{HttpResponse, post}; +use actix_web::web::{Data, Json}; +use log::error; +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter}; +use serde::{Serialize, Deserialize}; +use trifid_api_entities::user::Entity as UserEntity; +use trifid_api_entities::user; +use crate::AppState; +use crate::config::CONFIG; +use crate::error::{APIError, APIErrorsResponse}; +use crate::timers::expires_in_seconds; +use crate::tokens::random_token; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MagicLinkRequest { + pub email: String +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MagicLinkResponse { + pub data: MagicLinkResponseData, + pub metadata: MagicLinkResponseMetadata +} +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MagicLinkResponseData {} +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MagicLinkResponseMetadata {} + +#[post("/v1/auth/magic-link")] +pub async fn magic_link_request(data: Data, req: Json) -> HttpResponse { + let user: Option = match UserEntity::find().filter(user::Column::Email.eq(&req.email)).one(&data.conn).await { + Ok(r) => r, + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error with the database request, please try again later.".to_string(), + path: None, + } + ], + }) + } + }; + + let user = match user { + Some(u) => u, + None => { + return HttpResponse::Unauthorized().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_USER_DOES_NOT_EXIST".to_string(), + message: "That user does not exist.".to_string(), + path: None, + } + ], + }) + } + }; + + let model = trifid_api_entities::magic_link::Model { + id: random_token("ml"), + user: user.id, + expires_on: expires_in_seconds(CONFIG.tokens.magic_link_expiry_time_seconds) as i64, + }; + + let active_model = model.into_active_model(); + + match active_model.insert(&data.conn).await { + Ok(_) => (), + Err(e) => { + error!("database error: {}", e); + return HttpResponse::InternalServerError().json(APIErrorsResponse { + errors: vec![ + APIError { + code: "ERR_DB_ERROR".to_string(), + message: "There was an error with the database request, please try again later.".to_string(), + path: None, + } + ], + }) + } + } + + HttpResponse::Ok().json(MagicLinkResponse { + data: MagicLinkResponseData {}, + metadata: MagicLinkResponseMetadata {} + }) +} \ No newline at end of file diff --git a/trifid-api/src/routes/auth/mod.rs b/trifid-api/src/routes/auth/mod.rs new file mode 100644 index 0000000..600b7fa --- /dev/null +++ b/trifid-api/src/routes/auth/mod.rs @@ -0,0 +1 @@ +pub mod magic_link; \ No newline at end of file diff --git a/trifid-api/src/routes/mod.rs b/trifid-api/src/routes/mod.rs index e69de29..5696e21 100644 --- a/trifid-api/src/routes/mod.rs +++ b/trifid-api/src/routes/mod.rs @@ -0,0 +1 @@ +pub mod auth; \ No newline at end of file diff --git a/trifid-api/src/timers.rs b/trifid-api/src/timers.rs new file mode 100644 index 0000000..522cd4c --- /dev/null +++ b/trifid-api/src/timers.rs @@ -0,0 +1,9 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub fn expires_in_seconds(seconds: u64) -> u64 { + (SystemTime::now() + Duration::from_secs(seconds)).duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs() +} + +pub fn expired(time: u64) -> bool { + UNIX_EPOCH + Duration::from_secs(time) > SystemTime::now() +} \ No newline at end of file diff --git a/trifid-api/src/tokens.rs b/trifid-api/src/tokens.rs new file mode 100644 index 0000000..a1c2475 --- /dev/null +++ b/trifid-api/src/tokens.rs @@ -0,0 +1,33 @@ +use actix_web::http::header::HeaderValue; +use rand::Rng; + +pub const ID_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +pub const ID_LEN: u32 = 26; + +pub const TOKEN_CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; +pub const TOKEN_LEN: u32 = 43; + +// 26 +// format: [ID]-[26 chars] +pub fn random_id(identifier: &str) -> String { + format!("{}-{}", identifier, random_with_charset(ID_LEN, ID_CHARSET)) +} + +// 26 +// format: [ID]-[26 chars] +pub fn random_id_no_id() -> HeaderValue { + HeaderValue::from_str(&random_with_charset(ID_LEN, ID_CHARSET)).unwrap() +} + +// 43 +// format: [TYPE]-[43 chars] +pub fn random_token(identifier: &str) -> String { + format!("{}-{}", identifier, random_with_charset(TOKEN_LEN, TOKEN_CHARSET)) +} + +fn random_with_charset(len: u32, charset: &[u8]) -> String { + (0..len).map(|_| { + let idx = rand::thread_rng().gen_range(0..charset.len()); + charset[idx] as char + }).collect() +} \ No newline at end of file diff --git a/trifid-api/trifid_api_entities/src/lib.rs b/trifid-api/trifid_api_entities/src/lib.rs index 35e436f..d1b689d 100644 --- a/trifid-api/trifid_api_entities/src/lib.rs +++ b/trifid-api/trifid_api_entities/src/lib.rs @@ -3,3 +3,4 @@ pub mod prelude; pub mod user; +pub mod magic_link; \ No newline at end of file diff --git a/trifid-api/trifid_api_entities/src/magic_link.rs b/trifid-api/trifid_api_entities/src/magic_link.rs new file mode 100644 index 0000000..1707d7e --- /dev/null +++ b/trifid-api/trifid_api_entities/src/magic_link.rs @@ -0,0 +1,32 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.2 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "magic_link")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + pub user: String, + pub expires_on: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::User", + to = "super::user::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/trifid-api/trifid_api_entities/src/prelude.rs b/trifid-api/trifid_api_entities/src/prelude.rs index 5a361f5..527e6e0 100644 --- a/trifid-api/trifid_api_entities/src/prelude.rs +++ b/trifid-api/trifid_api_entities/src/prelude.rs @@ -1,3 +1,4 @@ //! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.2 +pub use super::magic_link::Entity as MagicLink; pub use super::user::Entity as User; diff --git a/trifid-api/trifid_api_entities/src/user.rs b/trifid-api/trifid_api_entities/src/user.rs index ab703b3..91414d6 100644 --- a/trifid-api/trifid_api_entities/src/user.rs +++ b/trifid-api/trifid_api_entities/src/user.rs @@ -7,11 +7,21 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: String, + #[sea_orm(unique)] pub email: String, pub password_hash: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} +pub enum Relation { + #[sea_orm(has_many = "super::magic_link::Entity")] + MagicLink, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MagicLink.def() + } +} impl ActiveModelBehavior for ActiveModel {} diff --git a/trifid-api/trifid_api_migration/src/lib.rs b/trifid-api/trifid_api_migration/src/lib.rs index 372443b..77e9136 100644 --- a/trifid-api/trifid_api_migration/src/lib.rs +++ b/trifid-api/trifid_api_migration/src/lib.rs @@ -3,12 +3,14 @@ pub use sea_orm_migration::prelude::*; pub struct Migrator; pub mod m20230402_162601_create_table_users; +pub mod m20230402_183515_create_table_magic_links; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { vec![ - Box::new(m20230402_162601_create_table_users::Migration) + Box::new(m20230402_162601_create_table_users::Migration), + Box::new(m20230402_183515_create_table_magic_links::Migration), ] } } diff --git a/trifid-api/trifid_api_migration/src/m20230402_162601_create_table_users.rs b/trifid-api/trifid_api_migration/src/m20230402_162601_create_table_users.rs index ce6457d..3b54040 100644 --- a/trifid-api/trifid_api_migration/src/m20230402_162601_create_table_users.rs +++ b/trifid-api/trifid_api_migration/src/m20230402_162601_create_table_users.rs @@ -12,7 +12,7 @@ impl MigrationTrait for Migration { .table(User::Table) .if_not_exists() .col(ColumnDef::new(User::Id).string().not_null().primary_key()) - .col(ColumnDef::new(User::Email).string().not_null()) + .col(ColumnDef::new(User::Email).string().not_null().unique_key()) .col(ColumnDef::new(User::PasswordHash).string().not_null()) .to_owned() ).await @@ -25,7 +25,7 @@ impl MigrationTrait for Migration { /// Learn more at https://docs.rs/sea-query#iden #[derive(Iden)] -enum User { +pub enum User { Table, Id, Email, diff --git a/trifid-api/trifid_api_migration/src/m20230402_183515_create_table_magic_links.rs b/trifid-api/trifid_api_migration/src/m20230402_183515_create_table_magic_links.rs new file mode 100644 index 0000000..43c70df --- /dev/null +++ b/trifid-api/trifid_api_migration/src/m20230402_183515_create_table_magic_links.rs @@ -0,0 +1,41 @@ +use sea_orm_migration::prelude::*; +use crate::m20230402_162601_create_table_users::User; + + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.create_table( + Table::create() + .table(MagicLink::Table) + .if_not_exists() + .col(ColumnDef::new(MagicLink::Id).string().not_null().primary_key()) + .col(ColumnDef::new(MagicLink::User).string().not_null()) + .col(ColumnDef::new(MagicLink::ExpiresOn).big_integer().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk_magiclink_user_users_id") + .from(MagicLink::Table, MagicLink::User) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade) + ).to_owned() + ).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.drop_table(Table::drop().table(MagicLink::Table).to_owned()).await + } +} + +/// Learn more at https://docs.rs/sea-query#iden +#[derive(Iden)] +pub enum MagicLink { + Table, + Id, + User, + ExpiresOn +}