diff --git a/prosody-config/src/prosody_config/mod.rs b/prosody-config/src/prosody_config/mod.rs index f0c18804..388e29e0 100644 --- a/prosody-config/src/prosody_config/mod.rs +++ b/prosody-config/src/prosody_config/mod.rs @@ -10,8 +10,8 @@ use linked_hash_set::LinkedHashSet; use std::hash::Hash; use std::path::PathBuf; -use crate::model::*; use crate::prosody_config_file::{Group, LuaDefinition}; +use crate::{model::*, LuaValue}; /// Prosody configuration. /// @@ -119,6 +119,16 @@ pub struct ProsodySettings { pub custom_settings: Vec>, } +impl ProsodySettings { + pub fn custom_setting(&self, name: &str) -> Option { + self.custom_settings + .iter() + .flat_map(|c| c.elements.clone()) + .find(|c| c.key == name) + .map(|d| d.value) + } +} + /// See . #[derive(Debug, Clone, Eq, PartialEq)] pub enum AuthenticationProvider { diff --git a/service/src/prosody/prosody_config_from_db.rs b/service/src/prosody/prosody_config_from_db.rs index 2e7de425..ec08d75e 100644 --- a/service/src/prosody/prosody_config_from_db.rs +++ b/service/src/prosody/prosody_config_from_db.rs @@ -50,6 +50,16 @@ impl ProsodyConfig { .unwrap_or_default() } + pub fn virtual_host_settings(&self, host: &str) -> Option<&ProsodySettings> { + self.additional_sections + .iter() + .find_map(|section| match section { + ProsodyConfigSection::VirtualHost { + hostname, settings, .. + } if hostname.as_str() == host => Some(settings), + _ => None, + }) + } pub fn component_settings(&self, name: &str) -> Option<&ProsodySettings> { self.additional_sections .iter() @@ -231,12 +241,15 @@ impl ProseDefault for prosody_config::ProsodyConfig { ), http_host: Some(app_config.server.local_hostname.to_owned()), custom_settings: vec![ + // See Group::new( "mod_http_oauth2", - vec![def( - "allowed_oauth2_grant_types", - vec!["password"], - )], + vec![ + def("allowed_oauth2_grant_types", vec!["password"]), + def("oauth2_access_token_ttl", 10800), + // We don't want tokens to be refreshed + def("oauth2_refresh_token_ttl", 0), + ], ), // // See . // Group::new( diff --git a/service/src/services/auth_service.rs b/service/src/services/auth_service.rs index d60105f9..6cdf94bb 100644 --- a/service/src/services/auth_service.rs +++ b/service/src/services/auth_service.rs @@ -78,8 +78,8 @@ impl AuthServiceImpl for LiveAuthService { let token = self.jwt_service.generate_jwt(jid, |claims| { // TODO: Do not store this in the JWT (potential security issue?) claims.insert( - JWT_PROSODY_TOKEN_KEY, - prosody_token.expose_secret().to_owned(), + JWT_PROSODY_TOKEN_KEY.into(), + prosody_token.expose_secret().to_owned().into(), ); })?; diff --git a/service/src/services/jwt_service.rs b/service/src/services/jwt_service.rs index ac263021..af994c68 100644 --- a/service/src/services/jwt_service.rs +++ b/service/src/services/jwt_service.rs @@ -3,18 +3,20 @@ // Copyright: 2024, Rémi Bardon // License: Mozilla Public License v2.0 (MPL v2.0) +use std::env; + +use chrono::{Duration, Utc}; use hmac::{Hmac, Mac}; use jwt::{SignWithKey as _, VerifyWithKey as _}; use prose_xmpp::BareJid; use secrecy::{ExposeSecret as _, SecretString}; use sha2::Sha256; -use std::{collections::BTreeMap, env}; use xmpp_parsers::jid; use super::auth_service::JWT_PROSODY_TOKEN_KEY; const ENV_JWT_SIGNING_KEY: &'static str = "JWT_SIGNING_KEY"; -pub const JWT_JID_KEY: &'static str = "jid"; +pub const JWT_JID_KEY: &'static str = "sub"; #[derive(Debug, Clone)] pub struct JWTService { @@ -34,18 +36,28 @@ impl JWTService { pub fn generate_jwt( &self, jid: &BareJid, - add_claims: impl FnOnce(&mut BTreeMap<&str, String>) -> (), + add_claims: impl FnOnce(&mut serde_json::Map) -> (), ) -> Result { let jwt_key = self.jwt_key.as_hmac_sha_256()?; - let mut claims = BTreeMap::new(); - claims.insert(JWT_JID_KEY, jid.to_string()); + let mut claims = serde_json::Map::new(); + let now = Utc::now(); + claims.insert("iss".into(), "https://prose.org".into()); + claims.insert(JWT_JID_KEY.into(), jid.to_string().into()); + claims.insert("iat".into(), (now.timestamp() as usize).into()); + claims.insert( + "exp".into(), + ((now + Duration::hours(3)).timestamp() as usize).into(), + ); add_claims(&mut claims); let jwt = claims.sign_with_key(&jwt_key).map_err(JWTError::Sign)?; Ok(SecretString::new(jwt)) } - pub fn verify(&self, jwt: &SecretString) -> Result, JWTError> { + pub fn verify( + &self, + jwt: &SecretString, + ) -> Result, JWTError> { let jwt_key = self.jwt_key.as_hmac_sha_256()?; jwt.expose_secret() .verify_with_key(&jwt_key) @@ -71,12 +83,17 @@ pub enum JWTError { pub enum InvalidJwtClaimError { #[error("The provided JWT does not contain a '{0}' claim")] MissingClaim(String), + #[error("Invalid '{key}' claim: {value:?}")] + InvalidClaim { + key: String, + value: serde_json::Value, + }, #[error("The JID present in the JWT could not be parsed to a valid JID: {0}")] InvalidJid(#[from] jid::Error), } pub struct JWT { - pub claims: BTreeMap, + pub claims: serde_json::Map, } impl JWT { @@ -86,20 +103,26 @@ impl JWT { } impl JWT { - pub fn jid(&self) -> Result { - let Some(jid) = self.claims.get(JWT_JID_KEY) else { - return Err(InvalidJwtClaimError::MissingClaim(JWT_JID_KEY.to_owned())); + fn string_claim<'a>(&'a self, key: &str) -> Result<&'a str, InvalidJwtClaimError> { + let Some(value) = self.claims.get(key) else { + return Err(InvalidJwtClaimError::MissingClaim(key.to_owned())); + }; + let Some(value) = value.as_str() else { + return Err(InvalidJwtClaimError::InvalidClaim { + key: key.to_owned(), + value: value.to_owned(), + }); }; - let jid = BareJid::new(jid.as_str())?; + Ok(value) + } + pub fn jid(&self) -> Result { + let claim_value = self.string_claim(JWT_JID_KEY)?; + let jid = BareJid::new(claim_value)?; Ok(jid) } pub fn prosody_token(&self) -> Result { - let Some(token) = self.claims.get(JWT_PROSODY_TOKEN_KEY) else { - return Err(InvalidJwtClaimError::MissingClaim( - JWT_PROSODY_TOKEN_KEY.to_owned(), - )); - }; - Ok(token.to_owned().into()) + let claim_value = self.string_claim(JWT_PROSODY_TOKEN_KEY)?; + Ok(claim_value.to_owned().into()) } } diff --git a/src/v1/routes.rs b/src/v1/routes.rs index ac628476..6a1bd0e7 100644 --- a/src/v1/routes.rs +++ b/src/v1/routes.rs @@ -25,6 +25,11 @@ impl From for LoginToken { Self(value.expose_secret().to_owned()) } } +impl Into for LoginToken { + fn into(self) -> SecretString { + SecretString::new(self.0) + } +} #[derive(Serialize, Deserialize)] pub struct LoginResponse { diff --git a/tests/behavior.rs b/tests/behavior.rs index 3ed4ef47..83d249cd 100644 --- a/tests/behavior.rs +++ b/tests/behavior.rs @@ -48,6 +48,7 @@ use service::{ secrets_store::{SecretsStore, SecretsStoreImpl}, server_ctl::{ServerCtl, ServerCtlImpl as _}, server_manager::ServerManager, + user_service::UserService, xmpp_service::XmppServiceInner, }, }; @@ -233,6 +234,10 @@ impl TestWorld { )) } + fn user_service(&self) -> UserService { + UserService::new(&self.server_ctl, &self.auth_service, &self.xmpp_service) + } + fn init_controller(&self) -> InitController { let db = self.db(); InitController { db } diff --git a/tests/cucumber_parameters/duration.rs b/tests/cucumber_parameters/duration.rs index fe697535..dca9cd2f 100644 --- a/tests/cucumber_parameters/duration.rs +++ b/tests/cucumber_parameters/duration.rs @@ -13,13 +13,25 @@ use service::model::{DateLike, PossiblyInfinite}; #[derive(Debug, Parameter)] #[param( name = "duration", - regex = r"\d+ (?:year|month|week|day)s?(?: \d+ (?:year|month|week|day)s?)*|infinite" + regex = r"\d+ (?:year|month|week|day|hour|minute|second)s?(?: \d+ (?:year|month|week|day|hour|minute|second)s?)*|infinite" )] pub enum Duration { - Finite(String), + Finite(ISODuration), Infinite, } +impl Duration { + pub fn seconds(&self) -> u32 { + match self { + Self::Finite(duration) => duration + .num_seconds() + .expect("Could not get seconds from duration.") + as u32, + Self::Infinite => panic!("Could not get seconds from infinite duration."), + } + } +} + impl FromStr for Duration { type Err = String; @@ -28,25 +40,47 @@ impl FromStr for Duration { return Ok(Self::Infinite); } - let patterns = vec![ + let date_patterns = vec![ (r"(\d+) years?", 'Y'), (r"(\d+) months?", 'M'), (r"(\d+) weeks?", 'W'), (r"(\d+) days?", 'D'), ]; + let time_patterns = vec![ + (r"(\d+) hours?", 'H'), + (r"(\d+) minutes?", 'M'), + (r"(\d+) seconds?", 'S'), + ]; let mut value = "P".to_string(); - for (pattern, designator) in patterns { + for (pattern, designator) in date_patterns { + let re = Regex::new(pattern).unwrap(); + if let Some(captures) = re.captures(s) { + value.push_str(captures.get(1).unwrap().as_str()); + value.push(designator); + } + } + + let mut has_time = false; + for (pattern, designator) in time_patterns { let re = Regex::new(pattern).unwrap(); if let Some(captures) = re.captures(s) { + if !has_time { + value.push('T'); + has_time = true; + } value.push_str(captures.get(1).unwrap().as_str()); value.push(designator); } } - match value.as_str() { - "P" => Err(format!("Invalid `Duration`: {s}")), - _ => Ok(Self::Finite(value)), + if value.as_str() == "P" { + return Err(format!("Invalid `Duration`: '{s}'")); + } + + match ISODuration::parse(value.as_str()) { + Ok(duration) => Ok(Self::Finite(duration)), + Err(err) => Err(format!("Invalid `Duration`: {err:?}")), } } } @@ -63,9 +97,7 @@ impl Display for Duration { impl Into>> for Duration { fn into(self) -> PossiblyInfinite> { match self { - Self::Finite(d) => { - PossiblyInfinite::Finite(ISODuration::parse(&d).unwrap().try_into().unwrap()) - } + Self::Finite(d) => PossiblyInfinite::Finite(d.try_into().unwrap()), Self::Infinite => PossiblyInfinite::Infinite, } } diff --git a/tests/features/access-tokens.feature b/tests/features/access-tokens.feature new file mode 100644 index 00000000..17eddc12 --- /dev/null +++ b/tests/features/access-tokens.feature @@ -0,0 +1,28 @@ +@access-tokens +@authentication +Feature: Prose Pod API access tokens + + Background: + Given the Prose Pod has been initialized + And the Prose Pod API has started + + Rule: Access tokens expire after 3 hours + + Scenario: User logs in + Given Alice is a member + When Alice logs into the Prose Pod API + Then their access token should expire after 3 hours + + """ + In order for the Prose Pod API to send stanzas to Prosody, it needs a Prosody access token. + This token is generated when a user logs in and is saved into the returned Prose Pod API access token. + Although this token is encrypted and only the Prose Pod API can read its contents, there is no need + for the Prosody access token to be valid longer than the Prose Pod API access token. + """ + @prosody + Rule: Prosody access tokens expire after 3 hours + + Scenario: User logs in + Given Alice is a member + When Alice logs into the Prose Pod API + Then their Prosody access token should expire after 3 hours diff --git a/tests/features/members-list.feature b/tests/features/members-list.feature index 04927199..538877d5 100644 --- a/tests/features/members-list.feature +++ b/tests/features/members-list.feature @@ -71,5 +71,5 @@ Feature: Members list And John has no avatar When Valerian gets detailed information about Rémi and John Then the response is a SSE stream - And one SSE event is "id:rémi@prose.org\nevent:enriched-member\ndata:{\"jid\":\"rémi@prose.org\",\"online\":true,\"nickname\":null,\"avatar\":\"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\"}" - And one SSE event is "id:john@prose.org\nevent:enriched-member\ndata:{\"jid\":\"john@prose.org\",\"online\":false,\"nickname\":null,\"avatar\":null}" + And one SSE event is "id:rémi@prose.org\nevent:enriched-member\ndata:{\"jid\":\"rémi@prose.org\",\"online\":true,\"nickname\":\"Rémi\",\"avatar\":\"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\"}" + And one SSE event is "id:john@prose.org\nevent:enriched-member\ndata:{\"jid\":\"john@prose.org\",\"online\":false,\"nickname\":\"John\",\"avatar\":null}" diff --git a/tests/prelude/mock_auth_service.rs b/tests/prelude/mock_auth_service.rs index df5ecd3c..cd6df9c1 100644 --- a/tests/prelude/mock_auth_service.rs +++ b/tests/prelude/mock_auth_service.rs @@ -50,7 +50,7 @@ impl MockAuthService { pub fn log_in_unchecked(&self, jid: &BareJid) -> Result { let token = self.jwt_service.generate_jwt(jid, |claims| { - claims.insert(JWT_PROSODY_TOKEN_KEY, "dummy-prosody-token".to_owned()); + claims.insert(JWT_PROSODY_TOKEN_KEY.into(), "dummy-prosody-token".into()); })?; Ok(token) diff --git a/tests/prelude/util.rs b/tests/prelude/util.rs index caa9dc0c..2c83a41c 100644 --- a/tests/prelude/util.rs +++ b/tests/prelude/util.rs @@ -36,3 +36,64 @@ macro_rules! user_token { .clone() }; } + +#[macro_export] +macro_rules! basic_auth_api_call_fn { + ($fn:ident, $method:ident, $route:expr) => { + async fn $fn<'a>( + client: &'a rocket::local::asynchronous::Client, + username: impl Display, + token: impl secrecy::ExposeSecret, + ) -> rocket::local::asynchronous::LocalResponse<'a> { + client + .$method($route) + .header(rocket::http::ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + format!("Basic {}", token.expose_secret()), + )) + .dispatch() + .await + } + }; +} +#[macro_export] +macro_rules! api_call_fn { + ($fn:ident, $method:ident, $route:expr) => { + async fn $fn<'a>( + client: &'a rocket::local::asynchronous::Client, + token: impl secrecy::ExposeSecret, + ) -> rocket::local::asynchronous::LocalResponse<'a> { + client + .$method($route) + .header(rocket::http::ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + format!("Bearer {}", token.expose_secret()), + )) + .dispatch() + .await + } + }; +} +#[macro_export] +macro_rules! api_call_with_body_fn { + ($fn:ident, $method:ident, $route:expr, $payload_type:ident, $var:ident, $var_type:ty) => { + async fn $fn<'a>( + client: &'a rocket::local::asynchronous::Client, + token: impl secrecy::ExposeSecret, + state: $var_type, + ) -> rocket::local::asynchronous::LocalResponse<'a> { + client + .$method($route) + .header(rocket::http::ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + format!("Bearer {}", token.expose_secret()), + )) + .body(serde_json::json!($payload_type { $var: state.into() }).to_string()) + .dispatch() + .await + } + }; +} diff --git a/tests/v1/mod.rs b/tests/v1/mod.rs index b7da8a8c..26c32f36 100644 --- a/tests/v1/mod.rs +++ b/tests/v1/mod.rs @@ -9,15 +9,29 @@ pub mod members; pub mod server; pub mod workspace; -use cucumber::given; -use prose_pod_api::error::{self, Error}; +use base64::{ + engine::general_purpose::{STANDARD as Base64, STANDARD_NO_PAD as Base64NoPad}, + Engine, +}; +use cucumber::{given, then, when}; +use prose_pod_api::{ + error::{self, Error}, + v1::LoginResponse, +}; +use rocket::{ + http::{ContentType, Header}, + local::asynchronous::{Client, LocalResponse}, +}; +use secrecy::{ExposeSecret, SecretString}; use service::{ model::MemberRole, prose_xmpp::{mods::AvatarData, BareJid}, + prosody_config::LuaValue, repositories::{MemberCreateForm, MemberRepository}, + services::jwt_service, }; -use crate::TestWorld; +use crate::{cucumber_parameters::Duration, DbErr, TestWorld}; async fn name_to_jid(world: &TestWorld, name: &str) -> Result { let domain = world.server_config().await?.domain; @@ -47,17 +61,21 @@ async fn given_admin(world: &mut TestWorld, name: String) -> Result<(), Error> { Ok(()) } -#[given(regex = r"^(.+) is (not an admin|a regular member)$")] +#[given(regex = r"^(.+) is (not an admin|a regular member|a member)$")] async fn given_not_admin(world: &mut TestWorld, name: String) -> Result<(), Error> { let db = world.db(); let jid = name_to_jid(world, &name).await?; - let member = MemberCreateForm { - jid: jid.clone(), - role: Some(MemberRole::Member), - joined_at: None, - }; - let model = MemberRepository::create(db, member).await?; + let model = world + .user_service() + .create_user( + db, + &jid, + &SecretString::new("password".to_owned()), + &name, + &Some(MemberRole::Member), + ) + .await?; let token = world.mock_auth_service.log_in_unchecked(&jid)?; @@ -103,10 +121,100 @@ async fn given_no_avatar(world: &mut TestWorld, name: String) -> Result<(), Erro // LOGIN -// async fn login<'a>(client: &'a Client) -> LocalResponse<'a> { -// client -// .post("/v1/login") -// .header(ContentType::JSON) -// .dispatch() -// .await -// } +async fn log_in<'a>( + client: &'a Client, + username: &BareJid, + password: SecretString, +) -> LocalResponse<'a> { + client + .post("/v1/login") + .header(ContentType::JSON) + .header(Header::new( + "Authorization", + format!("Basic {}", { + let mut buf = String::new(); + Base64.encode_string( + format!("{}:{}", username, password.expose_secret()), + &mut buf, + ); + buf + }), + )) + .dispatch() + .await +} + +#[when(expr = "{} logs into the Prose Pod API")] +async fn when_user_logs_in(world: &mut TestWorld, name: String) -> Result<(), Error> { + let jid = name_to_jid(world, &name).await?; + let password = world + .mock_server_ctl + .state + .read() + .unwrap() + .users + .get(&jid) + .expect("User must be created first") + .password + .clone(); + let res = log_in(world.client(), &jid, password).await; + world.result = Some(res.into()); + Ok(()) +} + +#[then(expr = "their access token should expire after {duration}")] +async fn then_token_expires_after( + world: &mut TestWorld, + duration: Duration, +) -> Result<(), jwt_service::Error> { + let response: LoginResponse = world.result().body_into(); + let token: SecretString = response.token.expose_secret().clone().into(); + let claims = world.jwt_service.verify(&token)?; + + fn date(claims: &serde_json::Map, claim: &str) -> u64 { + claims + .get(claim) + .expect(&format!("JWT has no '{claim}' claim.")) + .as_u64() + .expect(&format!("JWT '{claim}' claim could not be parsed.")) + } + + let issued_at = date(&claims, "iat"); + let expires_at = date(&claims, "exp"); + + let lifetime = expires_at - issued_at; + assert_eq!(lifetime, duration.seconds() as u64); + + Ok(()) +} + +#[then(expr = "their Prosody access token should expire after {duration}")] +async fn then_prosody_token_expires_after( + world: &mut TestWorld, + duration: Duration, +) -> Result<(), DbErr> { + let domain = world.server_config().await?.domain; + + let prosody_config = world + .mock_server_ctl + .state + .read() + .expect("`MockServerCtl` lock poisonned.") + .applied_config + .clone() + .expect("XMPP server config not initialized"); + let settings = prosody_config + .virtual_host_settings(&domain.to_string()) + .expect("Prosody config missing a `VirtualHost`."); + + assert_eq!( + settings.custom_setting("oauth2_access_token_ttl"), + Some(LuaValue::Number(duration.seconds().into())), + ); + assert_eq!( + settings.custom_setting("oauth2_refresh_token_ttl"), + Some(LuaValue::Number(0.into())), + ); + + Ok(()) +} diff --git a/tests/v1/server/config.rs b/tests/v1/server/config.rs index 53f8ce46..2932a4af 100644 --- a/tests/v1/server/config.rs +++ b/tests/v1/server/config.rs @@ -5,12 +5,6 @@ use cucumber::{given, then, when}; use prose_pod_api::{error::Error, v1::server::config::*}; -use rocket::{ - http::{ContentType, Header}, - local::asynchronous::{Client, LocalResponse}, -}; -use secrecy::{ExposeSecret as _, SecretString}; -use serde_json::json; use service::{ entity::server_config, prosody::IntoProsody as _, @@ -20,48 +14,13 @@ use service::{ }; use crate::{ + api_call_fn, api_call_with_body_fn, cucumber_parameters::{Duration, ToggleState}, user_token, util::*, TestWorld, }; -macro_rules! api_call_fn { - ($fn:ident, $method:ident, $route:expr) => { - async fn $fn<'a>(client: &'a Client, token: SecretString) -> LocalResponse<'a> { - client - .$method($route) - .header(ContentType::JSON) - .header(Header::new( - "Authorization", - format!("Bearer {}", token.expose_secret()), - )) - .dispatch() - .await - } - }; -} -macro_rules! api_call_with_body_fn { - ($fn:ident, $method:ident, $route:expr, $payload_type:ident, $var:ident, $var_type:ty) => { - async fn $fn<'a>( - client: &'a Client, - token: SecretString, - state: $var_type, - ) -> LocalResponse<'a> { - client - .$method($route) - .header(ContentType::JSON) - .header(Header::new( - "Authorization", - format!("Bearer {}", token.expose_secret()), - )) - .body(json!($payload_type { $var: state.into() }).to_string()) - .dispatch() - .await - } - }; -} - async fn given_server_config( world: &mut TestWorld, update: impl FnOnce(