Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auth overhaul (access tokens, refresh tokens, api tokens) #3636

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
193 changes: 193 additions & 0 deletions crates/api/src/local_user/auth.rs
Original file line number Diff line number Diff line change
@@ -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<GetAccessToken>,
context: Data<LemmyContext>,
) -> Result<HttpResponse, LemmyError> {
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<GetRefreshToken>,
context: Data<LemmyContext>,
) -> Result<HttpResponse, LemmyError> {
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())
}
1 change: 1 addition & 0 deletions crates/api/src/local_user/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod add_admin;
pub mod auth;
mod ban_person;
mod block;
mod change_password;
Expand Down
41 changes: 40 additions & 1 deletion crates/api_common/src/person.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,44 @@ 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<String>,
pub password: Sensitive<String>,
/// May be required, if totp is enabled for their account.
pub totp_2fa_token: Option<String>,
}

#[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<String>,
pub password: Sensitive<String>,
/// May be required, if totp is enabled for their account.
pub totp_2fa_token: Option<String>,
}

#[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<String>,
}

#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[cfg_attr(feature = "full", derive(TS))]
Expand Down Expand Up @@ -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<String>,
}

#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[cfg_attr(feature = "full", derive(TS))]
Expand Down
39 changes: 39 additions & 0 deletions crates/db_schema/src/impls/auth_api_token.rs
Original file line number Diff line number Diff line change
@@ -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<Self, Error> {
let conn = &mut get_conn(pool).await?;
insert_into(auth_api_token)
.values(form)
.get_result::<Self>(conn)
.await
}

pub async fn update_token(
pool: &mut DbPool<'_>,
input_token: &str,
form: &AuthApiTokenUpdateForm,
) -> Result<AuthApiToken, Error> {
let conn = &mut get_conn(pool).await?;
diesel::update(auth_api_token.filter(token.eq(input_token)))
.set(form)
.get_result::<Self>(conn)
.await
}
pub async fn read_from_token(
pool: &mut DbPool<'_>,
input_token: &str,
) -> Result<AuthApiToken, Error> {
let conn = &mut get_conn(pool).await?;
auth_api_token
.filter(token.eq(input_token))
.first::<Self>(conn)
.await
}
}
46 changes: 46 additions & 0 deletions crates/db_schema/src/impls/auth_refresh_token.rs
Original file line number Diff line number Diff line change
@@ -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<Self, Error> {
let conn = &mut get_conn(pool).await?;
insert_into(auth_refresh_token)
.values(form)
.get_result::<Self>(conn)
.await
}

pub async fn update_token(
pool: &mut DbPool<'_>,
input_token: &str,
form: &AuthRefreshTokenUpdateForm,
) -> Result<AuthRefreshToken, Error> {
let conn = &mut get_conn(pool).await?;
diesel::update(auth_refresh_token.filter(token.eq(input_token)))
.set(form)
.get_result::<Self>(conn)
.await
}
pub async fn read_from_token(
pool: &mut DbPool<'_>,
input_token: &str,
) -> Result<AuthRefreshToken, Error> {
let conn = &mut get_conn(pool).await?;
auth_refresh_token
.filter(token.eq(input_token))
.first::<Self>(conn)
.await
}
}
2 changes: 2 additions & 0 deletions crates/db_schema/src/impls/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading