Beginning password reset implementation

- Email send is not implemented yet
 - All of this is still untested, clearly
This commit is contained in:
Jacob Conley authored and Brandon Thomas committed 2023-11-10 16:13:58 -05:00
1 parent 1cc3c5eee4
commit 16d6542911
15 files changed
+206 -6

No files matched your search

Generated
+3 -3
View File
@@ -1178,7 +1178,7 @@ dependencies = [
name = "buckets"
version = "0.0.1"
dependencies = [
"crockford_deprecated",
"crockford",
"errors",
]
@@ -1654,7 +1654,7 @@ dependencies = [
]
[[package]]
name = "crockford_deprecated"
name = "crockford"
version = "0.0.1"
dependencies = [
"rand 0.8.5",
@@ -2187,7 +2187,7 @@ dependencies = [
"concurrency",
"config",
"container_common",
"crockford_deprecated",
"crockford",
"dotenv",
"easyenv",
"enums",
+1 -1
View File
@@ -28,7 +28,7 @@ members = [
"crates/lib/caching/redis_caching",
"crates/lib/concurrency",
"crates/lib/cookies",
"crates/lib/crockford_deprecated",
"crates/lib/crockford",
"crates/lib/datetimes",
"crates/lib/deprecated/config", # TODO(bt, 2023-02-13): Deprecate or repurpose
"crates/lib/deprecated/container_common", # TODO(bt, 2023-02-13): Pull apart and rehome pieces
+4
View File
@@ -30,6 +30,8 @@ pub(crate) enum TokenPrefix {
MediaFile,
MediaUpload,
NewsStory, // NB: aichatbot / sqlite
PasswordReset,
TtsModel,
TtsRenderTask, // NB: aichatbot / sqlite
User,
UserSession,
@@ -92,6 +94,8 @@ impl PrefixGenerator for TokenPrefix {
Self::MediaFile => "m_",
Self::MediaUpload => "mu_",
Self::NewsStory => "news_story_",
Self::PasswordReset => "pw_reset_",
Self::TtsModel => "TM:", // NB: Old-style prefix, do not use for future tokens.
Self::TtsRenderTask => "tts_task_",
Self::User => "user_", // NB: Previously "U:"
Self::UserSession => "session_",
+1
View File
@@ -9,6 +9,7 @@ pub mod generic_inference_jobs;
pub mod media_files;
pub mod media_uploads;
pub mod model_categories;
pub mod password_reset;
pub mod sqlite;
pub mod tts_inference_jobs;
pub mod tts_model_upload_jobs;
@@ -0,0 +1,14 @@
use std::fmt::Debug;
use serde::Deserialize;
use serde::Serialize;
use crate::prefixes::EntityType;
/// The primary key for `password_reset`s
#[derive(Clone, PartialEq, Eq, sqlx::Type, Debug, Serialize, Deserialize)]
#[sqlx(transparent)]
pub struct PasswordResetToken(pub String);
impl_string_token!(PasswordResetToken);
impl_crockford_generator!(PasswordResetToken, 32usize, EntityType::PasswordReset, CrockfordUpper);
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "email"
version = "0.1.0"
edition = "2021"
[lib]
path = "src/lib.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
errors = { path = "../errors" }
mail-smtp = "0.3.0"
+27
View File
@@ -0,0 +1,27 @@
use errors::AnyhowResult;
pub async fn send_password_reset(email_address: &str, secret_key: String) -> AnyhowResult<()> {
todo!();
// use futures::Future;
// use mail_headers::{
// headers::*,
// header_components::Domain
// };
// use mail_core::{Mail, default_impl::simple_context};
// use mail_smtp::{self as smtp, ConnectionConfig};
// let ctx = simple_context::new(Domain::from_unchecked("example.com".to_owned()), "asdkds".parse().unwrap())
// .unwrap();
// let mut mail = Mail::plain_text("Some body", &ctx);
// mail.insert_headers(headers! {
// _From: ["bla@example.com"],
// _To: ["blub@example.com"],
// Subject: "Some Mail"
// }.unwrap());
// let con_config = ConnectionConfig::builder_local_unencrypted().build();
// let fut = smtp::send(mail.into(), con_config, ctx);
// let results = fut.wait();
}
@@ -0,0 +1,31 @@
use anyhow::Context;
use sqlx::MySqlPool;
use errors::AnyhowResult;
use tokens::tokens::password_reset::PasswordResetToken;
use super::lookup_user_for_login_result::UserRecordForLogin;
// todo: ip_address_redemption nullable
pub async fn create_password_reset(pool: &MySqlPool, user: &UserRecordForLogin, ip_address: String, secret_key: String) -> AnyhowResult<()> {
let token = PasswordResetToken::generate();
sqlx::query!(
r#"
INSERT INTO password_resets
(token, user_token, secret_key, current_password_version, ip_address_creation, expires_at)
VALUES (?, ?, ?, ?, ?, NOW() + INTERVAL 3 hour);
"#,
token,
user.token,
secret_key,
user.password_version,
ip_address
)
.execute(pool)
.await
.context("inserting password reset")
.map(|_| ())
}
@@ -14,7 +14,8 @@ SELECT
username,
email_address,
password_hash,
is_banned
is_banned,
password_version
FROM users
WHERE email_address = ?
LIMIT 1
@@ -15,7 +15,8 @@ SELECT
username,
email_address,
password_hash,
is_banned
is_banned,
password_version
FROM users
WHERE username = ?
LIMIT 1
@@ -8,4 +8,5 @@ pub struct UserRecordForLogin {
pub email_address: String,
pub password_hash: Vec<u8>,
pub is_banned: i8,
pub password_version: i32,
}
@@ -1,4 +1,5 @@
pub mod create_account;
pub mod create_password_reset_request;
pub mod get_user_token_by_username;
pub mod list_users_query_builder;
pub mod lookup_user_for_login_by_email;
+3
View File
@@ -17,6 +17,7 @@ path = "src/lib.rs"
# Internal
container_common = { path = "../../../lib/deprecated/container_common" }
cookies = { path = "../../../lib/cookies" }
email = { path = "../../../lib/email" }
enums = { path = "../../../_types/enums" }
errors = { path = "../../../lib/errors" }
http_server_common = { path = "../../../lib/deprecated/http_server_common" }
@@ -65,3 +66,5 @@ sqlx = { version = "0.6.2", features = [ "mysql", "runtime-actix-native-tls", "o
# TODO: Shouldn't really be a dependency, but need to add caching for the DDoS attack.
# Redis Database
r2d2_redis = "0.14.0"
crockford = "1.1.2"
byteorder = "1.5.0"
@@ -3,4 +3,5 @@ pub mod edit_profile_handler;
pub mod get_profile_handler;
pub mod login_handler;
pub mod logout_handler;
pub mod password_reset_handler;
pub mod session_info_handler;
@@ -0,0 +1,101 @@
/*
~ B R A I N S T O R M ~
- redeem reset request
- needs a way to identify the user because only the tuple is unique (user + key)
username or email address
- provide new password
*/
use actix_web::{HttpRequest, HttpResponse, web};
use byteorder::ByteOrder;
use http_server_common::request::get_request_ip::get_request_ip;
use log::warn;
use mysql_queries::queries::users::user::create_password_reset_request::create_password_reset;
use mysql_queries::queries::users::user::lookup_user_for_login_by_email::lookup_user_for_login_by_email;
use mysql_queries::queries::users::user::lookup_user_for_login_by_username::lookup_user_for_login_by_username;
use rand::RngCore;
use serde::Deserialize;
use sqlx::MySqlPool;
/// This can be reused in login requests in the future!
#[derive(Deserialize)]
pub enum UserLogin {
Email(String),
Username(String),
}
#[derive(Deserialize)]
pub struct PasswordResetRequest {
login: UserLogin,
}
#[derive(Serialize)]
pub struct PasswordResetResponse {}
#[derive(Serialize)]
pub enum PasswordResetRequestError {
NoSuchUser,
Internal,
}
#[derive(Serialize)]
pub struct PasswordResetErrorResponse {
kind: PasswordResetRequestError,
}
impl From<PasswordResetRequestError> for PasswordResetErrorResponse {
fn from(value: PasswordResetRequestError) -> Self {
Self { kind: value }
}
}
impl From<errors::AnyhowError> for PasswordResetErrorResponse {
fn from(value: errors::AnyhowError) -> Self {
log::error!("Internal error: {value}");
Self { kind: PasswordResetRequestError::Internal }
}
}
///
/// Non-authenticated!
///
/// - create password reset request
/// - sends email?
/// - inserts record and stuff
pub async fn password_reset_request_handler(
http_request: HttpRequest,
request: web::Json<PasswordResetRequest>,
// session_cookie_manager: web::Data<SessionCookieManager>,
mysql_pool: web::Data<MySqlPool>,
) -> Result<HttpResponse, PasswordResetErrorResponse> {
let ip_address = get_request_ip(&http_request);
let user = match &request.login {
UserLogin::Email(email) => lookup_user_for_login_by_email(&email, &mysql_pool).await,
UserLogin::Username(username) => lookup_user_for_login_by_username(&username, &mysql_pool).await,
}.map_err(|e| {
warn!("Password reset user lookup: {:?}", e);
PasswordResetRequestError::NoSuchUser
//TODO: This could be anything, not necessarily a lookup. The name is misleading 🤷🏻
})?;
let mut rng = rand::thread_rng();
let mut secret_key_bytes = [0u8;40];
// Has to fit within (4 / 3) * string length (32 chars),
// but also be a multiple of 8 so we can read them as `u64`s
// (for Crockford)
rng.fill_bytes(&mut secret_key_bytes);
let mut secret_key = String::new();
secret_key_bytes.chunks_exact(8)
.map(|chunk| byteorder::LittleEndian::read_u64(chunk))
.for_each(|chunk| crockford::encode_into::<String>(chunk, &mut secret_key));
//TODO: Handle banned users, they shouldn't be able to do this
create_password_reset(&mysql_pool, &user, ip_address, secret_key.clone()).await?;
email::send_password_reset(&user.email_address, secret_key).await?;
Ok(HttpResponse::Ok().finish())
}