diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 9940050f..ec7edee8 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -33,5 +33,5 @@ pub use self::{ }, tokens::{AccessToken, RefreshToken, TokenFormatError, TokenType}, traits::{StorageBackend, StorageBackendMarker}, - users::{Authentication, BrowserSession, User}, + users::{Authentication, BrowserSession, User, UserEmail}, }; diff --git a/crates/data-model/src/traits.rs b/crates/data-model/src/traits.rs index 835801a8..db13138c 100644 --- a/crates/data-model/src/traits.rs +++ b/crates/data-model/src/traits.rs @@ -16,6 +16,7 @@ pub trait StorageBackendMarker: StorageBackend {} pub trait StorageBackend { type UserData: Clone + std::fmt::Debug + PartialEq; + type UserEmailData: Clone + std::fmt::Debug + PartialEq; type AuthenticationData: Clone + std::fmt::Debug + PartialEq; type BrowserSessionData: Clone + std::fmt::Debug + PartialEq; type ClientData: Clone + std::fmt::Debug + PartialEq; @@ -34,4 +35,5 @@ impl StorageBackend for () { type RefreshTokenData = (); type SessionData = (); type UserData = (); + type UserEmailData = (); } diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index 7ee592ae..6642c9c6 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -24,6 +24,7 @@ pub struct User { pub data: T::UserData, pub username: String, pub sub: String, + pub primary_email: Option>, } impl User @@ -36,6 +37,7 @@ where data: Default::default(), username: "john".to_string(), sub: "123-456".to_string(), + primary_email: None, }] } } @@ -46,6 +48,7 @@ impl From> for User<()> { data: (), username: u.username, sub: u.sub, + primary_email: u.primary_email.map(Into::into), } } } @@ -106,3 +109,47 @@ where .collect() } } + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(bound = "T: StorageBackend")] +pub struct UserEmail { + #[serde(skip_serializing)] + pub data: T::UserEmailData, + pub email: String, + pub created_at: DateTime, + pub confirmed_at: Option>, +} + +impl From> for UserEmail<()> { + fn from(e: UserEmail) -> Self { + UserEmail { + data: (), + email: e.email, + created_at: e.created_at, + confirmed_at: e.confirmed_at, + } + } +} + +impl UserEmail +where + T::UserEmailData: Default, +{ + #[must_use] + pub fn samples() -> Vec { + vec![ + UserEmail { + data: T::UserEmailData::default(), + email: "alice@example.com".to_string(), + created_at: Utc::now(), + confirmed_at: Some(Utc::now()), + }, + UserEmail { + data: T::UserEmailData::default(), + email: "bob@example.com".to_string(), + created_at: Utc::now(), + confirmed_at: None, + }, + ] + } +} diff --git a/crates/handlers/src/views/account.rs b/crates/handlers/src/views/account.rs index 0e274932..773d0c99 100644 --- a/crates/handlers/src/views/account.rs +++ b/crates/handlers/src/views/account.rs @@ -16,7 +16,7 @@ use argon2::Argon2; use mas_config::{CookiesConfig, CsrfConfig}; use mas_data_model::BrowserSession; use mas_storage::{ - user::{authenticate_session, count_active_sessions, set_password}, + user::{authenticate_session, count_active_sessions, get_user_emails, set_password}, PostgresqlBackend, }; use mas_templates::{AccountContext, TemplateContext, Templates}; @@ -25,13 +25,13 @@ use mas_warp_utils::{ filters::{ cookies::{encrypted_cookie_saver, EncryptedCookieSaver}, csrf::{protected_form, updated_csrf_token}, - database::{connection, transaction}, + database::transaction, session::session, with_templates, CsrfToken, }, }; use serde::Deserialize; -use sqlx::{pool::PoolConnection, PgExecutor, PgPool, Postgres, Transaction}; +use sqlx::{PgPool, Postgres, Transaction}; use warp::{filters::BoxedFilter, reply::html, Filter, Rejection, Reply}; pub(super) fn filter( @@ -44,7 +44,7 @@ pub(super) fn filter( .and(encrypted_cookie_saver(cookies_config)) .and(updated_csrf_token(cookies_config, csrf_config)) .and(session(pool, cookies_config)) - .and(connection(pool)) + .and(transaction(pool)) .and_then(get); let post = with_templates(templates) @@ -71,9 +71,9 @@ async fn get( cookie_saver: EncryptedCookieSaver, csrf_token: CsrfToken, session: BrowserSession, - mut conn: PoolConnection, + txn: Transaction<'_, Postgres>, ) -> Result, Rejection> { - render(templates, cookie_saver, csrf_token, session, &mut conn).await + render(templates, cookie_saver, csrf_token, session, txn).await } async fn render( @@ -81,18 +81,26 @@ async fn render( cookie_saver: EncryptedCookieSaver, csrf_token: CsrfToken, session: BrowserSession, - executor: impl PgExecutor<'_>, + mut txn: Transaction<'_, Postgres>, ) -> Result, Rejection> { - let active_sessions = count_active_sessions(executor, &session.user) + let active_sessions = count_active_sessions(&mut txn, &session.user) .await .wrap_error()?; - let ctx = AccountContext::new(active_sessions) + + let emails = get_user_emails(&mut txn, &session.user) + .await + .wrap_error()?; + + txn.commit().await.wrap_error()?; + + let ctx = AccountContext::new(active_sessions, emails) .with_session(session) .with_csrf(csrf_token.form_value()); let content = templates.render_account(&ctx).await?; let reply = html(content); let reply = cookie_saver.save_encrypted(&csrf_token, reply)?; + Ok(Box::new(reply)) } @@ -118,9 +126,7 @@ async fn post( .await .wrap_error()?; - let reply = render(templates, cookie_saver, csrf_token, session, &mut txn).await?; - - txn.commit().await.wrap_error()?; + let reply = render(templates, cookie_saver, csrf_token, session, txn).await?; Ok(reply) } diff --git a/crates/storage/migrations/20220114150141_user_emails.down.sql b/crates/storage/migrations/20220114150141_user_emails.down.sql new file mode 100644 index 00000000..8e8b097e --- /dev/null +++ b/crates/storage/migrations/20220114150141_user_emails.down.sql @@ -0,0 +1,15 @@ +-- Copyright 2021 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 user_emails; diff --git a/crates/storage/migrations/20220114150141_user_emails.up.sql b/crates/storage/migrations/20220114150141_user_emails.up.sql new file mode 100644 index 00000000..102a8ed8 --- /dev/null +++ b/crates/storage/migrations/20220114150141_user_emails.up.sql @@ -0,0 +1,24 @@ +-- Copyright 2021 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 user_emails ( + "id" BIGSERIAL PRIMARY KEY, + "user_id" BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "email" TEXT NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "confirmed_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL +); + +ALTER TABLE users + ADD COLUMN "primary_email_id" BIGINT REFERENCES user_emails (id) ON DELETE SET NULL; diff --git a/crates/storage/sqlx-data.json b/crates/storage/sqlx-data.json index 4ad3323f..2047cc86 100644 --- a/crates/storage/sqlx-data.json +++ b/crates/storage/sqlx-data.json @@ -1,31 +1,5 @@ { "db": "PostgreSQL", - "037ba804eabd0b4290d87d1de37054f358eb11397d3a8e4b69a81cdce0a178e0": { - "query": "\n SELECT id, username\n FROM users\n WHERE username = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "username", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - } - }, "0c056fcc1a85d00db88034bcc582376cf220e1933d2932e520c44ed9931f5c9d": { "query": "\n INSERT INTO oauth2_refresh_tokens\n (oauth2_session_id, oauth2_access_token_id, token)\n VALUES\n ($1, $2, $3)\n RETURNING\n id, created_at\n ", "describe": { @@ -54,483 +28,8 @@ ] } }, - "18d98b65c82142c28fb350f596c4439dbb04a55ff5b84586c1cb54601000d00d": { - "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.client_id AS 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 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 WHERE\n og.code = $1\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "grant_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "grant_created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "grant_cancelled_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "grant_fulfilled_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "grant_exchanged_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "grant_scope", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "grant_state", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "grant_redirect_uri", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "grant_response_mode", - "type_info": "Text" - }, - { - "ordinal": 9, - "name": "grant_nonce", - "type_info": "Text" - }, - { - "ordinal": 10, - "name": "grant_max_age", - "type_info": "Int4" - }, - { - "ordinal": 11, - "name": "grant_acr_values", - "type_info": "Text" - }, - { - "ordinal": 12, - "name": "client_id", - "type_info": "Text" - }, - { - "ordinal": 13, - "name": "grant_code", - "type_info": "Text" - }, - { - "ordinal": 14, - "name": "grant_response_type_code", - "type_info": "Bool" - }, - { - "ordinal": 15, - "name": "grant_response_type_token", - "type_info": "Bool" - }, - { - "ordinal": 16, - "name": "grant_response_type_id_token", - "type_info": "Bool" - }, - { - "ordinal": 17, - "name": "grant_code_challenge", - "type_info": "Text" - }, - { - "ordinal": 18, - "name": "grant_code_challenge_method", - "type_info": "Text" - }, - { - "ordinal": 19, - "name": "session_id?", - "type_info": "Int8" - }, - { - "ordinal": 20, - "name": "user_session_id?", - "type_info": "Int8" - }, - { - "ordinal": 21, - "name": "user_session_created_at?", - "type_info": "Timestamptz" - }, - { - "ordinal": 22, - "name": "user_id?", - "type_info": "Int8" - }, - { - "ordinal": 23, - "name": "user_username?", - "type_info": "Text" - }, - { - "ordinal": 24, - "name": "user_session_last_authentication_id?", - "type_info": "Int8" - }, - { - "ordinal": 25, - "name": "user_session_last_authentication_created_at?", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - true, - true, - true, - false, - true, - false, - false, - true, - true, - true, - false, - true, - false, - false, - false, - true, - true, - false, - false, - false, - false, - false, - false, - false - ] - } - }, - "307fd9f71e7a94a0a0d9ce523ee9792e127485d0d12480c43f179dd9b75afbab": { - "query": "\n INSERT INTO user_sessions (user_id)\n VALUES ($1)\n RETURNING id, created_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false - ] - } - }, - "3205a180aaa4661a016dada3a015ffd7a1019cd121e284f11e8120a6664e6288": { - "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.client_id AS 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 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 WHERE\n og.id = $1\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "grant_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "grant_created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "grant_cancelled_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "grant_fulfilled_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "grant_exchanged_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "grant_scope", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "grant_state", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "grant_redirect_uri", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "grant_response_mode", - "type_info": "Text" - }, - { - "ordinal": 9, - "name": "grant_nonce", - "type_info": "Text" - }, - { - "ordinal": 10, - "name": "grant_max_age", - "type_info": "Int4" - }, - { - "ordinal": 11, - "name": "grant_acr_values", - "type_info": "Text" - }, - { - "ordinal": 12, - "name": "client_id", - "type_info": "Text" - }, - { - "ordinal": 13, - "name": "grant_code", - "type_info": "Text" - }, - { - "ordinal": 14, - "name": "grant_response_type_code", - "type_info": "Bool" - }, - { - "ordinal": 15, - "name": "grant_response_type_token", - "type_info": "Bool" - }, - { - "ordinal": 16, - "name": "grant_response_type_id_token", - "type_info": "Bool" - }, - { - "ordinal": 17, - "name": "grant_code_challenge", - "type_info": "Text" - }, - { - "ordinal": 18, - "name": "grant_code_challenge_method", - "type_info": "Text" - }, - { - "ordinal": 19, - "name": "session_id?", - "type_info": "Int8" - }, - { - "ordinal": 20, - "name": "user_session_id?", - "type_info": "Int8" - }, - { - "ordinal": 21, - "name": "user_session_created_at?", - "type_info": "Timestamptz" - }, - { - "ordinal": 22, - "name": "user_id?", - "type_info": "Int8" - }, - { - "ordinal": 23, - "name": "user_username?", - "type_info": "Text" - }, - { - "ordinal": 24, - "name": "user_session_last_authentication_id?", - "type_info": "Int8" - }, - { - "ordinal": 25, - "name": "user_session_last_authentication_created_at?", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - true, - true, - true, - false, - true, - false, - false, - true, - true, - true, - false, - true, - false, - false, - false, - true, - true, - false, - false, - false, - false, - false, - false, - false - ] - } - }, - "38641231a3bff71252e8bc0ead3a033c9148762ea64d707642551c01a4c89b84": { - "query": "\n INSERT INTO oauth2_authorization_grants\n (client_id, redirect_uri, scope, state, nonce, max_age,\n acr_values, response_mode, code_challenge, code_challenge_method,\n response_type_code, response_type_token, response_type_id_token,\n code)\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n RETURNING id, created_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text", - "Text", - "Int4", - "Text", - "Text", - "Text", - "Text", - "Bool", - "Bool", - "Bool", - "Text" - ] - }, - "nullable": [ - false, - false - ] - } - }, - "581243a7f0c033548cc9644e0c60855ecb8bfefe51779eb135dd7547b886de79": { - "query": "\n UPDATE oauth2_sessions\n SET ended_at = NOW()\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - } - }, - "59e8a5de682642883a9b9fc1b522736fa4397f0a0c97074f2c8908e5956c0166": { - "query": "\n INSERT INTO oauth2_access_tokens\n (oauth2_session_id, token, expires_after)\n VALUES\n ($1, $2, $3)\n RETURNING\n id, created_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8", - "Text", - "Int4" - ] - }, - "nullable": [ - false, - false - ] - } - }, - "5d1a17b2ad6153217551ae31549ad9d62cc39d2f9a4e62a7ccb60fd91e0ac685": { - "query": "\n DELETE FROM oauth2_access_tokens\n WHERE created_at + (expires_after * INTERVAL '1 second') + INTERVAL '15 minutes' < now()\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - } - }, - "647a2a5bbde39d0ed3931d0287b468bc7dedf6171e1dc6171a5d9f079b9ed0fa": { - "query": "\n SELECT up.hashed_password\n FROM user_passwords up\n WHERE up.user_id = $1\n ORDER BY up.created_at DESC\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "hashed_password", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false - ] - } - }, - "6e3a2f75d1277dc8773f5a4274d0c265abc95e886512da877ecb78666eb82c7f": { - "query": "\n SELECT\n rt.id AS refresh_token_id,\n rt.token AS refresh_token,\n rt.created_at AS refresh_token_created_at,\n at.id AS \"access_token_id?\",\n at.token AS \"access_token?\",\n at.expires_after AS \"access_token_expires_after?\",\n at.created_at AS \"access_token_created_at?\",\n os.id AS \"session_id!\",\n os.client_id AS \"client_id!\",\n os.scope AS \"scope!\",\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 FROM oauth2_refresh_tokens rt\n LEFT JOIN oauth2_access_tokens at\n ON at.id = rt.oauth2_access_token_id\n INNER JOIN oauth2_sessions os\n ON os.id = rt.oauth2_session_id\n INNER JOIN user_sessions us\n ON us.id = os.user_session_id\n INNER 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\n WHERE rt.token = $1\n AND rt.next_token_id IS NULL\n AND us.active\n AND os.ended_at IS NULL\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n ", + "16df03346a3186c289bd64d1a3869103064ddb8f8827af8f19fc9ab93910ede5": { + "query": "\n SELECT\n rt.id AS refresh_token_id,\n rt.token AS refresh_token,\n rt.created_at AS refresh_token_created_at,\n at.id AS \"access_token_id?\",\n at.token AS \"access_token?\",\n at.expires_after AS \"access_token_expires_after?\",\n at.created_at AS \"access_token_created_at?\",\n os.id AS \"session_id!\",\n os.client_id AS \"client_id!\",\n os.scope AS \"scope!\",\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 oauth2_refresh_tokens rt\n LEFT JOIN oauth2_access_tokens at\n ON at.id = rt.oauth2_access_token_id\n INNER JOIN oauth2_sessions os\n ON os.id = rt.oauth2_session_id\n INNER JOIN user_sessions us\n ON us.id = os.user_session_id\n INNER 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 rt.token = $1\n AND rt.next_token_id IS NULL\n AND us.active\n AND os.ended_at IS NULL\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n ", "describe": { "columns": [ { @@ -612,6 +111,26 @@ "ordinal": 15, "name": "user_session_last_authentication_created_at?", "type_info": "Timestamptz" + }, + { + "ordinal": 16, + "name": "user_email_id?", + "type_info": "Int8" + }, + { + "ordinal": 17, + "name": "user_email?", + "type_info": "Text" + }, + { + "ordinal": 18, + "name": "user_email_created_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 19, + "name": "user_email_confirmed_at?", + "type_info": "Timestamptz" } ], "parameters": { @@ -635,33 +154,313 @@ false, false, false, - false + false, + false, + false, + false, + true ] } }, - "703850ba4e001d53776d77a64cbc1ee6feb61485ce41aff1103251f9b3778128": { - "query": "\n UPDATE oauth2_authorization_grants AS og\n SET\n oauth2_session_id = os.id,\n fulfilled_at = os.created_at\n FROM oauth2_sessions os\n WHERE\n og.id = $1 AND os.id = $2\n RETURNING fulfilled_at AS \"fulfilled_at!: DateTime\"\n ", + "2e8c6507df6c0af78deca3550157b9cc0286f204b15a646c2e7e24c51100e040": { + "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.client_id AS 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 ", "describe": { "columns": [ { "ordinal": 0, - "name": "fulfilled_at!: DateTime", + "name": "grant_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "grant_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "grant_cancelled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "grant_fulfilled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "grant_exchanged_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "grant_scope", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "grant_state", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "grant_redirect_uri", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "grant_response_mode", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "grant_nonce", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "grant_max_age", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "grant_acr_values", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "grant_code", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "grant_response_type_code", + "type_info": "Bool" + }, + { + "ordinal": 15, + "name": "grant_response_type_token", + "type_info": "Bool" + }, + { + "ordinal": 16, + "name": "grant_response_type_id_token", + "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "grant_code_challenge", + "type_info": "Text" + }, + { + "ordinal": 18, + "name": "grant_code_challenge_method", + "type_info": "Text" + }, + { + "ordinal": 19, + "name": "session_id?", + "type_info": "Int8" + }, + { + "ordinal": 20, + "name": "user_session_id?", + "type_info": "Int8" + }, + { + "ordinal": 21, + "name": "user_session_created_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 22, + "name": "user_id?", + "type_info": "Int8" + }, + { + "ordinal": 23, + "name": "user_username?", + "type_info": "Text" + }, + { + "ordinal": 24, + "name": "user_session_last_authentication_id?", + "type_info": "Int8" + }, + { + "ordinal": 25, + "name": "user_session_last_authentication_created_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 26, + "name": "user_email_id?", + "type_info": "Int8" + }, + { + "ordinal": 27, + "name": "user_email?", + "type_info": "Text" + }, + { + "ordinal": 28, + "name": "user_email_created_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 29, + "name": "user_email_confirmed_at?", "type_info": "Timestamptz" } ], "parameters": { "Left": [ - "Int8", "Int8" ] }, "nullable": [ + false, + false, + true, + true, + true, + false, + true, + false, + false, + true, + true, + true, + false, + true, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, true ] } }, - "7fa7d001583e98f5e4626751fa2c8743e7b83a240d1a51de50a7dba95c4e8a6b": { - "query": "\n SELECT\n s.id,\n u.id as user_id,\n u.username,\n s.created_at,\n a.id as \"last_authentication_id?\",\n a.created_at as \"last_authd_at?\"\n FROM user_sessions s\n INNER JOIN users u \n ON s.user_id = u.id\n LEFT JOIN user_session_authentications a\n ON a.session_id = s.id\n WHERE s.id = $1 AND s.active\n ORDER BY a.created_at DESC\n LIMIT 1\n ", + "307fd9f71e7a94a0a0d9ce523ee9792e127485d0d12480c43f179dd9b75afbab": { + "query": "\n INSERT INTO user_sessions (user_id)\n VALUES ($1)\n RETURNING id, created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + } + }, + "38641231a3bff71252e8bc0ead3a033c9148762ea64d707642551c01a4c89b84": { + "query": "\n INSERT INTO oauth2_authorization_grants\n (client_id, redirect_uri, scope, state, nonce, max_age,\n acr_values, response_mode, code_challenge, code_challenge_method,\n response_type_code, response_type_token, response_type_id_token,\n code)\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n RETURNING id, created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Int4", + "Text", + "Text", + "Text", + "Text", + "Bool", + "Bool", + "Bool", + "Text" + ] + }, + "nullable": [ + false, + false + ] + } + }, + "41b5ecd6860791ac6f90417ac51eb977b8c69a3dd81af4672b2592efb65963eb": { + "query": "\n SELECT \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 user_emails ue\n\n WHERE ue.user_id = $1\n\n ORDER BY ue.email ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_email_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "user_email_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "user_email_confirmed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + } + }, + "494c17c20047e761b0dbdac0e13854af7955743afd970bbcae83ba944838c58e": { + "query": "\n SELECT\n s.id,\n u.id AS user_id,\n u.username,\n s.created_at,\n a.id AS \"last_authentication_id?\",\n a.created_at AS \"last_authd_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 user_sessions s\n INNER JOIN users u \n ON s.user_id = u.id\n LEFT JOIN user_session_authentications a\n ON a.session_id = s.id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n WHERE s.id = $1 AND s.active\n ORDER BY a.created_at DESC\n LIMIT 1\n ", "describe": { "columns": [ { @@ -693,6 +492,26 @@ "ordinal": 5, "name": "last_authd_at?", "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "user_email_id?", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "user_email?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "user_email_created_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "user_email_confirmed_at?", + "type_info": "Timestamptz" } ], "parameters": { @@ -705,11 +524,106 @@ false, false, false, + false, + false, + false, + false, + false, + true + ] + } + }, + "581243a7f0c033548cc9644e0c60855ecb8bfefe51779eb135dd7547b886de79": { + "query": "\n UPDATE oauth2_sessions\n SET ended_at = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, + "59e8a5de682642883a9b9fc1b522736fa4397f0a0c97074f2c8908e5956c0166": { + "query": "\n INSERT INTO oauth2_access_tokens\n (oauth2_session_id, token, expires_after)\n VALUES\n ($1, $2, $3)\n RETURNING\n id, created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Int4" + ] + }, + "nullable": [ false, false ] } }, + "5d1a17b2ad6153217551ae31549ad9d62cc39d2f9a4e62a7ccb60fd91e0ac685": { + "query": "\n DELETE FROM oauth2_access_tokens\n WHERE created_at + (expires_after * INTERVAL '1 second') + INTERVAL '15 minutes' < now()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + } + }, + "647a2a5bbde39d0ed3931d0287b468bc7dedf6171e1dc6171a5d9f079b9ed0fa": { + "query": "\n SELECT up.hashed_password\n FROM user_passwords up\n WHERE up.user_id = $1\n ORDER BY up.created_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hashed_password", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + } + }, + "703850ba4e001d53776d77a64cbc1ee6feb61485ce41aff1103251f9b3778128": { + "query": "\n UPDATE oauth2_authorization_grants AS og\n SET\n oauth2_session_id = os.id,\n fulfilled_at = os.created_at\n FROM oauth2_sessions os\n WHERE\n og.id = $1 AND os.id = $2\n RETURNING fulfilled_at AS \"fulfilled_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "fulfilled_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + true + ] + } + }, "88ac8783bd5881c42eafd9cf87a16fe6031f3153fd6a8618e689694584aeb2de": { "query": "\n DELETE FROM oauth2_access_tokens\n WHERE id = $1\n ", "describe": { @@ -734,6 +648,56 @@ "nullable": [] } }, + "aea289a04e151da235825305a5085bc6aa100fce139dbf10a2c1bed4867fc52a": { + "query": "\n SELECT \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 FROM users u\n\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE u.username = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "user_email_id?", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "user_email?", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "user_email_created_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "user_email_confirmed_at?", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + } + }, "c29e741474aacc91c0aacc028a9e7452a5327d5ce6d4b791bf20a2636069087e": { "query": "\n INSERT INTO oauth2_sessions\n (user_session_id, client_id, scope)\n SELECT\n $1,\n og.client_id,\n og.scope\n FROM\n oauth2_authorization_grants og\n WHERE\n og.id = $2\n RETURNING id, created_at\n ", "describe": { @@ -774,6 +738,316 @@ "nullable": [] } }, + "cf1b7513a56d20e405bf11f806adea2853a08ee05497c952bb3ee1dadc866d4b": { + "query": "\n SELECT\n at.id AS \"access_token_id\",\n at.token AS \"access_token\",\n at.expires_after AS \"access_token_expires_after\",\n at.created_at AS \"access_token_created_at\",\n os.id AS \"session_id!\",\n os.client_id AS \"client_id!\",\n os.scope AS \"scope!\",\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\n FROM oauth2_access_tokens at\n INNER JOIN oauth2_sessions os\n ON os.id = at.oauth2_session_id\n INNER JOIN user_sessions us\n ON us.id = os.user_session_id\n INNER 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 at.token = $1\n AND at.created_at + (at.expires_after * INTERVAL '1 second') >= now()\n AND us.active\n AND os.ended_at IS NULL\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "access_token_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "access_token", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "access_token_expires_after", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "access_token_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "session_id!", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "client_id!", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "scope!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "user_session_id!", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "user_session_created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "user_id!", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "user_username!", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "user_session_last_authentication_id?", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "user_session_last_authentication_created_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "user_email_id?", + "type_info": "Int8" + }, + { + "ordinal": 14, + "name": "user_email?", + "type_info": "Text" + }, + { + "ordinal": 15, + "name": "user_email_created_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 16, + "name": "user_email_confirmed_at?", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + }, + "d3883020ad9a0e5ea72fb9ddd2801a067209488a6ef3179afbc8173e4cc729de": { + "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.client_id AS 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.code = $1\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "grant_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "grant_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "grant_cancelled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "grant_fulfilled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "grant_exchanged_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "grant_scope", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "grant_state", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "grant_redirect_uri", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "grant_response_mode", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "grant_nonce", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "grant_max_age", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "grant_acr_values", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "grant_code", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "grant_response_type_code", + "type_info": "Bool" + }, + { + "ordinal": 15, + "name": "grant_response_type_token", + "type_info": "Bool" + }, + { + "ordinal": 16, + "name": "grant_response_type_id_token", + "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "grant_code_challenge", + "type_info": "Text" + }, + { + "ordinal": 18, + "name": "grant_code_challenge_method", + "type_info": "Text" + }, + { + "ordinal": 19, + "name": "session_id?", + "type_info": "Int8" + }, + { + "ordinal": 20, + "name": "user_session_id?", + "type_info": "Int8" + }, + { + "ordinal": 21, + "name": "user_session_created_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 22, + "name": "user_id?", + "type_info": "Int8" + }, + { + "ordinal": 23, + "name": "user_username?", + "type_info": "Text" + }, + { + "ordinal": 24, + "name": "user_session_last_authentication_id?", + "type_info": "Int8" + }, + { + "ordinal": 25, + "name": "user_session_last_authentication_created_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 26, + "name": "user_email_id?", + "type_info": "Int8" + }, + { + "ordinal": 27, + "name": "user_email?", + "type_info": "Text" + }, + { + "ordinal": 28, + "name": "user_email_created_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 29, + "name": "user_email_confirmed_at?", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + false, + true, + false, + false, + true, + true, + true, + false, + true, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + }, "d604e13bdfb2ff3d354d995f0b68f04091847755db98bafea7c45bd7b5c4ab68": { "query": "\n UPDATE oauth2_authorization_grants\n SET\n exchanged_at = NOW()\n WHERE\n id = $1\n RETURNING exchanged_at AS \"exchanged_at!: DateTime\"\n ", "describe": { @@ -872,97 +1146,5 @@ null ] } - }, - "e5ea498197276d0948deda4d0d24ed0bb36bfbba362cd01fc9506afa68e6f183": { - "query": "\n SELECT\n at.id AS \"access_token_id\",\n at.token AS \"access_token\",\n at.expires_after AS \"access_token_expires_after\",\n at.created_at AS \"access_token_created_at\",\n os.id AS \"session_id!\",\n os.client_id AS \"client_id!\",\n os.scope AS \"scope!\",\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\n FROM oauth2_access_tokens at\n INNER JOIN oauth2_sessions os\n ON os.id = at.oauth2_session_id\n INNER JOIN user_sessions us\n ON us.id = os.user_session_id\n INNER 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\n WHERE at.token = $1\n AND at.created_at + (at.expires_after * INTERVAL '1 second') >= now()\n AND us.active\n AND os.ended_at IS NULL\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "access_token_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "access_token", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "access_token_expires_after", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "access_token_created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "session_id!", - "type_info": "Int8" - }, - { - "ordinal": 5, - "name": "client_id!", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "scope!", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "user_session_id!", - "type_info": "Int8" - }, - { - "ordinal": 8, - "name": "user_session_created_at!", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "user_id!", - "type_info": "Int8" - }, - { - "ordinal": 10, - "name": "user_username!", - "type_info": "Text" - }, - { - "ordinal": 11, - "name": "user_session_last_authentication_id?", - "type_info": "Int8" - }, - { - "ordinal": 12, - "name": "user_session_last_authentication_created_at?", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false - ] - } } } \ No newline at end of file diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 22de0947..7ef28451 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -38,6 +38,7 @@ impl StorageBackend for PostgresqlBackend { type RefreshTokenData = i64; type SessionData = i64; type UserData = i64; + type UserEmailData = i64; } impl StorageBackendMarker for PostgresqlBackend {} diff --git a/crates/storage/src/oauth2/access_token.rs b/crates/storage/src/oauth2/access_token.rs index cfde16e9..a2bfeb53 100644 --- a/crates/storage/src/oauth2/access_token.rs +++ b/crates/storage/src/oauth2/access_token.rs @@ -14,7 +14,9 @@ use anyhow::Context; use chrono::{DateTime, Duration, Utc}; -use mas_data_model::{AccessToken, Authentication, BrowserSession, Client, Session, User}; +use mas_data_model::{ + AccessToken, Authentication, BrowserSession, Client, Session, User, UserEmail, +}; use sqlx::PgExecutor; use thiserror::Error; @@ -71,6 +73,10 @@ pub struct OAuth2AccessTokenLookup { user_username: String, user_session_last_authentication_id: Option, user_session_last_authentication_created_at: Option>, + user_email_id: Option, + user_email: Option, + user_email_created_at: Option>, + user_email_confirmed_at: Option>, } #[derive(Debug, Error)] @@ -83,10 +89,7 @@ pub enum AccessTokenLookupError { impl AccessTokenLookupError { #[must_use] pub fn not_found(&self) -> bool { - matches!( - self, - &AccessTokenLookupError::Database(sqlx::Error::RowNotFound) - ) + matches!(self, Self::Database(sqlx::Error::RowNotFound)) } } @@ -110,7 +113,11 @@ pub async fn lookup_active_access_token( u.id AS "user_id!", u.username AS "user_username!", usa.id AS "user_session_last_authentication_id?", - usa.created_at AS "user_session_last_authentication_created_at?" + usa.created_at AS "user_session_last_authentication_created_at?", + 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 oauth2_access_tokens at INNER JOIN oauth2_sessions os @@ -121,6 +128,8 @@ pub async fn lookup_active_access_token( ON u.id = us.user_id LEFT JOIN user_session_authentications usa ON usa.session_id = us.id + LEFT JOIN user_emails ue + ON ue.id = u.primary_email_id WHERE at.token = $1 AND at.created_at + (at.expires_after * INTERVAL '1 second') >= now() @@ -148,10 +157,27 @@ pub async fn lookup_active_access_token( client_id: res.client_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, }; let last_authentication = match ( diff --git a/crates/storage/src/oauth2/authorization_grant.rs b/crates/storage/src/oauth2/authorization_grant.rs index 54dc92a1..86d3a73e 100644 --- a/crates/storage/src/oauth2/authorization_grant.rs +++ b/crates/storage/src/oauth2/authorization_grant.rs @@ -20,7 +20,7 @@ use anyhow::Context; use chrono::{DateTime, Utc}; use mas_data_model::{ Authentication, AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, BrowserSession, - Client, Pkce, Session, User, + Client, Pkce, Session, User, UserEmail, }; use mas_iana::oauth::PkceCodeChallengeMethod; use oauth2_types::{requests::ResponseMode, scope::Scope}; @@ -135,6 +135,10 @@ struct GrantLookup { user_username: Option, user_session_last_authentication_id: Option, user_session_last_authentication_created_at: Option>, + user_email_id: Option, + user_email: Option, + user_email_created_at: Option>, + user_email_confirmed_at: Option>, } impl TryInto> for GrantLookup { @@ -164,6 +168,22 @@ impl TryInto> for GrantLookup { _ => return Err(DatabaseInconsistencyError), }; + let primary_email = match ( + self.user_email_id, + self.user_email, + self.user_email_created_at, + self.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), + }; + let session = match ( self.session_id, self.user_session_id, @@ -171,6 +191,7 @@ impl TryInto> for GrantLookup { self.user_id, self.user_username, last_authentication, + primary_email, ) { ( Some(session_id), @@ -179,11 +200,13 @@ impl TryInto> for GrantLookup { Some(user_id), Some(user_username), last_authentication, + primary_email, ) => { let user = User { data: user_id, username: user_username, sub: format!("fake-sub-{}", user_id), + primary_email, }; let browser_session = BrowserSession { @@ -205,7 +228,7 @@ impl TryInto> for GrantLookup { Some(session) } - (None, None, None, None, None, None) => None, + (None, None, None, None, None, None, None) => None, _ => return Err(DatabaseInconsistencyError), }; @@ -333,7 +356,11 @@ pub async fn get_grant_by_id( u.id AS "user_id?", u.username AS "user_username?", usa.id AS "user_session_last_authentication_id?", - usa.created_at AS "user_session_last_authentication_created_at?" + usa.created_at AS "user_session_last_authentication_created_at?", + 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 oauth2_authorization_grants og LEFT JOIN oauth2_sessions os @@ -344,8 +371,10 @@ pub async fn get_grant_by_id( ON u.id = us.user_id LEFT JOIN user_session_authentications usa ON usa.session_id = us.id - WHERE - og.id = $1 + LEFT JOIN user_emails ue + ON ue.id = u.primary_email_id + + WHERE og.id = $1 ORDER BY usa.created_at DESC LIMIT 1 @@ -395,7 +424,11 @@ pub async fn lookup_grant_by_code( u.id AS "user_id?", u.username AS "user_username?", usa.id AS "user_session_last_authentication_id?", - usa.created_at AS "user_session_last_authentication_created_at?" + usa.created_at AS "user_session_last_authentication_created_at?", + 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 oauth2_authorization_grants og LEFT JOIN oauth2_sessions os @@ -406,8 +439,10 @@ pub async fn lookup_grant_by_code( ON u.id = us.user_id LEFT JOIN user_session_authentications usa ON usa.session_id = us.id - WHERE - og.code = $1 + LEFT JOIN user_emails ue + ON ue.id = u.primary_email_id + + WHERE og.code = $1 ORDER BY usa.created_at DESC LIMIT 1 diff --git a/crates/storage/src/oauth2/refresh_token.rs b/crates/storage/src/oauth2/refresh_token.rs index e5f91c6b..d54ed589 100644 --- a/crates/storage/src/oauth2/refresh_token.rs +++ b/crates/storage/src/oauth2/refresh_token.rs @@ -15,7 +15,7 @@ use anyhow::Context; use chrono::{DateTime, Duration, Utc}; use mas_data_model::{ - AccessToken, Authentication, BrowserSession, Client, RefreshToken, Session, User, + AccessToken, Authentication, BrowserSession, Client, RefreshToken, Session, User, UserEmail, }; use sqlx::PgExecutor; @@ -70,6 +70,10 @@ struct OAuth2RefreshTokenLookup { user_username: String, user_session_last_authentication_id: Option, user_session_last_authentication_created_at: Option>, + user_email_id: Option, + user_email: Option, + user_email_created_at: Option>, + user_email_confirmed_at: Option>, } #[allow(clippy::too_many_lines)] @@ -96,7 +100,11 @@ pub async fn lookup_active_refresh_token( u.id AS "user_id!", u.username AS "user_username!", usa.id AS "user_session_last_authentication_id?", - usa.created_at AS "user_session_last_authentication_created_at?" + usa.created_at AS "user_session_last_authentication_created_at?", + 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 oauth2_refresh_tokens rt LEFT JOIN oauth2_access_tokens at ON at.id = rt.oauth2_access_token_id @@ -108,6 +116,8 @@ pub async fn lookup_active_refresh_token( ON u.id = us.user_id LEFT JOIN user_session_authentications usa ON usa.session_id = us.id + LEFT JOIN user_emails ue + ON ue.id = u.primary_email_id WHERE rt.token = $1 AND rt.next_token_id IS NULL @@ -152,10 +162,27 @@ pub async fn lookup_active_refresh_token( client_id: res.client_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, }; let last_authentication = match ( diff --git a/crates/storage/src/user.rs b/crates/storage/src/user.rs index 46297903..63ab45c0 100644 --- a/crates/storage/src/user.rs +++ b/crates/storage/src/user.rs @@ -17,7 +17,7 @@ use std::borrow::BorrowMut; use anyhow::Context; use argon2::Argon2; use chrono::{DateTime, Utc}; -use mas_data_model::{errors::HtmlError, Authentication, BrowserSession, User}; +use mas_data_model::{errors::HtmlError, Authentication, BrowserSession, User, UserEmail}; use password_hash::{PasswordHash, PasswordHasher, SaltString}; use rand::rngs::OsRng; use sqlx::{Acquire, PgExecutor, Postgres, Transaction}; @@ -31,8 +31,12 @@ use crate::IdAndCreationTime; #[derive(Debug, Clone)] struct UserLookup { - pub id: i64, - pub username: 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)] @@ -41,7 +45,7 @@ pub enum LoginError { NotFound { username: String, #[source] - source: sqlx::Error, + source: UserLookupError, }, #[error("authentication failed for {username:?}")] @@ -75,7 +79,7 @@ pub async fn login( let user = lookup_user_by_username(&mut txn, username) .await .map_err(|source| { - if matches!(source, sqlx::Error::RowNotFound) { + if source.not_found() { LoginError::NotFound { username: username.to_string(), source, @@ -115,10 +119,7 @@ impl Reject for ActiveSessionLookupError {} impl ActiveSessionLookupError { #[must_use] pub fn not_found(&self) -> bool { - matches!( - self, - ActiveSessionLookupError::Fetch(sqlx::Error::RowNotFound) - ) + matches!(self, Self::Fetch(sqlx::Error::RowNotFound)) } } @@ -129,16 +130,37 @@ struct SessionLookup { created_at: DateTime, last_authentication_id: Option, last_authd_at: Option>, + user_email_id: Option, + user_email: Option, + user_email_created_at: Option>, + user_email_confirmed_at: Option>, } impl TryInto> for SessionLookup { type Error = DatabaseInconsistencyError; fn try_into(self) -> Result, Self::Error> { + let primary_email = match ( + self.user_email_id, + self.user_email, + self.user_email_created_at, + self.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), + }; + let user = User { data: self.user_id, username: self.username, sub: format!("fake-sub-{}", self.user_id), + primary_email, }; let last_authentication = match (self.last_authentication_id, self.last_authd_at) { @@ -169,16 +191,22 @@ pub async fn lookup_active_session( r#" SELECT s.id, - u.id as user_id, + u.id AS user_id, u.username, s.created_at, - a.id as "last_authentication_id?", - a.created_at as "last_authd_at?" + a.id AS "last_authentication_id?", + a.created_at AS "last_authd_at?", + 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 user_sessions s INNER JOIN users u ON s.user_id = u.id LEFT JOIN user_session_authentications a ON a.session_id = s.id + LEFT JOIN user_emails ue + ON ue.id = u.primary_email_id WHERE s.id = $1 AND s.active ORDER BY a.created_at DESC LIMIT 1 @@ -336,6 +364,7 @@ pub async fn register_user( data: id, username: username.to_string(), sub: format!("fake-sub-{}", id), + primary_email: None, }; set_password(txn.borrow_mut(), phf, &user, password).await?; @@ -390,17 +419,41 @@ pub async fn end_session( } } +#[derive(Debug, Error)] +#[error("failed to lookup user")] +pub enum UserLookupError { + Database(#[from] sqlx::Error), + Inconsistency(#[from] DatabaseInconsistencyError), +} + +impl UserLookupError { + #[must_use] + pub fn not_found(&self) -> bool { + matches!(self, Self::Database(sqlx::Error::RowNotFound)) + } +} + #[tracing::instrument(skip(executor))] pub async fn lookup_user_by_username( executor: impl PgExecutor<'_>, username: &str, -) -> Result, sqlx::Error> { +) -> Result, UserLookupError> { let res = sqlx::query_as!( UserLookup, r#" - SELECT id, username - FROM users - WHERE username = $1 + SELECT + 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 users u + + LEFT JOIN user_emails ue + ON ue.id = u.primary_email_id + + WHERE u.username = $1 "#, username, ) @@ -408,9 +461,73 @@ pub async fn lookup_user_by_username( .instrument(info_span!("Fetch user")) .await?; + 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()), + }; + Ok(User { - data: res.id, - username: res.username, - sub: format!("fake-sub-{}", res.id), + data: res.user_id, + username: res.user_username, + sub: format!("fake-sub-{}", res.user_id), + primary_email, }) } + +#[derive(Debug, Clone)] +struct UserEmailLookup { + user_email_id: i64, + user_email: String, + user_email_created_at: DateTime, + user_email_confirmed_at: Option>, +} + +impl From for UserEmail { + fn from(e: UserEmailLookup) -> UserEmail { + UserEmail { + data: e.user_email_id, + email: e.user_email, + created_at: e.user_email_created_at, + confirmed_at: e.user_email_confirmed_at, + } + } +} + +#[tracing::instrument(skip_all, fields(user.id = user.data, %user.username))] +pub async fn get_user_emails( + executor: impl PgExecutor<'_>, + user: &User, +) -> Result>, anyhow::Error> { + let res = sqlx::query_as!( + UserEmailLookup, + r#" + SELECT + 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 user_emails ue + + WHERE ue.user_id = $1 + + ORDER BY ue.email ASC + "#, + user.data, + ) + .fetch_all(executor) + .instrument(info_span!("Fetch user emails")) + .await?; + + Ok(res.into_iter().map(Into::into).collect()) +} diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 79289651..ca9c29a4 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -14,7 +14,9 @@ //! Contexts used in templates -use mas_data_model::{errors::ErroredForm, AuthorizationGrant, BrowserSession, StorageBackend}; +use mas_data_model::{ + errors::ErroredForm, AuthorizationGrant, BrowserSession, StorageBackend, UserEmail, +}; use oauth2_types::errors::OAuth2Error; use serde::{ser::SerializeStruct, Serialize}; use url::Url; @@ -386,12 +388,19 @@ pub struct ReauthContext { #[derive(Serialize)] pub struct AccountContext { active_sessions: usize, + emails: Vec>, } impl AccountContext { #[must_use] - pub fn new(active_sessions: usize) -> Self { - Self { active_sessions } + pub fn new(active_sessions: usize, emails: Vec) -> Self + where + T: Into>, + { + Self { + active_sessions, + emails: emails.into_iter().map(Into::into).collect(), + } } } @@ -400,7 +409,8 @@ impl TemplateContext for AccountContext { where Self: Sized, { - vec![Self::new(5)] + let emails: Vec> = UserEmail::samples(); + vec![Self::new(5, emails)] } } diff --git a/crates/templates/src/res/account.html b/crates/templates/src/res/account.html index ea91975d..76440a48 100644 --- a/crates/templates/src/res/account.html +++ b/crates/templates/src/res/account.html @@ -26,6 +26,10 @@ limitations under the License.
{{ current_session.user.sub }}
Active sessions
{{ active_sessions }}
+ {% if current_session.user.primary_email %} +
Primary email
+
{{ current_session.user.primary_email.email }}
+ {% endif %}

Change my password

@@ -49,5 +53,12 @@ limitations under the License. {{ button::link_ghost(text="Revalidate", href="/reauth", class="col-span-2 place-self-end") }} +
+

Emails

+ {% for email in emails %} +
{{ email.email }}
+
{% if email.confirmed_at %}Confirmed{% else %}Unconfirmed{% endif %}
+ {% endfor %} +
{% endblock content %}