1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-06 06:02:40 +03:00

Data model and repository for the user recovery flow

This commit is contained in:
Quentin Gliech
2024-06-21 13:13:55 +02:00
parent b4814e24f1
commit 43582e7eca
14 changed files with 802 additions and 7 deletions

View File

@@ -54,6 +54,6 @@ pub use self::{
user_agent::{DeviceType, UserAgent},
users::{
Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail,
UserEmailVerification, UserEmailVerificationState,
UserEmailVerification, UserEmailVerificationState, UserRecoverySession, UserRecoveryTicket,
},
};

View File

@@ -1,4 +1,4 @@
// Copyright 2021 The Matrix.org Foundation C.I.C.
// Copyright 2021-2024 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.
@@ -79,6 +79,44 @@ pub enum AuthenticationMethod {
Unknown,
}
/// A session to recover a user if they have lost their credentials
///
/// For each session intiated, there may be multiple [`UserRecoveryTicket`]s
/// sent to the user, either because multiple [`User`] have the same email
/// address, or because the user asked to send the recovery email again.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UserRecoverySession {
pub id: Ulid,
pub email: String,
pub user_agent: UserAgent,
pub ip_address: Option<IpAddr>,
pub locale: String,
pub created_at: DateTime<Utc>,
pub consumed_at: Option<DateTime<Utc>>,
}
/// A single recovery ticket for a user recovery session
///
/// Whenever a new recovery session is initiated, a new ticket is created for
/// each email address matching in the database. That ticket is sent by email,
/// as a link that the user can click to recover their account.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UserRecoveryTicket {
pub id: Ulid,
pub user_recovery_session_id: Ulid,
pub user_email_id: Ulid,
pub ticket: String,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl UserRecoveryTicket {
#[must_use]
pub fn active(&self, now: DateTime<Utc>) -> bool {
now < self.expires_at
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BrowserSession {
pub id: Ulid,

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE user_recovery_sessions\n SET consumed_at = $1\n WHERE user_recovery_session_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Uuid"
]
},
"nullable": []
},
"hash": "1764715e59f879f6b917ca30f8e3c1de5910c7a46e7fe52d1fb3bfd5561ac320"
}

View File

@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n user_recovery_ticket_id\n , user_recovery_session_id\n , user_email_id\n , ticket\n , created_at\n , expires_at\n FROM user_recovery_tickets\n WHERE ticket = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_recovery_ticket_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "user_recovery_session_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "user_email_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "ticket",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "607262ccf28b672df51e4e5d371e5cc5119a7d6e7fe784112703c0406f28300f"
}

View File

@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO user_recovery_sessions (\n user_recovery_session_id\n , email\n , user_agent\n , ip_address\n , locale\n , created_at\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"Inet",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "8275a440640ea28fd8f82e7df672e45a6eba981a0d621665ed8f8b60354b3389"
}

View File

@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO user_recovery_tickets (\n user_recovery_ticket_id\n , user_recovery_session_id\n , user_email_id\n , ticket\n , created_at\n , expires_at\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Text",
"Timestamptz",
"Timestamptz"
]
},
"nullable": []
},
"hash": "d7a0e4fa2f168976505405c7e7800847f3379f7b57c0972659a35bfb68b0f6cd"
}

View File

@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n user_recovery_session_id\n , email\n , user_agent\n , ip_address as \"ip_address: IpAddr\"\n , locale\n , created_at\n , consumed_at\n FROM user_recovery_sessions\n WHERE user_recovery_session_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_recovery_session_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "user_agent",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "ip_address: IpAddr",
"type_info": "Inet"
},
{
"ordinal": 4,
"name": "locale",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "consumed_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true,
false,
false,
true
]
},
"hash": "f46e87bbb149b35e1d13b2b3cd2bdeab3c28a56a395f52f001a7bb013a5dfece"
}

View File

@@ -0,0 +1,64 @@
-- Copyright 2024 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.
-- Stores user recovery sessions for when the user lost their credentials.
CREATE TABLE "user_recovery_sessions" (
"user_recovery_session_id" UUID NOT NULL
CONSTRAINT "user_recovery_sessions_pkey"
PRIMARY KEY,
-- The email address for which the recovery session was requested
"email" TEXT NOT NULL,
-- The user agent of the client that requested the recovery session
"user_agent" TEXT NOT NULL,
-- The IP address of the client that requested the recovery session
"ip_address" INET,
-- The language of the client that requested the recovery session
"locale" TEXT NOT NULL,
-- When the recovery session was created
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
-- When the recovery session was consumed
"consumed_at" TIMESTAMP WITH TIME ZONE
);
-- Stores the recovery tickets for a user recovery session.
CREATE TABLE "user_recovery_tickets" (
"user_recovery_ticket_id" UUID NOT NULL
CONSTRAINT "user_recovery_tickets_pkey"
PRIMARY KEY,
-- The recovery session this ticket belongs to
"user_recovery_session_id" UUID NOT NULL
REFERENCES "user_recovery_sessions" ("user_recovery_session_id")
ON DELETE CASCADE,
-- The user_email for which the recovery ticket was generated
"user_email_id" UUID NOT NULL
REFERENCES "user_emails" ("user_email_id")
ON DELETE CASCADE,
-- The recovery ticket
"ticket" TEXT NOT NULL,
-- When the recovery ticket was created
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
-- When the recovery ticket expires
"expires_at" TIMESTAMP WITH TIME ZONE NOT NULL
);

View File

@@ -1,4 +1,4 @@
// Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
// Copyright 2022-2024 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.
@@ -54,7 +54,7 @@ use crate::{
},
user::{
PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasswordRepository,
PgUserRepository, PgUserTermsRepository,
PgUserRecoveryRepository, PgUserRepository, PgUserTermsRepository,
},
DatabaseError,
};
@@ -179,6 +179,12 @@ where
Box::new(PgUserPasswordRepository::new(self.conn.as_mut()))
}
fn user_recovery<'c>(
&'c mut self,
) -> Box<dyn mas_storage::user::UserRecoveryRepository<Error = Self::Error> + 'c> {
Box::new(PgUserRecoveryRepository::new(self.conn.as_mut()))
}
fn user_terms<'c>(
&'c mut self,
) -> Box<dyn mas_storage::user::UserTermsRepository<Error = Self::Error> + 'c> {

View File

@@ -28,6 +28,7 @@ use crate::{tracing::ExecuteExt, DatabaseError};
mod email;
mod password;
mod recovery;
mod session;
mod terms;
@@ -36,7 +37,8 @@ mod tests;
pub use self::{
email::PgUserEmailRepository, password::PgUserPasswordRepository,
session::PgBrowserSessionRepository, terms::PgUserTermsRepository,
recovery::PgUserRecoveryRepository, session::PgBrowserSessionRepository,
terms::PgUserTermsRepository,
};
/// An implementation of [`UserRepository`] for a PostgreSQL connection

View File

@@ -0,0 +1,337 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::net::IpAddr;
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use mas_data_model::{UserAgent, UserEmail, UserRecoverySession, UserRecoveryTicket};
use mas_storage::{user::UserRecoveryRepository, Clock};
use rand::RngCore;
use sqlx::PgConnection;
use ulid::Ulid;
use uuid::Uuid;
use crate::{DatabaseError, ExecuteExt};
/// An implementation of [`UserRecoveryRepository`] for a PostgreSQL connection
pub struct PgUserRecoveryRepository<'c> {
conn: &'c mut PgConnection,
}
impl<'c> PgUserRecoveryRepository<'c> {
/// Create a new [`PgUserRecoveryRepository`] from an active PostgreSQL
/// connection
pub fn new(conn: &'c mut PgConnection) -> Self {
Self { conn }
}
}
struct UserRecoverySessionRow {
user_recovery_session_id: Uuid,
email: String,
user_agent: String,
ip_address: Option<IpAddr>,
locale: String,
created_at: DateTime<Utc>,
consumed_at: Option<DateTime<Utc>>,
}
impl From<UserRecoverySessionRow> for UserRecoverySession {
fn from(row: UserRecoverySessionRow) -> Self {
UserRecoverySession {
id: row.user_recovery_session_id.into(),
email: row.email,
user_agent: UserAgent::parse(row.user_agent),
ip_address: row.ip_address,
locale: row.locale,
created_at: row.created_at,
consumed_at: row.consumed_at,
}
}
}
struct UserRecoveryTicketRow {
user_recovery_ticket_id: Uuid,
user_recovery_session_id: Uuid,
user_email_id: Uuid,
ticket: String,
created_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
}
impl From<UserRecoveryTicketRow> for UserRecoveryTicket {
fn from(row: UserRecoveryTicketRow) -> Self {
Self {
id: row.user_recovery_ticket_id.into(),
user_recovery_session_id: row.user_recovery_session_id.into(),
user_email_id: row.user_email_id.into(),
ticket: row.ticket,
created_at: row.created_at,
expires_at: row.expires_at,
}
}
}
#[async_trait]
impl<'c> UserRecoveryRepository for PgUserRecoveryRepository<'c> {
type Error = DatabaseError;
#[tracing::instrument(
name = "db.user_recovery.lookup_session",
skip_all,
fields(
db.statement,
user_recovery_session.id = %id,
),
err,
)]
async fn lookup_session(
&mut self,
id: Ulid,
) -> Result<Option<UserRecoverySession>, Self::Error> {
let row = sqlx::query_as!(
UserRecoverySessionRow,
r#"
SELECT
user_recovery_session_id
, email
, user_agent
, ip_address as "ip_address: IpAddr"
, locale
, created_at
, consumed_at
FROM user_recovery_sessions
WHERE user_recovery_session_id = $1
"#,
Uuid::from(id),
)
.traced()
.fetch_optional(&mut *self.conn)
.await?;
let Some(row) = row else {
return Ok(None);
};
Ok(Some(row.into()))
}
#[tracing::instrument(
name = "db.user_recovery.add_session",
skip_all,
fields(
db.statement,
user_recovery_session.id,
user_recovery_session.email = email,
user_recovery_session.user_agent = &*user_agent,
user_recovery_session.ip_address = ip_address.map(|ip| ip.to_string()),
)
)]
async fn add_session(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
email: String,
user_agent: UserAgent,
ip_address: Option<IpAddr>,
locale: String,
) -> Result<UserRecoverySession, Self::Error> {
let created_at = clock.now();
let id = Ulid::from_datetime_with_source(created_at.into(), rng);
tracing::Span::current().record("user_recovery_session.id", tracing::field::display(id));
sqlx::query!(
r#"
INSERT INTO user_recovery_sessions (
user_recovery_session_id
, email
, user_agent
, ip_address
, locale
, created_at
)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
Uuid::from(id),
&email,
&*user_agent,
ip_address as Option<IpAddr>,
&locale,
created_at,
)
.traced()
.execute(&mut *self.conn)
.await?;
let user_recovery_session = UserRecoverySession {
id,
email,
user_agent,
ip_address,
locale,
created_at,
consumed_at: None,
};
Ok(user_recovery_session)
}
#[tracing::instrument(
name = "db.user_recovery.find_ticket",
skip_all,
fields(
db.statement,
user_recovery_ticket.id = ticket,
),
err,
)]
async fn find_ticket(
&mut self,
ticket: &str,
) -> Result<Option<UserRecoveryTicket>, Self::Error> {
let row = sqlx::query_as!(
UserRecoveryTicketRow,
r#"
SELECT
user_recovery_ticket_id
, user_recovery_session_id
, user_email_id
, ticket
, created_at
, expires_at
FROM user_recovery_tickets
WHERE ticket = $1
"#,
ticket,
)
.traced()
.fetch_optional(&mut *self.conn)
.await?;
let Some(row) = row else {
return Ok(None);
};
Ok(Some(row.into()))
}
#[tracing::instrument(
name = "db.user_recovery.add_ticket",
skip_all,
fields(
db.statement,
user_recovery_ticket.id,
user_recovery_ticket.id = ticket,
%user_recovery_session.id,
%user_email.id,
)
)]
async fn add_ticket(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
user_recovery_session: &UserRecoverySession,
user_email: &UserEmail,
ticket: String,
) -> Result<UserRecoveryTicket, Self::Error> {
let created_at = clock.now();
let id = Ulid::from_datetime_with_source(created_at.into(), rng);
tracing::Span::current().record("user_recovery_ticket.id", tracing::field::display(id));
// TODO: move that to a parameter
let expires_at = created_at + Duration::minutes(10);
sqlx::query!(
r#"
INSERT INTO user_recovery_tickets (
user_recovery_ticket_id
, user_recovery_session_id
, user_email_id
, ticket
, created_at
, expires_at
)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
Uuid::from(id),
Uuid::from(user_recovery_session.id),
Uuid::from(user_email.id),
&ticket,
created_at,
expires_at,
)
.traced()
.execute(&mut *self.conn)
.await?;
let ticket = UserRecoveryTicket {
id,
user_recovery_session_id: user_recovery_session.id,
user_email_id: user_email.id,
ticket,
created_at,
expires_at,
};
Ok(ticket)
}
#[tracing::instrument(
name = "db.user_recovery.consume_ticket",
skip_all,
fields(
db.statement,
%user_recovery_ticket.id,
user_email.id = %user_recovery_ticket.user_email_id,
%user_recovery_session.id,
%user_recovery_session.email,
),
err,
)]
async fn consume_ticket(
&mut self,
clock: &dyn Clock,
user_recovery_ticket: UserRecoveryTicket,
mut user_recovery_session: UserRecoverySession,
) -> Result<UserRecoverySession, Self::Error> {
// We don't really use the ticket, we just want to make sure we drop it
let _ = user_recovery_ticket;
// This should have been checked by the caller
if user_recovery_session.consumed_at.is_some() {
return Err(DatabaseError::invalid_operation());
}
let consumed_at = clock.now();
let res = sqlx::query!(
r#"
UPDATE user_recovery_sessions
SET consumed_at = $1
WHERE user_recovery_session_id = $2
"#,
consumed_at,
Uuid::from(user_recovery_session.id),
)
.traced()
.execute(&mut *self.conn)
.await?;
user_recovery_session.consumed_at = Some(consumed_at);
DatabaseError::ensure_affected_rows(&res, 1)?;
Ok(user_recovery_session)
}
}

View File

@@ -31,8 +31,8 @@ use crate::{
UpstreamOAuthSessionRepository,
},
user::{
BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository,
UserTermsRepository,
BrowserSessionRepository, UserEmailRepository, UserPasswordRepository,
UserRecoveryRepository, UserRepository, UserTermsRepository,
},
MapErr,
};
@@ -149,6 +149,10 @@ pub trait RepositoryAccess: Send {
fn user_password<'c>(&'c mut self)
-> Box<dyn UserPasswordRepository<Error = Self::Error> + 'c>;
/// Get an [`UserRecoveryRepository`]
fn user_recovery<'c>(&'c mut self)
-> Box<dyn UserRecoveryRepository<Error = Self::Error> + 'c>;
/// Get an [`UserTermsRepository`]
fn user_terms<'c>(&'c mut self) -> Box<dyn UserTermsRepository<Error = Self::Error> + 'c>;
@@ -322,6 +326,12 @@ mod impls {
Box::new(MapErr::new(self.inner.user_password(), &mut self.mapper))
}
fn user_recovery<'c>(
&'c mut self,
) -> Box<dyn crate::user::UserRecoveryRepository<Error = Self::Error> + 'c> {
Box::new(MapErr::new(self.inner.user_recovery(), &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))
}
@@ -456,6 +466,12 @@ mod impls {
(**self).user_password()
}
fn user_recovery<'c>(
&'c mut self,
) -> Box<dyn crate::user::UserRecoveryRepository<Error = Self::Error> + 'c> {
(**self).user_recovery()
}
fn user_terms<'c>(&'c mut self) -> Box<dyn UserTermsRepository<Error = Self::Error> + 'c> {
(**self).user_terms()
}

View File

@@ -23,12 +23,14 @@ use crate::{repository_impl, Clock};
mod email;
mod password;
mod recovery;
mod session;
mod terms;
pub use self::{
email::{UserEmailFilter, UserEmailRepository},
password::UserPasswordRepository,
recovery::UserRecoveryRepository,
session::{BrowserSessionFilter, BrowserSessionRepository},
terms::UserTermsRepository,
};

View File

@@ -0,0 +1,167 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::net::IpAddr;
use async_trait::async_trait;
use mas_data_model::{UserAgent, UserEmail, UserRecoverySession, UserRecoveryTicket};
use rand_core::RngCore;
use ulid::Ulid;
use crate::{repository_impl, Clock};
/// A [`UserRecoveryRepository`] helps interacting with [`UserRecoverySession`]
/// and [`UserRecoveryTicket`] saved in the storage backend
#[async_trait]
pub trait UserRecoveryRepository: Send + Sync {
/// The error type returned by the repository
type Error;
/// Lookup an [`UserRecoverySession`] by its ID
///
/// Returns `None` if no [`UserRecoverySession`] was found
///
/// # Parameters
///
/// * `id`: The ID of the [`UserRecoverySession`] to lookup
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn lookup_session(
&mut self,
id: Ulid,
) -> Result<Option<UserRecoverySession>, Self::Error>;
/// Create a new [`UserRecoverySession`] for the given email
///
/// Returns the newly created [`UserRecoverySession`]
///
/// # Parameters
///
/// * `rng`: The random number generator to use
/// * `clock`: The clock to use
/// * `email`: The email to create the session for
/// * `user_agent`: The user agent of the browser which initiated the
/// session
/// * `ip_address`: The IP address of the browser which initiated the
/// session, if known
/// * `locale`: The locale of the browser which initiated the session
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn add_session(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
email: String,
user_agent: UserAgent,
ip_address: Option<IpAddr>,
locale: String,
) -> Result<UserRecoverySession, Self::Error>;
/// Find a [`UserRecoveryTicket`] by its ticket
///
/// Returns `None` if no [`UserRecoveryTicket`] was found
///
/// # Parameters
///
/// * `ticket`: The ticket of the [`UserRecoveryTicket`] to lookup
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn find_ticket(
&mut self,
ticket: &str,
) -> Result<Option<UserRecoveryTicket>, Self::Error>;
/// Add a [`UserRecoveryTicket`] to the given [`UserRecoverySession`] for
/// the given [`UserEmail`]
///
/// # Parameters
///
/// * `rng`: The random number generator to use
/// * `clock`: The clock to use
/// * `session`: The [`UserRecoverySession`] to add the ticket to
/// * `user_email`: The [`UserEmail`] to add the ticket for
/// * `ticket`: The ticket to add
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn add_ticket(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
user_recovery_session: &UserRecoverySession,
user_email: &UserEmail,
ticket: String,
) -> Result<UserRecoveryTicket, Self::Error>;
/// Consume a [`UserRecoveryTicket`] and mark the session as used
///
/// # Parameters
///
/// * `clock`: The clock to use to record the time of consumption
/// * `ticket`: The [`UserRecoveryTicket`] to consume
/// * `session`: The [`UserRecoverySession`] to mark as used
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails or if the
/// recovery session was already used
async fn consume_ticket(
&mut self,
clock: &dyn Clock,
user_recovery_ticket: UserRecoveryTicket,
user_recovery_session: UserRecoverySession,
) -> Result<UserRecoverySession, Self::Error>;
}
repository_impl!(UserRecoveryRepository:
async fn lookup_session(&mut self, id: Ulid) -> Result<Option<UserRecoverySession>, Self::Error>;
async fn add_session(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
email: String,
user_agent: UserAgent,
ip_address: Option<IpAddr>,
locale: String,
) -> Result<UserRecoverySession, Self::Error>;
async fn find_ticket(
&mut self,
ticket: &str,
) -> Result<Option<UserRecoveryTicket>, Self::Error>;
async fn add_ticket(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
user_recovery_session: &UserRecoverySession,
user_email: &UserEmail,
ticket: String,
) -> Result<UserRecoveryTicket, Self::Error>;
async fn consume_ticket(
&mut self,
clock: &dyn Clock,
user_recovery_ticket: UserRecoveryTicket,
user_recovery_session: UserRecoverySession,
) -> Result<UserRecoverySession, Self::Error>;
);