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
This commit is contained in:
Bogdan Mart 2021-03-13 20:16:35 +02:00
parent b5aa4cf41a
commit ab947f1f08
8 changed files with 136 additions and 3 deletions

View file

@ -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_utils::{claims::Claims, settings::Settings, ApiError, ConnectionId, LemmyError};
use lemmy_websocket::{serialize_websocket_message, LemmyContext, UserOperation}; use lemmy_websocket::{serialize_websocket_message, LemmyContext, UserOperation};
use serde::Deserialize; use serde::Deserialize;
use std::process::Command; use std::{env, process::Command};
use url::Url; use url::Url;
pub mod comment; pub mod comment;
@ -84,6 +84,11 @@ pub(crate) async fn get_user_from_jwt(jwt: &str, pool: &DbPool) -> Result<User_,
if user.banned { if user.banned {
return Err(ApiError::err("site_ban").into()); 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) Ok(user)
} }
@ -111,6 +116,11 @@ pub(crate) async fn get_user_safe_settings_from_jwt(
if user.banned { if user.banned {
return Err(ApiError::err("site_ban").into()); 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) 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> { pub(crate) fn espeak_wav_base64(text: &str) -> Result<String, LemmyError> {
// Make a temp file path // Make a temp file path
let uuid = uuid::Uuid::new_v4().to_string(); 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 // Write the wav file
Command::new("espeak") Command::new("espeak")
@ -457,7 +471,82 @@ pub(crate) fn espeak_wav_base64(text: &str) -> Result<String, LemmyError> {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn test_espeak() { fn test_espeak() {

View file

@ -235,6 +235,33 @@ pub fn establish_unpooled_connection() -> PgConnection {
conn 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! { lazy_static! {
static ref EMAIL_REGEX: Regex = static ref EMAIL_REGEX: Regex =
Regex::new(r"^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$").unwrap(); Regex::new(r"^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$").unwrap();

View file

@ -173,6 +173,7 @@ mod safe_settings_type {
last_refreshed_at, last_refreshed_at,
banner, banner,
deleted, deleted,
validator_time,
); );
impl ToSafeSettings for User_ { impl ToSafeSettings for User_ {
@ -202,6 +203,7 @@ mod safe_settings_type {
last_refreshed_at, last_refreshed_at,
banner, banner,
deleted, deleted,
validator_time,
) )
} }
} }
@ -296,6 +298,7 @@ impl User for User_ {
.set(( .set((
password_encrypted.eq(password_hash), password_encrypted.eq(password_hash),
updated.eq(naive_now()), updated.eq(naive_now()),
validator_time.eq(naive_now()),
)) ))
.get_result::<Self>(conn) .get_result::<Self>(conn)
} }
@ -446,6 +449,7 @@ mod tests {
deleted: false, deleted: false,
inbox_url: inserted_user.inbox_url.to_owned(), inbox_url: inserted_user.inbox_url.to_owned(),
shared_inbox_url: None, shared_inbox_url: None,
validator_time: inserted_user.published,
}; };
let read_user = User_::read(&conn, inserted_user.id).unwrap(); let read_user = User_::read(&conn, inserted_user.id).unwrap();

View file

@ -408,6 +408,7 @@ table! {
deleted -> Bool, deleted -> Bool,
inbox_url -> Text, inbox_url -> Text,
shared_inbox_url -> Nullable<Text>, shared_inbox_url -> Nullable<Text>,
validator_time -> Timestamp,
} }
} }

View file

@ -35,6 +35,7 @@ pub struct User_ {
pub deleted: bool, pub deleted: bool,
pub inbox_url: Url, pub inbox_url: Url,
pub shared_inbox_url: Option<Url>, pub shared_inbox_url: Option<Url>,
pub validator_time: chrono::NaiveDateTime,
} }
/// A safe representation of user, without the sensitive info /// A safe representation of user, without the sensitive info
@ -86,6 +87,7 @@ pub struct UserSafeSettings {
pub last_refreshed_at: chrono::NaiveDateTime, pub last_refreshed_at: chrono::NaiveDateTime,
pub banner: Option<String>, pub banner: Option<String>,
pub deleted: bool, pub deleted: bool,
pub validator_time: chrono::NaiveDateTime,
} }
#[derive(Clone, Queryable, Identifiable, PartialEq, Debug, Serialize)] #[derive(Clone, Queryable, Identifiable, PartialEq, Debug, Serialize)]

View file

@ -6,8 +6,14 @@ type Jwt = String;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Claims { pub struct Claims {
/// User id, for backward compatibility with client apps.
/// Claim [sub](Claims::sub) is used in server-side checks.
pub id: i32, pub id: i32,
/// User id, standard claim by RFC 7519.
pub sub: i32,
pub iss: String, pub iss: String,
/// Time when this token was issued as UNIX-timestamp in seconds
pub iat: i64,
} }
impl Claims { impl Claims {
@ -26,7 +32,9 @@ impl Claims {
pub fn jwt(user_id: i32, hostname: String) -> Result<Jwt, jsonwebtoken::errors::Error> { pub fn jwt(user_id: i32, hostname: String) -> Result<Jwt, jsonwebtoken::errors::Error> {
let my_claims = Claims { let my_claims = Claims {
id: user_id, id: user_id,
sub: user_id,
iss: hostname, iss: hostname,
iat: chrono::Utc::now().timestamp_millis() / 1000,
}; };
encode( encode(
&Header::default(), &Header::default(),

View file

@ -0,0 +1 @@
ALTER TABLE user_ DROP COLUMN validator_time;

View file

@ -0,0 +1 @@
ALTER TABLE user_ ADD COLUMN validator_time timestamp not null default now();