mirror of https://github.com/LemmyNet/lemmy
Add db table for login tokens which allows for invalidation (#3818)
* wip * stuff * fmt * fmt 2 * fmt 3 * fix default feature * use Authorization header * store ip and user agent for each login * add list_logins endpoint * serde(skip) for token * fix api tests * A few suggestions for login_token (#3991) * A few suggestions. * Fixing SQL format. * review * review * rename cookie --------- Co-authored-by: Dessalines <dessalines@users.noreply.github.com>pull/4024/head
parent
b7d570cf35
commit
dc327652a5
@ -0,0 +1,14 @@
|
|||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::context::LemmyContext;
|
||||||
|
use lemmy_db_schema::source::login_token::LoginToken;
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::LemmyError;
|
||||||
|
|
||||||
|
pub async fn list_logins(
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> Result<Json<Vec<LoginToken>>, LemmyError> {
|
||||||
|
let logins = LoginToken::list(&mut context.pool(), local_user_view.local_user.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(logins))
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
use crate::read_auth_token;
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::{cookie::Cookie, HttpRequest, HttpResponse};
|
||||||
|
use lemmy_api_common::{context::LemmyContext, utils::AUTH_COOKIE_NAME};
|
||||||
|
use lemmy_db_schema::source::login_token::LoginToken;
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn logout(
|
||||||
|
req: HttpRequest,
|
||||||
|
// require login
|
||||||
|
_local_user_view: LocalUserView,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<HttpResponse> {
|
||||||
|
let jwt = read_auth_token(&req)?.ok_or(LemmyErrorType::NotLoggedIn)?;
|
||||||
|
LoginToken::invalidate(&mut context.pool(), &jwt).await?;
|
||||||
|
|
||||||
|
let mut res = HttpResponse::Ok().finish();
|
||||||
|
let cookie = Cookie::new(AUTH_COOKIE_NAME, "");
|
||||||
|
res.add_removal_cookie(&cookie)?;
|
||||||
|
Ok(res)
|
||||||
|
}
|
@ -0,0 +1,141 @@
|
|||||||
|
use crate::{context::LemmyContext, sensitive::Sensitive};
|
||||||
|
use actix_web::{http::header::USER_AGENT, HttpRequest};
|
||||||
|
use chrono::Utc;
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
newtypes::LocalUserId,
|
||||||
|
source::login_token::{LoginToken, LoginTokenCreateForm},
|
||||||
|
};
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
/// local_user_id, standard claim by RFC 7519.
|
||||||
|
pub sub: String,
|
||||||
|
pub iss: String,
|
||||||
|
/// Time when this token was issued as UNIX-timestamp in seconds
|
||||||
|
pub iat: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Claims {
|
||||||
|
pub async fn validate(jwt: &str, context: &LemmyContext) -> LemmyResult<LocalUserId> {
|
||||||
|
let mut validation = Validation::default();
|
||||||
|
validation.validate_exp = false;
|
||||||
|
validation.required_spec_claims.remove("exp");
|
||||||
|
let jwt_secret = &context.secret().jwt_secret;
|
||||||
|
let key = DecodingKey::from_secret(jwt_secret.as_ref());
|
||||||
|
let claims =
|
||||||
|
decode::<Claims>(jwt, &key, &validation).with_lemmy_type(LemmyErrorType::NotLoggedIn)?;
|
||||||
|
let user_id = LocalUserId(claims.claims.sub.parse()?);
|
||||||
|
let is_valid = LoginToken::validate(&mut context.pool(), user_id, jwt).await?;
|
||||||
|
if !is_valid {
|
||||||
|
Err(LemmyErrorType::NotLoggedIn)?
|
||||||
|
} else {
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate(
|
||||||
|
user_id: LocalUserId,
|
||||||
|
req: HttpRequest,
|
||||||
|
context: &LemmyContext,
|
||||||
|
) -> LemmyResult<Sensitive<String>> {
|
||||||
|
let hostname = context.settings().hostname.clone();
|
||||||
|
let my_claims = Claims {
|
||||||
|
sub: user_id.0.to_string(),
|
||||||
|
iss: hostname,
|
||||||
|
iat: Utc::now().timestamp(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let secret = &context.secret().jwt_secret;
|
||||||
|
let key = EncodingKey::from_secret(secret.as_ref());
|
||||||
|
let token = encode(&Header::default(), &my_claims, &key)?;
|
||||||
|
let ip = req
|
||||||
|
.connection_info()
|
||||||
|
.realip_remote_addr()
|
||||||
|
.map(ToString::to_string);
|
||||||
|
let user_agent = req
|
||||||
|
.headers()
|
||||||
|
.get(USER_AGENT)
|
||||||
|
.and_then(|ua| ua.to_str().ok())
|
||||||
|
.map(ToString::to_string);
|
||||||
|
let form = LoginTokenCreateForm {
|
||||||
|
token: token.clone(),
|
||||||
|
user_id,
|
||||||
|
ip,
|
||||||
|
user_agent,
|
||||||
|
};
|
||||||
|
LoginToken::create(&mut context.pool(), form).await?;
|
||||||
|
Ok(Sensitive::new(token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
#![allow(clippy::indexing_slicing)]
|
||||||
|
|
||||||
|
use crate::{claims::Claims, context::LemmyContext};
|
||||||
|
use actix_web::test::TestRequest;
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
instance::Instance,
|
||||||
|
local_user::{LocalUser, LocalUserInsertForm},
|
||||||
|
person::{Person, PersonInsertForm},
|
||||||
|
secret::Secret,
|
||||||
|
},
|
||||||
|
traits::Crud,
|
||||||
|
utils::build_db_pool_for_tests,
|
||||||
|
};
|
||||||
|
use lemmy_utils::rate_limit::{RateLimitCell, RateLimitConfig};
|
||||||
|
use reqwest::Client;
|
||||||
|
use reqwest_middleware::ClientBuilder;
|
||||||
|
use serial_test::serial;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_should_not_validate_user_token_after_password_change() {
|
||||||
|
let pool_ = build_db_pool_for_tests().await;
|
||||||
|
let pool = &mut (&pool_).into();
|
||||||
|
let secret = Secret::init(pool).await.unwrap();
|
||||||
|
let context = LemmyContext::create(
|
||||||
|
pool_.clone(),
|
||||||
|
ClientBuilder::new(Client::default()).build(),
|
||||||
|
secret,
|
||||||
|
RateLimitCell::new(RateLimitConfig::builder().build())
|
||||||
|
.await
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let new_person = PersonInsertForm::builder()
|
||||||
|
.name("Gerry9812".into())
|
||||||
|
.public_key("pubkey".to_string())
|
||||||
|
.instance_id(inserted_instance.id)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let inserted_person = Person::create(pool, &new_person).await.unwrap();
|
||||||
|
|
||||||
|
let local_user_form = LocalUserInsertForm::builder()
|
||||||
|
.person_id(inserted_person.id)
|
||||||
|
.password_encrypted("123456".to_string())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let inserted_local_user = LocalUser::create(pool, &local_user_form).await.unwrap();
|
||||||
|
|
||||||
|
let req = TestRequest::default().to_http_request();
|
||||||
|
let jwt = Claims::generate(inserted_local_user.id, req, &context)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let valid = Claims::validate(&jwt, &context).await;
|
||||||
|
assert!(valid.is_ok());
|
||||||
|
|
||||||
|
let num_deleted = Person::delete(pool, inserted_person.id).await.unwrap();
|
||||||
|
assert_eq!(1, num_deleted);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
use crate::{
|
||||||
|
diesel::{ExpressionMethods, QueryDsl},
|
||||||
|
newtypes::LocalUserId,
|
||||||
|
schema::login_token::{dsl::login_token, token, user_id},
|
||||||
|
source::login_token::{LoginToken, LoginTokenCreateForm},
|
||||||
|
utils::{get_conn, DbPool},
|
||||||
|
};
|
||||||
|
use diesel::{delete, dsl::exists, insert_into, result::Error, select};
|
||||||
|
use diesel_async::RunQueryDsl;
|
||||||
|
|
||||||
|
impl LoginToken {
|
||||||
|
pub async fn create(pool: &mut DbPool<'_>, form: LoginTokenCreateForm) -> Result<Self, Error> {
|
||||||
|
let conn = &mut get_conn(pool).await?;
|
||||||
|
insert_into(login_token)
|
||||||
|
.values(form)
|
||||||
|
.get_result::<Self>(conn)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the given token is valid for user.
|
||||||
|
pub async fn validate(
|
||||||
|
pool: &mut DbPool<'_>,
|
||||||
|
user_id_: LocalUserId,
|
||||||
|
token_: &str,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let conn = &mut get_conn(pool).await?;
|
||||||
|
select(exists(
|
||||||
|
login_token
|
||||||
|
.filter(user_id.eq(user_id_))
|
||||||
|
.filter(token.eq(token_)),
|
||||||
|
))
|
||||||
|
.get_result(conn)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
pool: &mut DbPool<'_>,
|
||||||
|
user_id_: LocalUserId,
|
||||||
|
) -> Result<Vec<LoginToken>, Error> {
|
||||||
|
let conn = &mut get_conn(pool).await?;
|
||||||
|
|
||||||
|
login_token
|
||||||
|
.filter(user_id.eq(user_id_))
|
||||||
|
.get_results(conn)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate specific token on user logout.
|
||||||
|
pub async fn invalidate(pool: &mut DbPool<'_>, token_: &str) -> Result<usize, Error> {
|
||||||
|
let conn = &mut get_conn(pool).await?;
|
||||||
|
delete(login_token.filter(token.eq(token_)))
|
||||||
|
.execute(conn)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate all logins of given user on password reset/change, account deletion or site ban.
|
||||||
|
pub async fn invalidate_all(
|
||||||
|
pool: &mut DbPool<'_>,
|
||||||
|
user_id_: LocalUserId,
|
||||||
|
) -> Result<usize, Error> {
|
||||||
|
let conn = &mut get_conn(pool).await?;
|
||||||
|
delete(login_token.filter(user_id.eq(user_id_)))
|
||||||
|
.execute(conn)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
use crate::newtypes::LocalUserId;
|
||||||
|
#[cfg(feature = "full")]
|
||||||
|
use crate::schema::login_token;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Stores data related to a specific user login session.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "full", derive(Queryable, Identifiable))]
|
||||||
|
#[cfg_attr(feature = "full", diesel(table_name = login_token))]
|
||||||
|
pub struct LoginToken {
|
||||||
|
pub id: i32,
|
||||||
|
/// Jwt token for this login
|
||||||
|
#[serde(skip)]
|
||||||
|
pub token: String,
|
||||||
|
pub user_id: LocalUserId,
|
||||||
|
/// Time of login
|
||||||
|
pub published: DateTime<Utc>,
|
||||||
|
/// IP address where login was made from, allows invalidating logins by IP address.
|
||||||
|
/// Could be stored in truncated format, or store derived information for better privacy.
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
|
||||||
|
#[cfg_attr(feature = "full", diesel(table_name = login_token))]
|
||||||
|
pub struct LoginTokenCreateForm {
|
||||||
|
pub token: String,
|
||||||
|
pub user_id: LocalUserId,
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
}
|
@ -1,4 +1,24 @@
|
|||||||
|
use lemmy_api_common::{claims::Claims, context::LemmyContext, utils::check_user_valid};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::LemmyError;
|
||||||
|
|
||||||
pub mod feeds;
|
pub mod feeds;
|
||||||
pub mod images;
|
pub mod images;
|
||||||
pub mod nodeinfo;
|
pub mod nodeinfo;
|
||||||
pub mod webfinger;
|
pub mod webfinger;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
async fn local_user_view_from_jwt(
|
||||||
|
jwt: &str,
|
||||||
|
context: &LemmyContext,
|
||||||
|
) -> Result<LocalUserView, LemmyError> {
|
||||||
|
let local_user_id = Claims::validate(jwt, context).await?;
|
||||||
|
let local_user_view = LocalUserView::read(&mut context.pool(), local_user_id).await?;
|
||||||
|
check_user_valid(
|
||||||
|
local_user_view.person.banned,
|
||||||
|
local_user_view.person.ban_expires,
|
||||||
|
local_user_view.person.deleted,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(local_user_view)
|
||||||
|
}
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
use crate::error::LemmyError;
|
|
||||||
use chrono::Utc;
|
|
||||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
type Jwt = String;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Claims {
|
|
||||||
/// local_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 {
|
|
||||||
pub fn decode(jwt: &str, jwt_secret: &str) -> Result<TokenData<Claims>, LemmyError> {
|
|
||||||
let mut validation = Validation::default();
|
|
||||||
validation.validate_exp = false;
|
|
||||||
validation.required_spec_claims.remove("exp");
|
|
||||||
let key = DecodingKey::from_secret(jwt_secret.as_ref());
|
|
||||||
Ok(decode::<Claims>(jwt, &key, &validation)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn jwt(local_user_id: i32, jwt_secret: &str, hostname: &str) -> Result<Jwt, LemmyError> {
|
|
||||||
let my_claims = Claims {
|
|
||||||
sub: local_user_id,
|
|
||||||
iss: hostname.to_string(),
|
|
||||||
iat: Utc::now().timestamp(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let key = EncodingKey::from_secret(jwt_secret.as_ref());
|
|
||||||
Ok(encode(&Header::default(), &my_claims, &key)?)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1 +1 @@
|
|||||||
Subproject commit e943f97fe481dc425acdebc8872bf1fdcabaf875
|
Subproject commit 18da10858d8c63750beb06247947f25d91944741
|
@ -0,0 +1,5 @@
|
|||||||
|
DROP TABLE login_token;
|
||||||
|
|
||||||
|
ALTER TABLE local_user
|
||||||
|
ADD COLUMN validator_time timestamp NOT NULL DEFAULT now();
|
||||||
|
|
@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE login_token (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
token text NOT NULL UNIQUE,
|
||||||
|
user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
|
||||||
|
published timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ip text,
|
||||||
|
user_agent text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_login_token_user_token ON login_token (user_id, token);
|
||||||
|
|
||||||
|
-- not needed anymore as we invalidate login tokens on password change
|
||||||
|
ALTER TABLE local_user
|
||||||
|
DROP COLUMN validator_time;
|
||||||
|
|
Loading…
Reference in New Issue