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

[PM-3436] Username generator #285

Merged
merged 21 commits into from
Dec 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
17 changes: 16 additions & 1 deletion crates/bitwarden-uniffi/src/tool/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use std::sync::Arc;

use bitwarden::{
tool::{ExportFormat, PassphraseGeneratorRequest, PasswordGeneratorRequest},
tool::{
ExportFormat, PassphraseGeneratorRequest, PasswordGeneratorRequest,
UsernameGeneratorRequest,
},
vault::{Cipher, Collection, Folder},
};

Expand Down Expand Up @@ -35,6 +38,18 @@
.passphrase(settings)
.await?)
}

/// **API Draft:** Generate Username
Hinton marked this conversation as resolved.
Show resolved Hide resolved
pub async fn username(&self, settings: UsernameGeneratorRequest) -> Result<String> {
Ok(self
.0
.0
.read()
.await
.generator()
.username(settings)
.await?)
}

Check warning on line 52 in crates/bitwarden-uniffi/src/tool/mod.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden-uniffi/src/tool/mod.rs#L43-L52

Added lines #L43 - L52 were not covered by tests
}

#[derive(uniffi::Object)]
Expand Down
5 changes: 5 additions & 0 deletions crates/bitwarden/src/client/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@
&self.__api_configurations
}

#[cfg(feature = "mobile")]
pub(crate) fn get_http_client(&self) -> &reqwest::Client {
&self.__api_configurations.api.client
Hinton marked this conversation as resolved.
Show resolved Hide resolved
}

Check warning on line 138 in crates/bitwarden/src/client/client.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/client/client.rs#L136-L138

Added lines #L136 - L138 were not covered by tests

#[cfg(feature = "secrets")]
#[deprecated(note = "Use auth().login_access_token() instead")]
pub async fn access_token_login(
Expand Down
33 changes: 29 additions & 4 deletions crates/bitwarden/src/tool/generators/client_generator.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
use crate::{
error::Result,
tool::generators::passphrase::{passphrase, PassphraseGeneratorRequest},
tool::generators::password::{password, PasswordGeneratorRequest},
tool::generators::{
passphrase::{passphrase, PassphraseGeneratorRequest},
password::{password, PasswordGeneratorRequest},
username::{username, UsernameGeneratorRequest},
},
Client,
};

pub struct ClientGenerator<'a> {
pub(crate) _client: &'a crate::Client,
pub(crate) client: &'a crate::Client,
}

impl<'a> ClientGenerator<'a> {
Expand Down Expand Up @@ -59,10 +62,32 @@
pub async fn passphrase(&self, input: PassphraseGeneratorRequest) -> Result<String> {
passphrase(input)
}

/// Generates a random username.
/// There are different username generation strategies, which can be customized using the `input` parameter.
///
/// Note that most generation strategies will be executed on the client side, but `Forwarded` will use third-party
/// services, which may require a specific setup or API key.
///
/// ```
/// use bitwarden::{Client, tool::{UsernameGeneratorRequest}, error::Result};
/// async fn test() -> Result<()> {
/// let input = UsernameGeneratorRequest::Word {
/// capitalize: true,
/// include_number: true,
/// };
/// let username = Client::new(None).generator().username(input).await.unwrap();
/// println!("{}", username);
/// Ok(())
/// }
/// ```
pub async fn username(&self, input: UsernameGeneratorRequest) -> Result<String> {
username(input, self.client.get_http_client()).await
}

Check warning on line 86 in crates/bitwarden/src/tool/generators/client_generator.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/client_generator.rs#L84-L86

Added lines #L84 - L86 were not covered by tests
}

impl<'a> Client {
pub fn generator(&'a self) -> ClientGenerator<'a> {
ClientGenerator { _client: self }
ClientGenerator { client: self }

Check warning on line 91 in crates/bitwarden/src/tool/generators/client_generator.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/client_generator.rs#L91

Added line #L91 was not covered by tests
}
}
3 changes: 3 additions & 0 deletions crates/bitwarden/src/tool/generators/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
mod client_generator;
mod passphrase;
mod password;
mod username;
mod username_forwarders;

pub use client_generator::ClientGenerator;
pub use passphrase::PassphraseGeneratorRequest;
pub use password::PasswordGeneratorRequest;
pub use username::{AppendType, ForwarderServiceType, UsernameGeneratorRequest};
13 changes: 1 addition & 12 deletions crates/bitwarden/src/tool/generators/passphrase.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::{error::Result, wordlist::EFF_LONG_WORD_LIST};
use crate::{error::Result, util::capitalize_first_letter, wordlist::EFF_LONG_WORD_LIST};
use rand::{seq::SliceRandom, Rng, RngCore};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -109,17 +109,6 @@ fn capitalize_words(words: &mut [String]) {
.for_each(|w| *w = capitalize_first_letter(w));
}

fn capitalize_first_letter(s: &str) -> String {
// Unicode case conversion can change the length of the string, so we can't capitalize in place.
// Instead we extract the first character and convert it to uppercase. This returns
// an iterator which we collect into a string, and then append the rest of the input.
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}

#[cfg(test)]
mod tests {
use rand::SeedableRng;
Expand Down
244 changes: 244 additions & 0 deletions crates/bitwarden/src/tool/generators/username.rs
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
use crate::{error::Result, util::capitalize_first_letter, wordlist::EFF_LONG_WORD_LIST};
use rand::{distributions::Distribution, seq::SliceRandom, Rng, RngCore};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug, JsonSchema)]

Check warning on line 6 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L6

Added line #L6 was not covered by tests
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "mobile", derive(uniffi::Enum))]

Check warning on line 8 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L8

Added line #L8 was not covered by tests
pub enum AppendType {
/// Generates a random string of 8 lowercase characters as part of your username
Random,
/// Uses the websitename as part of your username
WebsiteName { website: String },
}

#[derive(Serialize, Deserialize, Debug, JsonSchema)]

Check warning on line 16 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L16

Added line #L16 was not covered by tests
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "mobile", derive(uniffi::Enum))]

Check warning on line 18 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L18

Added line #L18 was not covered by tests
/// Configures the email forwarding service to use.
/// For instructions on how to configure each service, see the documentation:
/// <https://bitwarden.com/help/generator/#username-types>
pub enum ForwarderServiceType {
/// Previously known as "AnonAddy"
AddyIo {
api_token: String,
domain: String,
base_url: String,
},
DuckDuckGo {
token: String,
},
Firefox {
api_token: String,
},
Fastmail {
api_token: String,
},
ForwardEmail {
api_token: String,
domain: String,
},
SimpleLogin {
api_key: String,
},
}

#[derive(Serialize, Deserialize, Debug, JsonSchema)]

Check warning on line 47 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L47

Added line #L47 was not covered by tests
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "mobile", derive(uniffi::Enum))]

Check warning on line 49 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L49

Added line #L49 was not covered by tests
pub enum UsernameGeneratorRequest {
/// Generates a single word username
Word {
/// Capitalize the first letter of the word
capitalize: bool,
/// Include a 4 digit number at the end of the word
include_number: bool,
},
/// Generates an email using your provider's subaddressing capabilities.
/// Note that not all providers support this functionality.
/// This will generate an address of the format `[email protected]`
Subaddress {
/// The type of subaddress to add to the base email
r#type: AppendType,
/// The full email address to use as the base for the subaddress
email: String,
},
Catchall {
/// The type of username to use with the catchall email domain
r#type: AppendType,
/// The domain to use for the catchall email address
domain: String,
},
Forwarded {
/// The email forwarding service to use, see [ForwarderServiceType]
/// for instructions on how to configure each
service: ForwarderServiceType,
/// The website for which the email address is being generated
/// This is not used in all services, and is only used for display purposes
website: Option<String>,
},
}

impl ForwarderServiceType {
// Generate a username using the specified email forwarding service
// This requires an HTTP client to be passed in, as the service will need to make API calls
pub async fn generate(self, http: &reqwest::Client, website: Option<String>) -> Result<String> {
use crate::tool::generators::username_forwarders::*;
use ForwarderServiceType::*;

match self {

Check warning on line 90 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L86-L90

Added lines #L86 - L90 were not covered by tests
AddyIo {
api_token,
domain,
base_url,
} => addyio::generate(http, api_token, domain, base_url, website).await,
DuckDuckGo { token } => duckduckgo::generate(http, token).await,
Firefox { api_token } => firefox::generate(http, api_token, website).await,
Fastmail { api_token } => fastmail::generate(http, api_token, website).await,
ForwardEmail { api_token, domain } => {
forwardemail::generate(http, api_token, domain, website).await

Check warning on line 100 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L92-L100

Added lines #L92 - L100 were not covered by tests
}
SimpleLogin { api_key } => simplelogin::generate(http, api_key, website).await,

Check warning on line 102 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L102

Added line #L102 was not covered by tests
}
}

Check warning on line 104 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L104

Added line #L104 was not covered by tests
}

/// Implementation of the username generator. This is not accessible to the public API.
/// See [`ClientGenerator::username`](crate::ClientGenerator::username) for the API function.
/// Note: The HTTP client is passed in as a required parameter for convenience,
/// as some username generators require making API calls.
pub(super) async fn username(
input: UsernameGeneratorRequest,
http: &reqwest::Client,
) -> Result<String> {
use rand::thread_rng;
use UsernameGeneratorRequest::*;
match input {

Check warning on line 117 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L111-L117

Added lines #L111 - L117 were not covered by tests
Word {
capitalize,
include_number,
} => Ok(username_word(&mut thread_rng(), capitalize, include_number)),
Subaddress { r#type, email } => Ok(username_subaddress(&mut thread_rng(), r#type, email)),
Catchall { r#type, domain } => Ok(username_catchall(&mut thread_rng(), r#type, domain)),
Forwarded { service, website } => service.generate(http, website).await,

Check warning on line 124 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L119-L124

Added lines #L119 - L124 were not covered by tests
}
}

Check warning on line 126 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L126

Added line #L126 was not covered by tests

fn username_word(mut rng: impl RngCore, capitalize: bool, include_number: bool) -> String {
let word = EFF_LONG_WORD_LIST
.choose(&mut rng)
.expect("slice is not empty");

let mut word = if capitalize {
capitalize_first_letter(word)
} else {
word.to_string()
};

if include_number {
word.push_str(&random_number(&mut rng));
}

word
}

/// Generate a random 4 digit number, including leading zeros
fn random_number(mut rng: impl RngCore) -> String {
let num = rng.gen_range(0..=9999);
format!("{num:0>4}")
}

/// Generate a username using a plus addressed email address
/// The format is <username>+<random-or-website>@<domain>
fn username_subaddress(mut rng: impl RngCore, r#type: AppendType, email: String) -> String {
if email.len() < 3 {
return email;

Check warning on line 156 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L156

Added line #L156 was not covered by tests
}

let (email_begin, email_end) = match email.find('@') {
Some(pos) if pos > 0 && pos < email.len() - 1 => {
email.split_once('@').expect("The email contains @")
}
_ => return email,

Check warning on line 163 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L163

Added line #L163 was not covered by tests
};

let email_middle = match r#type {
AppendType::Random => random_lowercase_string(&mut rng, 8),
AppendType::WebsiteName { website } => website,
};

format!("{}+{}@{}", email_begin, email_middle, email_end)
}

/// Generate a username using a catchall email address
/// The format is <random-or-website>@<domain>
fn username_catchall(mut rng: impl RngCore, r#type: AppendType, domain: String) -> String {
if domain.is_empty() {
return domain;

Check warning on line 178 in crates/bitwarden/src/tool/generators/username.rs

View check run for this annotation

Codecov / codecov/patch

crates/bitwarden/src/tool/generators/username.rs#L178

Added line #L178 was not covered by tests
}

let email_start = match r#type {
AppendType::Random => random_lowercase_string(&mut rng, 8),
AppendType::WebsiteName { website } => website,
};

format!("{}@{}", email_start, domain)
}

fn random_lowercase_string(mut rng: impl RngCore, length: usize) -> String {
const LOWERCASE_ALPHANUMERICAL: &[u8] = b"abcdefghijklmnopqrstuvwxyz1234567890";
let dist = rand::distributions::Slice::new(LOWERCASE_ALPHANUMERICAL).expect("Non-empty slice");

dist.sample_iter(&mut rng)
.take(length)
.map(|&b| b as char)
.collect()
}

#[cfg(test)]
mod tests {
use rand::SeedableRng;

pub use super::*;

#[test]
fn test_username_word() {
let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
assert_eq!(username_word(&mut rng, true, true), "Subsystem6314");
assert_eq!(username_word(&mut rng, true, false), "Silenced");
assert_eq!(username_word(&mut rng, false, true), "dinginess4487");
}

#[test]
fn test_username_subaddress() {
let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
let user = username_subaddress(&mut rng, AppendType::Random, "[email protected]".into());
assert_eq!(user, "[email protected]");

let user = username_subaddress(
&mut rng,
AppendType::WebsiteName {
website: "bitwarden.com".into(),
},
"[email protected]".into(),
);
assert_eq!(user, "[email protected]");
}

#[test]
fn test_username_catchall() {
let mut rng = rand_chacha::ChaCha8Rng::from_seed([1u8; 32]);
let user = username_catchall(&mut rng, AppendType::Random, "test.com".into());
assert_eq!(user, "[email protected]");

let user = username_catchall(
&mut rng,
AppendType::WebsiteName {
website: "bitwarden.com".into(),
},
"test.com".into(),
);
assert_eq!(user, "[email protected]");
}
}
Loading