1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-09 10:01:45 +03:00

Simple consent screen and storage

This commit is contained in:
Quentin Gliech
2022-04-29 12:16:39 +02:00
parent bfb00e281d
commit 28ff912029
13 changed files with 419 additions and 6 deletions

View File

@ -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()))

View File

@ -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(&current_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();

View File

@ -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<ContinueAuthorizationGrant> for ConsentRequest {
fn from(grant: ContinueAuthorizationGrant) -> Self {
Self { grant }
}
}
impl ConsentRequest {
pub fn build_uri(&self) -> anyhow::Result<Uri> {
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<Templates>,
Extension(pool): Extension<PgPool>,
cookie_jar: PrivateCookieJar<Encrypter>,
Query(next): Query<ContinueAuthorizationGrant>,
) -> Result<Response, RouteError> {
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<PgPool>,
cookie_jar: PrivateCookieJar<Encrypter>,
Query(next): Query<ContinueAuthorizationGrant>,
Form(form): Form<ProtectedForm<()>>,
) -> Result<Response, RouteError> {
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())
}

View File

@ -13,6 +13,7 @@
// limitations under the License.
pub mod authorization;
pub mod consent;
pub mod discovery;
pub mod introspection;
pub mod keys;

View File

@ -84,6 +84,14 @@ impl ToString for ScopeToken {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Scope(HashSet<ScopeToken>);
impl std::ops::Deref for Scope {
type Target = HashSet<ScopeToken>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl FromStr for Scope {
type Err = InvalidScope;

View File

@ -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;

View File

@ -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();

View File

@ -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": [

View File

@ -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<PostgresqlBackend>,
client: &Client<PostgresqlBackend>,
) -> anyhow::Result<Scope> {
let scope_tokens: Vec<String> = 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, _> = scope_tokens
.into_iter()
.map(|s| ScopeToken::from_str(&s))
.collect();
Ok(scope?)
}
pub async fn insert_client_consent(
executor: impl PgExecutor<'_>,
user: &User<PostgresqlBackend>,
client: &Client<PostgresqlBackend>,
scope: &Scope,
) -> anyhow::Result<()> {
let tokens: Vec<String> = 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(())
}

View File

@ -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(

View File

@ -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<Self>
where
Self: Sized,
{
// TODO
vec![]
}
}
impl ConsentContext {
/// Constructs a context for the client consent page
#[must_use]
pub fn new<T>(grant: T) -> Self
where
T: Into<AuthorizationGrant<()>>,
{
Self {
grant: grant.into(),
}
}
}
/// Fields of the reauthentication form
#[derive(Serialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]

View File

@ -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<RegisterContext>) { "pages/register.html" }
/// Render the registration page
pub fn render_consent(WithCsrf<WithSession<ConsentContext>>) { "pages/consent.html" }
/// Render the home page
pub fn render_index(WithCsrf<WithOptionalSession<IndexContext>>) { "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?;

View File

@ -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 %}
<section class="flex items-center justify-center flex-1">
<form method="POST" class="grid grid-cols-1 gap-6 w-96 m-2">
<div class="text-center">
<h1 class="text-lg text-center font-medium">Authorize <em>{{ grant.client.client_name | default(value=grand.client.client_id) }}</em></h1>
</div>
<div>Scope: {{ grant.scope }}</div>
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{{ button::button(text="Allow") }}
</form>
</section>
{% endblock content %}