From 97635375cc98174a7ed0f75a158ec4fe5d4039f8 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 23 Feb 2023 14:47:13 +0100 Subject: [PATCH] handlers: Add test for the compatibility login API --- crates/data-model/src/compat/device.rs | 4 +- crates/handlers/src/compat/login.rs | 290 ++++++++++++++++++++++++- 2 files changed, 291 insertions(+), 3 deletions(-) diff --git a/crates/data-model/src/compat/device.rs b/crates/data-model/src/compat/device.rs index eebfd9ed..cac0f242 100644 --- a/crates/data-model/src/compat/device.rs +++ b/crates/data-model/src/compat/device.rs @@ -17,12 +17,12 @@ use rand::{ distributions::{Alphanumeric, DistString}, RngCore, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use thiserror::Error; static DEVICE_ID_LENGTH: usize = 10; -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct Device { id: String, diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 1cc18c8c..c3d303b0 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -117,7 +117,7 @@ pub enum Identifier { #[skip_serializing_none] #[serde_as] -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct ResponseBody { access_token: String, device_id: Device, @@ -386,3 +386,291 @@ async fn user_password_login( Ok((session, user)) } + +#[cfg(test)] +mod tests { + use hyper::Request; + use rand::distributions::{Alphanumeric, DistString}; + use sqlx::PgPool; + + use super::*; + use crate::test_utils::{init_tracing, RequestBuilderExt, ResponseExt, TestState}; + + /// Test that the server advertises the right login flows. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_login(pool: PgPool) { + init_tracing(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Now let's try to login with the password, without asking for a refresh token. + let request = Request::get("/_matrix/client/v3/login").empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + assert_eq!( + body, + serde_json::json!({ + "flows": [ + { + "type": "m.login.password", + }, + { + "type": "m.login.sso", + "org.matrix.msc3824.delegated_oidc_compatibility": true, + }, + { + "type": "m.login.token", + } + ], + }) + ); + } + + /// Test that a user can login with a password using the Matrix + /// compatibility API. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_user_password_login(pool: PgPool) { + init_tracing(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Let's provision a user and add a password to it. This part is hard to test + // with just HTTP requests, so we'll use the repository directly. + let mut repo = state.repository().await.unwrap(); + + let user = repo + .user() + .add(&mut state.rng(), &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let (version, hashed_password) = state + .password_manager + .hash( + &mut state.rng(), + Zeroizing::new("password".to_owned().into_bytes()), + ) + .await + .unwrap(); + + repo.user_password() + .add( + &mut state.rng(), + &state.clock, + &user, + version, + hashed_password, + None, + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // 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!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "alice", + }, + "password": "password", + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: ResponseBody = response.json(); + assert!(!body.access_token.is_empty()); + assert_eq!(body.device_id.as_str().len(), 10); + assert_eq!(body.user_id, "@alice:example.com"); + assert_eq!(body.refresh_token, None); + assert_eq!(body.expires_in_ms, None); + + // Do the same, but this time ask for a refresh token. + 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", + "refresh_token": true, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: ResponseBody = response.json(); + assert!(!body.access_token.is_empty()); + assert_eq!(body.device_id.as_str().len(), 10); + assert_eq!(body.user_id, "@alice:example.com"); + assert!(body.refresh_token.is_some()); + assert!(body.expires_in_ms.is_some()); + + // Try to login with a wrong password. + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "alice", + }, + "password": "wrongpassword", + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + assert_eq!(body["errcode"], "M_UNAUTHORIZED"); + + // Try to login with a wrong username. + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "bob", + }, + "password": "wrongpassword", + })); + + let old_body = body; + let response = state.request(request).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + + // 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); + } + + /// Test the response of an unsupported login flow. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_unsupported_login(pool: PgPool) { + init_tracing(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Try to login with an unsupported login flow. + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.unsupported", + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json(); + assert_eq!(body["errcode"], "M_UNRECOGNIZED"); + } + + /// Test `m.login.token` login flow. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_login_token_login(pool: PgPool) { + init_tracing(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Provision a user + let mut repo = state.repository().await.unwrap(); + + let user = repo + .user() + .add(&mut state.rng(), &state.clock, "alice".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + // First try with an invalid token + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.token", + "token": "someinvalidtoken", + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + assert_eq!(body["errcode"], "M_UNAUTHORIZED"); + + let (device, token) = get_login_token(&state, &user).await; + + // Try to login with the token. + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.token", + "token": token, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: ResponseBody = response.json(); + assert!(!body.access_token.is_empty()); + assert_eq!(body.device_id, device); + assert_eq!(body.user_id, "@alice:example.com"); + assert_eq!(body.refresh_token, None); + assert_eq!(body.expires_in_ms, None); + + // Try again with the same token, it should fail. + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.token", + "token": token, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + assert_eq!(body["errcode"], "M_UNAUTHORIZED"); + + // Try to login, but wait too long before sending the request. + let (_device, token) = get_login_token(&state, &user).await; + + // Advance the clock to make the token expire. + state.clock.advance(Duration::minutes(1)); + + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.token", + "token": token, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::FORBIDDEN); + let body: serde_json::Value = response.json(); + assert_eq!(body["errcode"], "M_UNAUTHORIZED"); + } + + /// Get a login token for a user. + /// Returns the device and the token. + /// + /// # Panics + /// + /// Panics if the repository fails. + async fn get_login_token(state: &TestState, user: &User) -> (Device, String) { + // XXX: This is a bit manual, but this is what basically the SSO login flow + // does. + let mut repo = state.repository().await.unwrap(); + + // Generate a device and a token randomly + let token = Alphanumeric.sample_string(&mut state.rng(), 32); + let device = Device::generate(&mut state.rng()); + + // Start a compat SSO login flow + let login = repo + .compat_sso_login() + .add( + &mut state.rng(), + &state.clock, + token.clone(), + "http://example.com/".parse().unwrap(), + ) + .await + .unwrap(); + + // Complete the flow by fulfilling it with a session + let compat_session = repo + .compat_session() + .add(&mut state.rng(), &state.clock, user, device.clone()) + .await + .unwrap(); + + repo.compat_sso_login() + .fulfill(&state.clock, login, &compat_session) + .await + .unwrap(); + + repo.save().await.unwrap(); + + (device, token) + } +}