Data model and storage layer for storing user registrations

This commit is contained in:
Quentin Gliech
2025-01-14 16:30:43 +01:00
parent e0f4882e46
commit dbb53160b6
20 changed files with 1423 additions and 11 deletions
+1 -1
View File
@@ -48,6 +48,6 @@ pub use self::{
users::{
Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail,
UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession,
UserRecoveryTicket,
UserRecoveryTicket, UserRegistration, UserRegistrationPassword,
},
};
+23
View File
@@ -10,6 +10,7 @@ use chrono::{DateTime, Utc};
use rand::Rng;
use serde::Serialize;
use ulid::Ulid;
use url::Url;
use crate::UserAgent;
@@ -112,6 +113,7 @@ impl UserRecoveryTicket {
pub struct UserEmailAuthentication {
pub id: Ulid,
pub user_session_id: Option<Ulid>,
pub user_registration_id: Option<Ulid>,
pub email: String,
pub created_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
@@ -192,3 +194,24 @@ impl UserEmail {
]
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UserRegistrationPassword {
pub hashed_password: String,
pub version: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UserRegistration {
pub id: Ulid,
pub username: String,
pub display_name: Option<String>,
pub terms_url: Option<Url>,
pub email_authentication_id: Option<Ulid>,
pub password: Option<UserRegistrationPassword>,
pub post_auth_action: Option<serde_json::Value>,
pub ip_address: Option<IpAddr>,
pub user_agent: Option<UserAgent>,
pub created_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO user_email_authentications\n ( user_email_authentication_id\n , user_registration_id\n , email\n , created_at\n )\n VALUES ($1, $2, $3, $4)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "0e1bce56e15751d82a622d532b279bfc50e22cb12ddf7495c7b0fedca61f9421"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE user_registrations\n SET terms_url = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "188a4aeef5a8b4bf3230c7176ded64d52804848df378dc74f8f54ec4404e094e"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE user_registrations\n SET email_authentication_id = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "4968c60adef69c7215a7efe2021baffb050b2f475ae106155c2e2f210a81191a"
}
@@ -0,0 +1,88 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_registration_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n , post_auth_action\n , username\n , display_name\n , terms_url\n , email_authentication_id\n , hashed_password\n , hashed_password_version\n , created_at\n , completed_at\n FROM user_registrations\n WHERE user_registration_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_registration_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "ip_address: IpAddr",
"type_info": "Inet"
},
{
"ordinal": 2,
"name": "user_agent",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "post_auth_action",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "terms_url",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "email_authentication_id",
"type_info": "Uuid"
},
{
"ordinal": 8,
"name": "hashed_password",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "hashed_password_version",
"type_info": "Int4"
},
{
"ordinal": 10,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 11,
"name": "completed_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
true,
true,
false,
true,
true,
true,
true,
true,
false,
true
]
},
"hash": "6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_email_authentication_id\n , user_session_id\n , email\n , created_at\n , completed_at\n FROM user_email_authentications\n WHERE user_email_authentication_id = $1\n ",
"query": "\n SELECT user_email_authentication_id\n , user_session_id\n , user_registration_id\n , email\n , created_at\n , completed_at\n FROM user_email_authentications\n WHERE user_email_authentication_id = $1\n ",
"describe": {
"columns": [
{
@@ -15,16 +15,21 @@
},
{
"ordinal": 2,
"name": "user_registration_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 3,
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"ordinal": 5,
"name": "completed_at",
"type_info": "Timestamptz"
}
@@ -37,10 +42,11 @@
"nullable": [
false,
true,
true,
false,
false,
true
]
},
"hash": "7fd19dac2c15091e7f8bd85531d2b99d8a42cc89fe7bb6e9411a886f68e38628"
"hash": "7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE user_registrations\n SET completed_at = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Timestamptz"
]
},
"nullable": []
},
"hash": "83d1b0720dfde3209d77f1142aa19359913b8a934ca8a642b7bb43c9a7a58a6d"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO user_registrations\n ( user_registration_id\n , ip_address\n , user_agent\n , post_auth_action\n , username\n , created_at\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Inet",
"Text",
"Jsonb",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "8d240d72d651f59d53bed7380710038e9d00492b1e282237c0ec0e03bc36a9c0"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE user_registrations\n SET display_name = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "8f5ce493e8b8473ba03d5263915a8b231f9e7c211ab83487536008e48316c269"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE user_registrations\n SET hashed_password = $2, hashed_password_version = $3\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Int4"
]
},
"nullable": []
},
"hash": "b60d34f4d250c12f75dba10491c1337d69aebad12be6fbfbdde91e34083ba4ed"
}
@@ -0,0 +1,49 @@
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- Add a table for storing user registrations
CREATE TABLE "user_registrations" (
"user_registration_id" UUID PRIMARY KEY,
-- The IP address of the user agent, if any
"ip_address" INET,
-- The user agent string of the user agent, if any
"user_agent" TEXT,
-- The post auth action to execute after the registration, if any
"post_auth_action" JSONB,
-- The username the user asked for
"username" TEXT NOT NULL,
-- The display name the user asked for
"display_name" TEXT,
-- The URL to the terms of service at the time of registration
"terms_url" TEXT,
-- The ID of the email authentication session
"email_authentication_id" UUID
REFERENCES "user_email_authentications" ("user_email_authentication_id")
ON DELETE SET NULL,
-- The hashed password of the user
"hashed_password" TEXT,
-- The scheme version used to hash the password
"hashed_password_version" INTEGER,
-- When the object was created
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
-- When the registration was completed
"completed_at" TIMESTAMP WITH TIME ZONE
);
-- Allow using user email authentications for user registrations
ALTER TABLE "user_email_authentications"
ADD COLUMN "user_registration_id" UUID
REFERENCES "user_registrations" ("user_registration_id")
ON DELETE CASCADE;
+8 -1
View File
@@ -49,7 +49,8 @@ use crate::{
},
user::{
PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasswordRepository,
PgUserRecoveryRepository, PgUserRepository, PgUserTermsRepository,
PgUserRecoveryRepository, PgUserRegistrationRepository, PgUserRepository,
PgUserTermsRepository,
},
DatabaseError,
};
@@ -191,6 +192,12 @@ where
Box::new(PgUserTermsRepository::new(self.conn.as_mut()))
}
fn user_registration<'c>(
&'c mut self,
) -> Box<dyn mas_storage::user::UserRegistrationRepository<Error = Self::Error> + 'c> {
Box::new(PgUserRegistrationRepository::new(self.conn.as_mut()))
}
fn browser_session<'c>(
&'c mut self,
) -> Box<dyn BrowserSessionRepository<Error = Self::Error> + 'c> {
+57
View File
@@ -8,6 +8,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{
BrowserSession, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode,
UserRegistration,
};
use mas_storage::{
user::{UserEmailFilter, UserEmailRepository},
@@ -66,6 +67,7 @@ impl From<UserEmailLookup> for UserEmail {
struct UserEmailAuthenticationLookup {
user_email_authentication_id: Uuid,
user_session_id: Option<Uuid>,
user_registration_id: Option<Uuid>,
email: String,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
@@ -76,6 +78,7 @@ impl From<UserEmailAuthenticationLookup> for UserEmailAuthentication {
UserEmailAuthentication {
id: value.user_email_authentication_id.into(),
user_session_id: value.user_session_id.map(Ulid::from),
user_registration_id: value.user_registration_id.map(Ulid::from),
email: value.email,
created_at: value.created_at,
completed_at: value.completed_at,
@@ -427,6 +430,59 @@ impl UserEmailRepository for PgUserEmailRepository<'_> {
Ok(UserEmailAuthentication {
id,
user_session_id: Some(session.id),
user_registration_id: None,
email,
created_at,
completed_at: None,
})
}
#[tracing::instrument(
name = "db.user_email.add_authentication_for_registration",
skip_all,
fields(
db.query.text,
%user_registration.id,
user_email_authentication.id,
user_email_authentication.email = email,
),
err,
)]
async fn add_authentication_for_registration(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
email: String,
user_registration: &UserRegistration,
) -> Result<UserEmailAuthentication, Self::Error> {
let created_at = clock.now();
let id = Ulid::from_datetime_with_source(created_at.into(), rng);
tracing::Span::current()
.record("user_email_authentication.id", tracing::field::display(id));
sqlx::query!(
r#"
INSERT INTO user_email_authentications
( user_email_authentication_id
, user_registration_id
, email
, created_at
)
VALUES ($1, $2, $3, $4)
"#,
Uuid::from(id),
Uuid::from(user_registration.id),
&email,
created_at,
)
.traced()
.execute(&mut *self.conn)
.await?;
Ok(UserEmailAuthentication {
id,
user_session_id: None,
user_registration_id: Some(user_registration.id),
email,
created_at,
completed_at: None,
@@ -509,6 +565,7 @@ impl UserEmailRepository for PgUserEmailRepository<'_> {
r#"
SELECT user_email_authentication_id
, user_session_id
, user_registration_id
, email
, created_at
, completed_at
+3 -2
View File
@@ -31,6 +31,7 @@ use crate::{
mod email;
mod password;
mod recovery;
mod registration;
mod session;
mod terms;
@@ -39,8 +40,8 @@ mod tests;
pub use self::{
email::PgUserEmailRepository, password::PgUserPasswordRepository,
recovery::PgUserRecoveryRepository, session::PgBrowserSessionRepository,
terms::PgUserTermsRepository,
recovery::PgUserRecoveryRepository, registration::PgUserRegistrationRepository,
session::PgBrowserSessionRepository, terms::PgUserTermsRepository,
};
/// An implementation of [`UserRepository`] for a PostgreSQL connection
+819
View File
@@ -0,0 +1,819 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use std::net::IpAddr;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{
UserAgent, UserEmailAuthentication, UserRegistration, UserRegistrationPassword,
};
use mas_storage::{user::UserRegistrationRepository, Clock};
use rand::RngCore;
use sqlx::PgConnection;
use ulid::Ulid;
use url::Url;
use uuid::Uuid;
use crate::{DatabaseError, DatabaseInconsistencyError, ExecuteExt as _};
/// An implementation of [`UserRegistrationRepository`] for a PostgreSQL
/// connection
pub struct PgUserRegistrationRepository<'c> {
conn: &'c mut PgConnection,
}
impl<'c> PgUserRegistrationRepository<'c> {
/// Create a new [`PgUserRegistrationRepository`] from an active PostgreSQL
/// connection
pub fn new(conn: &'c mut PgConnection) -> Self {
Self { conn }
}
}
struct UserRegistrationLookup {
user_registration_id: Uuid,
ip_address: Option<IpAddr>,
user_agent: Option<String>,
post_auth_action: Option<serde_json::Value>,
username: String,
display_name: Option<String>,
terms_url: Option<String>,
email_authentication_id: Option<Uuid>,
hashed_password: Option<String>,
hashed_password_version: Option<i32>,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
}
impl TryFrom<UserRegistrationLookup> for UserRegistration {
type Error = DatabaseInconsistencyError;
fn try_from(value: UserRegistrationLookup) -> Result<Self, Self::Error> {
let id = Ulid::from(value.user_registration_id);
let user_agent = value.user_agent.map(UserAgent::parse);
let password = match (value.hashed_password, value.hashed_password_version) {
(Some(hashed_password), Some(version)) => {
let version = version.try_into().map_err(|e| {
DatabaseInconsistencyError::on("user_registrations")
.column("hashed_password_version")
.row(id)
.source(e)
})?;
Some(UserRegistrationPassword {
hashed_password,
version,
})
}
(None, None) => None,
_ => {
return Err(DatabaseInconsistencyError::on("user_registrations")
.column("hashed_password")
.row(id));
}
};
let terms_url = value
.terms_url
.map(|u| u.parse())
.transpose()
.map_err(|e| {
DatabaseInconsistencyError::on("user_registrations")
.column("terms_url")
.row(id)
.source(e)
})?;
Ok(UserRegistration {
id,
ip_address: value.ip_address,
user_agent,
post_auth_action: value.post_auth_action,
username: value.username,
display_name: value.display_name,
terms_url,
email_authentication_id: value.email_authentication_id.map(Ulid::from),
password,
created_at: value.created_at,
completed_at: value.completed_at,
})
}
}
#[async_trait]
impl UserRegistrationRepository for PgUserRegistrationRepository<'_> {
type Error = DatabaseError;
#[tracing::instrument(
name = "db.user_registration.lookup",
skip_all,
fields(
db.query.text,
user_registration.id = %id,
),
err,
)]
async fn lookup(&mut self, id: Ulid) -> Result<Option<UserRegistration>, Self::Error> {
let res = sqlx::query_as!(
UserRegistrationLookup,
r#"
SELECT user_registration_id
, ip_address as "ip_address: IpAddr"
, user_agent
, post_auth_action
, username
, display_name
, terms_url
, email_authentication_id
, hashed_password
, hashed_password_version
, created_at
, completed_at
FROM user_registrations
WHERE user_registration_id = $1
"#,
Uuid::from(id),
)
.traced()
.fetch_optional(&mut *self.conn)
.await?;
let Some(res) = res else { return Ok(None) };
Ok(Some(res.try_into()?))
}
#[tracing::instrument(
name = "db.user_registration.add",
skip_all,
fields(
db.query.text,
user_registration.id,
),
err,
)]
async fn add(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
username: String,
ip_address: Option<IpAddr>,
user_agent: Option<UserAgent>,
post_auth_action: Option<serde_json::Value>,
) -> Result<UserRegistration, Self::Error> {
let created_at = clock.now();
let id = Ulid::from_datetime_with_source(created_at.into(), rng);
tracing::Span::current().record("user_registration.id", tracing::field::display(id));
sqlx::query!(
r#"
INSERT INTO user_registrations
( user_registration_id
, ip_address
, user_agent
, post_auth_action
, username
, created_at
)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
Uuid::from(id),
ip_address as Option<IpAddr>,
user_agent.as_deref(),
post_auth_action,
username,
created_at,
)
.traced()
.execute(&mut *self.conn)
.await?;
Ok(UserRegistration {
id,
ip_address,
user_agent,
post_auth_action,
created_at,
completed_at: None,
username,
display_name: None,
terms_url: None,
email_authentication_id: None,
password: None,
})
}
#[tracing::instrument(
name = "db.user_registration.set_display_name",
skip_all,
fields(
db.query.text,
user_registration.id = %user_registration.id,
user_registration.display_name = display_name,
),
err,
)]
async fn set_display_name(
&mut self,
mut user_registration: UserRegistration,
display_name: String,
) -> Result<UserRegistration, Self::Error> {
let res = sqlx::query!(
r#"
UPDATE user_registrations
SET display_name = $2
WHERE user_registration_id = $1 AND completed_at IS NULL
"#,
Uuid::from(user_registration.id),
display_name,
)
.traced()
.execute(&mut *self.conn)
.await?;
DatabaseError::ensure_affected_rows(&res, 1)?;
user_registration.display_name = Some(display_name);
Ok(user_registration)
}
#[tracing::instrument(
name = "db.user_registration.set_terms_url",
skip_all,
fields(
db.query.text,
user_registration.id = %user_registration.id,
user_registration.terms_url = %terms_url,
),
err,
)]
async fn set_terms_url(
&mut self,
mut user_registration: UserRegistration,
terms_url: Url,
) -> Result<UserRegistration, Self::Error> {
let res = sqlx::query!(
r#"
UPDATE user_registrations
SET terms_url = $2
WHERE user_registration_id = $1 AND completed_at IS NULL
"#,
Uuid::from(user_registration.id),
terms_url.as_str(),
)
.traced()
.execute(&mut *self.conn)
.await?;
DatabaseError::ensure_affected_rows(&res, 1)?;
user_registration.terms_url = Some(terms_url);
Ok(user_registration)
}
#[tracing::instrument(
name = "db.user_registration.set_email_authentication",
skip_all,
fields(
db.query.text,
%user_registration.id,
%user_email_authentication.id,
%user_email_authentication.email,
),
err,
)]
async fn set_email_authentication(
&mut self,
mut user_registration: UserRegistration,
user_email_authentication: &UserEmailAuthentication,
) -> Result<UserRegistration, Self::Error> {
let res = sqlx::query!(
r#"
UPDATE user_registrations
SET email_authentication_id = $2
WHERE user_registration_id = $1 AND completed_at IS NULL
"#,
Uuid::from(user_registration.id),
Uuid::from(user_email_authentication.id),
)
.traced()
.execute(&mut *self.conn)
.await?;
DatabaseError::ensure_affected_rows(&res, 1)?;
user_registration.email_authentication_id = Some(user_email_authentication.id);
Ok(user_registration)
}
#[tracing::instrument(
name = "db.user_registration.set_password",
skip_all,
fields(
db.query.text,
user_registration.id = %user_registration.id,
user_registration.hashed_password = hashed_password,
user_registration.hashed_password_version = version,
),
err,
)]
async fn set_password(
&mut self,
mut user_registration: UserRegistration,
hashed_password: String,
version: u16,
) -> Result<UserRegistration, Self::Error> {
let res = sqlx::query!(
r#"
UPDATE user_registrations
SET hashed_password = $2, hashed_password_version = $3
WHERE user_registration_id = $1 AND completed_at IS NULL
"#,
Uuid::from(user_registration.id),
hashed_password,
i32::from(version),
)
.traced()
.execute(&mut *self.conn)
.await?;
DatabaseError::ensure_affected_rows(&res, 1)?;
user_registration.password = Some(UserRegistrationPassword {
hashed_password,
version,
});
Ok(user_registration)
}
#[tracing::instrument(
name = "db.user_registration.complete",
skip_all,
fields(
db.query.text,
user_registration.id = %user_registration.id,
),
err,
)]
async fn complete(
&mut self,
clock: &dyn Clock,
mut user_registration: UserRegistration,
) -> Result<UserRegistration, Self::Error> {
let completed_at = clock.now();
let res = sqlx::query!(
r#"
UPDATE user_registrations
SET completed_at = $2
WHERE user_registration_id = $1 AND completed_at IS NULL
"#,
Uuid::from(user_registration.id),
completed_at,
)
.traced()
.execute(&mut *self.conn)
.await?;
DatabaseError::ensure_affected_rows(&res, 1)?;
user_registration.completed_at = Some(completed_at);
Ok(user_registration)
}
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use mas_data_model::{UserAgent, UserRegistrationPassword};
use mas_storage::{clock::MockClock, Clock};
use rand::SeedableRng;
use rand_chacha::ChaChaRng;
use sqlx::PgPool;
use crate::PgRepository;
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_create_lookup_complete(pool: PgPool) {
let mut rng = ChaChaRng::seed_from_u64(42);
let clock = MockClock::default();
let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
let registration = repo
.user_registration()
.add(&mut rng, &clock, "alice".to_owned(), None, None, None)
.await
.unwrap();
assert_eq!(registration.created_at, clock.now());
assert_eq!(registration.completed_at, None);
assert_eq!(registration.username, "alice");
assert_eq!(registration.display_name, None);
assert_eq!(registration.terms_url, None);
assert_eq!(registration.email_authentication_id, None);
assert_eq!(registration.password, None);
assert_eq!(registration.user_agent, None);
assert_eq!(registration.ip_address, None);
assert_eq!(registration.post_auth_action, None);
let lookup = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(lookup.id, registration.id);
assert_eq!(lookup.created_at, registration.created_at);
assert_eq!(lookup.completed_at, registration.completed_at);
assert_eq!(lookup.username, registration.username);
assert_eq!(lookup.display_name, registration.display_name);
assert_eq!(lookup.terms_url, registration.terms_url);
assert_eq!(
lookup.email_authentication_id,
registration.email_authentication_id
);
assert_eq!(lookup.password, registration.password);
assert_eq!(lookup.user_agent, registration.user_agent);
assert_eq!(lookup.ip_address, registration.ip_address);
assert_eq!(lookup.post_auth_action, registration.post_auth_action);
// Mark the registration as completed
let registration = repo
.user_registration()
.complete(&clock, registration)
.await
.unwrap();
assert_eq!(registration.completed_at, Some(clock.now()));
// Lookup the registration again
let lookup = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(lookup.completed_at, registration.completed_at);
// Do it again, it should fail
let res = repo
.user_registration()
.complete(&clock, registration)
.await;
assert!(res.is_err());
}
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_create_useragent_ipaddress(pool: PgPool) {
let mut rng = ChaChaRng::seed_from_u64(42);
let clock = MockClock::default();
let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
let registration = repo
.user_registration()
.add(
&mut rng,
&clock,
"alice".to_owned(),
Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
Some(UserAgent::parse("Mozilla/5.0".to_owned())),
Some(serde_json::json!({"action": "continue_compat_sso_login", "id": "01FSHN9AG0MKGTBNZ16RDR3PVY"})),
)
.await
.unwrap();
assert_eq!(
registration.user_agent,
Some(UserAgent::parse("Mozilla/5.0".to_owned()))
);
assert_eq!(
registration.ip_address,
Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))
);
assert_eq!(
registration.post_auth_action,
Some(
serde_json::json!({"action": "continue_compat_sso_login", "id": "01FSHN9AG0MKGTBNZ16RDR3PVY"})
)
);
let lookup = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(lookup.user_agent, registration.user_agent);
assert_eq!(lookup.ip_address, registration.ip_address);
assert_eq!(lookup.post_auth_action, registration.post_auth_action);
}
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_set_display_name(pool: PgPool) {
let mut rng = ChaChaRng::seed_from_u64(42);
let clock = MockClock::default();
let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
let registration = repo
.user_registration()
.add(&mut rng, &clock, "alice".to_owned(), None, None, None)
.await
.unwrap();
assert_eq!(registration.display_name, None);
let registration = repo
.user_registration()
.set_display_name(registration, "Alice".to_owned())
.await
.unwrap();
assert_eq!(registration.display_name, Some("Alice".to_owned()));
let lookup = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(lookup.display_name, registration.display_name);
// Setting it again should work
let registration = repo
.user_registration()
.set_display_name(registration, "Bob".to_owned())
.await
.unwrap();
assert_eq!(registration.display_name, Some("Bob".to_owned()));
let lookup = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(lookup.display_name, registration.display_name);
// Can't set it once completed
let registration = repo
.user_registration()
.complete(&clock, registration)
.await
.unwrap();
let res = repo
.user_registration()
.set_display_name(registration, "Charlie".to_owned())
.await;
assert!(res.is_err());
}
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_set_terms_url(pool: PgPool) {
let mut rng = ChaChaRng::seed_from_u64(42);
let clock = MockClock::default();
let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
let registration = repo
.user_registration()
.add(&mut rng, &clock, "alice".to_owned(), None, None, None)
.await
.unwrap();
assert_eq!(registration.terms_url, None);
let registration = repo
.user_registration()
.set_terms_url(registration, "https://example.com/terms".parse().unwrap())
.await
.unwrap();
assert_eq!(
registration.terms_url,
Some("https://example.com/terms".parse().unwrap())
);
let lookup = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(lookup.terms_url, registration.terms_url);
// Setting it again should work
let registration = repo
.user_registration()
.set_terms_url(registration, "https://example.com/terms2".parse().unwrap())
.await
.unwrap();
assert_eq!(
registration.terms_url,
Some("https://example.com/terms2".parse().unwrap())
);
let lookup = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(lookup.terms_url, registration.terms_url);
// Can't set it once completed
let registration = repo
.user_registration()
.complete(&clock, registration)
.await
.unwrap();
let res = repo
.user_registration()
.set_terms_url(registration, "https://example.com/terms3".parse().unwrap())
.await;
assert!(res.is_err());
}
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_set_email_authentication(pool: PgPool) {
let mut rng = ChaChaRng::seed_from_u64(42);
let clock = MockClock::default();
let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
let registration = repo
.user_registration()
.add(&mut rng, &clock, "alice".to_owned(), None, None, None)
.await
.unwrap();
assert_eq!(registration.email_authentication_id, None);
let authentication = repo
.user_email()
.add_authentication_for_registration(
&mut rng,
&clock,
"alice@example.com".to_owned(),
&registration,
)
.await
.unwrap();
let registration = repo
.user_registration()
.set_email_authentication(registration, &authentication)
.await
.unwrap();
assert_eq!(
registration.email_authentication_id,
Some(authentication.id)
);
let lookup = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(
lookup.email_authentication_id,
registration.email_authentication_id
);
// Setting it again should work
let registration = repo
.user_registration()
.set_email_authentication(registration, &authentication)
.await
.unwrap();
assert_eq!(
registration.email_authentication_id,
Some(authentication.id)
);
let lookup = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(
lookup.email_authentication_id,
registration.email_authentication_id
);
// Can't set it once completed
let registration = repo
.user_registration()
.complete(&clock, registration)
.await
.unwrap();
let res = repo
.user_registration()
.set_email_authentication(registration, &authentication)
.await;
assert!(res.is_err());
}
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_set_password(pool: PgPool) {
let mut rng = ChaChaRng::seed_from_u64(42);
let clock = MockClock::default();
let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
let registration = repo
.user_registration()
.add(&mut rng, &clock, "alice".to_owned(), None, None, None)
.await
.unwrap();
assert_eq!(registration.password, None);
let registration = repo
.user_registration()
.set_password(registration, "fakehashedpassword".to_owned(), 1)
.await
.unwrap();
assert_eq!(
registration.password,
Some(UserRegistrationPassword {
hashed_password: "fakehashedpassword".to_owned(),
version: 1,
})
);
let lookup = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(lookup.password, registration.password);
// Setting it again should work
let registration = repo
.user_registration()
.set_password(registration, "fakehashedpassword2".to_owned(), 2)
.await
.unwrap();
assert_eq!(
registration.password,
Some(UserRegistrationPassword {
hashed_password: "fakehashedpassword2".to_owned(),
version: 2,
})
);
let lookup = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(lookup.password, registration.password);
// Can't set it once completed
let registration = repo
.user_registration()
.complete(&clock, registration)
.await
.unwrap();
let res = repo
.user_registration()
.set_password(registration, "fakehashedpassword3".to_owned(), 3)
.await;
assert!(res.is_err());
}
}
+23 -3
View File
@@ -24,7 +24,7 @@ use crate::{
},
user::{
BrowserSessionRepository, UserEmailRepository, UserPasswordRepository,
UserRecoveryRepository, UserRepository, UserTermsRepository,
UserRecoveryRepository, UserRegistrationRepository, UserRepository, UserTermsRepository,
},
};
@@ -129,6 +129,11 @@ pub trait RepositoryAccess: Send {
fn user_recovery<'c>(&'c mut self)
-> Box<dyn UserRecoveryRepository<Error = Self::Error> + 'c>;
/// Get an [`UserRegistrationRepository`]
fn user_registration<'c>(
&'c mut self,
) -> Box<dyn UserRegistrationRepository<Error = Self::Error> + 'c>;
/// Get an [`UserTermsRepository`]
fn user_terms<'c>(&'c mut self) -> Box<dyn UserTermsRepository<Error = Self::Error> + 'c>;
@@ -224,8 +229,8 @@ mod impls {
UpstreamOAuthSessionRepository,
},
user::{
BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository,
UserTermsRepository,
BrowserSessionRepository, UserEmailRepository, UserPasswordRepository,
UserRegistrationRepository, UserRepository, UserTermsRepository,
},
MapErr, Repository, RepositoryTransaction,
};
@@ -316,6 +321,15 @@ mod impls {
Box::new(MapErr::new(self.inner.user_recovery(), &mut self.mapper))
}
fn user_registration<'c>(
&'c mut self,
) -> Box<dyn UserRegistrationRepository<Error = Self::Error> + 'c> {
Box::new(MapErr::new(
self.inner.user_registration(),
&mut self.mapper,
))
}
fn user_terms<'c>(&'c mut self) -> Box<dyn UserTermsRepository<Error = Self::Error> + 'c> {
Box::new(MapErr::new(self.inner.user_terms(), &mut self.mapper))
}
@@ -468,6 +482,12 @@ mod impls {
(**self).user_recovery()
}
fn user_registration<'c>(
&'c mut self,
) -> Box<dyn UserRegistrationRepository<Error = Self::Error> + 'c> {
(**self).user_registration()
}
fn user_terms<'c>(&'c mut self) -> Box<dyn UserTermsRepository<Error = Self::Error> + 'c> {
(**self).user_terms()
}
+30
View File
@@ -7,6 +7,7 @@
use async_trait::async_trait;
use mas_data_model::{
BrowserSession, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode,
UserRegistration,
};
use rand_core::RngCore;
use ulid::Ulid;
@@ -184,6 +185,27 @@ pub trait UserEmailRepository: Send + Sync {
session: &BrowserSession,
) -> Result<UserEmailAuthentication, Self::Error>;
/// Add a new [`UserEmailAuthentication`] for a [`UserRegistration`]
///
/// # Parameters
///
/// * `rng`: The random number generator to use
/// * `clock`: The clock to use
/// * `email`: The email address to add
/// * `registration`: The [`UserRegistration`] for which to add the
/// [`UserEmailAuthentication`]
///
/// # Errors
///
/// Returns an error if the underlying repository fails
async fn add_authentication_for_registration(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
email: String,
registration: &UserRegistration,
) -> Result<UserEmailAuthentication, Self::Error>;
/// Add a new [`UserEmailAuthenticationCode`] for a
/// [`UserEmailAuthentication`]
///
@@ -289,6 +311,14 @@ repository_impl!(UserEmailRepository:
session: &BrowserSession,
) -> Result<UserEmailAuthentication, Self::Error>;
async fn add_authentication_for_registration(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
email: String,
registration: &UserRegistration,
) -> Result<UserEmailAuthentication, Self::Error>;
async fn add_authentication_code(
&mut self,
rng: &mut (dyn RngCore + Send),
+2
View File
@@ -16,6 +16,7 @@ use crate::{repository_impl, Clock, Page, Pagination};
mod email;
mod password;
mod recovery;
mod registration;
mod session;
mod terms;
@@ -23,6 +24,7 @@ pub use self::{
email::{UserEmailFilter, UserEmailRepository},
password::UserPasswordRepository,
recovery::UserRecoveryRepository,
registration::UserRegistrationRepository,
session::{BrowserSessionFilter, BrowserSessionRepository},
terms::UserTermsRepository,
};
+198
View File
@@ -0,0 +1,198 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use std::net::IpAddr;
use async_trait::async_trait;
use mas_data_model::{UserAgent, UserEmailAuthentication, UserRegistration};
use rand_core::RngCore;
use ulid::Ulid;
use url::Url;
use crate::{repository_impl, Clock};
/// A [`UserRegistrationRepository`] helps interacting with [`UserRegistration`]
/// saved in the storage backend
#[async_trait]
pub trait UserRegistrationRepository: Send + Sync {
/// The error type returned by the repository
type Error;
/// Lookup a [`UserRegistration`] by its ID
///
/// Returns `None` if no [`UserRegistration`] was found
///
/// # Parameters
///
/// * `id`: The ID of the [`UserRegistration`] to lookup
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn lookup(&mut self, id: Ulid) -> Result<Option<UserRegistration>, Self::Error>;
/// Create a new [`UserRegistration`] session
///
/// Returns the newly created [`UserRegistration`]
///
/// # Parameters
///
/// * `rng`: The random number generator to use
/// * `clock`: The clock used to generate timestamps
/// * `username`: The username of the user
/// * `ip_address`: The IP address of the user agent, if any
/// * `user_agent`: The user agent of the user agent, if any
/// * `post_auth_action`: The post auth action to execute after the
/// registration, if any
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn add(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
username: String,
ip_address: Option<IpAddr>,
user_agent: Option<UserAgent>,
post_auth_action: Option<serde_json::Value>,
) -> Result<UserRegistration, Self::Error>;
/// Set the display name of a [`UserRegistration`]
///
/// Returns the updated [`UserRegistration`]
///
/// # Parameters
///
/// * `user_registration`: The [`UserRegistration`] to update
/// * `display_name`: The display name to set
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails or if the
/// registration is already completed
async fn set_display_name(
&mut self,
user_registration: UserRegistration,
display_name: String,
) -> Result<UserRegistration, Self::Error>;
/// Set the terms URL of a [`UserRegistration`]
///
/// Returns the updated [`UserRegistration`]
///
/// # Parameters
///
/// * `user_registration`: The [`UserRegistration`] to update
/// * `terms_url`: The terms URL to set
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails or if the
/// registration is already completed
async fn set_terms_url(
&mut self,
user_registration: UserRegistration,
terms_url: Url,
) -> Result<UserRegistration, Self::Error>;
/// Set the email authentication code of a [`UserRegistration`]
///
/// Returns the updated [`UserRegistration`]
///
/// # Parameters
///
/// * `user_registration`: The [`UserRegistration`] to update
/// * `email_authentication`: The [`UserEmailAuthentication`] to set
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails or if the
/// registration is already completed
async fn set_email_authentication(
&mut self,
user_registration: UserRegistration,
email_authentication: &UserEmailAuthentication,
) -> Result<UserRegistration, Self::Error>;
/// Set the password of a [`UserRegistration`]
///
/// Returns the updated [`UserRegistration`]
///
/// # Parameters
///
/// * `user_registration`: The [`UserRegistration`] to update
/// * `hashed_password`: The hashed password to set
/// * `version`: The version of the hashing scheme
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails or if the
/// registration is already completed
async fn set_password(
&mut self,
user_registration: UserRegistration,
hashed_password: String,
version: u16,
) -> Result<UserRegistration, Self::Error>;
/// Complete a [`UserRegistration`]
///
/// Returns the updated [`UserRegistration`]
///
/// # Parameters
///
/// * `clock`: The clock used to generate timestamps
/// * `user_registration`: The [`UserRegistration`] to complete
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails or if the
/// registration is already completed
async fn complete(
&mut self,
clock: &dyn Clock,
user_registration: UserRegistration,
) -> Result<UserRegistration, Self::Error>;
}
repository_impl!(UserRegistrationRepository:
async fn lookup(&mut self, id: Ulid) -> Result<Option<UserRegistration>, Self::Error>;
async fn add(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
username: String,
ip_address: Option<IpAddr>,
user_agent: Option<UserAgent>,
post_auth_action: Option<serde_json::Value>,
) -> Result<UserRegistration, Self::Error>;
async fn set_display_name(
&mut self,
user_registration: UserRegistration,
display_name: String,
) -> Result<UserRegistration, Self::Error>;
async fn set_terms_url(
&mut self,
user_registration: UserRegistration,
terms_url: Url,
) -> Result<UserRegistration, Self::Error>;
async fn set_email_authentication(
&mut self,
user_registration: UserRegistration,
email_authentication: &UserEmailAuthentication,
) -> Result<UserRegistration, Self::Error>;
async fn set_password(
&mut self,
user_registration: UserRegistration,
hashed_password: String,
version: u16,
) -> Result<UserRegistration, Self::Error>;
async fn complete(
&mut self,
clock: &dyn Clock,
user_registration: UserRegistration,
) -> Result<UserRegistration, Self::Error>;
);