diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 988dac27ab..55d6efecc5 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -12,7 +12,7 @@ use std::io::Cursor; mod comment; mod comment_report; mod community; -mod local_user; +pub mod local_user; mod post; mod post_report; mod private_message; diff --git a/crates/api/src/local_user/auth.rs b/crates/api/src/local_user/auth.rs new file mode 100644 index 0000000000..74552f2905 --- /dev/null +++ b/crates/api/src/local_user/auth.rs @@ -0,0 +1,193 @@ +use actix_web::{ + cookie::{time::Duration, Cookie, SameSite}, + web::{Data, Json}, + HttpRequest, + HttpResponse, +}; +use bcrypt::verify; +use lemmy_api_common::{ + context::LemmyContext, + person::{AccessTokenResponse, GetAccessToken, GetRefreshToken}, + utils::{check_registration_application, check_user_valid}, +}; +use lemmy_db_schema::{ + source::{ + auth_api_token::{AuthApiToken, AuthApiTokenUpdateForm}, + auth_refresh_token::{ + AuthRefreshToken, + AuthRefreshTokenCreateForm, + AuthRefreshTokenUpdateForm, + }, + }, + utils::naive_now, +}; +use lemmy_db_views::structs::{LocalUserView, SiteView}; +use lemmy_utils::{ + claims::{AuthMethod, Claims}, + error::{LemmyError, LemmyErrorExt, LemmyErrorType}, + utils::validation::check_totp_2fa_valid, +}; + +const REFRESH_TOKEN_EXPIRY_WEEKS: i64 = 2; + +pub async fn auth_access_token( + req: HttpRequest, + data: Json, + context: Data, +) -> Result { + let local_user_id = match req.cookie("refresh_token") { + Some(cookie) => { + let refresh_token = + AuthRefreshToken::read_from_token(&mut context.pool(), cookie.value()).await?; + if (naive_now() - refresh_token.last_used).num_weeks() >= REFRESH_TOKEN_EXPIRY_WEEKS { + return Err(LemmyErrorType::TokenNotFound)?; + } + refresh_token.local_user_id + } + None => { + // If no refresh_token cookie exists, then assume this request is made with an api token + let token = data + .api_token + .as_ref() + .expect("No api_token or refresh_token provided"); + + let api_token = AuthApiToken::read_from_token(&mut context.pool(), token).await?; + if naive_now() > api_token.expires { + return Err(LemmyErrorType::TokenNotFound)?; + } + api_token.local_user_id + } + }; + + 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, + )?; + + let mut response_builder = HttpResponse::Ok(); + + let auth_method = match req.cookie("refresh_token") { + Some(mut cookie) => { + // Update refresh token & cookie + let token_update_form = AuthRefreshTokenUpdateForm { + last_used: naive_now(), + last_ip: req + .connection_info() + .realip_remote_addr() + .unwrap() + .to_string(), + }; + + AuthRefreshToken::update_token(&mut context.pool(), cookie.value(), &token_update_form) + .await?; + cookie.set_max_age(Duration::weeks(REFRESH_TOKEN_EXPIRY_WEEKS)); + response_builder.cookie(cookie); + AuthMethod::Password + } + None => { + // Update api token + let token_update_form = AuthApiTokenUpdateForm { + last_used: naive_now(), + last_ip: req + .connection_info() + .realip_remote_addr() + .unwrap() + .to_string(), + }; + + AuthApiToken::update_token( + &mut context.pool(), + data.api_token.as_ref().unwrap(), + &token_update_form, + ) + .await?; + AuthMethod::Api + } + }; + + response_builder.json(AccessTokenResponse { + jwt: Claims::jwt_with_exp( + local_user_view.local_user.id.0, + &context.secret().jwt_secret, + &context.settings().hostname, + auth_method, + )? + .into(), + }); + + Ok(response_builder.finish()) +} + +pub async fn auth_refresh_token( + req: HttpRequest, + data: Json, + context: Data, +) -> Result { + let site_view = SiteView::read_local(&mut context.pool()).await?; + + // Fetch that username / email + let username_or_email = data.username_or_email.clone(); + let local_user_view = + LocalUserView::find_by_email_or_name(&mut context.pool(), &username_or_email) + .await + .with_lemmy_type(LemmyErrorType::IncorrectLogin)?; + + // Verify the password + let valid: bool = verify( + &data.password, + &local_user_view.local_user.password_encrypted, + ) + .unwrap_or(false); + if !valid { + return Err(LemmyErrorType::IncorrectLogin)?; + } + check_user_valid( + local_user_view.person.banned, + local_user_view.person.ban_expires, + local_user_view.person.deleted, + )?; + + // Check if the user's email is verified if email verification is turned on + // However, skip checking verification if the user is an admin + if !local_user_view.person.admin + && site_view.local_site.require_email_verification + && !local_user_view.local_user.email_verified + { + return Err(LemmyErrorType::EmailNotVerified)?; + } + + check_registration_application(&local_user_view, &site_view.local_site, &mut context.pool()) + .await?; + + // Check the totp + check_totp_2fa_valid( + &local_user_view.local_user.totp_2fa_secret, + &data.totp_2fa_token, + &site_view.site.name, + &local_user_view.person.name, + )?; + + // Create refresh token + let form = AuthRefreshTokenCreateForm { + local_user_id: local_user_view.local_user.id, + last_ip: req + .connection_info() + .realip_remote_addr() + .unwrap() + .to_string(), + }; + let refresh_token = AuthRefreshToken::create(&mut context.pool(), &form).await?; + + let cookie = Cookie::build("refresh_token", refresh_token.token) + .same_site(SameSite::Strict) + .max_age(Duration::weeks(REFRESH_TOKEN_EXPIRY_WEEKS)) + .path("/api/v3/access_token") // This can only be used for getting access tokens + .secure(true) + .http_only(true) + .finish(); + + Ok(HttpResponse::Ok().cookie(cookie).finish()) +} diff --git a/crates/api/src/local_user/mod.rs b/crates/api/src/local_user/mod.rs index 3a92beda57..0ff5d26f5c 100644 --- a/crates/api/src/local_user/mod.rs +++ b/crates/api/src/local_user/mod.rs @@ -1,4 +1,5 @@ mod add_admin; +pub mod auth; mod ban_person; mod block; mod change_password; diff --git a/crates/api_common/src/person.rs b/crates/api_common/src/person.rs index 031bc6c7e8..3cdc00525e 100644 --- a/crates/api_common/src/person.rs +++ b/crates/api_common/src/person.rs @@ -21,7 +21,7 @@ use ts_rs::TS; #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] -/// Logging into lemmy. +/// Logging into lemmy. DEPRECATED and will be removed, use refresh tokens or API tokens instead! pub struct Login { pub username_or_email: Sensitive, pub password: Sensitive, @@ -29,6 +29,36 @@ pub struct Login { pub totp_2fa_token: Option, } +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Username + password authentication. Puts the refresh token into a httpOnly cookie. +pub struct GetRefreshToken { + pub username_or_email: Sensitive, + pub password: Sensitive, + /// May be required, if totp is enabled for their account. + pub totp_2fa_token: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Marks refresh token as expired and removes the cookie. +pub struct RevokeRefreshToken {} + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Gets a short lived access token (jwt) which must be provided for authentication with other +/// endpoints. +pub struct GetAccessToken { + /// If the refreshToken cookie exists, then this parameter is ignored. + pub api_token: Option, +} + #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "full", derive(TS))] @@ -162,6 +192,15 @@ pub struct LoginResponse { pub verify_email_sent: bool, } +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Short lived access token that can be used to authenticate future requests. +pub struct AccessTokenResponse { + pub jwt: Sensitive, +} + #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "full", derive(TS))] diff --git a/crates/db_schema/src/impls/auth_api_token.rs b/crates/db_schema/src/impls/auth_api_token.rs new file mode 100644 index 0000000000..b72ee46ca8 --- /dev/null +++ b/crates/db_schema/src/impls/auth_api_token.rs @@ -0,0 +1,39 @@ +use crate::{ + schema::auth_api_token::dsl::{auth_api_token, token}, + source::auth_api_token::{AuthApiToken, AuthApiTokenCreateForm, AuthApiTokenUpdateForm}, + utils::{get_conn, DbPool}, +}; +use diesel::{dsl::insert_into, result::Error, ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; + +impl AuthApiToken { + pub async fn create(pool: &mut DbPool<'_>, form: &AuthApiTokenCreateForm) -> Result { + let conn = &mut get_conn(pool).await?; + insert_into(auth_api_token) + .values(form) + .get_result::(conn) + .await + } + + pub async fn update_token( + pool: &mut DbPool<'_>, + input_token: &str, + form: &AuthApiTokenUpdateForm, + ) -> Result { + let conn = &mut get_conn(pool).await?; + diesel::update(auth_api_token.filter(token.eq(input_token))) + .set(form) + .get_result::(conn) + .await + } + pub async fn read_from_token( + pool: &mut DbPool<'_>, + input_token: &str, + ) -> Result { + let conn = &mut get_conn(pool).await?; + auth_api_token + .filter(token.eq(input_token)) + .first::(conn) + .await + } +} diff --git a/crates/db_schema/src/impls/auth_refresh_token.rs b/crates/db_schema/src/impls/auth_refresh_token.rs new file mode 100644 index 0000000000..1141423b59 --- /dev/null +++ b/crates/db_schema/src/impls/auth_refresh_token.rs @@ -0,0 +1,46 @@ +use crate::{ + schema::auth_refresh_token::dsl::{auth_refresh_token, token}, + source::auth_refresh_token::{ + AuthRefreshToken, + AuthRefreshTokenCreateForm, + AuthRefreshTokenUpdateForm, + }, + utils::{get_conn, DbPool}, +}; +use diesel::{dsl::insert_into, result::Error, ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; + +impl AuthRefreshToken { + pub async fn create( + pool: &mut DbPool<'_>, + form: &AuthRefreshTokenCreateForm, + ) -> Result { + let conn = &mut get_conn(pool).await?; + insert_into(auth_refresh_token) + .values(form) + .get_result::(conn) + .await + } + + pub async fn update_token( + pool: &mut DbPool<'_>, + input_token: &str, + form: &AuthRefreshTokenUpdateForm, + ) -> Result { + let conn = &mut get_conn(pool).await?; + diesel::update(auth_refresh_token.filter(token.eq(input_token))) + .set(form) + .get_result::(conn) + .await + } + pub async fn read_from_token( + pool: &mut DbPool<'_>, + input_token: &str, + ) -> Result { + let conn = &mut get_conn(pool).await?; + auth_refresh_token + .filter(token.eq(input_token)) + .first::(conn) + .await + } +} diff --git a/crates/db_schema/src/impls/mod.rs b/crates/db_schema/src/impls/mod.rs index f13004d015..1ac037a1e5 100644 --- a/crates/db_schema/src/impls/mod.rs +++ b/crates/db_schema/src/impls/mod.rs @@ -1,5 +1,7 @@ pub mod activity; pub mod actor_language; +pub mod auth_api_token; +pub mod auth_refresh_token; pub mod captcha_answer; pub mod comment; pub mod comment_reply; diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index ae75c31d8c..3391574810 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -52,6 +52,28 @@ diesel::table! { } } +diesel::table! { + auth_api_token (id) { + id -> Int4, + local_user_id -> Int4, + label -> Text, + token -> Text, + expires -> Timestamp, + last_used -> Timestamp, + last_ip -> Text, + } +} + +diesel::table! { + auth_refresh_token (id) { + id -> Int4, + local_user_id -> Int4, + token -> Text, + last_used -> Timestamp, + last_ip -> Text, + } +} + diesel::table! { captcha_answer (id) { id -> Int4, @@ -848,6 +870,8 @@ diesel::joinable!(admin_purge_community -> person (admin_person_id)); diesel::joinable!(admin_purge_person -> person (admin_person_id)); diesel::joinable!(admin_purge_post -> community (community_id)); diesel::joinable!(admin_purge_post -> person (admin_person_id)); +diesel::joinable!(auth_api_token -> local_user (local_user_id)); +diesel::joinable!(auth_refresh_token -> local_user (local_user_id)); diesel::joinable!(comment -> language (language_id)); diesel::joinable!(comment -> person (creator_id)); diesel::joinable!(comment -> post (post_id)); @@ -930,6 +954,8 @@ diesel::allow_tables_to_appear_in_same_query!( admin_purge_community, admin_purge_person, admin_purge_post, + auth_api_token, + auth_refresh_token, captcha_answer, comment, comment_aggregates, diff --git a/crates/db_schema/src/source/auth_api_token.rs b/crates/db_schema/src/source/auth_api_token.rs new file mode 100644 index 0000000000..98b4a6fac8 --- /dev/null +++ b/crates/db_schema/src/source/auth_api_token.rs @@ -0,0 +1,30 @@ +use crate::newtypes::LocalUserId; +#[cfg(feature = "full")] +use crate::schema::auth_api_token; + +#[derive(PartialEq, Eq, Debug)] +#[cfg_attr(feature = "full", derive(Queryable, Identifiable))] +#[cfg_attr(feature = "full", diesel(table_name = auth_api_token))] +pub struct AuthApiToken { + pub id: i32, + pub local_user_id: LocalUserId, + pub token: String, + pub label: String, + pub expires: chrono::NaiveDateTime, + pub last_used: chrono::NaiveDateTime, + pub last_ip: String, +} + +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = auth_api_token))] +pub struct AuthApiTokenCreateForm { + pub local_user_id: LocalUserId, + pub last_ip: String, +} + +#[cfg_attr(feature = "full", derive(AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = auth_api_token))] +pub struct AuthApiTokenUpdateForm { + pub last_used: chrono::NaiveDateTime, + pub last_ip: String, +} diff --git a/crates/db_schema/src/source/auth_refresh_token.rs b/crates/db_schema/src/source/auth_refresh_token.rs new file mode 100644 index 0000000000..23fe63b338 --- /dev/null +++ b/crates/db_schema/src/source/auth_refresh_token.rs @@ -0,0 +1,28 @@ +use crate::newtypes::LocalUserId; +#[cfg(feature = "full")] +use crate::schema::auth_refresh_token; + +#[derive(PartialEq, Eq, Debug)] +#[cfg_attr(feature = "full", derive(Queryable, Identifiable))] +#[cfg_attr(feature = "full", diesel(table_name = auth_refresh_token))] +pub struct AuthRefreshToken { + pub id: i32, + pub local_user_id: LocalUserId, + pub token: String, + pub last_used: chrono::NaiveDateTime, + pub last_ip: String, +} + +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = auth_refresh_token))] +pub struct AuthRefreshTokenCreateForm { + pub local_user_id: LocalUserId, + pub last_ip: String, +} + +#[cfg_attr(feature = "full", derive(AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = auth_refresh_token))] +pub struct AuthRefreshTokenUpdateForm { + pub last_used: chrono::NaiveDateTime, + pub last_ip: String, +} diff --git a/crates/db_schema/src/source/mod.rs b/crates/db_schema/src/source/mod.rs index a46f4fb40b..dffc3b11a1 100644 --- a/crates/db_schema/src/source/mod.rs +++ b/crates/db_schema/src/source/mod.rs @@ -4,6 +4,8 @@ use url::Url; #[cfg(feature = "full")] pub mod activity; pub mod actor_language; +pub mod auth_api_token; +pub mod auth_refresh_token; pub mod captcha_answer; pub mod comment; pub mod comment_reply; diff --git a/crates/utils/src/claims.rs b/crates/utils/src/claims.rs index 77a7206949..dec6ab968c 100644 --- a/crates/utils/src/claims.rs +++ b/crates/utils/src/claims.rs @@ -1,10 +1,16 @@ use crate::error::LemmyError; -use chrono::Utc; +use chrono::{Duration, Utc}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation}; use serde::{Deserialize, Serialize}; type Jwt = String; +#[derive(Debug, Serialize, Deserialize)] +pub enum AuthMethod { + Password, + Api, +} + #[derive(Debug, Serialize, Deserialize)] pub struct Claims { /// local_user_id, standard claim by RFC 7519. @@ -12,22 +18,59 @@ pub struct Claims { pub iss: String, /// Time when this token was issued as UNIX-timestamp in seconds pub iat: i64, + // TODO: This should be made non-optional once deprecated /login endpoint has been removed + pub exp: Option, + // TODO: This should be made non-optional once deprecated /login endpoint has been removed + pub method: Option, } impl Claims { pub fn decode(jwt: &str, jwt_secret: &str) -> Result, 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::(jwt, &key, &validation)?) + + let decoded_token = match decode::(jwt, &key, &validation) { + Ok(res) => res, + Err(_) => { + // For backwards compatibility with deprecated authentication, we also allow JWTs with no + // expiry. + // TODO: This should be removed once the deprecated /login endpoint has been removed + validation.validate_exp = false; + validation.required_spec_claims.remove("exp"); + decode::(jwt, &key, &validation)? + } + }; + + Ok(decoded_token) } + // Used for deprecated /login endpoint, does not have an expiry + // TODO: This should be removed once the deprecated /login endpoint has been removed pub fn jwt(local_user_id: i32, jwt_secret: &str, hostname: &str) -> Result { let my_claims = Claims { sub: local_user_id, iss: hostname.to_string(), iat: Utc::now().timestamp(), + exp: None, + method: None, + }; + + let key = EncodingKey::from_secret(jwt_secret.as_ref()); + Ok(encode(&Header::default(), &my_claims, &key)?) + } + + pub fn jwt_with_exp( + local_user_id: i32, + jwt_secret: &str, + hostname: &str, + method: AuthMethod, + ) -> Result { + let my_claims = Claims { + sub: local_user_id, + iss: hostname.to_string(), + iat: Utc::now().timestamp(), + exp: Some((Utc::now() + Duration::minutes(5)).timestamp()), + method: Some(method), }; let key = EncodingKey::from_secret(jwt_secret.as_ref()); diff --git a/migrations/2023-07-16-123316_auth_overhaul/down.sql b/migrations/2023-07-16-123316_auth_overhaul/down.sql new file mode 100644 index 0000000000..85470fbb2a --- /dev/null +++ b/migrations/2023-07-16-123316_auth_overhaul/down.sql @@ -0,0 +1,5 @@ +-- This file should undo anything in `up.sql` +DROP INDEX idx_auth_api_token_token; +DROP TABLE auth_api_token; +DROP INDEX idx_auth_refresh_token_token; +DROP TABLE auth_refresh_token; diff --git a/migrations/2023-07-16-123316_auth_overhaul/up.sql b/migrations/2023-07-16-123316_auth_overhaul/up.sql new file mode 100644 index 0000000000..425eca5062 --- /dev/null +++ b/migrations/2023-07-16-123316_auth_overhaul/up.sql @@ -0,0 +1,28 @@ +-- Your SQL goes here +CREATE TABLE auth_refresh_token +( + id serial PRIMARY KEY, + local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + token text NOT NULL DEFAULT encode(digest(gen_random_bytes(1024), 'sha512'), 'hex'), + + last_used timestamp NOT NULL DEFAULT now(), + last_ip text NOT NULL +); + +CREATE INDEX idx_auth_refresh_token_token ON auth_refresh_token (token); + + +CREATE TABLE auth_api_token +( + id serial PRIMARY KEY, + local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + label text NOT NULL, + token text NOT NULL DEFAULT 'lemmyv1_' || + encode(digest(gen_random_bytes(1024), 'sha512'), 'hex'), + expires timestamp NOT NULL DEFAULT now(), + last_used timestamp NOT NULL DEFAULT now(), + last_ip text NOT NULL +); + + +CREATE INDEX idx_auth_api_token_token ON auth_api_token (token); \ No newline at end of file diff --git a/src/api_routes_http.rs b/src/api_routes_http.rs index cb735f807c..0e4f828c27 100644 --- a/src/api_routes_http.rs +++ b/src/api_routes_http.rs @@ -1,5 +1,8 @@ use actix_web::{guard, web, Error, HttpResponse, Result}; -use lemmy_api::Perform; +use lemmy_api::{ + local_user::auth::{auth_access_token, auth_refresh_token}, + Perform, +}; use lemmy_api_common::{ comment::{ CreateComment, @@ -300,6 +303,8 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) { .route("/block", web::post().to(route_post::)) // Account actions. I don't like that they're in /user maybe /accounts .route("/login", web::post().to(route_post::)) + .route("/get_refresh_token", web::post().to(auth_refresh_token)) + .route("/get_access_token", web::post().to(auth_access_token)) .route( "/delete_account", web::post().to(route_post_crud::),