diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 1e5086aa..6c6262cd 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -124,6 +124,10 @@ where "/oauth2/authorize/step", get(self::oauth2::authorization::step_get), ) + .route( + "/consent", + get(self::oauth2::consent::get).post(self::oauth2::consent::post), + ) .merge(api_router) .layer(Extension(pool.clone())) .layer(Extension(templates.clone())) diff --git a/crates/handlers/src/oauth2/authorization.rs b/crates/handlers/src/oauth2/authorization.rs index f4c8aa30..efbae932 100644 --- a/crates/handlers/src/oauth2/authorization.rs +++ b/crates/handlers/src/oauth2/authorization.rs @@ -38,6 +38,7 @@ use mas_storage::{ derive_session, fulfill_grant, get_grant_by_id, new_authorization_grant, }, client::{lookup_client_by_client_id, ClientFetchError}, + consent::fetch_client_consent, refresh_token::add_refresh_token, }, PostgresqlBackend, @@ -62,6 +63,7 @@ use sqlx::{PgConnection, PgPool, Postgres, Transaction}; use thiserror::Error; use url::Url; +use super::consent::ConsentRequest; use crate::views::{LoginRequest, PostAuthAction, ReauthRequest, RegisterRequest}; #[derive(Debug, Error)] @@ -508,8 +510,16 @@ async fn step( return Err(anyhow::anyhow!("authorization grant not pending").into()); } - let reply = match browser_session.last_authentication { - Some(Authentication { created_at, .. }) if created_at > grant.max_auth_time() => { + let current_consent = + fetch_client_consent(&mut txn, &browser_session.user, &grant.client).await?; + + let lacks_consent = grant + .scope + .difference(¤t_consent) + .any(|scope| !scope.starts_with("urn:matrix:device:")); + + let reply = match (lacks_consent, &browser_session.last_authentication) { + (false, Some(Authentication { created_at, .. })) if created_at > &grant.max_auth_time() => { let session = derive_session(&mut txn, &grant, browser_session).await?; let grant = fulfill_grant(&mut txn, grant, session.clone()).await?; @@ -562,6 +572,12 @@ async fn step( ) .await? } + (true, Some(Authentication { created_at, .. })) if created_at > &grant.max_auth_time() => { + let next: ConsentRequest = next.into(); + let next = next.build_uri()?; + + Redirect::to(&next.to_string()).into_response() + } _ => { let next: PostAuthAction = next.into(); let next: ReauthRequest = next.into(); diff --git a/crates/handlers/src/oauth2/consent.rs b/crates/handlers/src/oauth2/consent.rs new file mode 100644 index 00000000..af5fb50e --- /dev/null +++ b/crates/handlers/src/oauth2/consent.rs @@ -0,0 +1,165 @@ +// 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 axum::{ + extract::{Extension, Form, Query}, + http::uri::{Parts, PathAndQuery}, + response::{Html, IntoResponse, Redirect, Response}, +}; +use hyper::{StatusCode, Uri}; +use mas_axum_utils::{ + csrf::{CsrfExt, ProtectedForm}, + PrivateCookieJar, SessionInfoExt, +}; +use mas_config::Encrypter; +use mas_data_model::AuthorizationGrantStage; +use mas_storage::oauth2::consent::insert_client_consent; +use mas_templates::{ConsentContext, TemplateContext, Templates}; +use sqlx::PgPool; +use thiserror::Error; + +use super::ContinueAuthorizationGrant; +use crate::views::{LoginRequest, PostAuthAction}; + +#[derive(Debug, Error)] +pub enum RouteError { + #[error(transparent)] + Anyhow(#[from] anyhow::Error), +} + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } +} + +pub(crate) struct ConsentRequest { + grant: ContinueAuthorizationGrant, +} + +impl From for ConsentRequest { + fn from(grant: ContinueAuthorizationGrant) -> Self { + Self { grant } + } +} + +impl ConsentRequest { + pub fn build_uri(&self) -> anyhow::Result { + let qs = serde_urlencoded::to_string(&self.grant)?; + let path_and_query = PathAndQuery::try_from(format!("/consent?{}", qs))?; + let uri = Uri::from_parts({ + let mut parts = Parts::default(); + parts.path_and_query = Some(path_and_query); + parts + })?; + Ok(uri) + } +} + +pub(crate) async fn get( + Extension(templates): Extension, + Extension(pool): Extension, + cookie_jar: PrivateCookieJar, + Query(next): Query, +) -> Result { + let mut conn = pool + .acquire() + .await + .context("failed to acquire db connection")?; + + let (session_info, cookie_jar) = cookie_jar.session_info(); + + let maybe_session = session_info + .load_session(&mut conn) + .await + .context("could not load session")?; + + let grant = next.fetch_authorization_grant(&mut conn).await?; + + if !matches!(grant.stage, AuthorizationGrantStage::Pending) { + return Err(anyhow::anyhow!("authorization grant not pending").into()); + } + + if let Some(session) = maybe_session { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(); + + let ctx = ConsentContext::new(grant) + .with_session(session) + .with_csrf(csrf_token.form_value()); + + let content = templates + .render_consent(&ctx) + .await + .context("failed to render template")?; + + Ok((cookie_jar, Html(content)).into_response()) + } else { + let login = LoginRequest::from(PostAuthAction::from(next)); + let login = login.build_uri()?; + Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()) + } +} + +pub(crate) async fn post( + Extension(pool): Extension, + cookie_jar: PrivateCookieJar, + Query(next): Query, + Form(form): Form>, +) -> Result { + let mut txn = pool + .begin() + .await + .context("failed to begin db transaction")?; + + cookie_jar + .verify_form(form) + .context("csrf verification failed")?; + + let (session_info, cookie_jar) = cookie_jar.session_info(); + + let maybe_session = session_info + .load_session(&mut txn) + .await + .context("could not load session")?; + + let session = if let Some(session) = maybe_session { + session + } else { + let login = LoginRequest::from(PostAuthAction::from(next)); + let login = login.build_uri()?; + return Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()); + }; + + let grant = next.fetch_authorization_grant(&mut txn).await?; + // Do not consent for the "urn:matrix:device:*" scope + let scope_without_device = grant + .scope + .iter() + .filter(|s| !s.starts_with("urn:matrix:device:")) + .cloned() + .collect(); + insert_client_consent( + &mut txn, + &session.user, + &grant.client, + &scope_without_device, + ) + .await?; + + txn.commit().await.context("could not commit txn")?; + + let uri = next.build_uri()?; + Ok((cookie_jar, Redirect::to(&uri.to_string())).into_response()) +} diff --git a/crates/handlers/src/oauth2/mod.rs b/crates/handlers/src/oauth2/mod.rs index 865ec8e9..c749956d 100644 --- a/crates/handlers/src/oauth2/mod.rs +++ b/crates/handlers/src/oauth2/mod.rs @@ -13,6 +13,7 @@ // limitations under the License. pub mod authorization; +pub mod consent; pub mod discovery; pub mod introspection; pub mod keys; diff --git a/crates/oauth2-types/src/scope.rs b/crates/oauth2-types/src/scope.rs index 1cf6ec92..bb24508f 100644 --- a/crates/oauth2-types/src/scope.rs +++ b/crates/oauth2-types/src/scope.rs @@ -84,6 +84,14 @@ impl ToString for ScopeToken { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Scope(HashSet); +impl std::ops::Deref for Scope { + type Target = HashSet; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + impl FromStr for Scope { type Err = InvalidScope; diff --git a/crates/storage/migrations/20220429071609_oauth2_consent.down.sql b/crates/storage/migrations/20220429071609_oauth2_consent.down.sql new file mode 100644 index 00000000..16d939f0 --- /dev/null +++ b/crates/storage/migrations/20220429071609_oauth2_consent.down.sql @@ -0,0 +1,17 @@ +-- 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 TRIGGER set_timestamp ON oauth2_consents; +DROP INDEX oauth2_consents_client_id_user_id_key; +DROP TABLE oauth2_consents; diff --git a/crates/storage/migrations/20220429071609_oauth2_consent.up.sql b/crates/storage/migrations/20220429071609_oauth2_consent.up.sql new file mode 100644 index 00000000..d2f0cbc2 --- /dev/null +++ b/crates/storage/migrations/20220429071609_oauth2_consent.up.sql @@ -0,0 +1,32 @@ +-- 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 oauth2_consents ( + "id" BIGSERIAL PRIMARY KEY, + "oauth2_client_id" BIGINT NOT NULL REFERENCES oauth2_clients (id) ON DELETE CASCADE, + "user_id" BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "scope_token" TEXT NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + + CONSTRAINT user_client_scope_tuple UNIQUE ("oauth2_client_id", "user_id", "scope_token") +); + +CREATE INDEX oauth2_consents_client_id_user_id_key + ON oauth2_consents ("oauth2_client_id", "user_id"); + +CREATE TRIGGER set_timestamp + BEFORE UPDATE ON oauth2_consents + FOR EACH ROW + EXECUTE PROCEDURE trigger_set_timestamp(); diff --git a/crates/storage/sqlx-data.json b/crates/storage/sqlx-data.json index 3ab3b2b4..c486defc 100644 --- a/crates/storage/sqlx-data.json +++ b/crates/storage/sqlx-data.json @@ -399,6 +399,27 @@ }, "query": "\n SELECT\n og.id AS grant_id,\n og.created_at AS grant_created_at,\n og.cancelled_at AS grant_cancelled_at,\n og.fulfilled_at AS grant_fulfilled_at,\n og.exchanged_at AS grant_exchanged_at,\n og.scope AS grant_scope,\n og.state AS grant_state,\n og.redirect_uri AS grant_redirect_uri,\n og.response_mode AS grant_response_mode,\n og.nonce AS grant_nonce,\n og.max_age AS grant_max_age,\n og.acr_values AS grant_acr_values,\n og.oauth2_client_id AS oauth2_client_id,\n og.code AS grant_code,\n og.response_type_code AS grant_response_type_code,\n og.response_type_token AS grant_response_type_token,\n og.response_type_id_token AS grant_response_type_id_token,\n og.code_challenge AS grant_code_challenge,\n og.code_challenge_method AS grant_code_challenge_method,\n os.id AS \"session_id?\",\n us.id AS \"user_session_id?\",\n us.created_at AS \"user_session_created_at?\",\n u.id AS \"user_id?\",\n u.username AS \"user_username?\",\n usa.id AS \"user_session_last_authentication_id?\",\n usa.created_at AS \"user_session_last_authentication_created_at?\",\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 FROM\n oauth2_authorization_grants og\n LEFT JOIN oauth2_sessions os\n ON os.id = og.oauth2_session_id\n LEFT JOIN user_sessions us\n ON us.id = os.user_session_id\n LEFT JOIN users u\n ON u.id = us.user_id\n LEFT JOIN user_session_authentications usa\n ON usa.session_id = us.id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE og.id = $1\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n " }, + "51158bfcaa1a8d8e051bffe7c5ba0369bf53fb162f7622626054e89e68fc07bd": { + "describe": { + "columns": [ + { + "name": "scope_token", + "ordinal": 0, + "type_info": "Text" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n SELECT scope_token\n FROM oauth2_consents\n WHERE user_id = $1 AND oauth2_client_id = $2\n " + }, "581243a7f0c033548cc9644e0c60855ecb8bfefe51779eb135dd7547b886de79": { "describe": { "columns": [], @@ -1027,6 +1048,20 @@ }, "query": "\n DELETE FROM oauth2_access_tokens\n WHERE id = $1\n " }, + "8ff8e80c3af4f8ab47b30301a573dee26c85bf21f61317278a892d54875ae983": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "TextArray" + ] + } + }, + "query": "\n INSERT INTO oauth2_consents (user_id, oauth2_client_id, scope_token)\n SELECT $1, $2, scope_token FROM UNNEST($3::text[]) scope_token\n " + }, "99270fd3ddcc7421c5b26d0b8e0116356c13166887e7cf6ed6352cc879c80a68": { "describe": { "columns": [ diff --git a/crates/storage/src/oauth2/consent.rs b/crates/storage/src/oauth2/consent.rs new file mode 100644 index 00000000..1fd8bb45 --- /dev/null +++ b/crates/storage/src/oauth2/consent.rs @@ -0,0 +1,70 @@ +// 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 std::str::FromStr; + +use mas_data_model::{Client, User}; +use oauth2_types::scope::{Scope, ScopeToken}; +use sqlx::PgExecutor; + +use crate::PostgresqlBackend; + +pub async fn fetch_client_consent( + executor: impl PgExecutor<'_>, + user: &User, + client: &Client, +) -> anyhow::Result { + let scope_tokens: Vec = sqlx::query_scalar!( + r#" + SELECT scope_token + FROM oauth2_consents + WHERE user_id = $1 AND oauth2_client_id = $2 + "#, + user.data, + client.data, + ) + .fetch_all(executor) + .await?; + + let scope: Result = scope_tokens + .into_iter() + .map(|s| ScopeToken::from_str(&s)) + .collect(); + + Ok(scope?) +} + +pub async fn insert_client_consent( + executor: impl PgExecutor<'_>, + user: &User, + client: &Client, + scope: &Scope, +) -> anyhow::Result<()> { + let tokens: Vec = scope.iter().map(ToString::to_string).collect(); + + sqlx::query!( + r#" + INSERT INTO oauth2_consents (user_id, oauth2_client_id, scope_token) + SELECT $1, $2, scope_token FROM UNNEST($3::text[]) scope_token + ON CONFLICT (user_id, oauth2_client_id, scope_token) DO UPDATE SET updated_at = NOW() + "#, + user.data, + client.data, + &tokens, + ) + .execute(executor) + .await?; + + Ok(()) +} diff --git a/crates/storage/src/oauth2/mod.rs b/crates/storage/src/oauth2/mod.rs index 9202e30a..5a1cfef4 100644 --- a/crates/storage/src/oauth2/mod.rs +++ b/crates/storage/src/oauth2/mod.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. @@ -20,6 +20,7 @@ use crate::PostgresqlBackend; pub mod access_token; pub mod authorization_grant; pub mod client; +pub mod consent; pub mod refresh_token; pub async fn end_oauth_session( diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index fc4a71a6..c377120f 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -367,6 +367,35 @@ impl Default for RegisterContext { } } +/// Context used by the `consent.html` template +#[derive(Serialize)] +pub struct ConsentContext { + grant: AuthorizationGrant<()>, +} + +impl TemplateContext for ConsentContext { + fn sample() -> Vec + where + Self: Sized, + { + // TODO + vec![] + } +} + +impl ConsentContext { + /// Constructs a context for the client consent page + #[must_use] + pub fn new(grant: T) -> Self + where + T: Into>, + { + Self { + grant: grant.into(), + } + } +} + /// Fields of the reauthentication form #[derive(Serialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 585e3fde..bccc9733 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -43,9 +43,9 @@ mod functions; mod macros; pub use self::context::{ - AccountContext, AccountEmailsContext, EmailVerificationContext, EmptyContext, ErrorContext, - FormPostContext, IndexContext, LoginContext, LoginFormField, PostAuthContext, ReauthContext, - ReauthFormField, RegisterContext, RegisterFormField, TemplateContext, WithCsrf, + AccountContext, AccountEmailsContext, ConsentContext, EmailVerificationContext, EmptyContext, + ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, PostAuthContext, + ReauthContext, ReauthFormField, RegisterContext, RegisterFormField, TemplateContext, WithCsrf, WithOptionalSession, WithSession, }; @@ -285,6 +285,9 @@ register_templates! { /// Render the registration page pub fn render_register(WithCsrf) { "pages/register.html" } + /// Render the registration page + pub fn render_consent(WithCsrf>) { "pages/consent.html" } + /// Render the home page pub fn render_index(WithCsrf>) { "pages/index.html" } @@ -322,6 +325,7 @@ impl Templates { pub async fn check_render(&self) -> anyhow::Result<()> { check::render_login(self).await?; check::render_register(self).await?; + check::render_consent(self).await?; check::render_index(self).await?; check::render_account_index(self).await?; check::render_account_password(self).await?; diff --git a/crates/templates/src/res/pages/consent.html b/crates/templates/src/res/pages/consent.html new file mode 100644 index 00000000..4a335196 --- /dev/null +++ b/crates/templates/src/res/pages/consent.html @@ -0,0 +1,31 @@ +{# +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. +#} + +{% extends "base.html" %} + +{% block content %} +
+
+
+

Authorize {{ grant.client.client_name | default(value=grand.client.client_id) }}

+
+
Scope: {{ grant.scope }}
+ + {{ button::button(text="Allow") }} +
+
+{% endblock content %} +