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