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