Adding TOTP / 2FA to lemmy (#2741)

* Combine prod and dev docker setups using build-arg

- Fixes #2603

* Dont use cache for release build.

* Adding 2FA / TOTP support.

- Fixes #2363

* Changed name to totp_2fa for clarity.

* Switch to sha256 for totp.
This commit is contained in:
Dessalines 2023-03-02 15:37:41 -05:00 committed by GitHub
parent 985fe24669
commit 1dba94c9cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 157 additions and 13 deletions

35
Cargo.lock generated
View file

@ -599,6 +599,12 @@ dependencies = [
"rustc-demangle", "rustc-demangle",
] ]
[[package]]
name = "base32"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.13.1" version = "0.13.1"
@ -932,6 +938,12 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "constant_time_eq"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ad85c1f65dc7b37604eb0e89748faf0b9653065f2a8ef69f96a687ec1e9279"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.4.0" version = "0.4.0"
@ -2639,6 +2651,7 @@ dependencies = [
"strum", "strum",
"strum_macros", "strum_macros",
"tokio", "tokio",
"totp-rs",
"tracing", "tracing",
"tracing-error", "tracing-error",
"typed-builder", "typed-builder",
@ -5033,6 +5046,22 @@ dependencies = [
"syn 1.0.103", "syn 1.0.103",
] ]
[[package]]
name = "totp-rs"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fdd21080b6cf581e0c8fe849626ad627b42af1a0f71ce980244f2d6b1a47836"
dependencies = [
"base32",
"constant_time_eq",
"hmac",
"rand 0.8.5",
"sha1",
"sha2",
"url",
"urlencoding",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.4.13" version = "0.4.13"
@ -5387,6 +5416,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9"
[[package]] [[package]]
name = "utf-8" name = "utf-8"
version = "0.7.6" version = "0.7.6"

View file

@ -6,9 +6,13 @@ use lemmy_api_common::{
person::{Login, LoginResponse}, person::{Login, LoginResponse},
utils::{check_registration_application, check_user_valid}, utils::{check_registration_application, check_user_valid},
}; };
use lemmy_db_schema::source::local_site::LocalSite; use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_db_views::structs::LocalUserView; use lemmy_utils::{
use lemmy_utils::{claims::Claims, error::LemmyError, ConnectionId}; claims::Claims,
error::LemmyError,
utils::validation::check_totp_2fa_valid,
ConnectionId,
};
#[async_trait::async_trait(?Send)] #[async_trait::async_trait(?Send)]
impl Perform for Login { impl Perform for Login {
@ -22,7 +26,7 @@ impl Perform for Login {
) -> Result<LoginResponse, LemmyError> { ) -> Result<LoginResponse, LemmyError> {
let data: &Login = self; let data: &Login = self;
let local_site = LocalSite::read(context.pool()).await?; let site_view = SiteView::read_local(context.pool()).await?;
// Fetch that username / email // Fetch that username / email
let username_or_email = data.username_or_email.clone(); let username_or_email = data.username_or_email.clone();
@ -45,11 +49,20 @@ impl Perform for Login {
local_user_view.person.deleted, local_user_view.person.deleted,
)?; )?;
if local_site.require_email_verification && !local_user_view.local_user.email_verified { if site_view.local_site.require_email_verification && !local_user_view.local_user.email_verified
{
return Err(LemmyError::from_message("email_not_verified")); return Err(LemmyError::from_message("email_not_verified"));
} }
check_registration_application(&local_user_view, &local_site, context.pool()).await?; check_registration_application(&local_user_view, &site_view.local_site, context.pool()).await?;
// Check the totp
check_totp_2fa_valid(
&local_user_view.local_user.totp_2fa_secret,
&data.totp_2fa_token,
&site_view.site.name,
&local_user_view.person.name,
)?;
// Return the jwt // Return the jwt
Ok(LoginResponse { Ok(LoginResponse {

View file

@ -8,17 +8,22 @@ use lemmy_api_common::{
use lemmy_db_schema::{ use lemmy_db_schema::{
source::{ source::{
actor_language::LocalUserLanguage, actor_language::LocalUserLanguage,
local_site::LocalSite,
local_user::{LocalUser, LocalUserUpdateForm}, local_user::{LocalUser, LocalUserUpdateForm},
person::{Person, PersonUpdateForm}, person::{Person, PersonUpdateForm},
}, },
traits::Crud, traits::Crud,
utils::{diesel_option_overwrite, diesel_option_overwrite_to_url}, utils::{diesel_option_overwrite, diesel_option_overwrite_to_url},
}; };
use lemmy_db_views::structs::SiteView;
use lemmy_utils::{ use lemmy_utils::{
claims::Claims, claims::Claims,
error::LemmyError, error::LemmyError,
utils::validation::{is_valid_display_name, is_valid_matrix_id}, utils::validation::{
build_totp_2fa,
generate_totp_2fa_secret,
is_valid_display_name,
is_valid_matrix_id,
},
ConnectionId, ConnectionId,
}; };
@ -35,14 +40,13 @@ impl Perform for SaveUserSettings {
let data: &SaveUserSettings = self; let data: &SaveUserSettings = self;
let local_user_view = let local_user_view =
get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?; get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?;
let local_site = LocalSite::read(context.pool()).await?; let site_view = SiteView::read_local(context.pool()).await?;
let avatar = diesel_option_overwrite_to_url(&data.avatar)?; let avatar = diesel_option_overwrite_to_url(&data.avatar)?;
let banner = diesel_option_overwrite_to_url(&data.banner)?; let banner = diesel_option_overwrite_to_url(&data.banner)?;
let bio = diesel_option_overwrite(&data.bio); let bio = diesel_option_overwrite(&data.bio);
let display_name = diesel_option_overwrite(&data.display_name); let display_name = diesel_option_overwrite(&data.display_name);
let matrix_user_id = diesel_option_overwrite(&data.matrix_user_id); let matrix_user_id = diesel_option_overwrite(&data.matrix_user_id);
let bot_account = data.bot_account;
let email_deref = data.email.as_deref().map(str::to_lowercase); let email_deref = data.email.as_deref().map(str::to_lowercase);
let email = diesel_option_overwrite(&email_deref); let email = diesel_option_overwrite(&email_deref);
@ -57,7 +61,7 @@ impl Perform for SaveUserSettings {
// When the site requires email, make sure email is not Some(None). IE, an overwrite to a None value // When the site requires email, make sure email is not Some(None). IE, an overwrite to a None value
if let Some(email) = &email { if let Some(email) = &email {
if email.is_none() && local_site.require_email_verification { if email.is_none() && site_view.local_site.require_email_verification {
return Err(LemmyError::from_message("email_required")); return Err(LemmyError::from_message("email_required"));
} }
} }
@ -71,7 +75,7 @@ impl Perform for SaveUserSettings {
if let Some(Some(display_name)) = &display_name { if let Some(Some(display_name)) = &display_name {
if !is_valid_display_name( if !is_valid_display_name(
display_name.trim(), display_name.trim(),
local_site.actor_name_max_length as usize, site_view.local_site.actor_name_max_length as usize,
) { ) {
return Err(LemmyError::from_message("invalid_username")); return Err(LemmyError::from_message("invalid_username"));
} }
@ -92,7 +96,7 @@ impl Perform for SaveUserSettings {
.display_name(display_name) .display_name(display_name)
.bio(bio) .bio(bio)
.matrix_user_id(matrix_user_id) .matrix_user_id(matrix_user_id)
.bot_account(bot_account) .bot_account(data.bot_account)
.avatar(avatar) .avatar(avatar)
.banner(banner) .banner(banner)
.build(); .build();
@ -105,6 +109,20 @@ impl Perform for SaveUserSettings {
LocalUserLanguage::update(context.pool(), discussion_languages, local_user_id).await?; LocalUserLanguage::update(context.pool(), discussion_languages, local_user_id).await?;
} }
// If generate_totp is Some(false), this will clear it out from the database.
let (totp_2fa_secret, totp_2fa_url) = if let Some(generate) = data.generate_totp_2fa {
if generate {
let secret = generate_totp_2fa_secret();
let url =
build_totp_2fa(&site_view.site.name, &local_user_view.person.name, &secret)?.get_url();
(Some(Some(secret)), Some(Some(url)))
} else {
(Some(None), Some(None))
}
} else {
(None, None)
};
let local_user_form = LocalUserUpdateForm::builder() let local_user_form = LocalUserUpdateForm::builder()
.email(email) .email(email)
.show_avatars(data.show_avatars) .show_avatars(data.show_avatars)
@ -118,6 +136,8 @@ impl Perform for SaveUserSettings {
.default_listing_type(default_listing_type) .default_listing_type(default_listing_type)
.theme(data.theme.clone()) .theme(data.theme.clone())
.interface_language(data.interface_language.clone()) .interface_language(data.interface_language.clone())
.totp_2fa_secret(totp_2fa_secret)
.totp_2fa_url(totp_2fa_url)
.build(); .build();
let local_user_res = LocalUser::update(context.pool(), local_user_id, &local_user_form).await; let local_user_res = LocalUser::update(context.pool(), local_user_id, &local_user_form).await;

View file

@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize};
pub struct Login { pub struct Login {
pub username_or_email: Sensitive<String>, pub username_or_email: Sensitive<String>,
pub password: Sensitive<String>, pub password: Sensitive<String>,
pub totp_2fa_token: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize, Clone, Default)] #[derive(Debug, Serialize, Deserialize, Clone, Default)]
@ -70,6 +71,8 @@ pub struct SaveUserSettings {
pub show_read_posts: Option<bool>, pub show_read_posts: Option<bool>,
pub show_new_post_notifs: Option<bool>, pub show_new_post_notifs: Option<bool>,
pub discussion_languages: Option<Vec<LanguageId>>, pub discussion_languages: Option<Vec<LanguageId>>,
/// None leaves it as is, true will generate or regenerate it, false clears it out
pub generate_totp_2fa: Option<bool>,
pub auth: Sensitive<String>, pub auth: Sensitive<String>,
} }

View file

@ -170,6 +170,8 @@ table! {
show_new_post_notifs -> Bool, show_new_post_notifs -> Bool,
email_verified -> Bool, email_verified -> Bool,
accepted_application -> Bool, accepted_application -> Bool,
totp_2fa_secret -> Nullable<Text>,
totp_2fa_url -> Nullable<Text>,
} }
} }

View file

@ -27,6 +27,9 @@ pub struct LocalUser {
pub show_new_post_notifs: bool, pub show_new_post_notifs: bool,
pub email_verified: bool, pub email_verified: bool,
pub accepted_application: bool, pub accepted_application: bool,
#[serde(skip)]
pub totp_2fa_secret: Option<String>,
pub totp_2fa_url: Option<String>,
} }
#[derive(Clone, TypedBuilder)] #[derive(Clone, TypedBuilder)]
@ -52,6 +55,8 @@ pub struct LocalUserInsertForm {
pub show_new_post_notifs: Option<bool>, pub show_new_post_notifs: Option<bool>,
pub email_verified: Option<bool>, pub email_verified: Option<bool>,
pub accepted_application: Option<bool>, pub accepted_application: Option<bool>,
pub totp_2fa_secret: Option<Option<String>>,
pub totp_2fa_url: Option<Option<String>>,
} }
#[derive(Clone, TypedBuilder)] #[derive(Clone, TypedBuilder)]
@ -74,4 +79,6 @@ pub struct LocalUserUpdateForm {
pub show_new_post_notifs: Option<bool>, pub show_new_post_notifs: Option<bool>,
pub email_verified: Option<bool>, pub email_verified: Option<bool>,
pub accepted_application: Option<bool>, pub accepted_application: Option<bool>,
pub totp_2fa_secret: Option<Option<String>>,
pub totp_2fa_url: Option<Option<String>>,
} }

View file

@ -284,6 +284,8 @@ mod tests {
show_new_post_notifs: inserted_sara_local_user.show_new_post_notifs, show_new_post_notifs: inserted_sara_local_user.show_new_post_notifs,
email_verified: inserted_sara_local_user.email_verified, email_verified: inserted_sara_local_user.email_verified,
accepted_application: inserted_sara_local_user.accepted_application, accepted_application: inserted_sara_local_user.accepted_application,
totp_2fa_secret: inserted_sara_local_user.totp_2fa_secret,
totp_2fa_url: inserted_sara_local_user.totp_2fa_url,
password_encrypted: inserted_sara_local_user.password_encrypted, password_encrypted: inserted_sara_local_user.password_encrypted,
}, },
creator: Person { creator: Person {

View file

@ -46,6 +46,7 @@ smart-default = "0.6.0"
jsonwebtoken = "8.1.1" jsonwebtoken = "8.1.1"
lettre = "0.10.1" lettre = "0.10.1"
comrak = { version = "0.14.0", default-features = false } comrak = { version = "0.14.0", default-features = false }
totp-rs = { version = "4.2.0", features = ["gen_secret", "otpauth"] }
[dev-dependencies] [dev-dependencies]
reqwest = { workspace = true } reqwest = { workspace = true }

View file

@ -1,6 +1,8 @@
use crate::error::LemmyError;
use itertools::Itertools; use itertools::Itertools;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use totp_rs::{Secret, TOTP};
use url::Url; use url::Url;
static VALID_ACTOR_NAME_REGEX: Lazy<Regex> = static VALID_ACTOR_NAME_REGEX: Lazy<Regex> =
@ -56,10 +58,58 @@ pub fn clean_url_params(url: &Url) -> Url {
url_out url_out
} }
pub fn check_totp_2fa_valid(
totp_secret: &Option<String>,
totp_token: &Option<String>,
site_name: &str,
username: &str,
) -> Result<(), LemmyError> {
// Check only if they have a totp_secret in the DB
if let Some(totp_secret) = totp_secret {
// Throw an error if their token is missing
let token = totp_token
.as_deref()
.ok_or_else(|| LemmyError::from_message("missing_totp_token"))?;
let totp = build_totp_2fa(site_name, username, totp_secret)?;
let check_passed = totp.check_current(token)?;
if !check_passed {
return Err(LemmyError::from_message("incorrect_totp token"));
}
}
Ok(())
}
pub fn generate_totp_2fa_secret() -> String {
Secret::generate_secret().to_string()
}
pub fn build_totp_2fa(site_name: &str, username: &str, secret: &str) -> Result<TOTP, LemmyError> {
let sec = Secret::Raw(secret.as_bytes().to_vec());
let sec_bytes = sec
.to_bytes()
.map_err(|_| LemmyError::from_message("Couldnt parse totp secret"))?;
TOTP::new(
totp_rs::Algorithm::SHA256,
6,
1,
30,
sec_bytes,
Some(site_name.to_string()),
username.to_string(),
)
.map_err(|e| LemmyError::from_error_message(e, "Couldnt generate TOTP"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::build_totp_2fa;
use crate::utils::validation::{ use crate::utils::validation::{
clean_url_params, clean_url_params,
generate_totp_2fa_secret,
is_valid_actor_name, is_valid_actor_name,
is_valid_display_name, is_valid_display_name,
is_valid_matrix_id, is_valid_matrix_id,
@ -128,4 +178,11 @@ mod tests {
assert!(!is_valid_matrix_id(" @dess:matrix.org")); assert!(!is_valid_matrix_id(" @dess:matrix.org"));
assert!(!is_valid_matrix_id("@dess:matrix.org t")); assert!(!is_valid_matrix_id("@dess:matrix.org t"));
} }
#[test]
fn test_build_totp() {
let generated_secret = generate_totp_2fa_secret();
let totp = build_totp_2fa("lemmy", "my_name", &generated_secret);
assert!(totp.is_ok());
}
} }

View file

@ -0,0 +1,2 @@
alter table local_user drop column totp_2fa_secret;
alter table local_user drop column totp_2fa_url;

View file

@ -0,0 +1,2 @@
alter table local_user add column totp_2fa_secret text;
alter table local_user add column totp_2fa_url text;