diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 22a74a405..75b96417e 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -205,6 +205,9 @@ pub enum RouteError { #[error("invalid login token")] InvalidLoginToken, + #[error("user is locked")] + UserLocked, + #[error("failed to provision device")] ProvisionDeviceFailed(#[source] anyhow::Error), } @@ -263,6 +266,11 @@ impl IntoResponse for RouteError { error: "Invalid login token", status: StatusCode::FORBIDDEN, }, + Self::UserLocked => MatrixError { + errcode: "M_USER_LOCKED", + error: "User account has been locked", + status: StatusCode::UNAUTHORIZED, + }, }; (sentry_event_id, response).into_response() @@ -506,7 +514,15 @@ async fn token_login( browser_session.id = %browser_session_id, "Attempt to exchange login token but browser session is not active" ); - return Err(RouteError::InvalidLoginToken); + return Err( + if browser_session.finished_at.is_some() + || browser_session.user.deactivated_at.is_some() + { + RouteError::InvalidLoginToken + } else { + RouteError::UserLocked + }, + ); } // We're about to create a device, let's explicitly acquire a lock, so that @@ -565,9 +581,13 @@ async fn user_password_login( .user() .find_by_username(username) .await? - .filter(mas_data_model::User::is_valid) + .filter(|user| user.deactivated_at.is_none()) .ok_or(RouteError::UserNotFound)?; + if user.locked_at.is_some() { + return Err(RouteError::UserLocked); + } + // Check the rate limit limiter.check_password(requester, &user)?; @@ -785,7 +805,12 @@ mod tests { "###); } - async fn user_with_password(state: &TestState, username: &str, password: &str) { + async fn user_with_password( + state: &TestState, + username: &str, + password: &str, + locked: bool, + ) -> User { let mut rng = state.rng(); let mut repo = state.repository().await.unwrap(); @@ -811,7 +836,14 @@ mod tests { .await .unwrap(); + let user = if locked { + repo.user().lock(&state.clock, user).await.unwrap() + } else { + user + }; + repo.save().await.unwrap(); + user } /// Test that a user can login with a password using the Matrix @@ -821,7 +853,7 @@ mod tests { setup(); let state = TestState::from_pool(pool).await.unwrap(); - user_with_password(&state, "alice", "password").await; + let user = user_with_password(&state, "alice", "password", true).await; // Now let's try to login with the password, without asking for a refresh token. let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ @@ -833,14 +865,30 @@ mod tests { "password": "password", })); + // First try to login to a locked account + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::UNAUTHORIZED); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_USER_LOCKED", + "error": "User account has been locked" + } + "###); + + // Now try again after unlocking the account + let mut repo = state.repository().await.unwrap(); + let user = repo.user().unlock(user).await.unwrap(); + repo.save().await.unwrap(); + let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); insta::assert_json_snapshot!(body, @r###" { - "access_token": "mct_16tugBE5Ta9LIWoSJaAEHHq2g3fx8S_alcBB4", - "device_id": "ZGpSvYQqlq", + "access_token": "mct_cxG6gZXyvelQWW9XqfNbm5KAQovodf_XvJz43", + "device_id": "42oTpLoieH", "user_id": "@alice:example.com" } "###); @@ -862,10 +910,10 @@ mod tests { let body: serde_json::Value = response.json(); insta::assert_json_snapshot!(body, @r###" { - "access_token": "mct_cxG6gZXyvelQWW9XqfNbm5KAQovodf_XvJz43", - "device_id": "42oTpLoieH", + "access_token": "mct_PGMLvvMXC4Ds1A3lCWc6Hx4l9DGzqG_lVEIV2", + "device_id": "Yp7FM44zJN", "user_id": "@alice:example.com", - "refresh_token": "mcr_7IvDc44woP66fRQoS9MVcHXO9OeBmR_0jDGr1", + "refresh_token": "mcr_LoYqtrtBUBcWlE4RX6o47chBCGkadB_9gzpc1", "expires_in_ms": 300000 } "###); @@ -883,8 +931,8 @@ mod tests { let body: serde_json::Value = response.json(); insta::assert_json_snapshot!(body, @r###" { - "access_token": "mct_PGMLvvMXC4Ds1A3lCWc6Hx4l9DGzqG_lVEIV2", - "device_id": "Yp7FM44zJN", + "access_token": "mct_Xl3bbpfh9yNy9NzuRxyR3b3PLW0rqd_DiXAH2", + "device_id": "6cq7FqNSYo", "user_id": "@alice:example.com" } "###); @@ -930,6 +978,45 @@ mod tests { // The response should be the same as the previous one, so that we don't leak if // it's the user that is invalid or the password. assert_eq!(body, old_body); + + // Try to login to a deactivated account + let mut repo = state.repository().await.unwrap(); + let user = repo.user().deactivate(&state.clock, user).await.unwrap(); + repo.save().await.unwrap(); + + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "alice", + }, + "password": "password", + })); + + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_FORBIDDEN", + "error": "Invalid username/password" + } + "###); + + // Should get the same error if the deactivated user is also locked + let mut repo = state.repository().await.unwrap(); + let _user = repo.user().lock(&state.clock, user).await.unwrap(); + repo.save().await.unwrap(); + + let response = state.request(request).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_FORBIDDEN", + "error": "Invalid username/password" + } + "###); } /// Test that we can send a login request without a Content-Type header @@ -938,7 +1025,7 @@ mod tests { setup(); let state = TestState::from_pool(pool).await.unwrap(); - user_with_password(&state, "alice", "password").await; + user_with_password(&state, "alice", "password", false).await; // Try without a Content-Type header let mut request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ "type": "m.login.password", @@ -970,7 +1057,7 @@ mod tests { setup(); let state = TestState::from_pool(pool).await.unwrap(); - user_with_password(&state, "alice", "password").await; + let user = user_with_password(&state, "alice", "password", true).await; // Login with a full MXID as identifier let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ @@ -982,13 +1069,29 @@ mod tests { "password": "password", })); + // First try to login to a locked account + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::UNAUTHORIZED); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_USER_LOCKED", + "error": "User account has been locked" + } + "###); + + // Now try again after unlocking the account + let mut repo = state.repository().await.unwrap(); + let _ = repo.user().unlock(user).await.unwrap(); + repo.save().await.unwrap(); + let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); insta::assert_json_snapshot!(body, @r###" { - "access_token": "mct_16tugBE5Ta9LIWoSJaAEHHq2g3fx8S_alcBB4", - "device_id": "ZGpSvYQqlq", + "access_token": "mct_cxG6gZXyvelQWW9XqfNbm5KAQovodf_XvJz43", + "device_id": "42oTpLoieH", "user_id": "@alice:example.com" } "###); @@ -1132,6 +1235,8 @@ mod tests { .add(&mut state.rng(), &state.clock, "alice".to_owned()) .await .unwrap(); + // Start with a locked account + let user = repo.user().lock(&state.clock, user).await.unwrap(); repo.save().await.unwrap(); let mxid = state.homeserver_connection.mxid(&user.username); @@ -1164,14 +1269,29 @@ mod tests { "type": "m.login.token", "token": token, })); + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::UNAUTHORIZED); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_USER_LOCKED", + "error": "User account has been locked" + } + "###); + + // Now try again after unlocking the account + let mut repo = state.repository().await.unwrap(); + let user = repo.user().unlock(user).await.unwrap(); + repo.save().await.unwrap(); + let response = state.request(request).await; response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); insta::assert_json_snapshot!(body, @r#" { - "access_token": "mct_bnkWh1tPmm1MZOpygPaXwygX8PfxEY_hE6do1", - "device_id": "O3Ju1MUh3Z", + "access_token": "mct_bUTa4XIh92RARTPTjqQrCZLAkq2ild_0VsYE6", + "device_id": "uihy4bk51g", "user_id": "@alice:example.com" } "#); @@ -1212,6 +1332,41 @@ mod tests { "error": "Login token expired" } "###); + + // Try to login to a deactivated account + let token = get_login_token(&state, &user).await; + + let mut repo = state.repository().await.unwrap(); + let user = repo.user().deactivate(&state.clock, user).await.unwrap(); + repo.save().await.unwrap(); + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.token", + "token": token, + })); + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_FORBIDDEN", + "error": "Invalid login token" + } + "###); + + // Should get the same error if the deactivated user is also locked + let mut repo = state.repository().await.unwrap(); + let _user = repo.user().lock(&state.clock, user).await.unwrap(); + repo.save().await.unwrap(); + + let response = state.request(request).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_FORBIDDEN", + "error": "Invalid login token" + } + "###); } /// Get a login token for a user.