Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for magic links. Close #229 #243

Merged
merged 6 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/api-common/bindings/CreateMagicLinkData.ts
Original file line number Diff line number Diff line change
@@ -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, }
3 changes: 3 additions & 0 deletions crates/api-common/bindings/MagicLinkId.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions crates/api-common/bindings/MagicLinkLoginData.ts
Original file line number Diff line number Diff line change
@@ -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, }
10 changes: 8 additions & 2 deletions crates/api-common/src/bson.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -159,6 +159,12 @@ impl From<ServiceHostScope> for Bson {
}
}

impl From<MagicLinkId> for Bson {
fn from(id: MagicLinkId) -> Bson {
Bson::String(id.0)
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
35 changes: 35 additions & 0 deletions crates/api-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientId>,
#[ts(optional)]
pub redirect_uri: Option<String>,
}

#[derive(Serialize, Deserialize, Clone, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CreateMagicLinkData {
pub email: String,
#[ts(optional)]
pub redirect_uri: Option<String>,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
8 changes: 4 additions & 4 deletions crates/api/src/common.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down
18 changes: 17 additions & 1 deletion crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -249,6 +251,20 @@ impl Client {
Ok(response.json::<BannedAccount>().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,
Expand Down
40 changes: 37 additions & 3 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String>,
},
}

/// Manage projects (or roles)
#[derive(Subcommand, Debug)]
enum Projects {
Expand Down Expand Up @@ -573,6 +586,12 @@ struct UserCommand {
subcmd: Users,
}

#[derive(Parser, Debug)]
struct MagicLinkCommand {
#[clap(subcommand)]
subcmd: MagicLinks,
}

#[derive(Parser, Debug)]
struct ProjectCommand {
#[clap(subcommand)]
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions crates/cloud-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MagicLink> 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::*;
Expand Down
Loading
Loading