From 589c74cb1d8d0122a9091d50e3c6c7ff3e392dc0 Mon Sep 17 00:00:00 2001 From: Brian Broll Date: Mon, 5 Feb 2024 17:00:43 -0600 Subject: [PATCH 1/6] Add support for magic links. Close #229 --- crates/api-common/src/bson.rs | 10 +- crates/api-common/src/lib.rs | 35 +++ crates/cloud-common/src/lib.rs | 31 +++ crates/cloud/src/app_data/mod.rs | 65 ++++- crates/cloud/src/errors.rs | 6 + crates/cloud/src/login_helper.rs | 133 +++++++++ crates/cloud/src/magic_links/actions.rs | 252 ++++++++++++++++++ .../cloud/src/magic_links/email_template.rs | 102 +++++++ crates/cloud/src/magic_links/mod.rs | 3 + crates/cloud/src/magic_links/routes.rs | 96 +++++++ crates/cloud/src/main.rs | 3 + crates/cloud/src/test_utils.rs | 15 +- crates/cloud/src/users/actions.rs | 181 +------------ crates/cloud/src/users/routes.rs | 5 +- crates/cloud/src/utils.rs | 93 +++++++ 15 files changed, 843 insertions(+), 187 deletions(-) create mode 100644 crates/cloud/src/login_helper.rs create mode 100644 crates/cloud/src/magic_links/actions.rs create mode 100644 crates/cloud/src/magic_links/email_template.rs create mode 100644 crates/cloud/src/magic_links/mod.rs create mode 100644 crates/cloud/src/magic_links/routes.rs diff --git a/crates/api-common/src/bson.rs b/crates/api-common/src/bson.rs index 657502cd..994b3756 100644 --- a/crates/api-common/src/bson.rs +++ b/crates/api-common/src/bson.rs @@ -1,6 +1,6 @@ use crate::{ - oauth, FriendInvite, FriendLinkState, GroupId, InvitationState, LinkedAccount, ProjectId, - PublishState, RoleMetadata, SaveState, ServiceHost, ServiceHostScope, UserRole, + oauth, FriendInvite, FriendLinkState, GroupId, InvitationState, LinkedAccount, MagicLinkId, + ProjectId, PublishState, RoleMetadata, SaveState, ServiceHost, ServiceHostScope, UserRole, }; use bson::{doc, Bson, DateTime}; @@ -159,6 +159,12 @@ impl From for Bson { } } +impl From for Bson { + fn from(id: MagicLinkId) -> Bson { + Bson::String(id.0) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/api-common/src/lib.rs b/crates/api-common/src/lib.rs index ba53f869..4cfacef9 100644 --- a/crates/api-common/src/lib.rs +++ b/crates/api-common/src/lib.rs @@ -746,6 +746,41 @@ pub enum SendMessageTarget { }, } +#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[ts(export)] +pub struct MagicLinkId(String); + +impl MagicLinkId { + pub fn new(id: String) -> Self { + Self(id) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct MagicLinkLoginData { + pub link_id: MagicLinkId, + pub username: String, + #[ts(optional)] + pub client_id: Option, + #[ts(optional)] + pub redirect_uri: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct CreateMagicLinkData { + pub email: String, + #[ts(optional)] + pub redirect_uri: Option, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cloud-common/src/lib.rs b/crates/cloud-common/src/lib.rs index 1267efe3..36906118 100644 --- a/crates/cloud-common/src/lib.rs +++ b/crates/cloud-common/src/lib.rs @@ -813,6 +813,37 @@ pub(crate) fn sha512(text: &str) -> String { hex::encode(hash) } +/// A magic link is used for password-less login. It has no +/// api version since exposing it via the api would be a pretty +/// serious security vulnerability. +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct MagicLink { + pub id: api::MagicLinkId, + pub email: String, + pub created_at: DateTime, +} + +impl MagicLink { + pub fn new(email: String) -> Self { + Self { + id: api::MagicLinkId::new(Uuid::new_v4().to_string()), + email, + created_at: DateTime::now(), + } + } +} + +impl From for Bson { + fn from(link: MagicLink) -> Bson { + Bson::Document(doc! { + "id": link.id, + "email": link.email, + "createdAt": link.created_at, + }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cloud/src/app_data/mod.rs b/crates/cloud/src/app_data/mod.rs index 2d25fae6..8b013d7a 100644 --- a/crates/cloud/src/app_data/mod.rs +++ b/crates/cloud/src/app_data/mod.rs @@ -5,6 +5,8 @@ use crate::common::api::{oauth, NewUser, ProjectId, UserRole}; use crate::friends::actions::FriendActions; use crate::groups::actions::GroupActions; use crate::libraries::actions::LibraryActions; +use crate::login_helper::LoginHelper; +use crate::magic_links::actions::MagicLinkActions; use crate::network::actions::NetworkActions; use crate::oauth::actions::OAuthActions; use crate::projects::ProjectActions; @@ -20,7 +22,7 @@ use log::{error, info, warn}; use lru::LruCache; use mongodb::bson::{doc, Document}; use mongodb::options::{FindOptions, IndexOptions, UpdateOptions}; -use netsblox_cloud_common::api; +use netsblox_cloud_common::{api, MagicLink}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::net::IpAddr; @@ -55,6 +57,7 @@ pub struct AppData { pub(crate) users: Collection, pub(crate) banned_accounts: Collection, friends: Collection, + magic_links: Collection, pub(crate) project_metadata: Collection, pub(crate) libraries: Collection, pub(crate) authorized_services: Collection, @@ -139,6 +142,7 @@ impl AppData { let occupant_invites = db.collection::(&(prefix.to_owned() + "occupantInvites")); let friends = db.collection::(&(prefix.to_owned() + "friends")); + let magic_links = db.collection::(&(prefix.to_owned() + "magicLinks")); let recorded_messages = db.collection::(&(prefix.to_owned() + "recordedMessages")); let network = network.unwrap_or_else(|| { @@ -179,6 +183,7 @@ impl AppData { occupant_invites, password_tokens, friends, + magic_links, mailer, sender, @@ -210,9 +215,8 @@ impl AppData { } // Add database indexes - let index_opts = IndexOptions::builder() - .expire_after(Duration::from_secs(60 * 60)) - .build(); + let one_hour = Duration::from_secs(60 * 60); + let index_opts = IndexOptions::builder().expire_after(one_hour).build(); let occupant_invite_indexes = vec![ IndexModel::builder() .keys(doc! {"createdAt": 1}) @@ -227,9 +231,7 @@ impl AppData { .await .map_err(InternalError::DatabaseConnectionError)?; - let index_opts = IndexOptions::builder() - .expire_after(Duration::from_secs(60 * 60)) - .build(); + let index_opts = IndexOptions::builder().expire_after(one_hour).build(); let token_index = IndexModel::builder() .keys(doc! {"createdAt": 1}) .options(index_opts) @@ -239,6 +241,7 @@ impl AppData { .await .map_err(InternalError::DatabaseConnectionError)?; + let one_week = Duration::from_secs(60 * 60 * 24 * 7); self.project_metadata .create_indexes( vec![ @@ -261,7 +264,7 @@ impl AppData { .keys(doc! { "originTime": 1}) .options( IndexOptions::builder() - .expire_after(Duration::from_secs(60 * 60 * 24 * 7)) + .expire_after(one_week) .partial_filter_expression(doc! {"saveState": SaveState::Transient}) .background(true) .build(), @@ -278,6 +281,16 @@ impl AppData { .await .map_err(InternalError::DatabaseConnectionError)?; + let index_opts = IndexOptions::builder().expire_after(one_hour).build(); + let magic_link_indexes = vec![IndexModel::builder() + .keys(doc! {"createdAt": 1}) + .options(index_opts) + .build()]; + self.magic_links + .create_indexes(magic_link_indexes, None) + .await + .map_err(InternalError::DatabaseConnectionError)?; + self.network.do_send(SetStorage { app_data: self.clone(), }); @@ -521,6 +534,19 @@ impl AppData { Ok(()) } + #[cfg(test)] + pub(crate) async fn insert_magic_links( + &self, + links: &[MagicLink], + ) -> Result<(), InternalError> { + self.magic_links + .insert_many(links, None) + .await + .map_err(InternalError::DatabaseConnectionError)?; + + Ok(()) + } + pub(crate) async fn get_friends(&self, username: &str) -> Result, UserError> { crate::utils::get_friends( &self.users, @@ -561,6 +587,16 @@ impl AppData { ) } + pub(crate) fn as_magic_link_actions(&self) -> MagicLinkActions { + MagicLinkActions::new( + &self.magic_links, + &self.users, + &self.mailer, + &self.sender, + &self.settings.public_url, + ) + } + pub(crate) fn as_collab_invite_actions(&self) -> CollaborationInviteActions { CollaborationInviteActions::new( &self.collab_invites, @@ -595,10 +631,7 @@ impl AppData { password_tokens: &self.password_tokens, metrics: &self.metrics, - project_cache: &self.project_cache, - project_metadata: &self.project_metadata, network: &self.network, - friend_cache: &self.friend_cache, mailer: &self.mailer, @@ -612,6 +645,16 @@ impl AppData { HostActions::new(&self.authorized_services) } + pub(crate) fn as_login_helper(&self) -> LoginHelper { + LoginHelper::new( + &self.network, + &self.metrics, + &self.project_metadata, + &self.project_cache, + &self.banned_accounts, + ) + } + #[cfg(test)] pub(crate) async fn drop_all_data(&self) -> Result<(), InternalError> { let bucket = &self.settings.s3.bucket; diff --git a/crates/cloud/src/errors.rs b/crates/cloud/src/errors.rs index f1526bc7..74f0e1cf 100644 --- a/crates/cloud/src/errors.rs +++ b/crates/cloud/src/errors.rs @@ -37,6 +37,10 @@ pub enum UserError { MissingUrlOrXmlError, #[display(fmt = "Password reset link already sent. Only 1 can be sent per hour.")] PasswordResetLinkSentError, + #[display(fmt = "Magic link already sent. Only 1 can be sent per hour.")] + MagicLinkSentError, + #[display(fmt = "Magic link not found or no longer active.")] + MagicLinkNotFoundError, #[display(fmt = "Network trace not found.")] NetworkTraceNotFoundError, #[display(fmt = "Library not found.")] @@ -183,6 +187,7 @@ impl error::ResponseError for UserError { | Self::ServiceHostNotFoundError | Self::RoleNotFoundError | Self::InviteNotFoundError + | Self::MagicLinkNotFoundError | Self::UserNotFoundError | Self::FriendNotFoundError | Self::OAuthClientNotFoundError @@ -199,6 +204,7 @@ impl error::ResponseError for UserError { | Self::InvalidServiceHostIDError | Self::AccountAlreadyLinkedError | Self::PasswordResetLinkSentError + | Self::MagicLinkSentError | Self::InvalidAccountTypeError | Self::TorAddressError | Self::OperaVPNError diff --git a/crates/cloud/src/login_helper.rs b/crates/cloud/src/login_helper.rs new file mode 100644 index 00000000..f27d4c6b --- /dev/null +++ b/crates/cloud/src/login_helper.rs @@ -0,0 +1,133 @@ +use std::sync::{Arc, RwLock}; + +use actix::Addr; +use actix_session::Session; +use lru::LruCache; +use mongodb::{ + bson::{doc, DateTime}, + options::ReturnDocument, + Collection, +}; +use netsblox_cloud_common::{ + api::{self, ClientId}, + BannedAccount, ProjectMetadata, +}; + +use crate::{ + app_data::metrics, + errors::{InternalError, UserError}, + network::topology::{self, TopologyActor}, + utils, +}; + +/// This is a helper file containing logic reused across multiple resources. +/// Unlike `utils`, this is expected to be stateful and contains its own +/// references to collections, etc. + +pub(crate) struct LoginHelper<'a> { + network: &'a Addr, + metrics: &'a metrics::Metrics, + project_metadata: &'a Collection, + project_cache: &'a Arc>>, + + banned_accounts: &'a Collection, +} + +impl<'a> LoginHelper<'a> { + pub(crate) fn new( + network: &'a Addr, + metrics: &'a metrics::Metrics, + project_metadata: &'a Collection, + project_cache: &'a Arc>>, + banned_accounts: &'a Collection, + ) -> Self { + Self { + network, + metrics, + project_metadata, + project_cache, + banned_accounts, + } + } + + /// Login as the given user for the current session + pub(crate) async fn login( + &self, + session: Session, + user: &api::User, + client_id: Option, + ) -> Result<(), UserError> { + // TODO: make sure the user isn't banned + let query = doc! {"$or": [ + {"username": &user.username}, + {"email": &user.email}, + ]}; + + if self + .banned_accounts + .find_one(query.clone(), None) + .await + .map_err(InternalError::DatabaseConnectionError)? + .is_some() + { + return Err(UserError::BannedUserError); + } + + // update ownership, if applicable + if let Some(client_id) = client_id { + self.update_ownership(&client_id, &user.username).await?; + self.network.do_send(topology::SetClientUsername { + id: client_id, + username: Some(user.username.clone()), + }); + } + self.metrics.record_login(); + + session.insert("username", &user.username).unwrap(); + + Ok(()) + } + + async fn update_ownership( + &self, + client_id: &api::ClientId, + username: &str, + ) -> Result<(), UserError> { + // Update ownership of current project + if !client_id.as_str().starts_with('_') { + return Err(UserError::InvalidClientIdError); + } + + let query = doc! {"owner": client_id.as_str()}; + if let Some(metadata) = self + .project_metadata + .find_one(query.clone(), None) + .await + .map_err(InternalError::DatabaseConnectionError)? + { + // No project will be found for non-NetsBlox clients such as PyBlox + let name = + utils::get_valid_project_name(self.project_metadata, username, &metadata.name) + .await?; + let update = doc! { + "$set": { + "owner": username, + "name": name, + "updated": DateTime::now(), + } + }; + let options = mongodb::options::FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::After) + .build(); + let new_metadata = self + .project_metadata + .find_one_and_update(query, update, Some(options)) + .await + .map_err(InternalError::DatabaseConnectionError)? + .ok_or(UserError::ProjectNotFoundError)?; + + utils::on_room_changed(self.network, self.project_cache, new_metadata); + } + Ok(()) + } +} diff --git a/crates/cloud/src/magic_links/actions.rs b/crates/cloud/src/magic_links/actions.rs new file mode 100644 index 00000000..a50d79af --- /dev/null +++ b/crates/cloud/src/magic_links/actions.rs @@ -0,0 +1,252 @@ +use crate::utils; +use lettre::{ + message::{Mailbox, MultiPart}, + Address, Message, SmtpTransport, +}; +use mongodb::{bson::doc, options::ReturnDocument, Collection}; +use netsblox_cloud_common::api; +use netsblox_cloud_common::{MagicLink, User}; +use nonempty::NonEmpty; + +use crate::errors::{InternalError, UserError}; + +use super::email_template; + +pub(crate) struct MagicLinkActions<'a> { + links: &'a Collection, + users: &'a Collection, + + // email support + mailer: &'a SmtpTransport, + sender: &'a Mailbox, + public_url: &'a String, +} + +impl<'a> MagicLinkActions<'a> { + pub(crate) fn new( + links: &'a Collection, + users: &'a Collection, + mailer: &'a SmtpTransport, + sender: &'a Mailbox, + public_url: &'a String, + ) -> Self { + Self { + links, + users, + mailer, + sender, + public_url, + } + } + + /// Make a magic link for the given email address. It can be used to login as any user + /// registered to the given address + pub(crate) async fn create_link( + &self, + data: &api::CreateMagicLinkData, + ) -> Result<(), UserError> { + let usernames: NonEmpty = utils::find_usernames(self.users, &data.email).await?; + + let query = doc! {"email": &data.email}; + let link = MagicLink::new(data.email.clone()); + let update = doc! {"$setOnInsert": &link}; + let options = mongodb::options::FindOneAndUpdateOptions::builder() + .return_document(ReturnDocument::Before) + .upsert(true) + .build(); + let existing = self + .links + .find_one_and_update(query, update, options) + .await + .map_err(InternalError::DatabaseConnectionError)?; + + if existing.is_some() { + Err(UserError::MagicLinkSentError) + } else { + let email = MagicLinkEmail { + sender: self.sender.clone(), + public_url: self.public_url.clone(), + redirect_uri: data.redirect_uri.clone(), + link, + usernames, + }; + utils::send_email(self.mailer, email)?; + + Ok(()) + } + } + + pub(crate) async fn login( + &self, + username: &str, + link_id: &api::MagicLinkId, + ) -> Result { + let query = doc! {"id": &link_id}; + let link = self + .links + .find_one(query, None) + .await + .map_err(InternalError::DatabaseConnectionError)? + .ok_or(UserError::MagicLinkNotFoundError)?; + + let query = doc! {"username": username, "email": &link.email}; + + self.users + .find_one(query, None) + .await + .map_err(InternalError::DatabaseConnectionError)? + .ok_or(UserError::UserNotFoundError) + .map(|user| user.into()) + } +} + +struct MagicLinkEmail { + sender: Mailbox, + link: MagicLink, + usernames: NonEmpty, + public_url: String, + redirect_uri: Option, +} + +impl MagicLinkEmail { + fn render(&self) -> MultiPart { + email_template::magic_link_email( + &self.public_url, + &self.usernames, + &self.link.id, + self.redirect_uri.clone(), + ) + } +} + +impl TryFrom for lettre::Message { + type Error = UserError; + + fn try_from(data: MagicLinkEmail) -> Result { + let subject = "Magic sign-in link for NetsBlox"; + let body = data.render(); + let to_email = data.link.email; + let message = Message::builder() + .from(data.sender) + .to(Mailbox::new( + None, + to_email + .parse::
() + .map_err(|_err| UserError::InvalidEmailAddress)?, + )) + .subject(subject.to_string()) + .date_now() + .multipart(body) + .map_err(|_err| InternalError::EmailBuildError)?; + + Ok(message) + } +} + +#[cfg(test)] +mod tests { + use crate::test_utils; + use netsblox_cloud_common::{api, User}; + + use super::*; + + #[actix_web::test] + #[ignore] // TODO: figure out how to test with the email function... + async fn test_create_link() { + let user: User = api::NewUser { + username: "user".into(), + email: "user@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + + test_utils::setup() + .with_users(&[user.clone()]) + .run(|app_data| async move { + let actions = app_data.as_magic_link_actions(); + + let data = api::CreateMagicLinkData { + email: user.email.clone(), + redirect_uri: None, + }; + actions.create_link(&data).await.unwrap(); + let query = doc! {"email": &user.email}; + let link_res = actions.links.find_one(query, None).await.unwrap(); + + assert!(link_res.is_some()); + }) + .await; + } + + #[actix_web::test] + async fn test_create_link_duplicate() { + let user: User = api::NewUser { + username: "user".into(), + email: "user@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + + let l1 = MagicLink::new(user.email.clone()); + + test_utils::setup() + .with_users(&[user.clone()]) + .with_magic_links(&[l1]) + .run(|app_data| async move { + let actions = app_data.as_magic_link_actions(); + + let data = api::CreateMagicLinkData { + email: user.email.clone(), + redirect_uri: None, + }; + let res = actions.create_link(&data).await; + assert!(matches!(res, Err(UserError::MagicLinkSentError))) + }) + .await; + } + + #[actix_web::test] + async fn test_create_link_email_not_found() { + test_utils::setup() + .run(|app_data| async move { + let actions = app_data.as_magic_link_actions(); + + let data = api::CreateMagicLinkData { + email: "IDon'tExist!".into(), + redirect_uri: None, + }; + let res = actions.create_link(&data).await; + assert!(matches!(res, Err(UserError::UserNotFoundError))); + }) + .await; + } + + #[actix_web::test] + async fn test_login() { + let user: User = api::NewUser { + username: "user".into(), + email: "user@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + + let l1 = MagicLink::new(user.email.clone()); + + test_utils::setup() + .with_magic_links(&[l1.clone()]) + .with_users(&[user.clone()]) + .run(|app_data| async move { + let actions = app_data.as_magic_link_actions(); + + let data = actions.login(&user.username, &l1.id).await.unwrap(); + assert_eq!(data.username, user.username); + }) + .await; + } +} diff --git a/crates/cloud/src/magic_links/email_template.rs b/crates/cloud/src/magic_links/email_template.rs new file mode 100644 index 00000000..527d5d67 --- /dev/null +++ b/crates/cloud/src/magic_links/email_template.rs @@ -0,0 +1,102 @@ +use lettre::message::MultiPart; +use netsblox_cloud_common::api::MagicLinkId; +use nonempty::NonEmpty; + +pub(crate) fn magic_link_email( + cloud_url: &str, + usernames: &NonEmpty, + link_id: &MagicLinkId, + redirect_uri: Option, +) -> MultiPart { + let uri_param = redirect_uri + .map(|uri| format!("&redirectUri={}", uri)) + .unwrap_or_default(); + + let make_url = |username| { + format!( + "{cloud_url}/magic-links/login?username={username}&linkId={link_id}{uri_param}", + uri_param = uri_param, + cloud_url = cloud_url, + link_id = &link_id.as_str(), + username = username + ) + }; + + let (txt, html) = if usernames.len() == 1 { + let url = make_url(usernames.first()); + let html = format!( + "

Magic sign-in link for NetsBlox

+

+ Please click here to \"auto-magically\" sign-in to NetsBlox as {name}. +
+
+ + Cheers, + the NetsBlox team

", + name=usernames.first(), + url=url + ); + + let txt = format!( + "Magic sign-in link for NetsBlox + + Please click the link below to \"auto-magically\" sign-in to NetsBlox as {name}. + + {url} + + Cheers, + the NetsBlox team", + url = url, + name = usernames.first(), + ); + + (txt, html) + } else { + let login_links = usernames.iter().fold(String::new(), |prev_text, name| { + format!( + "{name}
{prev_text}", + name = name, + url = make_url(name), + prev_text = prev_text + ) + }); + let html = format!( + "

Magic sign-in link for NetsBlox

+

+ Please select an account below to \"auto-magically\" sign-in: +
+
+ + {login_links} + +
+
+ Cheers, + the NetsBlox team

" + ); + + let url_text = usernames.iter().fold(String::new(), |text, name| { + format!( + "{name}: {link}\n{prev_text}", + name = name, + link = make_url(name), + prev_text = text + ) + }); + let txt = format!( + "Magic sign-in link for NetsBlox + + Please select an account below to \"auto-magically\" sign-in: + + {urlText} + + Cheers, + the NetsBlox team", + urlText = url_text, + ); + + (txt, html) + }; + + MultiPart::alternative_plain_html(txt, html) +} diff --git a/crates/cloud/src/magic_links/mod.rs b/crates/cloud/src/magic_links/mod.rs new file mode 100644 index 00000000..f4f6a647 --- /dev/null +++ b/crates/cloud/src/magic_links/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod actions; +mod email_template; +pub(crate) mod routes; diff --git a/crates/cloud/src/magic_links/routes.rs b/crates/cloud/src/magic_links/routes.rs new file mode 100644 index 00000000..e33a579b --- /dev/null +++ b/crates/cloud/src/magic_links/routes.rs @@ -0,0 +1,96 @@ +use crate::app_data::AppData; +use crate::errors::UserError; +use actix_session::Session; +use actix_web::{get, post, HttpRequest}; +use actix_web::{web, HttpResponse}; + +use crate::common::api; + +#[post("/")] +async fn create_link( + app: web::Data, + body: web::Json, +) -> Result { + let actions = app.as_magic_link_actions(); + + let data = body.into_inner(); + actions.create_link(&data).await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[get("/login")] +async fn login( + app: web::Data, + req: HttpRequest, + session: Session, + params: web::Query, +) -> Result { + let req_addr = req.peer_addr().map(|addr| addr.ip()); + if let Some(addr) = req_addr { + app.ensure_not_tor_ip(&addr).await?; + } + + let data = params.into_inner(); + let actions = app.as_magic_link_actions(); + let user = actions.login(&data.username, &data.link_id).await?; + + let helper = app.as_login_helper(); + helper.login(session, &user, data.client_id).await?; + + if let Some(url) = data.redirect_uri { + Ok(HttpResponse::Found() + .insert_header(("Location", url.as_str())) + .finish()) + } else { + Ok(HttpResponse::Ok().body(format!("Logged in as {}", user.username))) + } +} + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(create_link).service(login); +} + +#[cfg(test)] +mod tests { + use actix_web::{http, test, App}; + use netsblox_cloud_common::{Group, MagicLink, User}; + + use super::*; + use crate::test_utils; + + #[actix_web::test] + async fn test_login_banned() { + let user: User = api::NewUser { + username: "user".into(), + email: "user@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + let l1 = MagicLink::new(user.email.clone()); + + test_utils::setup() + .with_users(&[user.clone()]) + .with_magic_links(&[l1.clone()]) + .with_banned_users(&[user.username.clone()]) + .run(|app_data| async move { + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_data.clone())) + .wrap(test_utils::cookie::middleware()) + .configure(config), + ) + .await; + + let req = test::TestRequest::get() + .uri(&format!("/login?linkId={}&username=user", &l1.id.as_str())) + .to_request(); + + let response = test::call_service(&app, req).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + }) + .await; + } +} diff --git a/crates/cloud/src/main.rs b/crates/cloud/src/main.rs index b9f54e48..cfcf8bc8 100644 --- a/crates/cloud/src/main.rs +++ b/crates/cloud/src/main.rs @@ -7,6 +7,8 @@ mod errors; mod friends; mod groups; mod libraries; +mod login_helper; +mod magic_links; mod network; mod oauth; mod projects; @@ -123,6 +125,7 @@ async fn main() -> std::io::Result<()> { .service(web::scope("/projects").configure(projects::routes::config)) .service(web::scope("/groups").configure(groups::routes::config)) .service(web::scope("/friends").configure(friends::routes::config)) + .service(web::scope("/magic-links").configure(magic_links::routes::config)) .service(web::scope("/network").configure(network::routes::config)) .service(web::scope("/oauth").configure(oauth::routes::config)) .service( diff --git a/crates/cloud/src/test_utils.rs b/crates/cloud/src/test_utils.rs index be0d8ff4..5d3e1f1a 100644 --- a/crates/cloud/src/test_utils.rs +++ b/crates/cloud/src/test_utils.rs @@ -5,7 +5,7 @@ use lazy_static::lazy_static; use mongodb::{bson::doc, Client}; use netsblox_cloud_common::{ api, AuthorizedServiceHost, BannedAccount, CollaborationInvite, FriendLink, Group, Library, - User, + MagicLink, User, }; use crate::{ @@ -32,6 +32,7 @@ pub(crate) fn setup() -> TestSetupBuilder { groups: Vec::new(), clients: Vec::new(), friends: Vec::new(), + magic_links: Vec::new(), collab_invites: Vec::new(), authorized_services: Vec::new(), // network: None, @@ -46,6 +47,7 @@ pub(crate) struct TestSetupBuilder { groups: Vec, clients: Vec, friends: Vec, + magic_links: Vec, banned_users: Vec, collab_invites: Vec, authorized_services: Vec, @@ -98,6 +100,11 @@ impl TestSetupBuilder { self } + pub(crate) fn with_magic_links(mut self, links: &[MagicLink]) -> Self { + self.magic_links.extend_from_slice(links); + self + } + // pub(crate) fn with_network(mut self, network: Addr) -> Self { // self.network = Some(network); // self @@ -198,6 +205,12 @@ impl TestSetupBuilder { if !self.friends.is_empty() { app_data.insert_friends(&self.friends).await.unwrap(); } + if !self.magic_links.is_empty() { + app_data + .insert_magic_links(&self.magic_links) + .await + .unwrap(); + } if !self.groups.is_empty() { app_data .groups diff --git a/crates/cloud/src/users/actions.rs b/crates/cloud/src/users/actions.rs index a47efd60..d0579946 100644 --- a/crates/cloud/src/users/actions.rs +++ b/crates/cloud/src/users/actions.rs @@ -13,11 +13,11 @@ use lettre::{ }; use lru::LruCache; use mongodb::{ - bson::{doc, DateTime}, + bson::doc, options::{FindOneAndUpdateOptions, ReturnDocument}, Collection, }; -use netsblox_cloud_common::{api, BannedAccount, ProjectMetadata, SetPasswordToken, User}; +use netsblox_cloud_common::{api, BannedAccount, SetPasswordToken, User}; use nonempty::NonEmpty; use regex::Regex; use rustrict::CensorStr; @@ -37,8 +37,6 @@ pub(crate) struct UserActions<'a> { password_tokens: &'a Collection, metrics: &'a metrics::Metrics, - project_metadata: &'a Collection, - project_cache: &'a Arc>>, network: &'a Addr, friend_cache: &'a Arc>>>, @@ -57,10 +55,7 @@ pub(crate) struct UserActionData<'a> { pub(crate) password_tokens: &'a Collection, pub(crate) metrics: &'a metrics::Metrics, - pub(crate) project_metadata: &'a Collection, - pub(crate) project_cache: &'a Arc>>, pub(crate) network: &'a Addr, - pub(crate) friend_cache: &'a Arc>>>, // email support @@ -77,8 +72,6 @@ impl<'a> UserActions<'a> { password_tokens: data.password_tokens, metrics: data.metrics, - project_cache: data.project_cache, - project_metadata: data.project_metadata, network: data.network, friend_cache: data.friend_cache, @@ -158,31 +151,9 @@ impl<'a> UserActions<'a> { } pub(crate) async fn login(&self, request: api::LoginRequest) -> Result { - let client_id = request.client_id.clone(); + //let client_id = request.client_id.clone(); let user = strategies::login(self.users, request.credentials).await?; - let query = doc! {"$or": [ - {"username": &user.username}, - {"email": &user.email}, - ]}; - - if let Some(_account) = self - .banned_accounts - .find_one(query.clone(), None) - .await - .map_err(InternalError::DatabaseConnectionError)? - { - return Err(UserError::BannedUserError); - } - - if let Some(client_id) = client_id { - self.update_ownership(&client_id, &user.username).await?; - self.network.do_send(topology::SetClientUsername { - id: client_id, - username: Some(user.username.clone()), - }); - } - self.metrics.record_login(); Ok(user.into()) } @@ -448,81 +419,18 @@ impl<'a> UserActions<'a> { Ok(()) } - async fn update_ownership( - &self, - client_id: &api::ClientId, - username: &str, - ) -> Result<(), UserError> { - // Update ownership of current project - if !client_id.as_str().starts_with('_') { - return Err(UserError::InvalidClientIdError); - } - - let query = doc! {"owner": client_id.as_str()}; - if let Some(metadata) = self - .project_metadata - .find_one(query.clone(), None) - .await - .map_err(InternalError::DatabaseConnectionError)? - { - // No project will be found for non-NetsBlox clients such as PyBlox - let name = - utils::get_valid_project_name(self.project_metadata, username, &metadata.name) - .await?; - let update = doc! { - "$set": { - "owner": username, - "name": name, - "updated": DateTime::now(), - } - }; - let options = mongodb::options::FindOneAndUpdateOptions::builder() - .return_document(ReturnDocument::After) - .build(); - let new_metadata = self - .project_metadata - .find_one_and_update(query, update, Some(options)) - .await - .map_err(InternalError::DatabaseConnectionError)? - .ok_or(UserError::ProjectNotFoundError)?; - - utils::on_room_changed(self.network, self.project_cache, new_metadata); - } - Ok(()) - } - pub(crate) async fn forgot_username(&self, email: &str) -> Result<(), UserError> { - let usernames = self.find_usernames(email).await?; - - let email = NonEmpty::from_vec(usernames) - .map(|usernames| ForgotUsernameEmail { - sender: self.sender.clone(), - email: email.to_owned(), - usernames, - }) - .ok_or(UserError::UserNotFoundError)?; + let usernames = utils::find_usernames(self.users, email).await?; + let email = ForgotUsernameEmail { + sender: self.sender.clone(), + email: email.to_owned(), + usernames, + }; utils::send_email(self.mailer, email)?; Ok(()) } - - async fn find_usernames(&self, email: &str) -> Result, UserError> { - let query = doc! {"email": email}; - let cursor = self - .users - .find(query, None) - .await - .map_err(InternalError::DatabaseConnectionError)?; - - let usernames = cursor - .map_ok(|user| user.username) - .try_collect::>() - .await - .map_err(InternalError::DatabaseConnectionError)?; - - Ok(usernames) - } } fn ensure_valid_email(email: &str) -> Result<(), UserError> { @@ -677,77 +585,6 @@ mod tests { .await; } - #[actix_web::test] - async fn test_find_usernames() { - let user: User = api::NewUser { - username: "user".into(), - email: "user@netsblox.org".into(), - password: None, - group_id: None, - role: None, - } - .into(); - let other: User = api::NewUser { - username: "other".into(), - email: "other@netsblox.org".into(), - password: None, - group_id: None, - role: None, - } - .into(); - - test_utils::setup() - .with_users(&[user.clone(), other]) - .run(|app_data| async move { - let actions = app_data.as_user_actions(); - - let usernames = actions.find_usernames(&user.email).await.unwrap(); - assert_eq!(usernames.len(), 1); - assert!(usernames.iter().any(|name| name == "user")); - }) - .await; - } - - #[actix_web::test] - async fn test_find_usernames_multi() { - let user: User = api::NewUser { - username: "user".into(), - email: "user@netsblox.org".into(), - password: None, - group_id: None, - role: None, - } - .into(); - let u2: User = api::NewUser { - username: "u2".into(), - email: "user@netsblox.org".into(), - password: None, - group_id: None, - role: None, - } - .into(); - let other: User = api::NewUser { - username: "other".into(), - email: "other@netsblox.org".into(), - password: None, - group_id: None, - role: None, - } - .into(); - - test_utils::setup() - .with_users(&[user.clone(), u2, other]) - .run(|app_data| async move { - let actions = app_data.as_user_actions(); - - let usernames = actions.find_usernames(&user.email).await.unwrap(); - assert_eq!(usernames.len(), 2); - assert!(usernames.iter().any(|name| name == "user")); - assert!(usernames.iter().any(|name| name == "u2")); - }) - .await; - } - #[actix_web::test] async fn test_forgot_username_none() { test_utils::setup() diff --git a/crates/cloud/src/users/routes.rs b/crates/cloud/src/users/routes.rs index fae4913c..9a0502df 100644 --- a/crates/cloud/src/users/routes.rs +++ b/crates/cloud/src/users/routes.rs @@ -59,9 +59,12 @@ async fn login( let request = request.into_inner(); let actions: UserActions = app.as_user_actions(); + let client_id = request.client_id.clone(); let user = actions.login(request).await?; - session.insert("username", &user.username).unwrap(); + let helper = app.as_login_helper(); + helper.login(session, &user, client_id).await?; + Ok(HttpResponse::Ok().json(user)) } diff --git a/crates/cloud/src/utils.rs b/crates/cloud/src/utils.rs index 86a7363b..de574d95 100644 --- a/crates/cloud/src/utils.rs +++ b/crates/cloud/src/utils.rs @@ -11,6 +11,7 @@ use netsblox_cloud_common::{ api::{self, GroupId, UserRole}, AuthorizedServiceHost, FriendLink, Group, ProjectMetadata, User, }; +use nonempty::NonEmpty; use regex::Regex; use rustrict::CensorStr; use sha2::{Digest, Sha512}; @@ -332,6 +333,29 @@ pub(crate) fn send_email( Ok(()) } +/// Find the usernames given the associated email address. +/// +/// Results in a UserNotFound error if no users are associated with the given email address. +/// +pub(crate) async fn find_usernames( + collection: &Collection, + email: &str, +) -> Result, UserError> { + let query = doc! {"email": email}; + let cursor = collection + .find(query, None) + .await + .map_err(InternalError::DatabaseConnectionError)?; + + let usernames = cursor + .map_ok(|user| user.username) + .try_collect::>() + .await + .map_err(InternalError::DatabaseConnectionError)?; + + NonEmpty::from_vec(usernames).ok_or(UserError::UserNotFoundError) +} + // TODO: tests for cache invalidation // - [ ] friends @@ -348,6 +372,8 @@ mod tests { use lru::LruCache; use mongodb::bson::DateTime; + use crate::test_utils; + use super::*; #[actix_web::test] @@ -478,4 +504,71 @@ mod tests { let name_res = get_unique_name(existing.iter().map(|n| n.as_str()), "name"); assert!(matches!(name_res, Err(UserError::RoleOrProjectNameExists))); } + + #[actix_web::test] + async fn test_find_usernames() { + let user: User = api::NewUser { + username: "user".into(), + email: "user@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + let other: User = api::NewUser { + username: "other".into(), + email: "other@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + + test_utils::setup() + .with_users(&[user.clone(), other]) + .run(|app_data| async move { + let usernames = find_usernames(&app_data.users, &user.email).await.unwrap(); + assert_eq!(usernames.len(), 1); + assert!(usernames.iter().any(|name| name == "user")); + }) + .await; + } + + #[actix_web::test] + async fn test_find_usernames_multi() { + let user: User = api::NewUser { + username: "user".into(), + email: "user@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + let u2: User = api::NewUser { + username: "u2".into(), + email: "user@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + let other: User = api::NewUser { + username: "other".into(), + email: "other@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + + test_utils::setup() + .with_users(&[user.clone(), u2, other]) + .run(|app_data| async move { + let usernames = find_usernames(&app_data.users, &user.email).await.unwrap(); + assert_eq!(usernames.len(), 2); + assert!(usernames.iter().any(|name| name == "user")); + assert!(usernames.iter().any(|name| name == "u2")); + }) + .await; + } } From ee0497973b107c96ccf531239dfc64b5512f14cf Mon Sep 17 00:00:00 2001 From: Brian Broll Date: Mon, 5 Feb 2024 17:06:39 -0600 Subject: [PATCH 2/6] Make magic links single use. --- crates/cloud/src/magic_links/actions.rs | 30 ++++++++++++++++++- .../cloud/src/magic_links/email_template.rs | 8 ++--- crates/cloud/src/magic_links/routes.rs | 2 +- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/cloud/src/magic_links/actions.rs b/crates/cloud/src/magic_links/actions.rs index a50d79af..377d1998 100644 --- a/crates/cloud/src/magic_links/actions.rs +++ b/crates/cloud/src/magic_links/actions.rs @@ -84,7 +84,7 @@ impl<'a> MagicLinkActions<'a> { let query = doc! {"id": &link_id}; let link = self .links - .find_one(query, None) + .find_one_and_delete(query, None) .await .map_err(InternalError::DatabaseConnectionError)? .ok_or(UserError::MagicLinkNotFoundError)?; @@ -249,4 +249,32 @@ mod tests { }) .await; } + + #[actix_web::test] + async fn test_login_one_time_only() { + let user: User = api::NewUser { + username: "user".into(), + email: "user@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + + let l1 = MagicLink::new(user.email.clone()); + + test_utils::setup() + .with_magic_links(&[l1.clone()]) + .with_users(&[user.clone()]) + .run(|app_data| async move { + let actions = app_data.as_magic_link_actions(); + + let res1 = actions.login(&user.username, &l1.id).await; + assert!(res1.is_ok()); + + let res2 = actions.login(&user.username, &l1.id).await; + assert!(res2.is_err(), "Should not allow more than one use."); + }) + .await; + } } diff --git a/crates/cloud/src/magic_links/email_template.rs b/crates/cloud/src/magic_links/email_template.rs index 527d5d67..4cfd827c 100644 --- a/crates/cloud/src/magic_links/email_template.rs +++ b/crates/cloud/src/magic_links/email_template.rs @@ -27,7 +27,7 @@ pub(crate) fn magic_link_email( let html = format!( "

Magic sign-in link for NetsBlox

- Please click here to \"auto-magically\" sign-in to NetsBlox as {name}. + Please click here to \"auto-magically\" sign-in to NetsBlox as {name}. Link can only be used once.

@@ -40,7 +40,7 @@ pub(crate) fn magic_link_email( let txt = format!( "Magic sign-in link for NetsBlox - Please click the link below to \"auto-magically\" sign-in to NetsBlox as {name}. + Please click the link below to \"auto-magically\" sign-in to NetsBlox as {name}. Link can only be used once. {url} @@ -63,7 +63,7 @@ pub(crate) fn magic_link_email( let html = format!( "

Magic sign-in link for NetsBlox

- Please select an account below to \"auto-magically\" sign-in: + Please select an account below to \"auto-magically\" sign-in. These links can only be used once (combined).

@@ -86,7 +86,7 @@ pub(crate) fn magic_link_email( let txt = format!( "Magic sign-in link for NetsBlox - Please select an account below to \"auto-magically\" sign-in: + Please select an account below to \"auto-magically\" sign-in. These links can only be used once (combined). {urlText} diff --git a/crates/cloud/src/magic_links/routes.rs b/crates/cloud/src/magic_links/routes.rs index e33a579b..16834b2e 100644 --- a/crates/cloud/src/magic_links/routes.rs +++ b/crates/cloud/src/magic_links/routes.rs @@ -54,7 +54,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { #[cfg(test)] mod tests { use actix_web::{http, test, App}; - use netsblox_cloud_common::{Group, MagicLink, User}; + use netsblox_cloud_common::{MagicLink, User}; use super::*; use crate::test_utils; From 7519514683399411fb094c84054fec0b344a6f03 Mon Sep 17 00:00:00 2001 From: Brian Broll Date: Tue, 6 Feb 2024 06:29:08 -0600 Subject: [PATCH 3/6] Add more tests --- crates/cloud/src/magic_links/routes.rs | 104 +++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/crates/cloud/src/magic_links/routes.rs b/crates/cloud/src/magic_links/routes.rs index 16834b2e..cf18e4ac 100644 --- a/crates/cloud/src/magic_links/routes.rs +++ b/crates/cloud/src/magic_links/routes.rs @@ -59,6 +59,46 @@ mod tests { use super::*; use crate::test_utils; + #[actix_web::test] + async fn test_login() { + let user: User = api::NewUser { + username: "user".into(), + email: "user@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + let l1 = MagicLink::new(user.email.clone()); + + test_utils::setup() + .with_users(&[user.clone()]) + .with_magic_links(&[l1.clone()]) + .run(|app_data| async move { + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_data.clone())) + .wrap(test_utils::cookie::middleware()) + .configure(config), + ) + .await; + + let req = test::TestRequest::get() + .uri(&format!("/login?linkId={}&username=user", &l1.id.as_str())) + .to_request(); + + let response = test::call_service(&app, req).await; + assert_eq!(response.status(), http::StatusCode::OK); + + let cookie = response.headers().get(http::header::SET_COOKIE); + assert!(cookie.is_some()); + + let cookie_data = cookie.unwrap().to_str().unwrap(); + assert!(cookie_data.starts_with("test_netsblox=")); + }) + .await; + } + #[actix_web::test] async fn test_login_banned() { let user: User = api::NewUser { @@ -93,4 +133,68 @@ mod tests { }) .await; } + + #[actix_web::test] + async fn test_login_bad_id() { + let user: User = api::NewUser { + username: "user".into(), + email: "user@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + + test_utils::setup() + .with_users(&[user.clone()]) + .run(|app_data| async move { + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_data.clone())) + .wrap(test_utils::cookie::middleware()) + .configure(config), + ) + .await; + + let req = test::TestRequest::get() + .uri("/login?linkId=SOME_ID&username=user") + .to_request(); + + let response = test::call_service(&app, req).await; + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); + }) + .await; + } + + #[actix_web::test] + async fn test_login_missing_id_400() { + let user: User = api::NewUser { + username: "user".into(), + email: "user@netsblox.org".into(), + password: None, + group_id: None, + role: None, + } + .into(); + + test_utils::setup() + .with_users(&[user.clone()]) + .run(|app_data| async move { + let app = test::init_service( + App::new() + .app_data(web::Data::new(app_data.clone())) + .wrap(test_utils::cookie::middleware()) + .configure(config), + ) + .await; + + let req = test::TestRequest::get() + .uri("/login?username=user") + .to_request(); + + let response = test::call_service(&app, req).await; + assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); + }) + .await; + } } From c88a5440dc3b1bdfcfadba1766eac6ca43c32987 Mon Sep 17 00:00:00 2001 From: Brian Broll Date: Tue, 6 Feb 2024 06:34:56 -0600 Subject: [PATCH 4/6] Refactor to test the happy path for magic link creation --- crates/cloud/src/magic_links/actions.rs | 26 +++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/cloud/src/magic_links/actions.rs b/crates/cloud/src/magic_links/actions.rs index 377d1998..e395358c 100644 --- a/crates/cloud/src/magic_links/actions.rs +++ b/crates/cloud/src/magic_links/actions.rs @@ -39,12 +39,22 @@ impl<'a> MagicLinkActions<'a> { } } - /// Make a magic link for the given email address. It can be used to login as any user - /// registered to the given address + /// Make a magic link for the email address and send it to the address. + /// The link can be used to login as any user associated with the given address. pub(crate) async fn create_link( &self, data: &api::CreateMagicLinkData, ) -> Result<(), UserError> { + let email = self.try_create_link(data).await?; + utils::send_email(self.mailer, email)?; + Ok(()) + } + + /// Try to create the given magic link running all the standard checks. + async fn try_create_link( + &self, + data: &api::CreateMagicLinkData, + ) -> Result { let usernames: NonEmpty = utils::find_usernames(self.users, &data.email).await?; let query = doc! {"email": &data.email}; @@ -63,16 +73,13 @@ impl<'a> MagicLinkActions<'a> { if existing.is_some() { Err(UserError::MagicLinkSentError) } else { - let email = MagicLinkEmail { + Ok(MagicLinkEmail { sender: self.sender.clone(), public_url: self.public_url.clone(), redirect_uri: data.redirect_uri.clone(), link, usernames, - }; - utils::send_email(self.mailer, email)?; - - Ok(()) + }) } } @@ -151,8 +158,7 @@ mod tests { use super::*; #[actix_web::test] - #[ignore] // TODO: figure out how to test with the email function... - async fn test_create_link() { + async fn test_try_create_link() { let user: User = api::NewUser { username: "user".into(), email: "user@netsblox.org".into(), @@ -171,7 +177,7 @@ mod tests { email: user.email.clone(), redirect_uri: None, }; - actions.create_link(&data).await.unwrap(); + actions.try_create_link(&data).await.unwrap(); let query = doc! {"email": &user.email}; let link_res = actions.links.find_one(query, None).await.unwrap(); From 56872007a0f24ac9efc948a2928c425a68e7d9eb Mon Sep 17 00:00:00 2001 From: Brian Broll Date: Tue, 6 Feb 2024 08:39:56 -0600 Subject: [PATCH 5/6] Send magic links from the CLI --- crates/api/src/common.rs | 8 ++++---- crates/api/src/lib.rs | 18 +++++++++++++++++- crates/cli/src/main.rs | 40 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/crates/api/src/common.rs b/crates/api/src/common.rs index 2a94f8a2..d854a1ff 100644 --- a/crates/api/src/common.rs +++ b/crates/api/src/common.rs @@ -1,9 +1,9 @@ pub use netsblox_api_common::{ oauth, AppId, AuthorizedServiceHost, BannedAccount, ClientConfig, ClientId, ClientInfo, - ClientState, ClientStateData, CollaborationInvite, CreateLibraryData, CreateProjectData, - Credentials, ExternalClient, ExternalClientState, Group, GroupId, InvitationId, - InvitationState, LibraryMetadata, LinkedAccount, LoginRequest, NewUser, Project, ProjectId, - PublishState, RoleData, RoleId, RoomState, SaveState, ServiceHost, ServiceHostScope, + ClientState, ClientStateData, CollaborationInvite, CreateLibraryData, CreateMagicLinkData, + CreateProjectData, Credentials, ExternalClient, ExternalClientState, Group, GroupId, + InvitationId, InvitationState, LibraryMetadata, LinkedAccount, LoginRequest, NewUser, Project, + ProjectId, PublishState, RoleData, RoleId, RoomState, SaveState, ServiceHost, ServiceHostScope, ServiceSettings, UpdateProjectData, UpdateRoleData, UserRole, }; pub use netsblox_api_common::{ diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 728da6a4..a3d82526 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -3,7 +3,9 @@ pub mod error; use crate::common::*; use futures_util::SinkExt; -use netsblox_api_common::{CreateGroupData, ServiceHostScope, UpdateGroupData}; +use netsblox_api_common::{ + CreateGroupData, CreateMagicLinkData, ServiceHostScope, UpdateGroupData, +}; use reqwest::{self, Method, RequestBuilder, Response}; use serde::{Deserialize, Serialize}; pub use serde_json; @@ -249,6 +251,20 @@ impl Client { Ok(response.json::().await.unwrap()) } + /// Send a magic link to the given email address. Usable for any user associated with the + /// address. + pub async fn send_magic_link(&self, data: &CreateMagicLinkData) -> Result<(), error::Error> { + let response = self + .request(Method::POST, "/magic-links/") + .json(data) + .send() + .await + .map_err(error::Error::RequestError)?; + + check_response(response).await?; + Ok(()) + } + // Project management pub async fn create_project( &self, diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c9e4a97f..e1c7564e 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -9,9 +9,9 @@ use clap::{Parser, Subcommand}; use futures_util::StreamExt; use inquire::{Confirm, Password, PasswordDisplayMode}; use netsblox_api::common::{ - oauth, ClientId, CreateProjectData, Credentials, FriendLinkState, InvitationState, - LinkedAccount, ProjectId, PublishState, RoleData, SaveState, ServiceHost, ServiceHostScope, - UserRole, + oauth, ClientId, CreateMagicLinkData, CreateProjectData, Credentials, FriendLinkState, + InvitationState, LinkedAccount, ProjectId, PublishState, RoleData, SaveState, ServiceHost, + ServiceHostScope, UserRole, }; use netsblox_api::{self, serde_json, Client}; use std::path::Path; @@ -97,6 +97,19 @@ enum Users { }, } +/// Send "magic links" for password-less sign in +#[derive(Subcommand, Debug)] +enum MagicLinks { + /// Send a magic link to the given email. Allows login by any user associated with the email address. + Send { + /// Email to send the magic link to. + email: String, + /// Redirect the user to this URL after login + #[clap(short, long)] + url: Option, + }, +} + /// Manage projects (or roles) #[derive(Subcommand, Debug)] enum Projects { @@ -573,6 +586,12 @@ struct UserCommand { subcmd: Users, } +#[derive(Parser, Debug)] +struct MagicLinkCommand { + #[clap(subcommand)] + subcmd: MagicLinks, +} + #[derive(Parser, Debug)] struct ProjectCommand { #[clap(subcommand)] @@ -636,6 +655,8 @@ enum Command { Logout, #[clap(alias = "user")] Users(UserCommand), + #[clap(alias = "magic-link")] + MagicLinks(MagicLinkCommand), #[clap(alias = "project")] Projects(ProjectCommand), Network(NetworkCommand), @@ -708,6 +729,9 @@ async fn do_command(mut cfg: Config, args: Cli) -> Result<(), error::Error> { let login_required = match &args.cmd { Command::Login => true, Command::Logout => false, + Command::MagicLinks(cmd) => match &cmd.subcmd { + MagicLinks::Send { .. } => false, + }, Command::Users(cmd) => match &cmd.subcmd { Users::Create { .. } => false, _ => !is_logged_in, @@ -837,6 +861,16 @@ async fn do_command(mut cfg: Config, args: Cli) -> Result<(), error::Error> { client.unban_user(username).await?; } }, + Command::MagicLinks(cmd) => match &cmd.subcmd { + MagicLinks::Send { email, url } => { + let data = CreateMagicLinkData { + email: email.clone(), + redirect_uri: url.to_owned(), + }; + client.send_magic_link(&data).await?; + println!("Magic link sent to {}!", email); + } + }, Command::Projects(cmd) => match &cmd.subcmd { Projects::Import { filename, From 593f0596e6d79e4a31a8030ec7561a47e15b8e4c Mon Sep 17 00:00:00 2001 From: Brian Broll Date: Tue, 6 Feb 2024 08:40:47 -0600 Subject: [PATCH 6/6] Add updated TS bindings for magic links --- crates/api-common/bindings/CreateMagicLinkData.ts | 3 +++ crates/api-common/bindings/MagicLinkId.ts | 3 +++ crates/api-common/bindings/MagicLinkLoginData.ts | 5 +++++ 3 files changed, 11 insertions(+) create mode 100644 crates/api-common/bindings/CreateMagicLinkData.ts create mode 100644 crates/api-common/bindings/MagicLinkId.ts create mode 100644 crates/api-common/bindings/MagicLinkLoginData.ts diff --git a/crates/api-common/bindings/CreateMagicLinkData.ts b/crates/api-common/bindings/CreateMagicLinkData.ts new file mode 100644 index 00000000..dab4cee4 --- /dev/null +++ b/crates/api-common/bindings/CreateMagicLinkData.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface CreateMagicLinkData { email: string, redirectUri?: string, } \ No newline at end of file diff --git a/crates/api-common/bindings/MagicLinkId.ts b/crates/api-common/bindings/MagicLinkId.ts new file mode 100644 index 00000000..1b1e561a --- /dev/null +++ b/crates/api-common/bindings/MagicLinkId.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MagicLinkId = string; \ No newline at end of file diff --git a/crates/api-common/bindings/MagicLinkLoginData.ts b/crates/api-common/bindings/MagicLinkLoginData.ts new file mode 100644 index 00000000..3d404e73 --- /dev/null +++ b/crates/api-common/bindings/MagicLinkLoginData.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClientId } from "./ClientId"; +import type { MagicLinkId } from "./MagicLinkId"; + +export interface MagicLinkLoginData { linkId: MagicLinkId, username: string, clientId?: ClientId, redirectUri?: string, } \ No newline at end of file