Skip to content

Commit 3a342b6

Browse files
authored
Add experimental and preliminary policy-driven session limiting when logging in compatibility sessions. (#5287)
2 parents 38897c9 + c0f2d3a commit 3a342b6

File tree

22 files changed

+704
-56
lines changed

22 files changed

+704
-56
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cli/src/util.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ pub async fn policy_factory_from_config(
145145
register: config.register_entrypoint.clone(),
146146
client_registration: config.client_registration_entrypoint.clone(),
147147
authorization_grant: config.authorization_grant_entrypoint.clone(),
148+
compat_login: config.compat_login_entrypoint.clone(),
148149
email: config.email_entrypoint.clone(),
149150
};
150151

crates/config/src/sections/policy.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ fn is_default_password_entrypoint(value: &String) -> bool {
6262
*value == default_password_entrypoint()
6363
}
6464

65+
fn default_compat_login_entrypoint() -> String {
66+
"compat_login/violation".to_owned()
67+
}
68+
69+
fn is_default_compat_login_entrypoint(value: &String) -> bool {
70+
*value == default_compat_login_entrypoint()
71+
}
72+
6573
fn default_email_entrypoint() -> String {
6674
"email/violation".to_owned()
6775
}
@@ -111,6 +119,13 @@ pub struct PolicyConfig {
111119
)]
112120
pub authorization_grant_entrypoint: String,
113121

122+
/// Entrypoint to use when evaluating compatibility logins
123+
#[serde(
124+
default = "default_compat_login_entrypoint",
125+
skip_serializing_if = "is_default_compat_login_entrypoint"
126+
)]
127+
pub compat_login_entrypoint: String,
128+
114129
/// Entrypoint to use when changing password
115130
#[serde(
116131
default = "default_password_entrypoint",
@@ -137,6 +152,7 @@ impl Default for PolicyConfig {
137152
client_registration_entrypoint: default_client_registration_entrypoint(),
138153
register_entrypoint: default_register_entrypoint(),
139154
authorization_grant_entrypoint: default_authorization_grant_entrypoint(),
155+
compat_login_entrypoint: default_compat_login_entrypoint(),
140156
password_entrypoint: default_password_entrypoint(),
141157
email_entrypoint: default_email_entrypoint(),
142158
data: default_data(),

crates/handlers/src/compat/login.rs

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use mas_data_model::{
1616
User,
1717
};
1818
use mas_matrix::HomeserverConnection;
19+
use mas_policy::{Policy, Requester, ViolationCode, model::CompatLogin};
1920
use mas_storage::{
2021
BoxRepository, BoxRepositoryFactory, RepositoryAccess,
2122
compat::{
@@ -37,6 +38,7 @@ use crate::{
3738
BoundActivityTracker, Limiter, METER, RequesterFingerprint, impl_from_error_for_route,
3839
passwords::{PasswordManager, PasswordVerificationResult},
3940
rate_limit::PasswordCheckLimitedError,
41+
session::count_user_sessions_for_limiting,
4042
};
4143

4244
static LOGIN_COUNTER: LazyLock<Counter<u64>> = LazyLock::new(|| {
@@ -213,9 +215,16 @@ pub enum RouteError {
213215

214216
#[error("failed to provision device")]
215217
ProvisionDeviceFailed(#[source] anyhow::Error),
218+
219+
#[error("login rejected by policy")]
220+
PolicyRejected,
221+
222+
#[error("login rejected by policy (hard session limit reached)")]
223+
PolicyHardSessionLimitReached,
216224
}
217225

218226
impl_from_error_for_route!(mas_storage::RepositoryError);
227+
impl_from_error_for_route!(mas_policy::EvaluationError);
219228

220229
impl From<anyhow::Error> for RouteError {
221230
fn from(err: anyhow::Error) -> Self {
@@ -274,6 +283,16 @@ impl IntoResponse for RouteError {
274283
error: "User account has been locked",
275284
status: StatusCode::UNAUTHORIZED,
276285
},
286+
Self::PolicyRejected => MatrixError {
287+
errcode: "M_FORBIDDEN",
288+
error: "Login denied by the policy enforced by this service",
289+
status: StatusCode::FORBIDDEN,
290+
},
291+
Self::PolicyHardSessionLimitReached => MatrixError {
292+
errcode: "M_FORBIDDEN",
293+
error: "You have reached your hard device limit. Please visit your account page to sign some out.",
294+
status: StatusCode::FORBIDDEN,
295+
},
277296
};
278297

279298
(sentry_event_id, response).into_response()
@@ -290,6 +309,7 @@ pub(crate) async fn post(
290309
State(homeserver): State<Arc<dyn HomeserverConnection>>,
291310
State(site_config): State<SiteConfig>,
292311
State(limiter): State<Limiter>,
312+
mut policy: Policy,
293313
requester: RequesterFingerprint,
294314
user_agent: Option<TypedHeader<headers::UserAgent>>,
295315
MatrixJsonBody(input): MatrixJsonBody<RequestBody>,
@@ -329,6 +349,11 @@ pub(crate) async fn post(
329349
&limiter,
330350
requester,
331351
&mut repo,
352+
&mut policy,
353+
Requester {
354+
ip_address: activity_tracker.ip(),
355+
user_agent: user_agent.clone(),
356+
},
332357
username,
333358
password,
334359
input.device_id, // TODO check for validity
@@ -342,6 +367,11 @@ pub(crate) async fn post(
342367
&mut rng,
343368
&clock,
344369
&mut repo,
370+
&mut policy,
371+
Requester {
372+
ip_address: activity_tracker.ip(),
373+
user_agent: user_agent.clone(),
374+
},
345375
&token,
346376
input.device_id,
347377
input.initial_device_display_name,
@@ -459,6 +489,8 @@ async fn token_login(
459489
rng: &mut (dyn RngCore + Send),
460490
clock: &dyn Clock,
461491
repo: &mut BoxRepository,
492+
policy: &mut Policy,
493+
requester: Requester,
462494
token: &str,
463495
requested_device_id: Option<String>,
464496
initial_device_display_name: Option<String>,
@@ -544,10 +576,38 @@ async fn token_login(
544576
Device::generate(rng)
545577
};
546578

547-
repo.app_session()
579+
let session_replaced = repo
580+
.app_session()
548581
.finish_sessions_to_replace_device(clock, &browser_session.user, &device)
549582
.await?;
550583

584+
let session_counts = count_user_sessions_for_limiting(repo, &browser_session.user).await?;
585+
586+
let res = policy
587+
.evaluate_compat_login(mas_policy::CompatLoginInput {
588+
user: &browser_session.user,
589+
login: CompatLogin::Token,
590+
session_replaced,
591+
session_counts,
592+
requester,
593+
})
594+
.await?;
595+
if !res.valid() {
596+
// If the only violation is that we have too many sessions, then handle that
597+
// separately.
598+
// In the future, we intend to evict some sessions automatically instead. We
599+
// don't trigger this if there was some other violation anyway, since that means
600+
// that removing a session wouldn't actually unblock the login.
601+
if res.violations.len() == 1 {
602+
let violation = &res.violations[0];
603+
if violation.code == Some(ViolationCode::TooManySessions) {
604+
// The only violation is having reached the session limit.
605+
return Err(RouteError::PolicyHardSessionLimitReached);
606+
}
607+
}
608+
return Err(RouteError::PolicyRejected);
609+
}
610+
551611
// We first create the session in the database, commit the transaction, then
552612
// create it on the homeserver, scheduling a device sync job afterwards to
553613
// make sure we don't end up in an inconsistent state.
@@ -578,6 +638,8 @@ async fn user_password_login(
578638
limiter: &Limiter,
579639
requester: RequesterFingerprint,
580640
repo: &mut BoxRepository,
641+
policy: &mut Policy,
642+
policy_requester: Requester,
581643
username: &str,
582644
password: String,
583645
requested_device_id: Option<String>,
@@ -647,10 +709,38 @@ async fn user_password_login(
647709
Device::generate(&mut rng)
648710
};
649711

650-
repo.app_session()
712+
let session_replaced = repo
713+
.app_session()
651714
.finish_sessions_to_replace_device(clock, &user, &device)
652715
.await?;
653716

717+
let session_counts = count_user_sessions_for_limiting(repo, &user).await?;
718+
719+
let res = policy
720+
.evaluate_compat_login(mas_policy::CompatLoginInput {
721+
user: &user,
722+
login: CompatLogin::Password,
723+
session_replaced,
724+
session_counts,
725+
requester: policy_requester,
726+
})
727+
.await?;
728+
if !res.valid() {
729+
// If the only violation is that we have too many sessions, then handle that
730+
// separately.
731+
// In the future, we intend to evict some sessions automatically instead. We
732+
// don't trigger this if there was some other violation anyway, since that means
733+
// that removing a session wouldn't actually unblock the login.
734+
if res.violations.len() == 1 {
735+
let violation = &res.violations[0];
736+
if violation.code == Some(ViolationCode::TooManySessions) {
737+
// The only violation is having reached the session limit.
738+
return Err(RouteError::PolicyHardSessionLimitReached);
739+
}
740+
}
741+
return Err(RouteError::PolicyRejected);
742+
}
743+
654744
let session = repo
655745
.compat_session()
656746
.add(

crates/handlers/src/compat/login_sso_complete.rs

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,27 @@ use axum::{
1111
extract::{Form, Path, State},
1212
response::{Html, IntoResponse, Redirect, Response},
1313
};
14-
use axum_extra::extract::Query;
14+
use axum_extra::{TypedHeader, extract::Query};
1515
use chrono::Duration;
16+
use hyper::StatusCode;
1617
use mas_axum_utils::{
1718
InternalError,
1819
cookies::CookieJar,
1920
csrf::{CsrfExt, ProtectedForm},
2021
};
2122
use mas_data_model::{BoxClock, BoxRng, Clock};
23+
use mas_policy::{Policy, model::CompatLogin};
2224
use mas_router::{CompatLoginSsoAction, UrlBuilder};
2325
use mas_storage::{BoxRepository, RepositoryAccess, compat::CompatSsoLoginRepository};
24-
use mas_templates::{CompatSsoContext, ErrorContext, TemplateContext, Templates};
26+
use mas_templates::{
27+
CompatLoginPolicyViolationContext, CompatSsoContext, ErrorContext, TemplateContext, Templates,
28+
};
2529
use serde::{Deserialize, Serialize};
2630
use ulid::Ulid;
2731

2832
use crate::{
29-
PreferredLanguage,
30-
session::{SessionOrFallback, load_session_or_fallback},
33+
BoundActivityTracker, PreferredLanguage,
34+
session::{SessionOrFallback, count_user_sessions_for_limiting, load_session_or_fallback},
3135
};
3236

3337
#[derive(Serialize)]
@@ -56,10 +60,15 @@ pub async fn get(
5660
mut repo: BoxRepository,
5761
State(templates): State<Templates>,
5862
State(url_builder): State<UrlBuilder>,
63+
mut policy: Policy,
64+
activity_tracker: BoundActivityTracker,
65+
user_agent: Option<TypedHeader<headers::UserAgent>>,
5966
cookie_jar: CookieJar,
6067
Path(id): Path<Ulid>,
6168
Query(params): Query<Params>,
6269
) -> Result<Response, InternalError> {
70+
let user_agent = user_agent.map(|ua| ua.to_string());
71+
6372
let (cookie_jar, maybe_session) = match load_session_or_fallback(
6473
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
6574
)
@@ -107,6 +116,35 @@ pub async fn get(
107116
return Ok((cookie_jar, Html(content)).into_response());
108117
}
109118

119+
let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?;
120+
121+
let res = policy
122+
.evaluate_compat_login(mas_policy::CompatLoginInput {
123+
user: &session.user,
124+
login: CompatLogin::Sso {
125+
redirect_uri: login.redirect_uri.to_string(),
126+
},
127+
// We don't know if there's going to be a replacement until we received the device ID,
128+
// which happens too late.
129+
session_replaced: false,
130+
session_counts,
131+
requester: mas_policy::Requester {
132+
ip_address: activity_tracker.ip(),
133+
user_agent,
134+
},
135+
})
136+
.await?;
137+
if !res.valid() {
138+
let ctx = CompatLoginPolicyViolationContext::for_violations(res.violations)
139+
.with_session(session)
140+
.with_csrf(csrf_token.form_value())
141+
.with_language(locale);
142+
143+
let content = templates.render_compat_login_policy_violation(&ctx)?;
144+
145+
return Ok((StatusCode::FORBIDDEN, cookie_jar, Html(content)).into_response());
146+
}
147+
110148
let ctx = CompatSsoContext::new(login)
111149
.with_session(session)
112150
.with_csrf(csrf_token.form_value())
@@ -129,11 +167,16 @@ pub async fn post(
129167
PreferredLanguage(locale): PreferredLanguage,
130168
State(templates): State<Templates>,
131169
State(url_builder): State<UrlBuilder>,
170+
mut policy: Policy,
171+
activity_tracker: BoundActivityTracker,
172+
user_agent: Option<TypedHeader<headers::UserAgent>>,
132173
cookie_jar: CookieJar,
133174
Path(id): Path<Ulid>,
134175
Query(params): Query<Params>,
135176
Form(form): Form<ProtectedForm<()>>,
136177
) -> Result<Response, InternalError> {
178+
let user_agent = user_agent.map(|ua| ua.to_string());
179+
137180
let (cookie_jar, maybe_session) = match load_session_or_fallback(
138181
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
139182
)
@@ -200,6 +243,37 @@ pub async fn post(
200243
redirect_uri
201244
};
202245

246+
let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?;
247+
248+
let res = policy
249+
.evaluate_compat_login(mas_policy::CompatLoginInput {
250+
user: &session.user,
251+
login: CompatLogin::Sso {
252+
redirect_uri: login.redirect_uri.to_string(),
253+
},
254+
session_counts,
255+
// We don't know if there's going to be a replacement until we received the device ID,
256+
// which happens too late.
257+
session_replaced: false,
258+
requester: mas_policy::Requester {
259+
ip_address: activity_tracker.ip(),
260+
user_agent,
261+
},
262+
})
263+
.await?;
264+
265+
if !res.valid() {
266+
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
267+
let ctx = CompatLoginPolicyViolationContext::for_violations(res.violations)
268+
.with_session(session)
269+
.with_csrf(csrf_token.form_value())
270+
.with_language(locale);
271+
272+
let content = templates.render_compat_login_policy_violation(&ctx)?;
273+
274+
return Ok((StatusCode::FORBIDDEN, cookie_jar, Html(content)).into_response());
275+
}
276+
203277
// Note that if the login is not Pending,
204278
// this fails and aborts the transaction.
205279
repo.compat_sso_login()

crates/handlers/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ where
272272
BoxRepository: FromRequestParts<S>,
273273
BoxClock: FromRequestParts<S>,
274274
BoxRng: FromRequestParts<S>,
275+
Policy: FromRequestParts<S>,
275276
{
276277
// A sub-router for human-facing routes with error handling
277278
let human_router = Router::new()

crates/handlers/src/test_utils.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ pub(crate) async fn policy_factory(
8282
register: "register/violation".to_owned(),
8383
client_registration: "client_registration/violation".to_owned(),
8484
authorization_grant: "authorization_grant/violation".to_owned(),
85+
compat_login: "compat_login/violation".to_owned(),
8586
email: "email/violation".to_owned(),
8687
};
8788

0 commit comments

Comments
 (0)