Skip to main content

sofie_identity/account/
mod.rs

1pub(crate) mod factory;
2
3use sofie::core::Instant;
4use sofie::db::{Outcome, Plan, Stamp};
5use sofie::prelude::*;
6
7use crate::PasswordHash;
8
9pub(crate) const EMAIL_INDEX: &str = "account_email";
10pub(crate) const ALREADY_REGISTERED: &str = "is already registered";
11#[cfg(any(feature = "account-data", feature = "oauth", feature = "impersonation"))]
12const CLOSED_DOMAIN: &str = "@closed.invalid";
13
14#[derive(Aggregate)]
15#[owner]
16pub struct Account {
17    meta: Meta,
18    email: Email,
19    password_hash: PasswordHash,
20    verified_at: Option<Instant>,
21}
22
23impl Account {
24    #[cfg(any(
25        feature = "links",
26        feature = "totp",
27        feature = "passkeys",
28        feature = "account-data",
29        feature = "impersonation",
30        feature = "oauth",
31        feature = "permissions"
32    ))]
33    pub(crate) async fn stored(read: &Read, id: crate::AccountId) -> Result<Self> {
34        Self::query()
35            .where_(Self::ID.eq(id))
36            .first(read)
37            .await?
38            .ok_or(Error::NotFound)
39    }
40
41    pub(crate) async fn of_email(read: &Read, email: Email) -> Result<Option<Self>> {
42        Self::query()
43            .where_(Self::EMAIL.eq(email))
44            .first(read)
45            .await
46    }
47
48    #[cfg(any(feature = "oauth", feature = "impersonation"))]
49    pub(crate) fn is_closed(&self) -> bool {
50        self.email.as_str().ends_with(CLOSED_DOMAIN)
51    }
52
53    #[must_use]
54    pub fn email(&self) -> &Email {
55        &self.email
56    }
57
58    #[must_use]
59    pub fn verified_at(&self) -> Option<Instant> {
60        self.verified_at
61    }
62
63    pub(crate) fn password_hash(&self) -> &PasswordHash {
64        &self.password_hash
65    }
66
67    pub(crate) fn change_password(&mut self, hash: PasswordHash) {
68        self.password_hash = hash;
69    }
70
71    pub fn confirm_email(&mut self, now: Instant) {
72        self.verified_at.get_or_insert(now);
73    }
74
75    /// Its first confirmation link is mailed once it is committed.
76    #[cfg(feature = "email-verification")]
77    pub(crate) fn asking_verification<A: crate::MemberArea>(mut self) -> Result<Self> {
78        let purpose = crate::account_link::LinkPurpose::EmailVerification;
79        let asked = crate::mailed::LinkAsked::of::<A>(self.email.clone(), purpose);
80        self.meta.raise(asked)?;
81        Ok(self)
82    }
83
84    /// Its address becomes a placeholder no mail reaches and its password matches nothing,
85    /// so the account names nobody and signs nobody in.
86    #[cfg(feature = "account-data")]
87    pub(crate) fn close(&mut self) -> Result<()> {
88        self.email = Email::parse(&format!("closed-{}{CLOSED_DOMAIN}", self.id().uuid()))?;
89        self.password_hash = PasswordHash::unmatchable()?;
90        self.verified_at = None;
91        Ok(())
92    }
93
94    #[cfg(feature = "account-data")]
95    pub(crate) fn raise<E: sofie::db::Event>(&mut self, event: E) -> Result<()> {
96        self.meta.raise(event)
97    }
98}
99
100pub trait Closable: Send {
101    fn plan(&self, plan: &mut Plan<'_>) -> Result<()>;
102    fn committed(&mut self, stamp: Stamp);
103}
104
105impl<T: Outcome> Closable for T {
106    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
107        Outcome::plan(self, plan)
108    }
109
110    fn committed(&mut self, stamp: Stamp) {
111        Outcome::committed(self, stamp);
112    }
113}
114
115/// Every way in that whoever held an address before anyone proved it may have set up, ended.
116pub(crate) struct Distrusted(Vec<Box<dyn Closable>>);
117
118impl Outcome for Distrusted {
119    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
120        self.0.iter().try_for_each(|record| record.plan(plan))
121    }
122
123    fn committed(&mut self, stamp: Stamp) {
124        for record in &mut self.0 {
125            record.committed(stamp);
126        }
127    }
128}
129
130impl Account {
131    pub(crate) fn is_confirmed(&self) -> bool {
132        self.verified_at.is_some()
133    }
134
135    #[cfg(any(feature = "password-reset", feature = "sign-in-links"))]
136    pub(crate) async fn prove_email(
137        &mut self,
138        read: &Read,
139        now: Instant,
140    ) -> Result<Option<Distrusted>> {
141        if self.is_confirmed() {
142            return Ok(None);
143        }
144        self.confirm_email(now);
145        self.distrust(read, now).await.map(Some)
146    }
147
148    /// The password, sessions and tokens an address's first holder knows stay; only what it
149    /// added to sign in without them goes, as confirming says nothing about who added it.
150    #[cfg(feature = "email-verification")]
151    pub(crate) async fn confirm_unproven(
152        &mut self,
153        read: &Read,
154        now: Instant,
155    ) -> Result<Option<Distrusted>> {
156        if self.is_confirmed() {
157            return Ok(None);
158        }
159        self.confirm_email(now);
160        let records = self.added_ways_in(read).await?;
161        Ok(Some(Distrusted(records)))
162    }
163
164    /// Only a mailed link, a reset or a provider's confirmed address signs in after this.
165    #[cfg_attr(not(any(feature = "sessions", feature = "tokens")), expect(unused_mut))]
166    pub(crate) async fn distrust(&mut self, read: &Read, now: Instant) -> Result<Distrusted> {
167        self.password_hash = PasswordHash::unmatchable()?;
168        let id = self.id();
169        let mut records = self.added_ways_in(read).await?;
170        #[cfg(feature = "sessions")]
171        records.push(Box::new(
172            crate::sessions::ending_everywhere(read, id, now).await?,
173        ));
174        #[cfg(feature = "tokens")]
175        records.push(Box::new(crate::tokens::revoking(read, id).await?));
176        #[cfg(not(feature = "sessions"))]
177        let _ = now;
178        #[cfg(not(any(feature = "tokens", feature = "sessions")))]
179        let _ = id;
180        Ok(Distrusted(records))
181    }
182
183    /// Passkeys, providers and the second factor.
184    #[cfg_attr(
185        not(any(feature = "passkeys", feature = "oauth", feature = "totp")),
186        expect(clippy::unused_async_trait_impl, unused_mut)
187    )]
188    async fn added_ways_in(&self, read: &Read) -> Result<Vec<Box<dyn Closable>>> {
189        let id = self.id();
190        let mut records: Vec<Box<dyn Closable>> = Vec::new();
191        #[cfg(feature = "passkeys")]
192        records.push(Box::new(crate::passkeys::removing(read, id).await?));
193        #[cfg(feature = "oauth")]
194        records.push(Box::new(crate::oauth::removing(read, id).await?));
195        #[cfg(feature = "totp")]
196        records.push(Box::new(crate::two_factor::removing(read, id).await?));
197        #[cfg(not(any(feature = "passkeys", feature = "oauth", feature = "totp")))]
198        let _ = (read, id);
199        Ok(records)
200    }
201}