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:
parent
985fe24669
commit
1dba94c9cb
11 changed files with 157 additions and 13 deletions
35
Cargo.lock
generated
35
Cargo.lock
generated
|
@ -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"
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>>,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
2
migrations/2023-02-16-194139_add_totp_secret/down.sql
Normal file
2
migrations/2023-02-16-194139_add_totp_secret/down.sql
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
alter table local_user drop column totp_2fa_secret;
|
||||||
|
alter table local_user drop column totp_2fa_url;
|
2
migrations/2023-02-16-194139_add_totp_secret/up.sql
Normal file
2
migrations/2023-02-16-194139_add_totp_secret/up.sql
Normal 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;
|
Loading…
Reference in a new issue