From: Bogdan Mart <mart.bogdan@gmail.com>
Date: Sat, 13 Mar 2021 18:16:35 +0000 (+0200)
Subject: User token revocation upon password change
X-Git-Url: http://these/git/%22https:/image.com/static/README.zh.hant.md?a=commitdiff_plain;h=ab947f1f0873adaf90b1dfdca69dd8b00d904346;p=lemmy.git

User token revocation upon password change

Added DB column validator_time and chedking that is is less then token's "Issuead at time"
Wip on #1462
---

diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs
index 5642c4b9..e1cc4451 100644
--- a/crates/api/src/lib.rs
+++ b/crates/api/src/lib.rs
@@ -22,7 +22,7 @@ use lemmy_structs::{blocking, comment::*, community::*, post::*, site::*, user::
 use lemmy_utils::{claims::Claims, settings::Settings, ApiError, ConnectionId, LemmyError};
 use lemmy_websocket::{serialize_websocket_message, LemmyContext, UserOperation};
 use serde::Deserialize;
-use std::process::Command;
+use std::{env, process::Command};
 use url::Url;
 
 pub mod comment;
@@ -84,6 +84,11 @@ pub(crate) async fn get_user_from_jwt(jwt: &str, pool: &DbPool) -> Result<User_,
   if user.banned {
     return Err(ApiError::err("site_ban").into());
   }
+  // if user's token was issued before user's password reset.
+  let user_validation_time = user.validator_time.timestamp_millis() / 1000;
+  if user_validation_time > claims.iat {
+    return Err(ApiError::err("not_logged_in").into());
+  }
   Ok(user)
 }
 
@@ -111,6 +116,11 @@ pub(crate) async fn get_user_safe_settings_from_jwt(
   if user.banned {
     return Err(ApiError::err("site_ban").into());
   }
+  // if user's token was issued before user's password reset.
+  let user_validation_time = user.validator_time.timestamp_millis() / 1000;
+  if user_validation_time >= claims.iat {
+    return Err(ApiError::err("not_logged_in").into());
+  }
   Ok(user)
 }
 
@@ -434,7 +444,11 @@ pub(crate) fn captcha_espeak_wav_base64(captcha: &str) -> Result<String, LemmyEr
 pub(crate) fn espeak_wav_base64(text: &str) -> Result<String, LemmyError> {
   // Make a temp file path
   let uuid = uuid::Uuid::new_v4().to_string();
-  let file_path = format!("/tmp/lemmy_espeak_{}.wav", &uuid);
+  let file_path = format!(
+    "{}/lemmy_espeak_{}.wav",
+    env::temp_dir().to_string_lossy(),
+    &uuid
+  );
 
   // Write the wav file
   Command::new("espeak")
@@ -457,7 +471,82 @@ pub(crate) fn espeak_wav_base64(text: &str) -> Result<String, LemmyError> {
 
 #[cfg(test)]
 mod tests {
-  use crate::captcha_espeak_wav_base64;
+  use crate::{captcha_espeak_wav_base64, get_user_from_jwt};
+  use lemmy_db_queries::{
+    establish_pooled_connection,
+    source::user::User,
+    Crud,
+    ListingType,
+    SortType,
+  };
+  use lemmy_db_schema::source::user::{UserForm, User_};
+  use lemmy_utils::claims::Claims;
+  use std::{
+    env::{current_dir, set_current_dir},
+    path::PathBuf,
+  };
+
+  #[actix_rt::test]
+  async fn test_should_not_validate_user_token_after_password_change() {
+    struct CwdGuard(PathBuf);
+    impl Drop for CwdGuard {
+      fn drop(&mut self) {
+        let _ = set_current_dir(&self.0);
+      }
+    }
+
+    let _dir_bkp = CwdGuard(current_dir().unwrap());
+
+    // so configs could be read
+    let _ = set_current_dir("../..");
+
+    let conn = establish_pooled_connection();
+
+    let new_user = UserForm {
+      name: "user_df342sgf".into(),
+      preferred_username: None,
+      password_encrypted: "nope".into(),
+      email: None,
+      matrix_user_id: None,
+      avatar: None,
+      banner: None,
+      admin: false,
+      banned: Some(false),
+      published: None,
+      updated: None,
+      show_nsfw: false,
+      theme: "browser".into(),
+      default_sort_type: SortType::Hot as i16,
+      default_listing_type: ListingType::Subscribed as i16,
+      lang: "browser".into(),
+      show_avatars: true,
+      send_notifications_to_email: false,
+      actor_id: None,
+      bio: None,
+      local: true,
+      private_key: None,
+      public_key: None,
+      last_refreshed_at: None,
+      inbox_url: None,
+      shared_inbox_url: None,
+    };
+
+    let inserted_user: User_ = User_::create(&conn.get().unwrap(), &new_user).unwrap();
+
+    let jwt_token = Claims::jwt(inserted_user.id, String::from("my-host.com")).unwrap();
+
+    get_user_from_jwt(&jwt_token, &conn)
+      .await
+      .expect("User should be decoded");
+
+    std::thread::sleep(std::time::Duration::from_secs(1));
+
+    User_::update_password(&conn.get().unwrap(), inserted_user.id, &"password111").unwrap();
+
+    let jwt_decode_res = get_user_from_jwt(&jwt_token, &conn).await;
+
+    jwt_decode_res.expect_err("JWT decode should fail after password change");
+  }
 
   #[test]
   fn test_espeak() {
diff --git a/crates/db_queries/src/lib.rs b/crates/db_queries/src/lib.rs
index 5667b426..ad3603c6 100644
--- a/crates/db_queries/src/lib.rs
+++ b/crates/db_queries/src/lib.rs
@@ -235,6 +235,33 @@ pub fn establish_unpooled_connection() -> PgConnection {
   conn
 }
 
+pub fn establish_pooled_connection(
+) -> diesel::r2d2::Pool<diesel::r2d2::ConnectionManager<diesel::PgConnection>> {
+  use diesel::r2d2::{ConnectionManager, Pool};
+
+  // Set up the r2d2 connection pool
+  let db_url = match get_database_url_from_env() {
+    Ok(url) => url,
+    Err(e) => panic!(
+      "Failed to read database URL from env var LEMMY_DATABASE_URL: {}",
+      e
+    ),
+  };
+
+  let manager = ConnectionManager::<PgConnection>::new(&db_url);
+  let pool = Pool::builder()
+    .max_size(1)
+    .build(manager)
+    .unwrap_or_else(|_| panic!("Error connecting to {}", db_url));
+
+  let conn = pool.get().unwrap();
+
+  // Run the migrations from code
+  embedded_migrations::run(&conn).unwrap();
+
+  pool
+}
+
 lazy_static! {
   static ref EMAIL_REGEX: Regex =
     Regex::new(r"^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$").unwrap();
diff --git a/crates/db_queries/src/source/user.rs b/crates/db_queries/src/source/user.rs
index 20b187b4..cb414de0 100644
--- a/crates/db_queries/src/source/user.rs
+++ b/crates/db_queries/src/source/user.rs
@@ -173,6 +173,7 @@ mod safe_settings_type {
     last_refreshed_at,
     banner,
     deleted,
+    validator_time,
   );
 
   impl ToSafeSettings for User_ {
@@ -202,6 +203,7 @@ mod safe_settings_type {
         last_refreshed_at,
         banner,
         deleted,
+        validator_time,
       )
     }
   }
@@ -296,6 +298,7 @@ impl User for User_ {
       .set((
         password_encrypted.eq(password_hash),
         updated.eq(naive_now()),
+        validator_time.eq(naive_now()),
       ))
       .get_result::<Self>(conn)
   }
@@ -446,6 +449,7 @@ mod tests {
       deleted: false,
       inbox_url: inserted_user.inbox_url.to_owned(),
       shared_inbox_url: None,
+      validator_time: inserted_user.published,
     };
 
     let read_user = User_::read(&conn, inserted_user.id).unwrap();
diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs
index 3786e00c..665e5e68 100644
--- a/crates/db_schema/src/schema.rs
+++ b/crates/db_schema/src/schema.rs
@@ -408,6 +408,7 @@ table! {
         deleted -> Bool,
         inbox_url -> Text,
         shared_inbox_url -> Nullable<Text>,
+        validator_time -> Timestamp,
     }
 }
 
diff --git a/crates/db_schema/src/source/user.rs b/crates/db_schema/src/source/user.rs
index d72929fa..47c61c4f 100644
--- a/crates/db_schema/src/source/user.rs
+++ b/crates/db_schema/src/source/user.rs
@@ -35,6 +35,7 @@ pub struct User_ {
   pub deleted: bool,
   pub inbox_url: Url,
   pub shared_inbox_url: Option<Url>,
+  pub validator_time: chrono::NaiveDateTime,
 }
 
 /// A safe representation of user, without the sensitive info
@@ -86,6 +87,7 @@ pub struct UserSafeSettings {
   pub last_refreshed_at: chrono::NaiveDateTime,
   pub banner: Option<String>,
   pub deleted: bool,
+  pub validator_time: chrono::NaiveDateTime,
 }
 
 #[derive(Clone, Queryable, Identifiable, PartialEq, Debug, Serialize)]
diff --git a/crates/utils/src/claims.rs b/crates/utils/src/claims.rs
index dff79d85..5ca77e0b 100644
--- a/crates/utils/src/claims.rs
+++ b/crates/utils/src/claims.rs
@@ -6,8 +6,14 @@ type Jwt = String;
 
 #[derive(Debug, Serialize, Deserialize)]
 pub struct Claims {
+  /// User id, for backward compatibility with client apps.
+  /// Claim [sub](Claims::sub) is used in server-side checks.
   pub id: i32,
+  /// User id, standard claim by RFC 7519.
+  pub sub: i32,
   pub iss: String,
+  /// Time when this token was issued as UNIX-timestamp in seconds
+  pub iat: i64,
 }
 
 impl Claims {
@@ -26,7 +32,9 @@ impl Claims {
   pub fn jwt(user_id: i32, hostname: String) -> Result<Jwt, jsonwebtoken::errors::Error> {
     let my_claims = Claims {
       id: user_id,
+      sub: user_id,
       iss: hostname,
+      iat: chrono::Utc::now().timestamp_millis() / 1000,
     };
     encode(
       &Header::default(),
diff --git a/migrations/2021-02-28-192405_add_col_user_validator_time/down.sql b/migrations/2021-02-28-192405_add_col_user_validator_time/down.sql
new file mode 100644
index 00000000..717b8087
--- /dev/null
+++ b/migrations/2021-02-28-192405_add_col_user_validator_time/down.sql
@@ -0,0 +1 @@
+ALTER TABLE user_ DROP COLUMN validator_time;
\ No newline at end of file
diff --git a/migrations/2021-02-28-192405_add_col_user_validator_time/up.sql b/migrations/2021-02-28-192405_add_col_user_validator_time/up.sql
new file mode 100644
index 00000000..fcba2311
--- /dev/null
+++ b/migrations/2021-02-28-192405_add_col_user_validator_time/up.sql
@@ -0,0 +1 @@
+ALTER TABLE user_ ADD COLUMN validator_time timestamp not null default now();
\ No newline at end of file