summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorColin Reeder <vpzomtrrfrt@gmail.com>2020-09-26 18:37:20 -0600
committerColin Reeder <vpzomtrrfrt@gmail.com>2020-09-26 18:37:20 -0600
commit1eac81cbe4bedc848c7941fa3b075f76a078542f (patch)
tree505dbe065252401d0281590749b8e7bf7ebe264e
parentaf3947530d06f63a48512892233852e67be60ec2 (diff)
parent1d2736c5029e239490141846f5bd75698a46066c (diff)
Merge branch 'forgot-password' into master
-rw-r--r--Cargo.lock7
-rw-r--r--Cargo.toml2
-rw-r--r--migrations/20200926033710_email-lower-idx/down.sql4
-rw-r--r--migrations/20200926033710_email-lower-idx/up.sql4
-rw-r--r--migrations/20200926034626_forgot-password-key/down.sql3
-rw-r--r--migrations/20200926034626_forgot-password-key/up.sql7
-rw-r--r--openapi/openapi.json77
-rw-r--r--res/lang/en.ftl4
-rw-r--r--src/routes/api/forgot_password.rs231
-rw-r--r--src/routes/api/mod.rs4
10 files changed, 342 insertions, 1 deletions
diff --git a/Cargo.lock b/Cargo.lock
index af0eabd..b09a8c0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
diff --git a/Cargo.toml b/Cargo.toml
index a6425b1..7d8029c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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()),
)
}