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#[cfg(feature = "impersonation")]
69impl SignedIn {
70    pub(crate) fn session(&self) -> sofie::db::Uuid {
71        self.session
72    }
73
74    pub(crate) fn impersonated(mut self, by: &Email, until: sofie::core::Instant) -> Self {
75        self.record.impersonated(by, until);
76        self
77    }
78}
79
80impl Outcome for SignedIn {
81    #[cfg(feature = "sessions")]
82    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
83        self.record.plan(plan)
84    }
85
86    #[cfg(feature = "sessions")]
87    fn committed(&mut self, stamp: Stamp) {
88        self.record.committed(stamp);
89    }
90}
91
92/// Refused in an impersonation, so nothing it changes outlasts its half hour and its record.
93#[cfg(any(
94    feature = "passkeys",
95    feature = "oauth",
96    feature = "totp",
97    feature = "account-data",
98    feature = "session-screens"
99))]
100pub(crate) struct OwnSession;
101
102#[cfg(any(
103    feature = "passkeys",
104    feature = "oauth",
105    feature = "totp",
106    feature = "account-data",
107    feature = "session-screens"
108))]
109impl<A: Area, M> Resolve<RequestContext<A, M>> for OwnSession {
110    #[cfg_attr(
111        not(feature = "impersonation"),
112        expect(clippy::unused_async_trait_impl)
113    )]
114    async fn resolve(context: &mut RequestContext<A, M>) -> Result<Self> {
115        #[cfg(feature = "impersonation")]
116        if crate::impersonation::Impersonating::resolve(context)
117            .await?
118            .by()
119            .is_some()
120        {
121            return Err(Error::Rule(
122                "An impersonation cannot change how this account signs in, end its sessions, export or close it."
123                    .to_owned(),
124            ));
125        }
126        #[cfg(not(feature = "impersonation"))]
127        let _ = context;
128        Ok(Self)
129    }
130}
131
132/// So a session taken over adds no way to sign in that outlasts it, nor a second factor.
133#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
134pub(crate) struct RecentSignIn;
135
136#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
137impl<A: Area> Resolve<RequestContext<A>> for RecentSignIn {
138    async fn resolve(context: &mut RequestContext<A>) -> Result<Self> {
139        if !SignedInRecently::resolve(context).await?.0 {
140            return Err(Error::Rule(SIGN_IN_AGAIN.to_owned()));
141        }
142        Ok(Self)
143    }
144}
145
146/// The own session, and whether it signed in within the last ten minutes.
147#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
148pub(crate) struct SignedInRecently(pub(crate) bool);
149
150#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
151impl<A: Area, M> Resolve<RequestContext<A, M>> for SignedInRecently {
152    async fn resolve(context: &mut RequestContext<A, M>) -> Result<Self> {
153        OwnSession::resolve(context).await?;
154        let current = sofie::http::CurrentSession::resolve(context).await?.id();
155        let now = Clock::resolve(context).await?.now();
156        let read = Read::resolve(context).await?;
157        let Some(current) = current else {
158            return Ok(Self(false));
159        };
160        crate::sessions::signed_in_recently(&read, current, now)
161            .await
162            .map(Self)
163    }
164}
165
166pub(crate) struct Door {
167    session: Session,
168    read: Read,
169    clock: Clock,
170    #[cfg(feature = "totp")]
171    urls: sofie::http::SignedUrls,
172    #[cfg(feature = "totp")]
173    next: sofie::http::Next,
174}
175
176impl Resolve<RequestContext<Web>> for Door {
177    async fn resolve(context: &mut RequestContext<Web>) -> Result<Self> {
178        Ok(Self {
179            session: Session::resolve(context).await?,
180            read: Read::resolve(context).await?,
181            clock: Clock::resolve(context).await?,
182            #[cfg(feature = "totp")]
183            urls: sofie::http::SignedUrls::resolve(context).await?,
184            #[cfg(feature = "totp")]
185            next: sofie::http::Next::resolve(context).await?,
186        })
187    }
188}
189
190/// Replies where the browser goes next only when that is not where signing in goes:
191/// a route with `then_redirect_or_follow` keeps its `next`. `saved` is committed with it.
192pub(crate) struct Entered<T = ()> {
193    signed_in: Option<SignedIn>,
194    #[cfg(feature = "totp")]
195    pending: Option<crate::account_link::AccountLink>,
196    location: Option<String>,
197    saved: T,
198}
199
200impl Door {
201    pub(crate) fn read(&self) -> &Read {
202        &self.read
203    }
204
205    #[cfg(any(feature = "links", feature = "passkeys", feature = "oauth"))]
206    pub(crate) fn clock(&self) -> &Clock {
207        &self.clock
208    }
209
210    #[cfg(any(feature = "passkeys", feature = "oauth"))]
211    pub(crate) fn session(&self) -> &Session {
212        &self.session
213    }
214
215    pub(crate) async fn enter<A: MemberArea>(&self, account: AccountId) -> Result<Entered> {
216        self.enter_after::<A>(account, false).await
217    }
218
219    /// `second_proved` when the first step was a second factor too, as a verified passkey is.
220    /// An area needing a confirmed address admits no account without one, however it signed in.
221    pub(crate) async fn enter_after<A: MemberArea>(
222        &self,
223        account: AccountId,
224        second_proved: bool,
225    ) -> Result<Entered> {
226        #[cfg(feature = "email-verification")]
227        if A::VERIFIED_EMAIL
228            && crate::account::Account::stored(&self.read, account)
229                .await?
230                .verified_at()
231                .is_none()
232        {
233            return Err(Error::Rule(UNCONFIRMED.to_owned()));
234        }
235        self.admit::<A>(account, second_proved).await
236    }
237
238    /// For a mailed link or a provider's confirmed address, which confirm the account's
239    /// address in the same commit; `distrusted` when that commit removes its second factor.
240    #[cfg(any(feature = "links", feature = "oauth"))]
241    pub(crate) async fn enter_confirming<A: MemberArea>(
242        &self,
243        account: AccountId,
244        distrusted: bool,
245    ) -> Result<Entered> {
246        self.admit::<A>(account, distrusted).await
247    }
248
249    #[cfg_attr(not(feature = "totp"), expect(clippy::unused_async_trait_impl))]
250    async fn admit<A: MemberArea>(
251        &self,
252        account: AccountId,
253        second_proved: bool,
254    ) -> Result<Entered> {
255        #[cfg(not(feature = "totp"))]
256        let _ = second_proved;
257        #[cfg(feature = "totp")]
258        if !second_proved
259            && crate::two_factor::required(&self.read, account)
260                .await?
261                .is_some()
262        {
263            let (pending, location) =
264                crate::two_factor::asking::<A>(account, &self.urls, &self.clock, &self.next)?;
265            return Ok(Entered {
266                signed_in: None,
267                pending: Some(pending),
268                location: Some(location),
269                saved: (),
270            });
271        }
272        Ok(Entered {
273            signed_in: Some(sign_in::<A>(&self.session, account, &self.clock)?),
274            #[cfg(feature = "totp")]
275            pending: None,
276            location: None,
277            saved: (),
278        })
279    }
280}
281
282impl Entered {
283    /// For a caller that has no `next` to keep, such as `engine.js`'s passkey ceremony.
284    #[cfg(feature = "passkeys")]
285    pub(crate) fn located<A: MemberArea>(self) -> Self {
286        Self {
287            location: self.location.or_else(|| Some(A::HOME.to_owned())),
288            ..self
289        }
290    }
291
292    pub(crate) fn saving<T: Outcome>(self, saved: T) -> Entered<T> {
293        Entered {
294            signed_in: self.signed_in,
295            #[cfg(feature = "totp")]
296            pending: self.pending,
297            location: self.location,
298            saved,
299        }
300    }
301}
302
303impl<T: Outcome> Outcome for Entered<T> {
304    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
305        self.saved.plan(plan)?;
306        if let Some(signed_in) = &self.signed_in {
307            signed_in.plan(plan)?;
308        }
309        #[cfg(feature = "totp")]
310        if let Some(pending) = &self.pending {
311            pending.plan(plan)?;
312        }
313        Ok(())
314    }
315
316    fn committed(&mut self, stamp: Stamp) {
317        self.saved.committed(stamp);
318        if let Some(signed_in) = &mut self.signed_in {
319            signed_in.committed(stamp);
320        }
321        #[cfg(feature = "totp")]
322        if let Some(pending) = &mut self.pending {
323            pending.committed(stamp);
324        }
325    }
326
327    fn reply(self) -> Result<Reply> {
328        match self.location {
329            Some(location) => Ok(Reply::Json(json!({
330                "location": location,
331                "second_factor": self.signed_in.is_none(),
332            }))),
333            None => Ok(Reply::Empty),
334        }
335    }
336}