Save user emails in database

This commit is contained in:
Quentin Gliech
2022-01-14 18:42:37 +01:00
parent e98ff94b14
commit 1667b5a67f
14 changed files with 1157 additions and 654 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,7 @@ impl StorageBackend for PostgresqlBackend {
type RefreshTokenData = i64;
type SessionData = i64;
type UserData = i64;
type UserEmailData = i64;
}
impl StorageBackendMarker for PostgresqlBackend {}

View File

@@ -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<i64>,
user_session_last_authentication_created_at: Option<DateTime<Utc>>,
user_email_id: Option<i64>,
user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>,
}
#[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 (

View File

@@ -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<String>,
user_session_last_authentication_id: Option<i64>,
user_session_last_authentication_created_at: Option<DateTime<Utc>>,
user_email_id: Option<i64>,
user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>,
}
impl TryInto<AuthorizationGrant<PostgresqlBackend>> for GrantLookup {
@@ -164,6 +168,22 @@ impl TryInto<AuthorizationGrant<PostgresqlBackend>> 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<AuthorizationGrant<PostgresqlBackend>> for GrantLookup {
self.user_id,
self.user_username,
last_authentication,
primary_email,
) {
(
Some(session_id),
@@ -179,11 +200,13 @@ impl TryInto<AuthorizationGrant<PostgresqlBackend>> 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<AuthorizationGrant<PostgresqlBackend>> 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

View File

@@ -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<i64>,
user_session_last_authentication_created_at: Option<DateTime<Utc>>,
user_email_id: Option<i64>,
user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>,
}
#[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 (

View File

@@ -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<i64>,
user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>,
}
#[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<Utc>,
last_authentication_id: Option<i64>,
last_authd_at: Option<DateTime<Utc>>,
user_email_id: Option<i64>,
user_email: Option<String>,
user_email_created_at: Option<DateTime<Utc>>,
user_email_confirmed_at: Option<DateTime<Utc>>,
}
impl TryInto<BrowserSession<PostgresqlBackend>> for SessionLookup {
type Error = DatabaseInconsistencyError;
fn try_into(self) -> Result<BrowserSession<PostgresqlBackend>, 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<User<PostgresqlBackend>, sqlx::Error> {
) -> Result<User<PostgresqlBackend>, 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<Utc>,
user_email_confirmed_at: Option<DateTime<Utc>>,
}
impl From<UserEmailLookup> for UserEmail<PostgresqlBackend> {
fn from(e: UserEmailLookup) -> UserEmail<PostgresqlBackend> {
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<PostgresqlBackend>,
) -> Result<Vec<UserEmail<PostgresqlBackend>>, 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())
}