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>
This commit is contained in:
Nutomic 2023-10-09 12:46:12 +02:00 committed by GitHub
parent b7d570cf35
commit dc327652a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 586 additions and 288 deletions

16
Cargo.lock generated
View file

@ -2625,6 +2625,7 @@ version = "0.19.0-beta.7"
dependencies = [ dependencies = [
"activitypub_federation", "activitypub_federation",
"actix-web", "actix-web",
"actix-web-httpauth",
"anyhow", "anyhow",
"async-trait", "async-trait",
"base64 0.21.2", "base64 0.21.2",
@ -2660,6 +2661,7 @@ dependencies = [
"encoding", "encoding",
"futures", "futures",
"getrandom", "getrandom",
"jsonwebtoken",
"lemmy_db_schema", "lemmy_db_schema",
"lemmy_db_views", "lemmy_db_views",
"lemmy_db_views_actor", "lemmy_db_views_actor",
@ -2673,6 +2675,7 @@ dependencies = [
"rosetta-i18n", "rosetta-i18n",
"serde", "serde",
"serde_with", "serde_with",
"serial_test",
"tokio", "tokio",
"tracing", "tracing",
"ts-rs", "ts-rs",
@ -2938,7 +2941,6 @@ dependencies = [
"html2text", "html2text",
"http", "http",
"itertools 0.11.0", "itertools 0.11.0",
"jsonwebtoken",
"lettre", "lettre",
"markdown-it", "markdown-it",
"once_cell", "once_cell",
@ -3366,9 +3368,9 @@ dependencies = [
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.3" version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"num-integer", "num-integer",
@ -3398,9 +3400,9 @@ dependencies = [
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.15" version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2"
dependencies = [ dependencies = [
"autocfg", "autocfg",
] ]
@ -3669,9 +3671,9 @@ checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
[[package]] [[package]]
name = "pem" name = "pem"
version = "1.1.0" version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c64931a1a212348ec4f3b4362585eca7159d0d09cbdf4a7f74f02173596fd4" checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8"
dependencies = [ dependencies = [
"base64 0.13.1", "base64 0.13.1",
] ]

View file

@ -82,6 +82,7 @@ actix-web = { version = "4.3.1", default-features = false, features = [
"compress-brotli", "compress-brotli",
"compress-gzip", "compress-gzip",
"compress-zstd", "compress-zstd",
"cookies",
] } ] }
tracing = "0.1.37" tracing = "0.1.37"
tracing-actix-web = { version = "0.7.5", default-features = false } tracing-actix-web = { version = "0.7.5", default-features = false }
@ -135,6 +136,7 @@ lemmy_utils = { workspace = true }
lemmy_db_schema = { workspace = true } lemmy_db_schema = { workspace = true }
lemmy_api_common = { workspace = true } lemmy_api_common = { workspace = true }
lemmy_routes = { workspace = true } lemmy_routes = { workspace = true }
lemmy_federate = { version = "0.19.0-beta.7", path = "crates/federate" }
activitypub_federation = { workspace = true } activitypub_federation = { workspace = true }
diesel = { workspace = true } diesel = { workspace = true }
diesel-async = { workspace = true } diesel-async = { workspace = true }
@ -169,4 +171,3 @@ actix-web-prom = { version = "0.6.0", optional = true }
serial_test = { workspace = true } serial_test = { workspace = true }
clap = { version = "4.3.19", features = ["derive"] } clap = { version = "4.3.19", features = ["derive"] }
actix-web-httpauth = "0.8.1" actix-web-httpauth = "0.8.1"
lemmy_federate = { version = "0.19.0-beta.7", path = "crates/federate" }

View file

@ -36,10 +36,11 @@ import {
waitUntil, waitUntil,
waitForPost, waitForPost,
alphaUrl, alphaUrl,
loginUser,
} from "./shared"; } from "./shared";
import { PostView } from "lemmy-js-client/dist/types/PostView"; import { PostView } from "lemmy-js-client/dist/types/PostView";
import { CreatePost } from "lemmy-js-client/dist/types/CreatePost"; import { CreatePost } from "lemmy-js-client/dist/types/CreatePost";
import { LemmyHttp } from "lemmy-js-client"; import { LemmyHttp, Login } from "lemmy-js-client";
let betaCommunity: CommunityView | undefined; let betaCommunity: CommunityView | undefined;
@ -391,8 +392,9 @@ test("Enforce site ban for federated user", async () => {
let alpha_user = new LemmyHttp(alphaUrl, { let alpha_user = new LemmyHttp(alphaUrl, {
headers: { Authorization: `Bearer ${alphaUserJwt.jwt ?? ""}` }, headers: { Authorization: `Bearer ${alphaUserJwt.jwt ?? ""}` },
}); });
let alphaUserActorId = (await getSite(alpha_user)).my_user?.local_user_view let alphaUserPerson = (await getSite(alpha_user)).my_user?.local_user_view
.person.actor_id; .person;
let alphaUserActorId = alphaUserPerson?.actor_id;
if (!alphaUserActorId) { if (!alphaUserActorId) {
throw "Missing alpha user actor id"; throw "Missing alpha user actor id";
} }
@ -438,8 +440,13 @@ test("Enforce site ban for federated user", async () => {
); );
expect(unBanAlpha.banned).toBe(false); expect(unBanAlpha.banned).toBe(false);
// Login gets invalidated by ban, need to login again
let newAlphaUserJwt = await loginUser(alpha, alphaUserPerson?.name!);
alpha_user.setHeaders({
Authorization: "Bearer " + newAlphaUserJwt.jwt ?? "",
});
// alpha makes new post in beta community, it federates // alpha makes new post in beta community, it federates
let postRes2 = await createPost(alpha_user, betaCommunity.community.id); let postRes2 = await createPost(alpha_user, betaCommunity!.community.id);
let searchBeta3 = await waitForPost(beta, postRes2.post_view.post); let searchBeta3 = await waitForPost(beta, postRes2.post_view.post);
let alphaUserOnBeta2 = await resolvePerson(beta, alphaUserActorId!); let alphaUserOnBeta2 = await resolvePerson(beta, alphaUserActorId!);

View file

@ -619,6 +619,17 @@ export async function registerUser(
return api.register(form); return api.register(form);
} }
export async function loginUser(
api: LemmyHttp,
username: string,
): Promise<LoginResponse> {
let form: Login = {
username_or_email: username,
password: password,
};
return api.login(form);
}
export async function saveUserSettingsBio( export async function saveUserSettingsBio(
api: LemmyHttp, api: LemmyHttp,
): Promise<LoginResponse> { ): Promise<LoginResponse> {

View file

@ -35,6 +35,7 @@ url = { workspace = true }
wav = "1.0.0" wav = "1.0.0"
sitemap-rs = "0.2.0" sitemap-rs = "0.2.0"
totp-rs = { version = "5.0.2", features = ["gen_secret", "otpauth"] } totp-rs = { version = "5.0.2", features = ["gen_secret", "otpauth"] }
actix-web-httpauth = "0.8.1"
[dev-dependencies] [dev-dependencies]
serial_test = { workspace = true } serial_test = { workspace = true }

View file

@ -1,6 +1,8 @@
use actix_web::{http::header::Header, HttpRequest};
use actix_web_httpauth::headers::authorization::{Authorization, Bearer};
use base64::{engine::general_purpose::STANDARD_NO_PAD as base64, Engine}; use base64::{engine::general_purpose::STANDARD_NO_PAD as base64, Engine};
use captcha::Captcha; use captcha::Captcha;
use lemmy_api_common::utils::local_site_to_slur_regex; use lemmy_api_common::utils::{local_site_to_slur_regex, AUTH_COOKIE_NAME};
use lemmy_db_schema::source::local_site::LocalSite; use lemmy_db_schema::source::local_site::LocalSite;
use lemmy_db_views::structs::LocalUserView; use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{ use lemmy_utils::{
@ -69,6 +71,28 @@ pub(crate) fn check_report_reason(reason: &str, local_site: &LocalSite) -> Resul
} }
} }
pub fn read_auth_token(req: &HttpRequest) -> Result<Option<String>, LemmyError> {
// Try reading jwt from auth header
if let Ok(header) = Authorization::<Bearer>::parse(req) {
Ok(Some(header.as_ref().token().to_string()))
}
// If that fails, try to read from cookie
else if let Some(cookie) = &req.cookie(AUTH_COOKIE_NAME) {
// ensure that its marked as httponly and secure
let secure = cookie.secure().unwrap_or_default();
let http_only = cookie.http_only().unwrap_or_default();
if !secure || !http_only {
Err(LemmyError::from(LemmyErrorType::AuthCookieInsecure))
} else {
Ok(Some(cookie.value().to_string()))
}
}
// Otherwise, there's no auth
else {
Ok(None)
}
}
pub(crate) fn check_totp_2fa_valid( pub(crate) fn check_totp_2fa_valid(
local_user_view: &LocalUserView, local_user_view: &LocalUserView,
totp_token: &Option<String>, totp_token: &Option<String>,

View file

@ -8,6 +8,7 @@ use lemmy_api_common::{
}; };
use lemmy_db_schema::{ use lemmy_db_schema::{
source::{ source::{
login_token::LoginToken,
moderator::{ModBan, ModBanForm}, moderator::{ModBan, ModBanForm},
person::{Person, PersonUpdateForm}, person::{Person, PersonUpdateForm},
}, },
@ -44,6 +45,12 @@ pub async fn ban_from_site(
.await .await
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?; .with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
let local_user_id = LocalUserView::read_person(&mut context.pool(), data.person_id)
.await?
.local_user
.id;
LoginToken::invalidate_all(&mut context.pool(), local_user_id).await?;
// Remove their data if that's desired // Remove their data if that's desired
let remove_data = data.remove_data.unwrap_or(false); let remove_data = data.remove_data.unwrap_or(false);
if remove_data { if remove_data {

View file

@ -1,20 +1,22 @@
use actix_web::web::{Data, Json}; use actix_web::{
web::{Data, Json},
HttpRequest,
};
use bcrypt::verify; use bcrypt::verify;
use lemmy_api_common::{ use lemmy_api_common::{
claims::Claims,
context::LemmyContext, context::LemmyContext,
person::{ChangePassword, LoginResponse}, person::{ChangePassword, LoginResponse},
utils::password_length_check, utils::password_length_check,
}; };
use lemmy_db_schema::source::local_user::LocalUser; use lemmy_db_schema::source::{local_user::LocalUser, login_token::LoginToken};
use lemmy_db_views::structs::LocalUserView; use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{ use lemmy_utils::error::{LemmyError, LemmyErrorType};
claims::Claims,
error::{LemmyError, LemmyErrorType},
};
#[tracing::instrument(skip(context))] #[tracing::instrument(skip(context))]
pub async fn change_password( pub async fn change_password(
data: Json<ChangePassword>, data: Json<ChangePassword>,
req: HttpRequest,
context: Data<LemmyContext>, context: Data<LemmyContext>,
local_user_view: LocalUserView, local_user_view: LocalUserView,
) -> Result<Json<LoginResponse>, LemmyError> { ) -> Result<Json<LoginResponse>, LemmyError> {
@ -40,16 +42,11 @@ pub async fn change_password(
let updated_local_user = let updated_local_user =
LocalUser::update_password(&mut context.pool(), local_user_id, &new_password).await?; LocalUser::update_password(&mut context.pool(), local_user_id, &new_password).await?;
LoginToken::invalidate_all(&mut context.pool(), local_user_view.local_user.id).await?;
// Return the jwt // Return the jwt
Ok(Json(LoginResponse { Ok(Json(LoginResponse {
jwt: Some( jwt: Some(Claims::generate(updated_local_user.id, req, &context).await?),
Claims::jwt(
updated_local_user.id.0,
&context.secret().jwt_secret,
&context.settings().hostname,
)?
.into(),
),
verify_email_sent: false, verify_email_sent: false,
registration_created: false, registration_created: false,
})) }))

View file

@ -6,6 +6,7 @@ use lemmy_api_common::{
}; };
use lemmy_db_schema::source::{ use lemmy_db_schema::source::{
local_user::LocalUser, local_user::LocalUser,
login_token::LoginToken,
password_reset_request::PasswordResetRequest, password_reset_request::PasswordResetRequest,
}; };
use lemmy_utils::error::{LemmyError, LemmyErrorExt, LemmyErrorType}; use lemmy_utils::error::{LemmyError, LemmyErrorExt, LemmyErrorType};
@ -34,6 +35,8 @@ pub async fn change_password_after_reset(
.await .await
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?; .with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
LoginToken::invalidate_all(&mut context.pool(), local_user_id).await?;
Ok(Json(LoginResponse { Ok(Json(LoginResponse {
jwt: None, jwt: None,
verify_email_sent: false, verify_email_sent: false,

View file

@ -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))
}

View file

@ -1,10 +1,16 @@
use crate::check_totp_2fa_valid; use crate::check_totp_2fa_valid;
use actix_web::web::{Data, Json}; use actix_web::{
http::StatusCode,
web::{Data, Json},
HttpRequest,
HttpResponse,
};
use bcrypt::verify; use bcrypt::verify;
use lemmy_api_common::{ use lemmy_api_common::{
claims::Claims,
context::LemmyContext, context::LemmyContext,
person::{Login, LoginResponse}, person::{Login, LoginResponse},
utils::check_user_valid, utils::{check_user_valid, create_login_cookie},
}; };
use lemmy_db_schema::{ use lemmy_db_schema::{
source::{local_site::LocalSite, registration_application::RegistrationApplication}, source::{local_site::LocalSite, registration_application::RegistrationApplication},
@ -12,16 +18,14 @@ use lemmy_db_schema::{
RegistrationMode, RegistrationMode,
}; };
use lemmy_db_views::structs::{LocalUserView, SiteView}; use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_utils::{ use lemmy_utils::error::{LemmyError, LemmyErrorExt, LemmyErrorType};
claims::Claims,
error::{LemmyError, LemmyErrorExt, LemmyErrorType},
};
#[tracing::instrument(skip(context))] #[tracing::instrument(skip(context))]
pub async fn login( pub async fn login(
data: Json<Login>, data: Json<Login>,
req: HttpRequest,
context: Data<LemmyContext>, context: Data<LemmyContext>,
) -> Result<Json<LoginResponse>, LemmyError> { ) -> Result<HttpResponse, LemmyError> {
let site_view = SiteView::read_local(&mut context.pool()).await?; let site_view = SiteView::read_local(&mut context.pool()).await?;
// Fetch that username / email // Fetch that username / email
@ -63,19 +67,17 @@ pub async fn login(
check_totp_2fa_valid(&local_user_view, &data.totp_2fa_token, &site_view.site.name)?; check_totp_2fa_valid(&local_user_view, &data.totp_2fa_token, &site_view.site.name)?;
} }
// Return the jwt let jwt = Claims::generate(local_user_view.local_user.id, req, &context).await?;
Ok(Json(LoginResponse {
jwt: Some( let json = LoginResponse {
Claims::jwt( jwt: Some(jwt.clone()),
local_user_view.local_user.id.0,
&context.secret().jwt_secret,
&context.settings().hostname,
)?
.into(),
),
verify_email_sent: false, verify_email_sent: false,
registration_created: false, registration_created: false,
})) };
let mut res = HttpResponse::build(StatusCode::OK).json(json);
res.add_cookie(&create_login_cookie(jwt))?;
Ok(res)
} }
async fn check_registration_application( async fn check_registration_application(

View file

@ -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)
}

View file

@ -6,7 +6,9 @@ pub mod change_password_after_reset;
pub mod generate_totp_secret; pub mod generate_totp_secret;
pub mod get_captcha; pub mod get_captcha;
pub mod list_banned; pub mod list_banned;
pub mod list_logins;
pub mod login; pub mod login;
pub mod logout;
pub mod notifications; pub mod notifications;
pub mod report_count; pub mod report_count;
pub mod reset_password; pub mod reset_password;

View file

@ -1,8 +1,9 @@
use actix_web::web::{Data, Json}; use actix_web::web::{Data, Json};
use lemmy_api_common::{ use lemmy_api_common::{
context::LemmyContext, context::LemmyContext,
person::{LoginResponse, SaveUserSettings}, person::SaveUserSettings,
utils::{sanitize_html_api_opt, send_verification_email}, utils::{sanitize_html_api_opt, send_verification_email},
SuccessResponse,
}; };
use lemmy_db_schema::{ use lemmy_db_schema::{
source::{ source::{
@ -15,7 +16,6 @@ use lemmy_db_schema::{
}; };
use lemmy_db_views::structs::{LocalUserView, SiteView}; use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_utils::{ use lemmy_utils::{
claims::Claims,
error::{LemmyError, LemmyErrorExt, LemmyErrorType}, error::{LemmyError, LemmyErrorExt, LemmyErrorType},
utils::validation::{is_valid_bio_field, is_valid_display_name, is_valid_matrix_id}, utils::validation::{is_valid_bio_field, is_valid_display_name, is_valid_matrix_id},
}; };
@ -25,7 +25,7 @@ pub async fn save_user_settings(
data: Json<SaveUserSettings>, data: Json<SaveUserSettings>,
context: Data<LemmyContext>, context: Data<LemmyContext>,
local_user_view: LocalUserView, local_user_view: LocalUserView,
) -> Result<Json<LoginResponse>, LemmyError> { ) -> Result<Json<SuccessResponse>, LemmyError> {
let site_view = SiteView::read_local(&mut context.pool()).await?; let site_view = SiteView::read_local(&mut context.pool()).await?;
let bio = sanitize_html_api_opt(&data.bio); let bio = sanitize_html_api_opt(&data.bio);
@ -41,8 +41,11 @@ pub async fn save_user_settings(
if let Some(Some(email)) = &email { if let Some(Some(email)) = &email {
let previous_email = local_user_view.local_user.email.clone().unwrap_or_default(); let previous_email = local_user_view.local_user.email.clone().unwrap_or_default();
// Only send the verification email if there was an email change // if email was changed, check that it is not taken and send verification mail
if previous_email.ne(email) { if &previous_email != email {
if LocalUser::is_email_taken(&mut context.pool(), email).await? {
return Err(LemmyErrorType::EmailAlreadyExists)?;
}
send_verification_email( send_verification_email(
&local_user_view, &local_user_view,
email, email,
@ -119,34 +122,7 @@ pub async fn save_user_settings(
..Default::default() ..Default::default()
}; };
let local_user_res = LocalUser::update(&mut context.pool(), local_user_id, &local_user_form).await?;
LocalUser::update(&mut context.pool(), local_user_id, &local_user_form).await;
let updated_local_user = match local_user_res {
Ok(u) => u,
Err(e) => {
let err_type = if e.to_string()
== "duplicate key value violates unique constraint \"local_user_email_key\""
{
LemmyErrorType::EmailAlreadyExists
} else {
LemmyErrorType::UserAlreadyExists
};
return Err(e).with_lemmy_type(err_type); Ok(Json(SuccessResponse::default()))
}
};
// Return the jwt
Ok(Json(LoginResponse {
jwt: Some(
Claims::jwt(
updated_local_user.id.0,
&context.secret().jwt_secret,
&context.settings().hostname,
)?
.into(),
),
verify_email_sent: false,
registration_created: false,
}))
} }

View file

@ -34,6 +34,7 @@ full = [
"actix-web", "actix-web",
"futures", "futures",
"once_cell", "once_cell",
"jsonwebtoken",
] ]
[dependencies] [dependencies]
@ -64,5 +65,10 @@ reqwest = { workspace = true, optional = true }
ts-rs = { workspace = true, optional = true } ts-rs = { workspace = true, optional = true }
once_cell = { workspace = true, optional = true } once_cell = { workspace = true, optional = true }
actix-web = { workspace = true, optional = true } actix-web = { workspace = true, optional = true }
jsonwebtoken = { version = "8.3.0", optional = true }
# necessary for wasmt compilation # necessary for wasmt compilation
getrandom = { version = "0.2.10", features = ["js"] } getrandom = { version = "0.2.10", features = ["js"] }
[dev-dependencies]
serial_test = { workspace = true }
reqwest-middleware = { workspace = true }

View file

@ -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);
}
}

View file

@ -1,5 +1,7 @@
#[cfg(feature = "full")] #[cfg(feature = "full")]
pub mod build_response; pub mod build_response;
#[cfg(feature = "full")]
pub mod claims;
pub mod comment; pub mod comment;
pub mod community; pub mod community;
#[cfg(feature = "full")] #[cfg(feature = "full")]
@ -21,3 +23,19 @@ pub extern crate lemmy_db_schema;
pub extern crate lemmy_db_views; pub extern crate lemmy_db_views;
pub extern crate lemmy_db_views_actor; pub extern crate lemmy_db_views_actor;
pub extern crate lemmy_db_views_moderator; pub extern crate lemmy_db_views_moderator;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "full", derive(ts_rs::TS))]
#[cfg_attr(feature = "full", ts(export))]
/// Saves settings for your user.
pub struct SuccessResponse {
pub success: bool,
}
impl Default for SuccessResponse {
fn default() -> Self {
SuccessResponse { success: true }
}
}

View file

@ -1,4 +1,10 @@
use crate::{context::LemmyContext, request::purge_image_from_pictrs, site::FederatedInstances}; use crate::{
context::LemmyContext,
request::purge_image_from_pictrs,
sensitive::Sensitive,
site::FederatedInstances,
};
use actix_web::cookie::{Cookie, SameSite};
use anyhow::Context; use anyhow::Context;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use lemmy_db_schema::{ use lemmy_db_schema::{
@ -38,6 +44,8 @@ use rosetta_i18n::{Language, LanguageId};
use tracing::warn; use tracing::warn;
use url::{ParseError, Url}; use url::{ParseError, Url};
pub static AUTH_COOKIE_NAME: &str = "auth";
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub async fn is_mod_or_admin( pub async fn is_mod_or_admin(
pool: &mut DbPool<'_>, pool: &mut DbPool<'_>,
@ -743,6 +751,14 @@ pub fn sanitize_html_federation_opt(data: &Option<String>) -> Option<String> {
data.as_ref().map(|d| sanitize_html_federation(d)) data.as_ref().map(|d| sanitize_html_federation(d))
} }
pub fn create_login_cookie(jwt: Sensitive<String>) -> Cookie<'static> {
let mut cookie = Cookie::new(AUTH_COOKIE_NAME, jwt.into_inner());
cookie.set_secure(true);
cookie.set_same_site(SameSite::Lax);
cookie.set_http_only(true);
cookie
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#![allow(clippy::unwrap_used)] #![allow(clippy::unwrap_used)]

View file

@ -24,8 +24,8 @@ use lemmy_utils::{
#[tracing::instrument(skip(context))] #[tracing::instrument(skip(context))]
pub async fn get_site( pub async fn get_site(
context: Data<LemmyContext>,
local_user_view: Option<LocalUserView>, local_user_view: Option<LocalUserView>,
context: Data<LemmyContext>,
) -> Result<Json<GetSiteResponse>, LemmyError> { ) -> Result<Json<GetSiteResponse>, LemmyError> {
let site_view = SiteView::read_local(&mut context.pool()).await?; let site_view = SiteView::read_local(&mut context.pool()).await?;

View file

@ -1,9 +1,11 @@
use activitypub_federation::{config::Data, http_signatures::generate_actor_keypair}; use activitypub_federation::{config::Data, http_signatures::generate_actor_keypair};
use actix_web::web::Json; use actix_web::{http::StatusCode, web::Json, HttpRequest, HttpResponse, HttpResponseBuilder};
use lemmy_api_common::{ use lemmy_api_common::{
claims::Claims,
context::LemmyContext, context::LemmyContext,
person::{LoginResponse, Register}, person::{LoginResponse, Register},
utils::{ utils::{
create_login_cookie,
generate_inbox_url, generate_inbox_url,
generate_local_apub_endpoint, generate_local_apub_endpoint,
generate_shared_inbox_url, generate_shared_inbox_url,
@ -30,7 +32,6 @@ use lemmy_db_schema::{
}; };
use lemmy_db_views::structs::{LocalUserView, SiteView}; use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_utils::{ use lemmy_utils::{
claims::Claims,
error::{LemmyError, LemmyErrorExt, LemmyErrorType}, error::{LemmyError, LemmyErrorExt, LemmyErrorType},
utils::{ utils::{
slurs::{check_slurs, check_slurs_opt}, slurs::{check_slurs, check_slurs_opt},
@ -41,8 +42,9 @@ use lemmy_utils::{
#[tracing::instrument(skip(context))] #[tracing::instrument(skip(context))]
pub async fn register( pub async fn register(
data: Json<Register>, data: Json<Register>,
req: HttpRequest,
context: Data<LemmyContext>, context: Data<LemmyContext>,
) -> Result<Json<LoginResponse>, LemmyError> { ) -> Result<HttpResponse, LemmyError> {
let site_view = SiteView::read_local(&mut context.pool()).await?; let site_view = SiteView::read_local(&mut context.pool()).await?;
let local_site = site_view.local_site; let local_site = site_view.local_site;
let require_registration_application = let require_registration_application =
@ -164,6 +166,7 @@ pub async fn register(
.await?; .await?;
} }
let mut res = HttpResponseBuilder::new(StatusCode::OK);
let mut login_response = LoginResponse { let mut login_response = LoginResponse {
jwt: None, jwt: None,
registration_created: false, registration_created: false,
@ -174,14 +177,9 @@ pub async fn register(
if !local_site.site_setup if !local_site.site_setup
|| (!require_registration_application && !local_site.require_email_verification) || (!require_registration_application && !local_site.require_email_verification)
{ {
login_response.jwt = Some( let jwt = Claims::generate(inserted_local_user.id, req, &context).await?;
Claims::jwt( res.cookie(create_login_cookie(jwt.clone()));
inserted_local_user.id.0, login_response.jwt = Some(jwt);
&context.secret().jwt_secret,
&context.settings().hostname,
)?
.into(),
);
} else { } else {
if local_site.require_email_verification { if local_site.require_email_verification {
let local_user_view = LocalUserView { let local_user_view = LocalUserView {
@ -211,5 +209,5 @@ pub async fn register(
} }
} }
Ok(Json(login_response)) Ok(res.json(login_response))
} }

View file

@ -7,7 +7,7 @@ use lemmy_api_common::{
send_activity::{ActivityChannel, SendActivityData}, send_activity::{ActivityChannel, SendActivityData},
utils::purge_user_account, utils::purge_user_account,
}; };
use lemmy_db_schema::source::person::Person; use lemmy_db_schema::source::{login_token::LoginToken, person::Person};
use lemmy_db_views::structs::LocalUserView; use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyError, LemmyErrorType}; use lemmy_utils::error::{LemmyError, LemmyErrorType};
@ -33,6 +33,8 @@ pub async fn delete_account(
Person::delete_account(&mut context.pool(), local_user_view.person.id).await?; Person::delete_account(&mut context.pool(), local_user_view.person.id).await?;
} }
LoginToken::invalidate_all(&mut context.pool(), local_user_view.local_user.id).await?;
ActivityChannel::submit_activity( ActivityChannel::submit_activity(
SendActivityData::DeleteUser(local_user_view.person, data.delete_content), SendActivityData::DeleteUser(local_user_view.person, data.delete_content),
&context, &context,

View file

@ -6,14 +6,17 @@ use crate::{
email_verified, email_verified,
local_user, local_user,
password_encrypted, password_encrypted,
validator_time,
}, },
source::{ source::{
actor_language::{LocalUserLanguage, SiteLanguage}, actor_language::{LocalUserLanguage, SiteLanguage},
local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm}, local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm},
}, },
traits::Crud, traits::Crud,
utils::{get_conn, naive_now, DbPool}, utils::{
functions::{coalesce, lower},
get_conn,
DbPool,
},
}; };
use bcrypt::{hash, DEFAULT_COST}; use bcrypt::{hash, DEFAULT_COST};
use diesel::{dsl::insert_into, result::Error, ExpressionMethods, QueryDsl}; use diesel::{dsl::insert_into, result::Error, ExpressionMethods, QueryDsl};
@ -29,10 +32,7 @@ impl LocalUser {
let password_hash = hash(new_password, DEFAULT_COST).expect("Couldn't hash password"); let password_hash = hash(new_password, DEFAULT_COST).expect("Couldn't hash password");
diesel::update(local_user.find(local_user_id)) diesel::update(local_user.find(local_user_id))
.set(( .set((password_encrypted.eq(password_hash),))
password_encrypted.eq(password_hash),
validator_time.eq(naive_now()),
))
.get_result::<Self>(conn) .get_result::<Self>(conn)
.await .await
} }
@ -58,9 +58,11 @@ impl LocalUser {
pub async fn is_email_taken(pool: &mut DbPool<'_>, email_: &str) -> Result<bool, Error> { pub async fn is_email_taken(pool: &mut DbPool<'_>, email_: &str) -> Result<bool, Error> {
use diesel::dsl::{exists, select}; use diesel::dsl::{exists, select};
let conn = &mut get_conn(pool).await?; let conn = &mut get_conn(pool).await?;
select(exists(local_user.filter(email.eq(email_)))) select(exists(
.get_result(conn) local_user.filter(lower(coalesce(email, "")).eq(email_.to_lowercase())),
.await ))
.get_result(conn)
.await
} }
} }

View file

@ -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
}
}

View file

@ -17,6 +17,7 @@ pub mod language;
pub mod local_site; pub mod local_site;
pub mod local_site_rate_limit; pub mod local_site_rate_limit;
pub mod local_user; pub mod local_user;
pub mod login_token;
pub mod moderator; pub mod moderator;
pub mod password_reset_request; pub mod password_reset_request;
pub mod person; pub mod person;

View file

@ -68,10 +68,7 @@ impl Person {
// Set the local user info to none // Set the local user info to none
diesel::update(local_user::table.filter(local_user::person_id.eq(person_id))) diesel::update(local_user::table.filter(local_user::person_id.eq(person_id)))
.set(( .set(local_user::email.eq::<Option<String>>(None))
local_user::email.eq::<Option<String>>(None),
local_user::validator_time.eq(naive_now()),
))
.execute(conn) .execute(conn)
.await?; .await?;

View file

@ -428,7 +428,6 @@ diesel::table! {
interface_language -> Varchar, interface_language -> Varchar,
show_avatars -> Bool, show_avatars -> Bool,
send_notifications_to_email -> Bool, send_notifications_to_email -> Bool,
validator_time -> Timestamptz,
show_scores -> Bool, show_scores -> Bool,
show_bot_accounts -> Bool, show_bot_accounts -> Bool,
show_read_posts -> Bool, show_read_posts -> Bool,
@ -454,6 +453,17 @@ diesel::table! {
} }
} }
diesel::table! {
login_token (id) {
id -> Int4,
token -> Text,
user_id -> Int4,
published -> Timestamptz,
ip -> Nullable<Text>,
user_agent -> Nullable<Text>,
}
}
diesel::table! { diesel::table! {
mod_add (id) { mod_add (id) {
id -> Int4, id -> Int4,
@ -945,6 +955,7 @@ diesel::joinable!(local_site_rate_limit -> local_site (local_site_id));
diesel::joinable!(local_user -> person (person_id)); diesel::joinable!(local_user -> person (person_id));
diesel::joinable!(local_user_language -> language (language_id)); diesel::joinable!(local_user_language -> language (language_id));
diesel::joinable!(local_user_language -> local_user (local_user_id)); diesel::joinable!(local_user_language -> local_user (local_user_id));
diesel::joinable!(login_token -> local_user (user_id));
diesel::joinable!(mod_add_community -> community (community_id)); diesel::joinable!(mod_add_community -> community (community_id));
diesel::joinable!(mod_ban_from_community -> community (community_id)); diesel::joinable!(mod_ban_from_community -> community (community_id));
diesel::joinable!(mod_feature_post -> person (mod_person_id)); diesel::joinable!(mod_feature_post -> person (mod_person_id));
@ -1024,6 +1035,7 @@ diesel::allow_tables_to_appear_in_same_query!(
local_site_rate_limit, local_site_rate_limit,
local_user, local_user,
local_user_language, local_user_language,
login_token,
mod_add, mod_add,
mod_add_community, mod_add_community,
mod_ban, mod_ban,

View file

@ -6,7 +6,6 @@ use crate::{
PostListingMode, PostListingMode,
SortType, SortType,
}; };
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
#[cfg(feature = "full")] #[cfg(feature = "full")]
@ -35,8 +34,6 @@ pub struct LocalUser {
/// Whether to show avatars. /// Whether to show avatars.
pub show_avatars: bool, pub show_avatars: bool,
pub send_notifications_to_email: bool, pub send_notifications_to_email: bool,
/// A validation ID used in logging out sessions.
pub validator_time: DateTime<Utc>,
/// Whether to show comment / post scores. /// Whether to show comment / post scores.
pub show_scores: bool, pub show_scores: bool,
/// Whether to show bot accounts. /// Whether to show bot accounts.

View file

@ -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>,
}

View file

@ -22,6 +22,7 @@ pub mod language;
pub mod local_site; pub mod local_site;
pub mod local_site_rate_limit; pub mod local_site_rate_limit;
pub mod local_user; pub mod local_user;
pub mod login_token;
pub mod moderator; pub mod moderator;
pub mod password_reset_request; pub mod password_reset_request;
pub mod person; pub mod person;

View file

@ -254,7 +254,6 @@ mod tests {
interface_language: inserted_sara_local_user.interface_language, interface_language: inserted_sara_local_user.interface_language,
show_avatars: inserted_sara_local_user.show_avatars, show_avatars: inserted_sara_local_user.show_avatars,
send_notifications_to_email: inserted_sara_local_user.send_notifications_to_email, send_notifications_to_email: inserted_sara_local_user.send_notifications_to_email,
validator_time: inserted_sara_local_user.validator_time,
show_bot_accounts: inserted_sara_local_user.show_bot_accounts, show_bot_accounts: inserted_sara_local_user.show_bot_accounts,
show_scores: inserted_sara_local_user.show_scores, show_scores: inserted_sara_local_user.show_scores,
show_read_posts: inserted_sara_local_user.show_read_posts, show_read_posts: inserted_sara_local_user.show_read_posts,

View file

@ -1,19 +1,18 @@
use crate::local_user_view_from_jwt;
use actix_web::{error::ErrorBadRequest, web, Error, HttpRequest, HttpResponse, Result}; use actix_web::{error::ErrorBadRequest, web, Error, HttpRequest, HttpResponse, Result};
use anyhow::anyhow; use anyhow::anyhow;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use lemmy_api_common::context::LemmyContext; use lemmy_api_common::context::LemmyContext;
use lemmy_db_schema::{ use lemmy_db_schema::{
newtypes::LocalUserId, source::{community::Community, person::Person},
source::{community::Community, local_user::LocalUser, person::Person}, traits::ApubActor,
traits::{ApubActor, Crud},
utils::DbPool,
CommentSortType, CommentSortType,
ListingType, ListingType,
SortType, SortType,
}; };
use lemmy_db_views::{ use lemmy_db_views::{
post_view::PostQuery, post_view::PostQuery,
structs::{LocalUserView, PostView, SiteView}, structs::{PostView, SiteView},
}; };
use lemmy_db_views_actor::{ use lemmy_db_views_actor::{
comment_reply_view::CommentReplyQuery, comment_reply_view::CommentReplyQuery,
@ -22,7 +21,6 @@ use lemmy_db_views_actor::{
}; };
use lemmy_utils::{ use lemmy_utils::{
cache_header::cache_1hour, cache_header::cache_1hour,
claims::Claims,
error::LemmyError, error::LemmyError,
utils::markdown::markdown_to_html, utils::markdown::markdown_to_html,
}; };
@ -182,53 +180,38 @@ async fn get_feed(
_ => return Err(ErrorBadRequest(LemmyError::from(anyhow!("wrong_type")))), _ => return Err(ErrorBadRequest(LemmyError::from(anyhow!("wrong_type")))),
}; };
let jwt_secret = context.secret().jwt_secret.clone();
let protocol_and_hostname = context.settings().get_protocol_and_hostname();
let builder = match request_type { let builder = match request_type {
RequestType::User => { RequestType::User => {
get_feed_user( get_feed_user(
&mut context.pool(), &context,
&info.sort_type()?, &info.sort_type()?,
&info.get_limit(), &info.get_limit(),
&info.get_page(), &info.get_page(),
&param, &param,
&protocol_and_hostname,
) )
.await .await
} }
RequestType::Community => { RequestType::Community => {
get_feed_community( get_feed_community(
&mut context.pool(), &context,
&info.sort_type()?, &info.sort_type()?,
&info.get_limit(), &info.get_limit(),
&info.get_page(), &info.get_page(),
&param, &param,
&protocol_and_hostname,
) )
.await .await
} }
RequestType::Front => { RequestType::Front => {
get_feed_front( get_feed_front(
&mut context.pool(), &context,
&jwt_secret,
&info.sort_type()?, &info.sort_type()?,
&info.get_limit(), &info.get_limit(),
&info.get_page(), &info.get_page(),
&param, &param,
&protocol_and_hostname,
)
.await
}
RequestType::Inbox => {
get_feed_inbox(
&mut context.pool(),
&jwt_secret,
&param,
&protocol_and_hostname,
) )
.await .await
} }
RequestType::Inbox => get_feed_inbox(&context, &param).await,
} }
.map_err(ErrorBadRequest)?; .map_err(ErrorBadRequest)?;
@ -243,15 +226,14 @@ async fn get_feed(
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
async fn get_feed_user( async fn get_feed_user(
pool: &mut DbPool<'_>, context: &LemmyContext,
sort_type: &SortType, sort_type: &SortType,
limit: &i64, limit: &i64,
page: &i64, page: &i64,
user_name: &str, user_name: &str,
protocol_and_hostname: &str,
) -> Result<ChannelBuilder, LemmyError> { ) -> Result<ChannelBuilder, LemmyError> {
let site_view = SiteView::read_local(pool).await?; let site_view = SiteView::read_local(&mut context.pool()).await?;
let person = Person::read_from_name(pool, user_name, false).await?; let person = Person::read_from_name(&mut context.pool(), user_name, false).await?;
let posts = PostQuery { let posts = PostQuery {
listing_type: (Some(ListingType::All)), listing_type: (Some(ListingType::All)),
@ -261,10 +243,10 @@ async fn get_feed_user(
page: (Some(*page)), page: (Some(*page)),
..Default::default() ..Default::default()
} }
.list(pool) .list(&mut context.pool())
.await?; .await?;
let items = create_post_items(posts, protocol_and_hostname)?; let items = create_post_items(posts, &context.settings().get_protocol_and_hostname())?;
let mut channel_builder = ChannelBuilder::default(); let mut channel_builder = ChannelBuilder::default();
channel_builder channel_builder
@ -278,15 +260,14 @@ async fn get_feed_user(
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
async fn get_feed_community( async fn get_feed_community(
pool: &mut DbPool<'_>, context: &LemmyContext,
sort_type: &SortType, sort_type: &SortType,
limit: &i64, limit: &i64,
page: &i64, page: &i64,
community_name: &str, community_name: &str,
protocol_and_hostname: &str,
) -> Result<ChannelBuilder, LemmyError> { ) -> Result<ChannelBuilder, LemmyError> {
let site_view = SiteView::read_local(pool).await?; let site_view = SiteView::read_local(&mut context.pool()).await?;
let community = Community::read_from_name(pool, community_name, false).await?; let community = Community::read_from_name(&mut context.pool(), community_name, false).await?;
let posts = PostQuery { let posts = PostQuery {
sort: (Some(*sort_type)), sort: (Some(*sort_type)),
@ -295,10 +276,10 @@ async fn get_feed_community(
page: (Some(*page)), page: (Some(*page)),
..Default::default() ..Default::default()
} }
.list(pool) .list(&mut context.pool())
.await?; .await?;
let items = create_post_items(posts, protocol_and_hostname)?; let items = create_post_items(posts, &context.settings().get_protocol_and_hostname())?;
let mut channel_builder = ChannelBuilder::default(); let mut channel_builder = ChannelBuilder::default();
channel_builder channel_builder
@ -316,17 +297,14 @@ async fn get_feed_community(
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
async fn get_feed_front( async fn get_feed_front(
pool: &mut DbPool<'_>, context: &LemmyContext,
jwt_secret: &str,
sort_type: &SortType, sort_type: &SortType,
limit: &i64, limit: &i64,
page: &i64, page: &i64,
jwt: &str, jwt: &str,
protocol_and_hostname: &str,
) -> Result<ChannelBuilder, LemmyError> { ) -> Result<ChannelBuilder, LemmyError> {
let site_view = SiteView::read_local(pool).await?; let site_view = SiteView::read_local(&mut context.pool()).await?;
let local_user_id = LocalUserId(Claims::decode(jwt, jwt_secret)?.claims.sub); let local_user = local_user_view_from_jwt(jwt, context).await?;
let local_user = LocalUserView::read(pool, local_user_id).await?;
let posts = PostQuery { let posts = PostQuery {
listing_type: (Some(ListingType::Subscribed)), listing_type: (Some(ListingType::Subscribed)),
@ -336,10 +314,11 @@ async fn get_feed_front(
page: (Some(*page)), page: (Some(*page)),
..Default::default() ..Default::default()
} }
.list(pool) .list(&mut context.pool())
.await?; .await?;
let items = create_post_items(posts, protocol_and_hostname)?; let protocol_and_hostname = context.settings().get_protocol_and_hostname();
let items = create_post_items(posts, &protocol_and_hostname)?;
let mut channel_builder = ChannelBuilder::default(); let mut channel_builder = ChannelBuilder::default();
channel_builder channel_builder
@ -356,17 +335,11 @@ async fn get_feed_front(
} }
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
async fn get_feed_inbox( async fn get_feed_inbox(context: &LemmyContext, jwt: &str) -> Result<ChannelBuilder, LemmyError> {
pool: &mut DbPool<'_>, let site_view = SiteView::read_local(&mut context.pool()).await?;
jwt_secret: &str, let local_user = local_user_view_from_jwt(jwt, context).await?;
jwt: &str, let person_id = local_user.local_user.person_id;
protocol_and_hostname: &str, let show_bot_accounts = local_user.local_user.show_bot_accounts;
) -> Result<ChannelBuilder, LemmyError> {
let site_view = SiteView::read_local(pool).await?;
let local_user_id = LocalUserId(Claims::decode(jwt, jwt_secret)?.claims.sub);
let local_user = LocalUser::read(pool, local_user_id).await?;
let person_id = local_user.person_id;
let show_bot_accounts = local_user.show_bot_accounts;
let sort = CommentSortType::New; let sort = CommentSortType::New;
@ -378,7 +351,7 @@ async fn get_feed_inbox(
limit: (Some(RSS_FETCH_LIMIT)), limit: (Some(RSS_FETCH_LIMIT)),
..Default::default() ..Default::default()
} }
.list(pool) .list(&mut context.pool())
.await?; .await?;
let mentions = PersonMentionQuery { let mentions = PersonMentionQuery {
@ -389,10 +362,11 @@ async fn get_feed_inbox(
limit: (Some(RSS_FETCH_LIMIT)), limit: (Some(RSS_FETCH_LIMIT)),
..Default::default() ..Default::default()
} }
.list(pool) .list(&mut context.pool())
.await?; .await?;
let items = create_reply_and_mention_items(replies, mentions, protocol_and_hostname)?; let protocol_and_hostname = context.settings().get_protocol_and_hostname();
let items = create_reply_and_mention_items(replies, mentions, &protocol_and_hostname)?;
let mut channel_builder = ChannelBuilder::default(); let mut channel_builder = ChannelBuilder::default();
channel_builder channel_builder

View file

@ -93,17 +93,15 @@ fn adapt_request(
async fn upload( async fn upload(
req: HttpRequest, req: HttpRequest,
body: web::Payload, body: web::Payload,
client: web::Data<ClientWithMiddleware>,
context: web::Data<LemmyContext>,
// require login // require login
local_user_view: LocalUserView, local_user_view: LocalUserView,
context: web::Data<LemmyContext>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
// TODO: check rate limit here // TODO: check rate limit here
let pictrs_config = context.settings().pictrs_config()?; let pictrs_config = context.settings().pictrs_config()?;
let image_url = format!("{}image", pictrs_config.url); let image_url = format!("{}image", pictrs_config.url);
let mut client_req = adapt_request(&req, &client, image_url); let mut client_req = adapt_request(&req, context.client(), image_url);
if let Some(addr) = req.head().peer_addr { if let Some(addr) = req.head().peer_addr {
client_req = client_req.header("X-Forwarded-For", addr.to_string()) client_req = client_req.header("X-Forwarded-For", addr.to_string())

View file

@ -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)
}

View file

@ -44,7 +44,6 @@ openssl = "0.10.55"
html2text = "0.6.0" html2text = "0.6.0"
deser-hjson = "1.2.0" deser-hjson = "1.2.0"
smart-default = "0.7.1" smart-default = "0.7.1"
jsonwebtoken = "8.3.0"
lettre = { version = "0.10.4", features = ["tokio1", "tokio1-native-tls"] } lettre = { version = "0.10.4", features = ["tokio1", "tokio1-native-tls"] }
markdown-it = "0.5.1" markdown-it = "0.5.1"
ts-rs = { workspace = true, optional = true } ts-rs = { workspace = true, optional = true }

View file

@ -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)?)
}
}

View file

@ -213,6 +213,7 @@ pub enum LemmyErrorType {
CouldntSendWebmention, CouldntSendWebmention,
ContradictingFilters, ContradictingFilters,
InstanceBlockAlreadyExists, InstanceBlockAlreadyExists,
/// `jwt` cookie must be marked secure and httponly
AuthCookieInsecure, AuthCookieInsecure,
Unknown(String), Unknown(String),
} }

View file

@ -6,13 +6,11 @@ extern crate smart_default;
pub mod apub; pub mod apub;
pub mod cache_header; pub mod cache_header;
pub mod email; pub mod email;
pub mod rate_limit;
pub mod settings;
pub mod claims;
pub mod error; pub mod error;
pub mod rate_limit;
pub mod request; pub mod request;
pub mod response; pub mod response;
pub mod settings;
pub mod utils; pub mod utils;
pub mod version; pub mod version;

@ -1 +1 @@
Subproject commit e943f97fe481dc425acdebc8872bf1fdcabaf875 Subproject commit 18da10858d8c63750beb06247947f25d91944741

View file

@ -0,0 +1,5 @@
DROP TABLE login_token;
ALTER TABLE local_user
ADD COLUMN validator_time timestamp NOT NULL DEFAULT now();

View file

@ -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;

View file

@ -23,7 +23,9 @@ use lemmy_api::{
generate_totp_secret::generate_totp_secret, generate_totp_secret::generate_totp_secret,
get_captcha::get_captcha, get_captcha::get_captcha,
list_banned::list_banned_users, list_banned::list_banned_users,
list_logins::list_logins,
login::login, login::login,
logout::logout,
notifications::{ notifications::{
list_mentions::list_mentions, list_mentions::list_mentions,
list_replies::list_replies, list_replies::list_replies,
@ -273,6 +275,7 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) {
.route("/block", web::post().to(block_person)) .route("/block", web::post().to(block_person))
// Account actions. I don't like that they're in /user maybe /accounts // Account actions. I don't like that they're in /user maybe /accounts
.route("/login", web::post().to(login)) .route("/login", web::post().to(login))
.route("/logout", web::post().to(logout))
.route("/delete_account", web::post().to(delete_account)) .route("/delete_account", web::post().to(delete_account))
.route("/password_reset", web::post().to(reset_password)) .route("/password_reset", web::post().to(reset_password))
.route( .route(
@ -291,7 +294,8 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) {
.route("/verify_email", web::post().to(verify_email)) .route("/verify_email", web::post().to(verify_email))
.route("/leave_admin", web::post().to(leave_admin)) .route("/leave_admin", web::post().to(leave_admin))
.route("/totp/generate", web::post().to(generate_totp_secret)) .route("/totp/generate", web::post().to(generate_totp_secret))
.route("/totp/update", web::post().to(update_totp)), .route("/totp/update", web::post().to(update_totp))
.route("/list_logins", web::get().to(list_logins)),
) )
// Admin Actions // Admin Actions
.service( .service(

View file

@ -1,30 +1,23 @@
use actix_web::{ use actix_web::{
body::MessageBody, body::MessageBody,
cookie::SameSite,
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
http::header::{Header, CACHE_CONTROL}, http::header::CACHE_CONTROL,
Error, Error,
HttpMessage, HttpMessage,
}; };
use actix_web_httpauth::headers::authorization::{Authorization, Bearer};
use chrono::{DateTime, Utc};
use core::future::Ready; use core::future::Ready;
use futures_util::future::LocalBoxFuture; use futures_util::future::LocalBoxFuture;
use lemmy_api::read_auth_token;
use lemmy_api_common::{ use lemmy_api_common::{
claims::Claims,
context::LemmyContext, context::LemmyContext,
lemmy_db_views::structs::LocalUserView, lemmy_db_views::structs::LocalUserView,
utils::check_user_valid, utils::check_user_valid,
}; };
use lemmy_db_schema::newtypes::LocalUserId; use lemmy_utils::error::{LemmyError, LemmyErrorExt2, LemmyErrorType};
use lemmy_utils::{
claims::Claims,
error::{LemmyError, LemmyErrorExt2, LemmyErrorType},
};
use reqwest::header::HeaderValue; use reqwest::header::HeaderValue;
use std::{future::ready, rc::Rc}; use std::{future::ready, rc::Rc};
static AUTH_COOKIE_NAME: &str = "auth";
#[derive(Clone)] #[derive(Clone)]
pub struct SessionMiddleware { pub struct SessionMiddleware {
context: LemmyContext, context: LemmyContext,
@ -77,25 +70,7 @@ where
let context = self.context.clone(); let context = self.context.clone();
Box::pin(async move { Box::pin(async move {
let auth_header = Authorization::<Bearer>::parse(&req).ok(); let jwt = read_auth_token(req.request())?;
let jwt = if let Some(a) = auth_header {
Some(a.as_ref().token().to_string())
}
// If that fails, try auth cookie. Dont use the `jwt` cookie from lemmy-ui because
// its not http-only.
else {
let auth_cookie = req.cookie(AUTH_COOKIE_NAME);
if let Some(a) = &auth_cookie {
// ensure that its marked as httponly and secure
let secure = a.secure().unwrap_or_default();
let http_only = a.http_only().unwrap_or_default();
let same_site = a.same_site();
if !secure || !http_only || same_site != Some(SameSite::Strict) {
return Err(LemmyError::from(LemmyErrorType::AuthCookieInsecure).into());
}
}
auth_cookie.map(|c| c.value().to_string())
};
if let Some(jwt) = &jwt { if let Some(jwt) = &jwt {
// Ignore any invalid auth so the site can still be used // Ignore any invalid auth so the site can still be used
@ -130,10 +105,9 @@ async fn local_user_view_from_jwt(
jwt: &str, jwt: &str,
context: &LemmyContext, context: &LemmyContext,
) -> Result<LocalUserView, LemmyError> { ) -> Result<LocalUserView, LemmyError> {
let claims = Claims::decode(jwt, &context.secret().jwt_secret) let local_user_id = Claims::validate(jwt, context)
.with_lemmy_type(LemmyErrorType::NotLoggedIn)? .await
.claims; .with_lemmy_type(LemmyErrorType::NotLoggedIn)?;
let local_user_id = LocalUserId(claims.sub);
let local_user_view = LocalUserView::read(&mut context.pool(), local_user_id).await?; let local_user_view = LocalUserView::read(&mut context.pool(), local_user_id).await?;
check_user_valid( check_user_valid(
local_user_view.person.banned, local_user_view.person.banned,
@ -141,27 +115,16 @@ async fn local_user_view_from_jwt(
local_user_view.person.deleted, local_user_view.person.deleted,
)?; )?;
check_validator_time(&local_user_view.local_user.validator_time, &claims)?;
Ok(local_user_view) Ok(local_user_view)
} }
/// Checks if user's token was issued before user's password reset.
fn check_validator_time(validator_time: &DateTime<Utc>, claims: &Claims) -> Result<(), LemmyError> {
let user_validation_time = validator_time.timestamp();
if user_validation_time > claims.iat {
Err(LemmyErrorType::NotLoggedIn)?
} else {
Ok(())
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#![allow(clippy::unwrap_used)] #![allow(clippy::unwrap_used)]
#![allow(clippy::indexing_slicing)] #![allow(clippy::indexing_slicing)]
use super::*; use super::*;
use actix_web::test::TestRequest;
use lemmy_db_schema::{ use lemmy_db_schema::{
source::{ source::{
instance::Instance, instance::Instance,
@ -172,21 +135,29 @@ mod tests {
traits::Crud, traits::Crud,
utils::build_db_pool_for_tests, utils::build_db_pool_for_tests,
}; };
use lemmy_utils::{claims::Claims, settings::SETTINGS}; use lemmy_utils::rate_limit::{RateLimitCell, RateLimitConfig};
use reqwest::Client;
use reqwest_middleware::ClientBuilder;
use serial_test::serial; use serial_test::serial;
use std::env; use std::env::set_current_dir;
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn test_session_auth() { async fn test_session_auth() {
let pool = &build_db_pool_for_tests().await; // hack, necessary so that config file can be loaded from hardcoded, relative path
let pool = &mut pool.into(); set_current_dir("crates/utils").unwrap();
let secret = Secret::init(pool).await.unwrap();
// test.sh sets `LEMMY_CONFIG_LOCATION=../../config/config.hjson` for code under crates folder. let pool_ = build_db_pool_for_tests().await;
// this results in a config not found error, so we need to unset this var and use default. let pool = &mut (&pool_).into();
env::remove_var("LEMMY_CONFIG_LOCATION"); let secret = Secret::init(pool).await.unwrap();
let settings = &SETTINGS.to_owned(); 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()) let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string())
.await .await
@ -207,23 +178,13 @@ mod tests {
let inserted_local_user = LocalUser::create(pool, &local_user_form).await.unwrap(); let inserted_local_user = LocalUser::create(pool, &local_user_form).await.unwrap();
let jwt = Claims::jwt( let req = TestRequest::default().to_http_request();
inserted_local_user.id.0, let jwt = Claims::generate(inserted_local_user.id, req, &context)
&secret.jwt_secret, .await
&settings.hostname, .unwrap();
)
.unwrap();
let claims = Claims::decode(&jwt, &secret.jwt_secret).unwrap().claims;
let check = check_validator_time(&inserted_local_user.validator_time, &claims);
assert!(check.is_ok());
// The check should fail, since the validator time is now newer than the jwt issue time let valid = Claims::validate(&jwt, &context).await;
let updated_local_user = assert!(valid.is_ok());
LocalUser::update_password(pool, inserted_local_user.id, "password111")
.await
.unwrap();
let check_after = check_validator_time(&updated_local_user.validator_time, &claims);
assert!(check_after.is_err());
let num_deleted = Person::delete(pool, inserted_person.id).await.unwrap(); let num_deleted = Person::delete(pool, inserted_person.id).await.unwrap();
assert_eq!(1, num_deleted); assert_eq!(1, num_deleted);