Skip to content
New issue

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

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

Already on GitHub? # to your account

feat: Access tokens expire after 3 hours #49

Merged
merged 1 commit into from
Sep 2, 2024
Merged
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
12 changes: 11 additions & 1 deletion prosody-config/src/prosody_config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -119,6 +119,16 @@ pub struct ProsodySettings {
pub custom_settings: Vec<Group<LuaDefinition>>,
}

impl ProsodySettings {
pub fn custom_setting(&self, name: &str) -> Option<LuaValue> {
self.custom_settings
.iter()
.flat_map(|c| c.elements.clone())
.find(|c| c.key == name)
.map(|d| d.value)
}
}

/// See <https://prosody.im/doc/authentication#providers>.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum AuthenticationProvider {
Expand Down
21 changes: 17 additions & 4 deletions service/src/prosody/prosody_config_from_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -231,12 +241,15 @@ impl ProseDefault for prosody_config::ProsodyConfig {
),
http_host: Some(app_config.server.local_hostname.to_owned()),
custom_settings: vec![
// See <https://modules.prosody.im/mod_http_oauth2>
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 <https://github.com/prose-im/prose-pod-server/blob/3b54d071880dff669f0193a8068733b089936751/plugins/mod_init_admin.lua>.
// Group::new(
Expand Down
4 changes: 2 additions & 2 deletions service/src/services/auth_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);
})?;

Expand Down
57 changes: 40 additions & 17 deletions service/src/services/jwt_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@
// Copyright: 2024, Rémi Bardon <remi@remibardon.name>
// 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 {
Expand All @@ -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<String, serde_json::Value>) -> (),
) -> Result<SecretString, JWTError> {
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<BTreeMap<String, String>, JWTError> {
pub fn verify(
&self,
jwt: &SecretString,
) -> Result<serde_json::Map<String, serde_json::Value>, JWTError> {
let jwt_key = self.jwt_key.as_hmac_sha_256()?;
jwt.expose_secret()
.verify_with_key(&jwt_key)
Expand All @@ -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<String, String>,
pub claims: serde_json::Map<String, serde_json::Value>,
}

impl JWT {
Expand All @@ -86,20 +103,26 @@ impl JWT {
}

impl JWT {
pub fn jid(&self) -> Result<BareJid, InvalidJwtClaimError> {
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<BareJid, InvalidJwtClaimError> {
let claim_value = self.string_claim(JWT_JID_KEY)?;
let jid = BareJid::new(claim_value)?;
Ok(jid)
}
pub fn prosody_token(&self) -> Result<SecretString, InvalidJwtClaimError> {
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())
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/v1/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ impl From<SecretString> for LoginToken {
Self(value.expose_secret().to_owned())
}
}
impl Into<SecretString> for LoginToken {
fn into(self) -> SecretString {
SecretString::new(self.0)
}
}

#[derive(Serialize, Deserialize)]
pub struct LoginResponse {
Expand Down
5 changes: 5 additions & 0 deletions tests/behavior.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ use service::{
secrets_store::{SecretsStore, SecretsStoreImpl},
server_ctl::{ServerCtl, ServerCtlImpl as _},
server_manager::ServerManager,
user_service::UserService,
xmpp_service::XmppServiceInner,
},
};
Expand Down Expand Up @@ -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 }
Expand Down
52 changes: 42 additions & 10 deletions tests/cucumber_parameters/duration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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:?}")),
}
}
}
Expand All @@ -63,9 +97,7 @@ impl Display for Duration {
impl Into<PossiblyInfinite<service::model::Duration<DateLike>>> for Duration {
fn into(self) -> PossiblyInfinite<service::model::Duration<DateLike>> {
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,
}
}
Expand Down
28 changes: 28 additions & 0 deletions tests/features/access-tokens.feature
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading