From 1aff98bdb3783326bd9800e23f5e8843b187f071 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 12 May 2022 18:47:13 +0200 Subject: [PATCH] Working legacy login endpoint --- crates/cli/src/commands/server.rs | 3 + crates/config/src/sections/matrix.rs | 82 +++++++ crates/config/src/sections/mod.rs | 8 + crates/data-model/src/lib.rs | 4 +- crates/data-model/src/tokens.rs | 24 ++- crates/data-model/src/traits.rs | 2 + crates/handlers/src/compat/mod.rs | 92 +++++++- crates/handlers/src/lib.rs | 4 +- crates/handlers/src/oauth2/introspection.rs | 69 +++++- .../20220512150806_compat_login.down.sql | 15 ++ .../20220512150806_compat_login.up.sql | 23 ++ crates/storage/sqlx-data.json | 108 ++++++++++ crates/storage/src/compat.rs | 202 ++++++++++++++++++ crates/storage/src/lib.rs | 4 +- 14 files changed, 615 insertions(+), 25 deletions(-) create mode 100644 crates/config/src/sections/matrix.rs create mode 100644 crates/storage/migrations/20220512150806_compat_login.down.sql create mode 100644 crates/storage/migrations/20220512150806_compat_login.up.sql create mode 100644 crates/storage/src/compat.rs diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 59259383..e614f867 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -192,6 +192,8 @@ impl Options { let static_files = mas_static_files::service(&config.http.web_root); + let matrix_config = config.matrix.clone(); + // Explicitely the config to properly zeroize secret keys drop(config); @@ -214,6 +216,7 @@ impl Options { &encrypter, &mailer, &url_builder, + &matrix_config, ) .fallback(static_files) .layer(ServerLayer::default()); diff --git a/crates/config/src/sections/matrix.rs b/crates/config/src/sections/matrix.rs new file mode 100644 index 00000000..8bcd1e98 --- /dev/null +++ b/crates/config/src/sections/matrix.rs @@ -0,0 +1,82 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use async_trait::async_trait; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; + +use super::ConfigurationSection; + +fn default_homeserver() -> String { + "localhost:8008".to_string() +} + +/// Configuration related to the Matrix homeserver +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MatrixConfig { + /// Time-to-live of a CSRF token in seconds + #[serde(default = "default_homeserver")] + pub homeserver: String, +} + +impl Default for MatrixConfig { + fn default() -> Self { + Self { + homeserver: default_homeserver(), + } + } +} + +#[async_trait] +impl ConfigurationSection<'_> for MatrixConfig { + fn path() -> &'static str { + "matrix" + } + + async fn generate() -> anyhow::Result { + Ok(Self::default()) + } + + fn test() -> Self { + Self::default() + } +} + +#[cfg(test)] +mod tests { + use figment::Jail; + + use super::*; + + #[test] + fn load_config() { + Jail::expect_with(|jail| { + jail.create_file( + "config.yaml", + r#" + matrix: + homeserver: matrix.org + "#, + )?; + + let config = MatrixConfig::load_from_file("config.yaml")?; + + assert_eq!(config.homeserver, "matrix.org".to_string()); + + Ok(()) + }); + } +} diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index 16ce9875..73c0b0a9 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -21,6 +21,7 @@ mod csrf; mod database; mod email; mod http; +mod matrix; mod secrets; mod telemetry; mod templates; @@ -31,6 +32,7 @@ pub use self::{ database::DatabaseConfig, email::{EmailConfig, EmailSmtpMode, EmailTransportConfig}, http::HttpConfig, + matrix::MatrixConfig, secrets::{Encrypter, SecretsConfig}, telemetry::{ MetricsConfig, MetricsExporterConfig, Propagator, TelemetryConfig, TracingConfig, @@ -73,6 +75,10 @@ pub struct RootConfig { /// Application secrets pub secrets: SecretsConfig, + + /// Configuration related to the homeserver + #[serde(default)] + pub matrix: MatrixConfig, } #[async_trait] @@ -91,6 +97,7 @@ impl ConfigurationSection<'_> for RootConfig { csrf: CsrfConfig::generate().await?, email: EmailConfig::generate().await?, secrets: SecretsConfig::generate().await?, + matrix: MatrixConfig::generate().await?, }) } @@ -104,6 +111,7 @@ impl ConfigurationSection<'_> for RootConfig { csrf: CsrfConfig::test(), email: EmailConfig::test(), secrets: SecretsConfig::test(), + matrix: MatrixConfig::test(), } } } diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 9f6ef9f8..e1d18c35 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2021 The Matrix.org Foundation C.I.C. +// Copyright 2021, 2022 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -32,7 +32,7 @@ pub use self::{ AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, }, - tokens::{AccessToken, RefreshToken, TokenFormatError, TokenType}, + tokens::{AccessToken, CompatAccessToken, RefreshToken, TokenFormatError, TokenType}, traits::{StorageBackend, StorageBackendMarker}, users::{ Authentication, BrowserSession, User, UserEmail, UserEmailVerification, diff --git a/crates/data-model/src/tokens.rs b/crates/data-model/src/tokens.rs index 2932e4f2..e98fa4fd 100644 --- a/crates/data-model/src/tokens.rs +++ b/crates/data-model/src/tokens.rs @@ -66,13 +66,26 @@ impl From> for RefreshToken<()> { } } +#[derive(Debug, Clone, PartialEq)] +pub struct CompatAccessToken { + pub data: T::CompatAccessTokenData, + pub token: String, + pub device_id: String, + pub created_at: DateTime, + pub deleted_at: Option>, +} + /// Type of token to generate or validate #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TokenType { /// An access token, used by Relying Parties to authenticate requests AccessToken, + /// A refresh token, used by the refresh token grant RefreshToken, + + /// A legacy access token + CompatAccessToken, } impl TokenType { @@ -80,6 +93,7 @@ impl TokenType { match self { TokenType::AccessToken => "mat", TokenType::RefreshToken => "mar", + TokenType::CompatAccessToken => "mct", } } @@ -87,6 +101,7 @@ impl TokenType { match prefix { "mat" => Some(TokenType::AccessToken), "mar" => Some(TokenType::RefreshToken), + "mct" => Some(TokenType::CompatAccessToken), _ => None, } } @@ -163,8 +178,10 @@ impl PartialEq for TokenType { fn eq(&self, other: &OAuthTokenTypeHint) -> bool { matches!( (self, other), - (TokenType::AccessToken, OAuthTokenTypeHint::AccessToken) - | (TokenType::RefreshToken, OAuthTokenTypeHint::RefreshToken) + ( + TokenType::AccessToken | TokenType::CompatAccessToken, + OAuthTokenTypeHint::AccessToken + ) | (TokenType::RefreshToken, OAuthTokenTypeHint::RefreshToken) ) } } @@ -217,7 +234,8 @@ mod tests { #[test] fn test_prefix_match() { - use TokenType::{AccessToken, RefreshToken}; + use TokenType::{AccessToken, CompatAccessToken, RefreshToken}; + assert_eq!(TokenType::match_prefix("mct"), Some(CompatAccessToken)); assert_eq!(TokenType::match_prefix("mat"), Some(AccessToken)); assert_eq!(TokenType::match_prefix("mar"), Some(RefreshToken)); assert_eq!(TokenType::match_prefix("matt"), None); diff --git a/crates/data-model/src/traits.rs b/crates/data-model/src/traits.rs index d4376740..0cf3dfe2 100644 --- a/crates/data-model/src/traits.rs +++ b/crates/data-model/src/traits.rs @@ -34,6 +34,7 @@ pub trait StorageBackend { type AuthorizationGrantData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; type AccessTokenData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; type RefreshTokenData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; + type CompatAccessTokenData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; } impl StorageBackend for () { @@ -42,6 +43,7 @@ impl StorageBackend for () { type AuthorizationGrantData = (); type BrowserSessionData = (); type ClientData = (); + type CompatAccessTokenData = (); type RefreshTokenData = (); type SessionData = (); type UserData = (); diff --git a/crates/handlers/src/compat/mod.rs b/crates/handlers/src/compat/mod.rs index 2e8b8460..f73356a3 100644 --- a/crates/handlers/src/compat/mod.rs +++ b/crates/handlers/src/compat/mod.rs @@ -12,9 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -use axum::{response::IntoResponse, Json}; +use axum::{response::IntoResponse, Extension, Json}; use hyper::StatusCode; +use mas_config::MatrixConfig; +use mas_data_model::TokenType; +use mas_storage::compat::compat_login; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; use serde::{Deserialize, Serialize}; +use sqlx::PgPool; #[derive(Debug, Serialize)] struct MatrixError { @@ -73,11 +78,84 @@ pub enum LoginIdentifier { Unsupported, } -pub(crate) async fn post(Json(input): Json) -> impl IntoResponse { - tracing::info!(?input, "Got Matrix login"); - MatrixError { - errcode: "M_UNKNOWN", - error: "Not implemented", - status: StatusCode::NOT_IMPLEMENTED, +#[derive(Debug, Serialize)] +pub struct SuccessfulLogin { + access_token: String, + device_id: String, + user_id: String, +} + +pub enum RouteError { + Internal(Box), + Unsupported, + LoginFailed, +} + +impl From for RouteError { + fn from(e: sqlx::Error) -> Self { + Self::Internal(Box::new(e)) } } + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + match self { + Self::Internal(_e) => MatrixError { + errcode: "M_UNKNOWN", + error: "Internal server error", + status: StatusCode::INTERNAL_SERVER_ERROR, + }, + Self::Unsupported => MatrixError { + errcode: "M_UNRECOGNIZED", + error: "Invalid login type", + status: StatusCode::BAD_REQUEST, + }, + Self::LoginFailed => MatrixError { + errcode: "M_UNAUTHORIZED", + error: "Invalid username/password", + status: StatusCode::FORBIDDEN, + }, + } + .into_response() + } +} + +pub(crate) async fn post( + Extension(pool): Extension, + Extension(config): Extension, + Json(input): Json, +) -> Result { + let mut conn = pool.acquire().await?; + let (username, password) = match input { + IncomingLogin::Password { + identifier: LoginIdentifier::User { user }, + password, + } => (user, password), + _ => { + return Err(RouteError::Unsupported); + } + }; + + let (token, device_id) = { + let mut rng = thread_rng(); + let token = TokenType::CompatAccessToken.generate(&mut rng); + let device_id: String = rng + .sample_iter(&Alphanumeric) + .take(10) + .map(char::from) + .collect(); + (token, device_id) + }; + + let (token, user) = compat_login(&mut conn, &username, &password, device_id, token) + .await + .map_err(|_| RouteError::LoginFailed)?; + + let user_id = format!("@{}:{}", user.username, config.homeserver); + + Ok(Json(SuccessfulLogin { + access_token: token.token, + device_id: token.device_id, + user_id, + })) +} diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 2974c5c9..191e61e9 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -29,7 +29,7 @@ use axum::{ Router, }; use hyper::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_LANGUAGE, CONTENT_TYPE}; -use mas_config::Encrypter; +use mas_config::{Encrypter, MatrixConfig}; use mas_email::Mailer; use mas_http::CorsLayerExt; use mas_jose::StaticKeystore; @@ -53,6 +53,7 @@ pub fn router( encrypter: &Encrypter, mailer: &Mailer, url_builder: &UrlBuilder, + matrix_config: &MatrixConfig, ) -> Router where B: HttpBody + Send + 'static, @@ -186,4 +187,5 @@ where .layer(Extension(encrypter.clone())) .layer(Extension(url_builder.clone())) .layer(Extension(mailer.clone())) + .layer(Extension(matrix_config.clone())) } diff --git a/crates/handlers/src/oauth2/introspection.rs b/crates/handlers/src/oauth2/introspection.rs index 453d2703..3e9b9665 100644 --- a/crates/handlers/src/oauth2/introspection.rs +++ b/crates/handlers/src/oauth2/introspection.rs @@ -18,21 +18,40 @@ use mas_axum_utils::client_authorization::{ClientAuthorization, CredentialsVerif use mas_config::Encrypter; use mas_data_model::{TokenFormatError, TokenType}; use mas_iana::oauth::{OAuthClientAuthenticationMethod, OAuthTokenTypeHint}; -use mas_storage::oauth2::{ - access_token::{lookup_active_access_token, AccessTokenLookupError}, - client::ClientFetchError, - refresh_token::{lookup_active_refresh_token, RefreshTokenLookupError}, +use mas_storage::{ + compat::{lookup_active_compat_access_token, CompatAccessTokenLookupError}, + oauth2::{ + access_token::{lookup_active_access_token, AccessTokenLookupError}, + client::ClientFetchError, + refresh_token::{lookup_active_refresh_token, RefreshTokenLookupError}, + }, +}; +use oauth2_types::{ + requests::{IntrospectionRequest, IntrospectionResponse}, + scope::ScopeToken, }; -use oauth2_types::requests::{IntrospectionRequest, IntrospectionResponse}; use sqlx::PgPool; +use thiserror::Error; +#[derive(Debug, Error)] pub enum RouteError { + #[error(transparent)] Internal(Box), + + #[error("could not find client")] ClientNotFound, + + #[error("client is not allowed to introspect")] NotAllowed, + + #[error("unknown token")] UnknownToken, + + #[error("bad request")] BadRequest, - ClientCredentialsVerification(CredentialsVerificationError), + + #[error(transparent)] + ClientCredentialsVerification(#[from] CredentialsVerificationError), } impl IntoResponse for RouteError { @@ -88,8 +107,8 @@ impl From for RouteError { } } -impl From for RouteError { - fn from(e: RefreshTokenLookupError) -> Self { +impl From for RouteError { + fn from(e: CompatAccessTokenLookupError) -> Self { if e.not_found() { Self::UnknownToken } else { @@ -98,9 +117,13 @@ impl From for RouteError { } } -impl From for RouteError { - fn from(e: CredentialsVerificationError) -> Self { - Self::ClientCredentialsVerification(e) +impl From for RouteError { + fn from(e: RefreshTokenLookupError) -> Self { + if e.not_found() { + Self::UnknownToken + } else { + Self::Internal(Box::new(e)) + } } } @@ -119,6 +142,7 @@ const INACTIVE: IntrospectionResponse = IntrospectionResponse { jti: None, }; +#[tracing::instrument(skip_all, err)] pub(crate) async fn post( Extension(pool): Extension, Extension(encrypter): Extension, @@ -192,6 +216,29 @@ pub(crate) async fn post( jti: None, } } + TokenType::CompatAccessToken => { + let (token, user) = lookup_active_compat_access_token(&mut conn, token).await?; + + let device_scope: ScopeToken = format!("urn:matrix:device:{}", token.device_id) + .parse() + .unwrap(); + let scope = [device_scope].into_iter().collect(); + + IntrospectionResponse { + active: true, + scope: Some(scope), + client_id: Some("legacy".into()), + username: Some(user.username), + token_type: Some(OAuthTokenTypeHint::AccessToken), + exp: None, + iat: Some(token.created_at), + nbf: Some(token.created_at), + sub: Some(user.sub), + aud: None, + iss: None, + jti: None, + } + } }; Ok(Json(reply)) diff --git a/crates/storage/migrations/20220512150806_compat_login.down.sql b/crates/storage/migrations/20220512150806_compat_login.down.sql new file mode 100644 index 00000000..44f09300 --- /dev/null +++ b/crates/storage/migrations/20220512150806_compat_login.down.sql @@ -0,0 +1,15 @@ +-- Copyright 2022 The Matrix.org Foundation C.I.C. +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +DROP TABLE compat_access_tokens; diff --git a/crates/storage/migrations/20220512150806_compat_login.up.sql b/crates/storage/migrations/20220512150806_compat_login.up.sql new file mode 100644 index 00000000..cea31338 --- /dev/null +++ b/crates/storage/migrations/20220512150806_compat_login.up.sql @@ -0,0 +1,23 @@ +-- Copyright 2022 The Matrix.org Foundation C.I.C. +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +CREATE TABLE compat_access_tokens ( + "id" BIGSERIAL PRIMARY KEY, + "user_id" BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "token" TEXT UNIQUE NOT NULL, + "device_id" TEXT UNIQUE NOT NULL, + + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP WITH TIME ZONE +) diff --git a/crates/storage/sqlx-data.json b/crates/storage/sqlx-data.json index 9080c9cd..b1177957 100644 --- a/crates/storage/sqlx-data.json +++ b/crates/storage/sqlx-data.json @@ -1331,6 +1331,34 @@ }, "query": "UPDATE user_sessions SET active = FALSE WHERE id = $1" }, + "a741cb29c617fb2df97f9e4bc84dbf85ff3c02ac87066770291d7bc153451695": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "created_at", + "ordinal": 1, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text" + ] + } + }, + "query": "\n INSERT INTO compat_access_tokens (user_id, token, device_id)\n VALUES ($1, $2, $3)\n RETURNING id, created_at\n " + }, "a80c14ba82cfc29493048d9e9578ec5ca482c9228efc7c7212dae4fed86b8367": { "describe": { "columns": [], @@ -1659,6 +1687,86 @@ }, "query": "\n INSERT INTO users (username)\n VALUES ($1)\n RETURNING id\n " }, + "de1ed5db37e48382a38075247c001cf4d01b7eeff165aa3aa62bb21a3284d5b7": { + "describe": { + "columns": [ + { + "name": "compat_access_token_id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "compat_access_token", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "compat_access_token_created_at", + "ordinal": 2, + "type_info": "Timestamptz" + }, + { + "name": "compat_access_token_deleted_at", + "ordinal": 3, + "type_info": "Timestamptz" + }, + { + "name": "compat_access_token_device_id", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "user_id!", + "ordinal": 5, + "type_info": "Int8" + }, + { + "name": "user_username!", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "user_email_id?", + "ordinal": 7, + "type_info": "Int8" + }, + { + "name": "user_email?", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "user_email_created_at?", + "ordinal": 9, + "type_info": "Timestamptz" + }, + { + "name": "user_email_confirmed_at?", + "ordinal": 10, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT\n ct.id AS \"compat_access_token_id\",\n ct.token AS \"compat_access_token\",\n ct.created_at AS \"compat_access_token_created_at\",\n ct.deleted_at AS \"compat_access_token_deleted_at\",\n ct.device_id AS \"compat_access_token_device_id\",\n u.id AS \"user_id!\",\n u.username AS \"user_username!\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n\n FROM compat_access_tokens ct\n INNER JOIN users u\n ON u.id = ct.user_id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE ct.token = $1\n AND ct.deleted_at IS NULL\n " + }, "df38de13e2f345175f9ef46b4ae2a4f6637dbf74bb28559da8f4d8969f411d14": { "describe": { "columns": [ diff --git a/crates/storage/src/compat.rs b/crates/storage/src/compat.rs new file mode 100644 index 00000000..a03b70cb --- /dev/null +++ b/crates/storage/src/compat.rs @@ -0,0 +1,202 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use anyhow::Context; +use argon2::{Argon2, PasswordHash}; +use chrono::{DateTime, Utc}; +use mas_data_model::{CompatAccessToken, User, UserEmail}; +use sqlx::{Acquire, PgExecutor, Postgres}; +use thiserror::Error; +use tokio::task; +use tracing::{info_span, Instrument}; + +use crate::{ + user::lookup_user_by_username, DatabaseInconsistencyError, IdAndCreationTime, PostgresqlBackend, +}; + +pub struct CompatAccessTokenLookup { + compat_access_token_id: i64, + compat_access_token: String, + compat_access_token_created_at: DateTime, + compat_access_token_deleted_at: Option>, + compat_access_token_device_id: String, + user_id: i64, + user_username: String, + user_email_id: Option, + user_email: Option, + user_email_created_at: Option>, + user_email_confirmed_at: Option>, +} + +#[derive(Debug, Error)] +#[error("failed to lookup compat access token")] +pub enum CompatAccessTokenLookupError { + Database(#[from] sqlx::Error), + Inconsistency(#[from] DatabaseInconsistencyError), +} + +impl CompatAccessTokenLookupError { + #[must_use] + pub fn not_found(&self) -> bool { + matches!(self, Self::Database(sqlx::Error::RowNotFound)) + } +} + +#[tracing::instrument(skip(executor))] +pub async fn lookup_active_compat_access_token( + executor: impl PgExecutor<'_>, + token: &str, +) -> Result< + ( + CompatAccessToken, + User, + ), + CompatAccessTokenLookupError, +> { + let res = sqlx::query_as!( + CompatAccessTokenLookup, + r#" + SELECT + ct.id AS "compat_access_token_id", + ct.token AS "compat_access_token", + ct.created_at AS "compat_access_token_created_at", + ct.deleted_at AS "compat_access_token_deleted_at", + ct.device_id AS "compat_access_token_device_id", + u.id AS "user_id!", + u.username AS "user_username!", + ue.id AS "user_email_id?", + ue.email AS "user_email?", + ue.created_at AS "user_email_created_at?", + ue.confirmed_at AS "user_email_confirmed_at?" + + FROM compat_access_tokens ct + INNER JOIN users u + ON u.id = ct.user_id + LEFT JOIN user_emails ue + ON ue.id = u.primary_email_id + + WHERE ct.token = $1 + AND ct.deleted_at IS NULL + "#, + token, + ) + .fetch_one(executor) + .instrument(info_span!("Fetch compat access token")) + .await?; + + let token = CompatAccessToken { + data: res.compat_access_token_id, + token: res.compat_access_token, + created_at: res.compat_access_token_created_at, + deleted_at: res.compat_access_token_deleted_at, + device_id: res.compat_access_token_device_id, + }; + + let primary_email = match ( + res.user_email_id, + res.user_email, + res.user_email_created_at, + res.user_email_confirmed_at, + ) { + (Some(id), Some(email), Some(created_at), confirmed_at) => Some(UserEmail { + data: id, + email, + created_at, + confirmed_at, + }), + (None, None, None, None) => None, + _ => return Err(DatabaseInconsistencyError.into()), + }; + + let user = User { + data: res.user_id, + username: res.user_username, + sub: format!("fake-sub-{}", res.user_id), + primary_email, + }; + + Ok((token, user)) +} + +#[tracing::instrument(skip(conn, password))] +pub async fn compat_login( + conn: impl Acquire<'_, Database = Postgres>, + username: &str, + password: &str, + device_id: String, + token: String, +) -> Result< + ( + CompatAccessToken, + User, + ), + anyhow::Error, +> { + let mut txn = conn.begin().await.context("could not start transaction")?; + + // First, lookup the user + let user = lookup_user_by_username(&mut txn, username).await?; + + // Now, fetch the hashed password from the user associated with that session + let hashed_password: String = sqlx::query_scalar!( + r#" + SELECT up.hashed_password + FROM user_passwords up + WHERE up.user_id = $1 + ORDER BY up.created_at DESC + LIMIT 1 + "#, + user.data, + ) + .fetch_one(&mut txn) + .instrument(tracing::info_span!("Lookup hashed password")) + .await?; + + // TODO: pass verifiers list as parameter + // Verify the password in a blocking thread to avoid blocking the async executor + let password = password.to_string(); + task::spawn_blocking(move || { + let context = Argon2::default(); + let hasher = PasswordHash::new(&hashed_password)?; + hasher.verify_password(&[&context], &password) + }) + .instrument(tracing::info_span!("Verify hashed password")) + .await??; + + let res = sqlx::query_as!( + IdAndCreationTime, + r#" + INSERT INTO compat_access_tokens (user_id, token, device_id) + VALUES ($1, $2, $3) + RETURNING id, created_at + "#, + user.data, + token, + device_id, + ) + .fetch_one(&mut txn) + .await + .context("could not insert compat access token")?; + + let token = CompatAccessToken { + data: res.id, + token, + created_at: res.created_at, + deleted_at: None, + device_id, + }; + + txn.commit().await.context("could not commit transaction")?; + Ok((token, user)) +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index efe1390e..cd351f2f 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2021 The Matrix.org Foundation C.I.C. +// Copyright 2021, 2022 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -42,6 +42,7 @@ impl StorageBackend for PostgresqlBackend { type AuthorizationGrantData = i64; type BrowserSessionData = i64; type ClientData = i64; + type CompatAccessTokenData = i64; type RefreshTokenData = i64; type SessionData = i64; type UserData = i64; @@ -56,6 +57,7 @@ struct IdAndCreationTime { created_at: DateTime, } +pub mod compat; pub mod oauth2; pub mod user;