diff options
author | Colin Reeder <vpzomtrrfrt@gmail.com> | 2020-09-26 18:37:20 -0600 |
---|---|---|
committer | Colin Reeder <vpzomtrrfrt@gmail.com> | 2020-09-26 18:37:20 -0600 |
commit | 1eac81cbe4bedc848c7941fa3b075f76a078542f (patch) | |
tree | 505dbe065252401d0281590749b8e7bf7ebe264e | |
parent | af3947530d06f63a48512892233852e67be60ec2 (diff) | |
parent | 1d2736c5029e239490141846f5bd75698a46066c (diff) |
Merge branch 'forgot-password' into master
-rw-r--r-- | Cargo.lock | 7 | ||||
-rw-r--r-- | Cargo.toml | 2 | ||||
-rw-r--r-- | migrations/20200926033710_email-lower-idx/down.sql | 4 | ||||
-rw-r--r-- | migrations/20200926033710_email-lower-idx/up.sql | 4 | ||||
-rw-r--r-- | migrations/20200926034626_forgot-password-key/down.sql | 3 | ||||
-rw-r--r-- | migrations/20200926034626_forgot-password-key/up.sql | 7 | ||||
-rw-r--r-- | openapi/openapi.json | 77 | ||||
-rw-r--r-- | res/lang/en.ftl | 4 | ||||
-rw-r--r-- | src/routes/api/forgot_password.rs | 231 | ||||
-rw-r--r-- | src/routes/api/mod.rs | 4 |
10 files changed, 342 insertions, 1 deletions
@@ -144,6 +144,12 @@ dependencies = [ ] [[package]] +name = "bs58" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476e9cd489f9e121e02ffa6014a8ef220ecb15c05ed23fc34cca13925dc283fb" + +[[package]] name = "bumpalo" version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -939,6 +945,7 @@ dependencies = [ "activitystreams-ext", "async-trait", "bcrypt", + "bs58", "bytes", "chrono", "deadpool-postgres", @@ -43,6 +43,8 @@ activitystreams = "0.7.0-alpha.3" activitystreams-ext = "0.1.0-alpha.2" fast_chemail = "0.9.6" lettre = { version = "0.10.0-alpha.2", features = ["tokio02", "tokio02-native-tls"] } +rand = "0.7.3" +bs58 = "0.3.1" [dev-dependencies] rand = "0.7.3" diff --git a/migrations/20200926033710_email-lower-idx/down.sql b/migrations/20200926033710_email-lower-idx/down.sql new file mode 100644 index 0000000..57ff6e2 --- /dev/null +++ b/migrations/20200926033710_email-lower-idx/down.sql @@ -0,0 +1,4 @@ +BEGIN; + DROP INDEX person_lower_email_address_idx; + ALTER TABLE person ADD CONSTRAINT person_email_address_key UNIQUE (email_address); +COMMIT; diff --git a/migrations/20200926033710_email-lower-idx/up.sql b/migrations/20200926033710_email-lower-idx/up.sql new file mode 100644 index 0000000..50cdffa --- /dev/null +++ b/migrations/20200926033710_email-lower-idx/up.sql @@ -0,0 +1,4 @@ +BEGIN; + CREATE UNIQUE INDEX person_lower_email_address_idx ON person (LOWER(email_address)) WHERE local; + ALTER TABLE person DROP CONSTRAINT person_email_address_key; +COMMIT; diff --git a/migrations/20200926034626_forgot-password-key/down.sql b/migrations/20200926034626_forgot-password-key/down.sql new file mode 100644 index 0000000..65953e1 --- /dev/null +++ b/migrations/20200926034626_forgot-password-key/down.sql @@ -0,0 +1,3 @@ +BEGIN; + DROP TABLE forgot_password_key; +COMMIT; diff --git a/migrations/20200926034626_forgot-password-key/up.sql b/migrations/20200926034626_forgot-password-key/up.sql new file mode 100644 index 0000000..0a4ac48 --- /dev/null +++ b/migrations/20200926034626_forgot-password-key/up.sql @@ -0,0 +1,7 @@ +BEGIN; + CREATE TABLE forgot_password_key ( + key INTEGER PRIMARY KEY, + person BIGINT NOT NULL REFERENCES person, + created TIMESTAMPTZ NOT NULL + ); +COMMIT; diff --git a/openapi/openapi.json b/openapi/openapi.json index d24f2ee..2aa0059 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -750,6 +750,83 @@ "security": [{"bearer": []}] } }, + "/api/unstable/forgot_password/keys": { + "post": { + "summary": "Request a password reset", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email_address"], + "properties": { + "email_address": {"type": "string", "format": "email"} + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully requested." + } + } + } + }, + "/api/unstable/forgot_password/keys/{key}": { + "get": { + "summary": "Check validity of a password reset key", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Key is valid." + }, + "404": { + "description": "No such key, or is expired." + } + } + } + }, + "/api/unstable/forgot_password/keys/{key}/reset": { + "post": { + "summary": "Reset a password using a password reset key", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["new_password"], + "properties": { + "new_password": {"type": "string", "format": "password"} + } + } + } + } + }, + "responses": { + "200": { + "description": "Successfully reset." + } + } + } + }, "/api/unstable/instance": { "get": { "summary": "Fetch information about the instance", diff --git a/res/lang/en.ftl b/res/lang/en.ftl index 5322545..45ff178 100644 --- a/res/lang/en.ftl +++ b/res/lang/en.ftl @@ -2,12 +2,16 @@ comment_content_conflict = Exactly one of content_markdown and content_text must comment_not_yours = That's not your comment community_edit_denied = You are not authorized to modify this community community_name_disallowed_chars = Community name contains disallowed characters +email_content_forgot_password = Hi { $username }, if you requested a password reset from lotide, use this code: { $key } +email_not_configured = Email is not configured on this server moderators_only_local = Only local users can be community moderators must_be_moderator = You must be a community moderator to perform this action name_in_use = That name is already in use no_password = No password set for this user no_such_comment = No such comment no_such_community = No such community +no_such_forgot_password_key = No such password reset key, or it has expired +no_such_local_user_by_email = No local user found by that email address no_such_local_user_by_name = No local user found by that name no_such_post = No such post no_such_user = No such user diff --git a/src/routes/api/forgot_password.rs b/src/routes/api/forgot_password.rs new file mode 100644 index 0000000..14e1697 --- /dev/null +++ b/src/routes/api/forgot_password.rs @@ -0,0 +1,231 @@ +use crate::UserLocalID; +use lettre::Tokio02Transport; +use rand::Rng; +use serde_derive::Deserialize; +use std::borrow::Cow; +use std::sync::Arc; + +struct ForgotPasswordKey { + value: i32, +} + +impl ForgotPasswordKey { + pub fn generate() -> Self { + Self { + value: rand::thread_rng().gen(), + } + } + + pub fn as_int(&self) -> i32 { + self.value + } +} + +// implementing this trait is discouraged in favor of Display, but bs58 doesn't do streaming output +impl std::string::ToString for ForgotPasswordKey { + fn to_string(&self) -> String { + bs58::encode(&self.value.to_be_bytes()).into_string() + } +} + +impl std::str::FromStr for ForgotPasswordKey { + type Err = bs58::decode::Error; + + fn from_str(src: &str) -> Result<Self, Self::Err> { + let src = src.trim_matches(|c: char| !c.is_alphanumeric()); + + let mut buf = [0; 4]; + bs58::decode(src).into(&mut buf)?; + Ok(Self { + value: i32::from_be_bytes(buf), + }) + } +} + +async fn route_unstable_forgot_password_keys_create( + _: (), + ctx: Arc<crate::RouteContext>, + req: hyper::Request<hyper::Body>, +) -> Result<hyper::Response<hyper::Body>, crate::Error> { + let lang = crate::get_lang_for_req(&req); + + if ctx.mailer.is_none() { + return Err(crate::Error::UserError(crate::simple_response( + hyper::StatusCode::INTERNAL_SERVER_ERROR, + lang.tr("email_not_configured", None).into_owned(), + ))); + } + + #[derive(Deserialize)] + struct ForgotPasswordBody<'a> { + email_address: Cow<'a, str>, + } + + let body = hyper::body::to_bytes(req.into_body()).await?; + let body: ForgotPasswordBody = serde_json::from_slice(&body)?; + + let db = ctx.db_pool.get().await?; + + let user_row = db.query_opt("SELECT id, username, email_address FROM person WHERE local AND LOWER(email_address) = LOWER($1)", &[&body.email_address]).await? + .ok_or_else(|| { + crate::Error::UserError(crate::simple_response( + hyper::StatusCode::BAD_REQUEST, + lang.tr("no_such_local_user_by_email", None).into_owned(), + )) + })?; + + let user_id = UserLocalID(user_row.get(0)); + let username: &str = user_row.get(1); + let user_email: &str = user_row.get(2); + + let user_email = lettre::Mailbox::new(None, user_email.parse()?); + + let key = ForgotPasswordKey::generate(); + db.execute( + "INSERT INTO forgot_password_key (key, person, created) VALUES ($1, $2, current_timestamp)", + &[&key.as_int(), &user_id], + ) + .await?; + + let msg_body = lang + .tr( + "email_content_forgot_password", + Some(&fluent::fluent_args!["key" => key.to_string(), "username" => username]), + ) + .into_owned(); + + let msg = lettre::Message::builder() + .date_now() + .subject("Forgot Password Request") + .from(ctx.mail_from.as_ref().unwrap().clone()) + .to(user_email) + .singlepart( + lettre::message::SinglePart::binary() + .header(lettre::message::header::ContentType::text_utf8()) + .body(msg_body), + )?; + + crate::spawn_task(async move { + ctx.mailer.as_ref().unwrap().send(msg).await?; + + Ok(()) + }); + + Ok(hyper::Response::builder() + .header(hyper::header::CONTENT_TYPE, "application/json") + .body("{}".into())?) +} + +async fn route_unstable_forgot_password_keys_get( + params: (ForgotPasswordKey,), + ctx: Arc<crate::RouteContext>, + req: hyper::Request<hyper::Body>, +) -> Result<hyper::Response<hyper::Body>, crate::Error> { + let (key,) = params; + + let lang = crate::get_lang_for_req(&req); + let db = ctx.db_pool.get().await?; + + let row = db.query_opt("SELECT created < (current_timestamp - INTERVAL '1 HOUR') FROM forgot_password_key WHERE key=$1", &[&key.as_int()]).await?; + + let found = match row { + None => false, + Some(row) => !row.get::<_, bool>(0), + }; + + if found { + Ok(hyper::Response::builder() + .header(hyper::header::CONTENT_TYPE, "application/json") + .body("{}".into())?) + } else { + Err(crate::Error::UserError(crate::simple_response( + hyper::StatusCode::NOT_FOUND, + lang.tr("no_such_forgot_password_key", None).into_owned(), + ))) + } +} + +async fn route_unstable_forgot_password_keys_reset( + params: (ForgotPasswordKey,), + ctx: Arc<crate::RouteContext>, + req: hyper::Request<hyper::Body>, +) -> Result<hyper::Response<hyper::Body>, crate::Error> { + let (key,) = params; + + #[derive(Deserialize)] + struct PasswordResetBody { + new_password: String, + } + + let lang = crate::get_lang_for_req(&req); + + let body = hyper::body::to_bytes(req.into_body()).await?; + let body: PasswordResetBody = serde_json::from_slice(&body)?; + + let mut db = ctx.db_pool.get().await?; + + let row = db.query_opt("SELECT created < (current_timestamp - INTERVAL '1 HOUR'), person FROM forgot_password_key WHERE key=$1", &[&key.as_int()]).await?; + + let user_id = match row { + None => None, + Some(row) => { + if row.get(0) { + None + } else { + Some(UserLocalID(row.get(1))) + } + } + }; + + match user_id { + Some(user_id) => { + let passhash = tokio::task::spawn_blocking(move || { + bcrypt::hash(body.new_password, bcrypt::DEFAULT_COST) + }) + .await??; + + { + let trans = db.transaction().await?; + trans + .execute( + "UPDATE person SET passhash=$1 WHERE id=$2", + &[&passhash, &user_id], + ) + .await?; + trans + .execute( + "DELETE FROM forgot_password_key WHERE key=$1", + &[&key.as_int()], + ) + .await?; + + trans.commit().await?; + } + + Ok(hyper::Response::builder() + .header(hyper::header::CONTENT_TYPE, "application/json") + .body("{}".into())?) + } + None => Err(crate::Error::UserError(crate::simple_response( + hyper::StatusCode::NOT_FOUND, + lang.tr("no_such_forgot_password_key", None).into_owned(), + ))), + } +} + +pub fn route_forgot_password() -> crate::RouteNode<()> { + crate::RouteNode::new().with_child( + "keys", + crate::RouteNode::new() + .with_handler_async("POST", route_unstable_forgot_password_keys_create) + .with_child_parse::<ForgotPasswordKey, _>( + crate::RouteNode::new() + .with_handler_async("GET", route_unstable_forgot_password_keys_get) + .with_child( + "reset", + crate::RouteNode::new() + .with_handler_async("POST", route_unstable_forgot_password_keys_reset), + ), + ), + ) +} diff --git a/src/routes/api/mod.rs b/src/routes/api/mod.rs index da21cab..88f64aa 100644 --- a/src/routes/api/mod.rs +++ b/src/routes/api/mod.rs @@ -8,6 +8,7 @@ use std::sync::Arc; mod comments; mod communities; +mod forgot_password; mod posts; mod users; @@ -176,7 +177,8 @@ pub fn route_api() -> crate::RouteNode<()> { ) .with_child("posts", posts::route_posts()) .with_child("comments", comments::route_comments()) - .with_child("users", users::route_users()), + .with_child("users", users::route_users()) + .with_child("forgot_password", forgot_password::route_forgot_password()), ) } |