diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 5642c4b9c..e1cc44515 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 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 Result { // 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 { #[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 5667b4262..ad3603c63 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> { + 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::::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 20b187b46..cb414de06 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::(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 3786e00ca..665e5e685 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, + validator_time -> Timestamp, } } diff --git a/crates/db_schema/src/source/user.rs b/crates/db_schema/src/source/user.rs index d72929fa8..47c61c4f4 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, + 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, 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 dff79d859..5ca77e0bd 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 { 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 000000000..717b80878 --- /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 000000000..fcba2311e --- /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