From c48b2e241a7e9f4aa2d5a7446dfd1b0e83221064 Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 20 Feb 2024 22:44:29 +0100 Subject: [PATCH] JWT Refresh Token --- src/api/identity.rs | 94 ++++++++++--------- src/api/mod.rs | 2 +- src/auth.rs | 199 +++++++++++++++++++++++++++++++++++++++- src/db/models/device.rs | 61 +----------- 4 files changed, 248 insertions(+), 108 deletions(-) diff --git a/src/api/identity.rs b/src/api/identity.rs index ad51d664..e15373f4 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -3,6 +3,7 @@ use num_traits::FromPrimitive; use rocket::serde::json::Json; use rocket::{ form::{Form, FromForm}, + http::Status, Route, }; use serde_json::Value; @@ -17,7 +18,8 @@ use crate::{ push::register_push_device, ApiResult, EmptyResult, JsonResult, JsonUpcase, }, - auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp}, + auth, + auth::{generate_organization_api_key_login_claims, AuthMethod, AuthMethodScope, ClientHeaders, ClientIp}, db::{models::*, DbConn}, error::MapResult, mail, util, CONFIG, @@ -96,43 +98,43 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult { // Extract token - let token = data.refresh_token.unwrap(); + let refresh_token = match data.refresh_token { + Some(token) => token, + None => err_code!("Missing refresh_token", Status::Unauthorized.code), + }; - // Get device by refresh token - let mut device = Device::find_by_refresh_token(&token, conn).await.map_res("Invalid refresh token")?; - - let scope = "api offline_access"; - let scope_vec = vec!["api".into(), "offline_access".into()]; - - // Common - let user = User::find_by_uuid(&device.user_uuid, conn).await.unwrap(); // --- // Disabled this variable, it was used to generate the JWT // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- // let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await; - let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); - device.save(conn).await?; + match auth::refresh_tokens(&refresh_token, conn).await { + Err(err) => err_code!(err.to_string(), Status::Unauthorized.code), + Ok((mut device, user, auth_tokens)) => { + // Save to update `device.updated_at` to track usage + device.save(conn).await?; - let result = json!({ - "access_token": access_token, - "expires_in": expires_in, - "token_type": "Bearer", - "refresh_token": device.refresh_token, - "Key": user.akey, - "PrivateKey": user.private_key, + let result = json!({ + "refresh_token": auth_tokens.refresh_token(), + "access_token": auth_tokens.access_token(), + "expires_in": auth_tokens.expires_in(), + "token_type": "Bearer", + "Key": user.akey, + "PrivateKey": user.private_key, - "Kdf": user.client_kdf_type, - "KdfIterations": user.client_kdf_iter, - "KdfMemory": user.client_kdf_memory, - "KdfParallelism": user.client_kdf_parallelism, - "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing - "scope": scope, - "unofficialServer": true, - }); + "Kdf": user.client_kdf_type, + "KdfIterations": user.client_kdf_iter, + "KdfMemory": user.client_kdf_memory, + "KdfParallelism": user.client_kdf_parallelism, + "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing + "scope": auth_tokens.scope(), + "unofficialServer": true, + }); - Ok(Json(result)) + Ok(Json(result)) + } + } } async fn _password_login( @@ -142,11 +144,7 @@ async fn _password_login( ip: &ClientIp, ) -> JsonResult { // Validate scope - let scope = data.scope.as_ref().unwrap(); - if scope != "api offline_access" { - err!("Scope not supported") - } - let scope_vec = vec!["api".into(), "offline_access".into()]; + AuthMethod::Password.check_scope(data.scope.as_ref())?; // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; @@ -279,14 +277,14 @@ async fn _password_login( // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- // let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await; - let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); + let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password); device.save(conn).await?; let mut result = json!({ - "access_token": access_token, - "expires_in": expires_in, + "access_token": auth_tokens.access_token(), + "expires_in": auth_tokens.expires_in(), "token_type": "Bearer", - "refresh_token": device.refresh_token, + "refresh_token": auth_tokens.refresh_token(), "Key": user.akey, "PrivateKey": user.private_key, //"TwoFactorToken": "11122233333444555666777888999" @@ -301,7 +299,7 @@ async fn _password_login( "object": "masterPasswordPolicy", }, - "scope": scope, + "scope": auth_tokens.scope(), "unofficialServer": true, "UserDecryptionOptions": { "HasMasterPassword": !user.password_hash.is_empty(), @@ -327,9 +325,9 @@ async fn _api_key_login( crate::ratelimit::check_limit_login(&ip.ip)?; // Validate scope - match data.scope.as_ref().unwrap().as_ref() { - "api" => _user_api_key_login(data, user_uuid, conn, ip).await, - "api.organization" => _organization_api_key_login(data, conn, ip).await, + match data.scope.as_ref() { + Some(scope) if scope == &AuthMethod::UserApiKey.scope() => _user_api_key_login(data, user_uuid, conn, ip).await, + Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await, _ => err!("Scope not supported"), } } @@ -395,15 +393,15 @@ async fn _user_api_key_login( } } - // Common - let scope_vec = vec!["api".into()]; // --- // Disabled this variable, it was used to generate the JWT // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- // let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await; - let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); + let access_claims = auth::LoginJwtClaims::default(&device, &user, &auth::AuthMethod::UserApiKey); + + // Save to update `device.updated_at` to track usage device.save(conn).await?; info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); @@ -411,8 +409,8 @@ async fn _user_api_key_login( // Note: No refresh_token is returned. The CLI just repeats the // client_credentials login flow when the existing token expires. let result = json!({ - "access_token": access_token, - "expires_in": expires_in, + "access_token": access_claims.token(), + "expires_in": access_claims.expires_in(), "token_type": "Bearer", "Key": user.akey, "PrivateKey": user.private_key, @@ -422,7 +420,7 @@ async fn _user_api_key_login( "KdfMemory": user.client_kdf_memory, "KdfParallelism": user.client_kdf_parallelism, "ResetMasterPassword": false, // TODO: Same as above - "scope": "api", + "scope": auth::AuthMethod::UserApiKey.scope(), "unofficialServer": true, }); @@ -454,7 +452,7 @@ async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: & "access_token": access_token, "expires_in": 3600, "token_type": "Bearer", - "scope": "api.organization", + "scope": auth::AuthMethod::OrgApiKey.scope(), "unofficialServer": true, }))) } diff --git a/src/api/mod.rs b/src/api/mod.rs index de81630d..2c485e42 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -36,7 +36,7 @@ use crate::db::{models::User, DbConn}; use crate::util; // Type aliases for API methods results -type ApiResult = Result; +pub type ApiResult = Result; pub type JsonResult = ApiResult>; pub type EmptyResult = ApiResult<()>; diff --git a/src/auth.rs b/src/auth.rs index f05eba65..a79b7524 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -9,11 +9,13 @@ use openssl::rsa::Rsa; use serde::de::DeserializeOwned; use serde::ser::Serialize; +use crate::api::ApiResult; use crate::{error::Error, CONFIG}; const JWT_ALGORITHM: Algorithm = Algorithm::RS256; -pub static DEFAULT_VALIDITY: Lazy = Lazy::new(|| TimeDelta::try_hours(2).unwrap()); +pub static DEFAULT_REFRESH_VALIDITY: Lazy = Lazy::new(|| TimeDelta::try_days(30).unwrap()); +pub static DEFAULT_ACCESS_VALIDITY: Lazy = Lazy::new(|| TimeDelta::try_hours(2).unwrap()); static JWT_HEADER: Lazy
= Lazy::new(|| Header::new(JWT_ALGORITHM)); pub static JWT_LOGIN_ISSUER: Lazy = Lazy::new(|| format!("{}|login", CONFIG.domain_origin())); @@ -91,6 +93,10 @@ fn decode_jwt(token: &str, issuer: String) -> Result Result { + decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) +} + pub fn decode_login(token: &str) -> Result { decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) } @@ -164,6 +170,68 @@ pub struct LoginJwtClaims { pub amr: Vec, } +impl LoginJwtClaims { + pub fn new(device: &Device, user: &User, nbf: i64, exp: i64, scope: Vec) -> Self { + // --- + // Disabled these keys to be added to the JWT since they could cause the JWT to get too large + // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients + // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out + // --- + // fn arg: orgs: Vec, + // --- + // let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect(); + // let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect(); + // let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect(); + // let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect(); + + // Create the JWT claims struct, to send to the client + Self { + nbf, + exp, + iss: JWT_LOGIN_ISSUER.to_string(), + sub: user.uuid.clone(), + premium: true, + name: user.name.clone(), + email: user.email.clone(), + email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(), + + // --- + // Disabled these keys to be added to the JWT since they could cause the JWT to get too large + // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients + // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out + // See: https://github.com/dani-garcia/vaultwarden/issues/4156 + // --- + // orgowner, + // orgadmin, + // orguser, + // orgmanager, + sstamp: user.security_stamp.clone(), + device: device.uuid.clone(), + scope, + amr: vec!["Application".into()], + } + } + + pub fn default(device: &Device, user: &User, auth_method: &AuthMethod) -> Self { + let time_now = Utc::now(); + Self::new( + device, + user, + time_now.timestamp(), + (time_now + *DEFAULT_ACCESS_VALIDITY).timestamp(), + auth_method.scope_vec(), + ) + } + + pub fn token(&self) -> String { + encode_jwt(&self) + } + + pub fn expires_in(&self) -> i64 { + self.exp - Utc::now().timestamp() + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct InviteJwtClaims { // Not before @@ -876,3 +944,132 @@ impl<'r> FromRequest<'r> for WsAccessTokenHeader { }) } } + +#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthMethod { + OrgApiKey, + Password, + UserApiKey, +} + +pub trait AuthMethodScope { + fn scope_vec(&self) -> Vec; + fn scope(&self) -> String; + fn check_scope(&self, scope: Option<&String>) -> ApiResult; +} + +impl AuthMethodScope for AuthMethod { + fn scope(&self) -> String { + match self { + AuthMethod::OrgApiKey => "api.organization".to_string(), + AuthMethod::Password => "api offline_access".to_string(), + AuthMethod::UserApiKey => "api".to_string(), + } + } + + fn scope_vec(&self) -> Vec { + self.scope().split_whitespace().map(str::to_string).collect() + } + + fn check_scope(&self, scope: Option<&String>) -> ApiResult { + let method_scope = self.scope(); + match scope { + None => err!("Missing scope"), + Some(scope) if scope == &method_scope => Ok(method_scope), + Some(scope) => err!(format!("Scope ({scope}) not supported")), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RefreshJwtClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: AuthMethod, + + pub device_token: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthTokens { + pub refresh_claims: RefreshJwtClaims, + pub access_claims: LoginJwtClaims, +} + +impl AuthTokens { + pub fn refresh_token(&self) -> String { + encode_jwt(&self.refresh_claims) + } + + pub fn access_token(&self) -> String { + self.access_claims.token() + } + + pub fn expires_in(&self) -> i64 { + self.access_claims.expires_in() + } + + pub fn scope(&self) -> String { + self.refresh_claims.sub.scope() + } + + // Create refresh_token and access_token with default validity + pub fn new(device: &Device, user: &User, sub: AuthMethod) -> Self { + let time_now = Utc::now(); + + let access_claims = LoginJwtClaims::default(device, user, &sub); + + let refresh_claims = RefreshJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp(), + iss: JWT_LOGIN_ISSUER.to_string(), + sub, + device_token: device.refresh_token.clone(), + }; + + Self { + refresh_claims, + access_claims, + } + } +} + +pub async fn refresh_tokens(refresh_token: &str, conn: &mut DbConn) -> ApiResult<(Device, User, AuthTokens)> { + let time_now = Utc::now(); + + let refresh_claims = match decode_refresh(refresh_token) { + Err(err) => err!(format!("Impossible to read refresh_token: {err}")), + Ok(claims) => claims, + }; + + // Get device by refresh token + let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await { + None => err!("Invalid refresh token"), + Some(device) => device, + }; + + // Save to update `updated_at`. + device.save(conn).await?; + + let user = match User::find_by_uuid(&device.user_uuid, conn).await { + None => err!("Impossible to find user"), + Some(user) => user, + }; + + if refresh_claims.exp < time_now.timestamp() { + err!("Expired refresh token"); + } + + let auth_tokens = match refresh_claims.sub { + AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub), + _ => err!("Invalid auth method cannot refresh token"), + }; + + Ok((device, user, auth_tokens)) +} diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 60c63589..33e6ffd4 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -1,6 +1,7 @@ use chrono::{NaiveDateTime, Utc}; +use data_encoding::{BASE64, BASE64URL}; -use crate::{crypto, CONFIG}; +use crate::crypto; use core::fmt; db_object! { @@ -42,13 +43,12 @@ impl Device { push_uuid: None, push_token: None, - refresh_token: String::new(), + refresh_token: crypto::encode_random_bytes::<64>(BASE64URL), twofactor_remember: None, } } pub fn refresh_twofactor_remember(&mut self) -> String { - use data_encoding::BASE64; let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64); self.twofactor_remember = Some(twofactor_remember.clone()); @@ -59,61 +59,6 @@ impl Device { self.twofactor_remember = None; } - pub fn refresh_tokens(&mut self, user: &super::User, scope: Vec) -> (String, i64) { - // If there is no refresh token, we create one - if self.refresh_token.is_empty() { - use data_encoding::BASE64URL; - self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL); - } - - // Update the expiration of the device and the last update date - let time_now = Utc::now(); - self.updated_at = time_now.naive_utc(); - - // --- - // Disabled these keys to be added to the JWT since they could cause the JWT to get too large - // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients - // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out - // --- - // fn arg: orgs: Vec, - // --- - // let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect(); - // let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect(); - // let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect(); - // let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect(); - - // Create the JWT claims struct, to send to the client - use crate::auth::{encode_jwt, LoginJwtClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER}; - let claims = LoginJwtClaims { - nbf: time_now.timestamp(), - exp: (time_now + *DEFAULT_VALIDITY).timestamp(), - iss: JWT_LOGIN_ISSUER.to_string(), - sub: user.uuid.clone(), - - premium: true, - name: user.name.clone(), - email: user.email.clone(), - email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(), - - // --- - // Disabled these keys to be added to the JWT since they could cause the JWT to get too large - // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients - // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out - // See: https://github.com/dani-garcia/vaultwarden/issues/4156 - // --- - // orgowner, - // orgadmin, - // orguser, - // orgmanager, - sstamp: user.security_stamp.clone(), - device: self.uuid.clone(), - scope, - amr: vec!["Application".into()], - }; - - (encode_jwt(&claims), DEFAULT_VALIDITY.num_seconds()) - } - pub fn is_push_device(&self) -> bool { matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios) }