Skip to main content

sofie_identity/
signing_in.rs

1use sofie::db::{Outcome, Plan, Reply, Resolve, Stamp};
2use sofie::http::{PrincipalRecord, RequestContext, SignInRight};
3use sofie::prelude::*;
4use sofie::serde_json::json;
5
6use crate::{AccountId, MemberArea};
7
8struct Identity;
9
10impl sofie::http::Admitter for Identity {}
11
12const RIGHT: SignInRight = SignInRight::of::<Identity>();
13#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
14const SIGN_IN_AGAIN: &str = "Sign in again first: this needs a sign-in from the last ten minutes.";
15#[cfg(feature = "email-verification")]
16const UNCONFIRMED: &str = "Confirm your email address first: follow the link mailed to it.";
17
18pub struct SignedIn {
19    #[cfg(feature = "impersonation")]
20    session: sofie::db::Uuid,
21    #[cfg(feature = "sessions")]
22    record: crate::sessions::AccountSession,
23}
24
25/// Every way of signing in ends here, after the account proved itself and its membership.
26pub(crate) fn sign_in<A: Area>(
27    session: &Session,
28    account: AccountId,
29    clock: &Clock,
30) -> Result<SignedIn> {
31    sign_in_for::<A>(
32        session,
33        RIGHT,
34        Id::from_uuid(account.uuid()),
35        account,
36        clock,
37    )
38}
39
40/// Signs `account` in to an area whose principal is another of its records, such as an
41/// organization it belongs to; the session is listed as the account's. The caller, an
42/// `Admitter` of its own, has checked that the account may act as `principal`.
43pub fn sign_in_for<A: Area>(
44    session: &Session,
45    right: SignInRight,
46    principal: Id<PrincipalRecord<A>>,
47    account: AccountId,
48    clock: &Clock,
49) -> Result<SignedIn> {
50    let id = session.sign_in::<A>(right, principal)?;
51    #[cfg(not(feature = "sessions"))]
52    let _ = (id, account, clock);
53    Ok(SignedIn {
54        #[cfg(feature = "impersonation")]
55        session: id,
56        #[cfg(feature = "sessions")]
57        record: crate::sessions::started(
58            id,
59            account,
60            A::NAME,
61            principal.uuid(),
62            session.user_agent().as_deref(),
63            clock.now(),
64        )?,
65    })
66}
67
68/// Signs the impersonator's own session back in, so stopping proves no recent sign-in.
69#[cfg(feature = "impersonation")]
70pub(crate) fn resume<A: Area>(session: &Session, resumed: &crate::sessions::Resumable) {
71    session.resume::<A>(
72        RIGHT,
73        resumed.session,
74        Id::from_uuid(resumed.principal),
75        resumed.signed_in_at.timestamp(),
76    );
77}
78
79#[cfg(feature = "impersonation")]
80impl SignedIn {
81    pub(crate) fn session(&self) -> sofie::db::Uuid {
82        self.session
83    }
84
85    pub(crate) fn impersonated(mut self, by: &Email, until: sofie::core::Instant) -> Self {
86        self.record.impersonated(by, until);
87        self
88    }
89}
90
91impl Outcome for SignedIn {
92    #[cfg(feature = "sessions")]
93    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
94        self.record.plan(plan)
95    }
96
97    #[cfg(feature = "sessions")]
98    fn committed(&mut self, stamp: Stamp) {
99        self.record.committed(stamp);
100    }
101}
102
103/// Refused in an impersonation, so nothing it changes outlasts its half hour and its record.
104#[cfg(any(
105    feature = "passkeys",
106    feature = "oauth",
107    feature = "totp",
108    feature = "account-data",
109    feature = "session-screens",
110    feature = "token-screens"
111))]
112pub(crate) struct OwnSession;
113
114#[cfg(any(
115    feature = "passkeys",
116    feature = "oauth",
117    feature = "totp",
118    feature = "account-data",
119    feature = "session-screens",
120    feature = "token-screens"
121))]
122impl<A: Area, M> Resolve<RequestContext<A, M>> for OwnSession {
123    #[cfg_attr(
124        not(feature = "impersonation"),
125        expect(clippy::unused_async_trait_impl)
126    )]
127    async fn resolve(context: &mut RequestContext<A, M>) -> Result<Self> {
128        #[cfg(feature = "impersonation")]
129        if crate::impersonation::Impersonating::resolve(context)
130            .await?
131            .by()
132            .is_some()
133        {
134            return Err(Error::Rule(
135                "An impersonation cannot change how this account signs in, end its sessions, export or close it."
136                    .to_owned(),
137            ));
138        }
139        #[cfg(not(feature = "impersonation"))]
140        let _ = context;
141        Ok(Self)
142    }
143}
144
145/// So a session taken over adds no way to sign in that outlasts it, nor a second factor.
146#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
147pub(crate) struct RecentSignIn;
148
149#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
150impl<A: Area> Resolve<RequestContext<A>> for RecentSignIn {
151    async fn resolve(context: &mut RequestContext<A>) -> Result<Self> {
152        if !SignedInRecently::resolve(context).await?.0 {
153            return Err(Error::Rule(SIGN_IN_AGAIN.to_owned()));
154        }
155        Ok(Self)
156    }
157}
158
159/// The own session, and whether it signed in within the last ten minutes.
160#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
161pub(crate) struct SignedInRecently(pub(crate) bool);
162
163#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
164impl<A: Area, M> Resolve<RequestContext<A, M>> for SignedInRecently {
165    async fn resolve(context: &mut RequestContext<A, M>) -> Result<Self> {
166        OwnSession::resolve(context).await?;
167        let current = sofie::http::CurrentSession::resolve(context).await?.id();
168        let now = Clock::resolve(context).await?.now();
169        let read = Read::resolve(context).await?;
170        let Some(current) = current else {
171            return Ok(Self(false));
172        };
173        crate::sessions::signed_in_recently(&read, current, now)
174            .await
175            .map(Self)
176    }
177}
178
179pub(crate) struct Door {
180    session: Session,
181    read: Read,
182    clock: Clock,
183    #[cfg(feature = "totp")]
184    urls: sofie::http::SignedUrls,
185    #[cfg(feature = "totp")]
186    next: sofie::http::Next,
187}
188
189impl Resolve<RequestContext<Web>> for Door {
190    async fn resolve(context: &mut RequestContext<Web>) -> Result<Self> {
191        Ok(Self {
192            session: Session::resolve(context).await?,
193            read: Read::resolve(context).await?,
194            clock: Clock::resolve(context).await?,
195            #[cfg(feature = "totp")]
196            urls: sofie::http::SignedUrls::resolve(context).await?,
197            #[cfg(feature = "totp")]
198            next: sofie::http::Next::resolve(context).await?,
199        })
200    }
201}
202
203/// Replies where the browser goes next only when that is not where signing in goes:
204/// a route with `then_redirect_or_follow` keeps its `next`. `saved` is committed with it.
205pub(crate) struct Entered<T = ()> {
206    signed_in: Option<SignedIn>,
207    #[cfg(feature = "totp")]
208    pending: Option<crate::account_link::AccountLink>,
209    location: Option<String>,
210    saved: T,
211}
212
213impl Door {
214    pub(crate) fn read(&self) -> &Read {
215        &self.read
216    }
217
218    #[cfg(any(feature = "links", feature = "passkeys", feature = "oauth"))]
219    pub(crate) fn clock(&self) -> &Clock {
220        &self.clock
221    }
222
223    #[cfg(any(feature = "passkeys", feature = "oauth"))]
224    pub(crate) fn session(&self) -> &Session {
225        &self.session
226    }
227
228    pub(crate) async fn enter<A: MemberArea>(&self, account: AccountId) -> Result<Entered> {
229        self.enter_after::<A>(account, false).await
230    }
231
232    /// `second_proved` when the first step was a second factor too, as a verified passkey is.
233    /// An area needing a confirmed address admits no account without one, however it signed in.
234    pub(crate) async fn enter_after<A: MemberArea>(
235        &self,
236        account: AccountId,
237        second_proved: bool,
238    ) -> Result<Entered> {
239        #[cfg(feature = "email-verification")]
240        if A::VERIFIED_EMAIL
241            && crate::account::Account::stored(&self.read, account)
242                .await?
243                .verified_at()
244                .is_none()
245        {
246            return Err(Error::Rule(UNCONFIRMED.to_owned()));
247        }
248        self.admit::<A>(account, second_proved).await
249    }
250
251    /// For a mailed link or a provider's confirmed address, which confirm the account's
252    /// address in the same commit; `distrusted` when that commit removes its second factor.
253    #[cfg(any(feature = "links", feature = "oauth"))]
254    pub(crate) async fn enter_confirming<A: MemberArea>(
255        &self,
256        account: AccountId,
257        distrusted: bool,
258    ) -> Result<Entered> {
259        self.admit::<A>(account, distrusted).await
260    }
261
262    #[cfg_attr(not(feature = "totp"), expect(clippy::unused_async_trait_impl))]
263    async fn admit<A: MemberArea>(
264        &self,
265        account: AccountId,
266        second_proved: bool,
267    ) -> Result<Entered> {
268        #[cfg(not(feature = "totp"))]
269        let _ = second_proved;
270        #[cfg(feature = "totp")]
271        if !second_proved
272            && crate::two_factor::required(&self.read, account)
273                .await?
274                .is_some()
275        {
276            let (pending, location) =
277                crate::two_factor::asking::<A>(account, &self.urls, &self.clock, &self.next)?;
278            return Ok(Entered {
279                signed_in: None,
280                pending: Some(pending),
281                location: Some(location),
282                saved: (),
283            });
284        }
285        Ok(Entered {
286            signed_in: Some(sign_in::<A>(&self.session, account, &self.clock)?),
287            #[cfg(feature = "totp")]
288            pending: None,
289            location: None,
290            saved: (),
291        })
292    }
293}
294
295impl Entered {
296    /// For a caller that has no `next` to keep, such as `engine.js`'s passkey ceremony.
297    #[cfg(feature = "passkeys")]
298    pub(crate) fn located<A: MemberArea>(self) -> Self {
299        Self {
300            location: self.location.or_else(|| Some(A::HOME.to_owned())),
301            ..self
302        }
303    }
304
305    pub(crate) fn saving<T: Outcome>(self, saved: T) -> Entered<T> {
306        Entered {
307            signed_in: self.signed_in,
308            #[cfg(feature = "totp")]
309            pending: self.pending,
310            location: self.location,
311            saved,
312        }
313    }
314}
315
316impl<T: Outcome> Outcome for Entered<T> {
317    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
318        self.saved.plan(plan)?;
319        if let Some(signed_in) = &self.signed_in {
320            signed_in.plan(plan)?;
321        }
322        #[cfg(feature = "totp")]
323        if let Some(pending) = &self.pending {
324            pending.plan(plan)?;
325        }
326        Ok(())
327    }
328
329    fn committed(&mut self, stamp: Stamp) {
330        self.saved.committed(stamp);
331        if let Some(signed_in) = &mut self.signed_in {
332            signed_in.committed(stamp);
333        }
334        #[cfg(feature = "totp")]
335        if let Some(pending) = &mut self.pending {
336            pending.committed(stamp);
337        }
338    }
339
340    fn reply(self) -> Result<Reply> {
341        match self.location {
342            Some(location) => Ok(Reply::Json(json!({
343                "location": location,
344                "second_factor": self.signed_in.is_none(),
345            }))),
346            None => Ok(Reply::Empty),
347        }
348    }
349}