Skip to main content

sofie_identity/sessions/
mod.rs

1#[cfg(feature = "session-screens")]
2mod screen;
3
4use std::time::Duration;
5
6use sofie::core::Instant;
7use sofie::db::{Db, Query, Uuid};
8use sofie::http::SessionRecord;
9use sofie::prelude::*;
10
11#[cfg(feature = "session-screens")]
12pub use screen::routes;
13
14use crate::AccountId;
15
16/// Kept short enough to show in a list, without anything that would mislead there.
17const DEVICE_CHARACTERS: usize = 200;
18/// A write per request would cost more than the request itself.
19const LAST_SEEN_PRECISION: Duration = Duration::from_secs(60);
20/// A cookie is sealed as the response is written, after its record: kept a while past the
21/// lifetime, so no ended record goes while a cookie it refuses still opens.
22const PRUNE_MARGIN: Duration = Duration::from_hours(1);
23#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
24const RECENT_SIGN_IN: Duration = Duration::from_mins(10);
25
26/// Ended, not deleted, so a cookie still carrying it is refused until it expires by itself.
27#[derive(Aggregate)]
28pub struct AccountSession {
29    meta: Meta,
30    #[owner]
31    account: AccountId,
32    session: Uuid,
33    area: String,
34    /// What it signed in as, such as an organization the account acts for; none before it was kept.
35    principal: Option<Uuid>,
36    device: Option<String>,
37    last_seen_at: Instant,
38    ended_at: Option<Instant>,
39    /// Ends by itself then, as an impersonation's does.
40    expires_at: Option<Instant>,
41}
42
43impl AccountSession {
44    #[must_use]
45    pub fn area(&self) -> &str {
46        &self.area
47    }
48
49    #[must_use]
50    pub fn device(&self) -> Option<&str> {
51        self.device.as_deref()
52    }
53
54    #[must_use]
55    pub fn last_seen_at(&self) -> Instant {
56        self.last_seen_at
57    }
58
59    fn end(&mut self, now: Instant) {
60        self.ended_at.get_or_insert(now);
61    }
62
63    #[cfg(feature = "impersonation")]
64    pub(crate) fn resumable(&self, account: AccountId, area: &str) -> Option<Resumable> {
65        if self.account != account || self.area != area || self.ended_at.is_some() {
66            return None;
67        }
68        Some(Resumable {
69            session: self.session,
70            principal: self.principal.unwrap_or_else(|| account.uuid()),
71            signed_in_at: self.created_at()?,
72        })
73    }
74
75    #[cfg(feature = "impersonation")]
76    pub(crate) fn impersonated(&mut self, by: &Email, until: Instant) {
77        self.device = Some(format!("Impersonated by {}", by.as_str()));
78        self.expires_at = Some(until);
79    }
80}
81
82#[cfg(feature = "impersonation")]
83pub(crate) struct Resumable {
84    pub(crate) session: Uuid,
85    pub(crate) principal: Uuid,
86    pub(crate) signed_in_at: Instant,
87}
88
89pub(crate) fn started(
90    session: Uuid,
91    account: AccountId,
92    area: &str,
93    principal: Uuid,
94    user_agent: Option<&str>,
95    now: Instant,
96) -> Result<AccountSession> {
97    Ok(AccountSession {
98        meta: Meta::new()?,
99        account,
100        session,
101        area: area.to_owned(),
102        principal: Some(principal),
103        device: user_agent.map(device),
104        last_seen_at: now,
105        ended_at: None,
106        expires_at: None,
107    })
108}
109
110fn device(user_agent: &str) -> String {
111    user_agent
112        .chars()
113        .filter(|character| !sofie::core::misleads_display(*character))
114        .take(DEVICE_CHARACTERS)
115        .collect()
116}
117
118/// A session the store never recorded is live: only an ended one is refused.
119pub(crate) async fn record(
120    db: &Db,
121    session: Uuid,
122    seen: Option<sofie::core::Timestamp>,
123) -> Result<SessionRecord> {
124    let read = db.read();
125    let Some(mut record) = recorded(&read, session).await? else {
126        return Ok(SessionRecord::Unrecorded);
127    };
128    if record.ended_at.is_some() {
129        return Ok(SessionRecord::Ended);
130    }
131    // Only an impersonation's session ends by itself, so no other is looked up.
132    #[cfg(feature = "impersonation")]
133    let impersonator = match record.expires_at {
134        Some(_) => match crate::impersonation::checked(&read, session).await? {
135            crate::impersonation::Checked::Absent => None,
136            crate::impersonation::Checked::By(impersonator) => Some(impersonator),
137            crate::impersonation::Checked::Beyond(impersonation) => {
138                return ending_impersonated(db, record, *impersonation).await;
139            }
140        },
141        None => None,
142    };
143    #[cfg(not(feature = "impersonation"))]
144    let impersonator = None;
145    let held = SessionRecord::Live {
146        account: record.account.uuid(),
147        expires_at: record.expires_at,
148        impersonator,
149    };
150    let Some(seen) = seen.map(Instant::from) else {
151        return Ok(held);
152    };
153    if !held.is_live(seen) || seen < record.last_seen_at.checked_add(LAST_SEEN_PRECISION)? {
154        return Ok(held);
155    }
156    record.last_seen_at = seen;
157    match db.commit(&mut record).await {
158        // Ended, or seen by a concurrent request, in between: the next request tells which.
159        Err(Error::Conflict) | Ok(()) => Ok(held),
160        Err(error) => Err(error),
161    }
162}
163
164#[cfg(feature = "impersonation")]
165async fn ending_impersonated(
166    db: &Db,
167    mut record: AccountSession,
168    mut impersonation: crate::impersonation::Impersonation,
169) -> Result<SessionRecord> {
170    let now = db.clock().now();
171    impersonation.end(now)?;
172    record.end(now);
173    match db.commit(&mut (record, impersonation)).await {
174        // Ended by a concurrent request already.
175        Err(Error::Conflict) | Ok(()) => Ok(SessionRecord::Ended),
176        Err(error) => Err(error),
177    }
178}
179
180/// A record unseen for longer than a cookie lives can refuse no cookie, ended or not.
181pub(crate) async fn prune(prune: &sofie::db::Prune, clock: &Clock) -> Result<()> {
182    let kept = sofie::auth::SessionClaims::ABSOLUTE_LIFETIME + PRUNE_MARGIN;
183    let before = clock.now().checked_sub(kept)?;
184    prune
185        .before(AccountSession::LAST_SEEN_AT, before)
186        .await
187        .map(drop)
188}
189
190/// The account a live session signed in, read beyond the request's reach.
191pub async fn account_of(read: &Read, session: Uuid) -> Result<Option<AccountId>> {
192    let record = recorded(read, session).await?;
193    Ok(record
194        .filter(|record| record.ended_at.is_none())
195        .map(|record| record.account))
196}
197
198pub(crate) async fn recorded(read: &Read, session: Uuid) -> Result<Option<AccountSession>> {
199    AccountSession::query()
200        .where_(AccountSession::SESSION.eq(session))
201        .first(read)
202        .await
203}
204
205#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
206pub(crate) async fn signed_in_recently(read: &Read, session: Uuid, now: Instant) -> Result<bool> {
207    AccountSession::query()
208        .where_(AccountSession::SESSION.eq(session))
209        .where_(AccountSession::ENDED_AT.is_null())
210        .where_(AccountSession::CREATED_AT.gt(now.checked_sub(RECENT_SIGN_IN)?))
211        .exists(read)
212        .await
213}
214
215pub(crate) async fn ending_current(
216    read: &Read,
217    session: &Session,
218    clock: &Clock,
219) -> Result<Vec<AccountSession>> {
220    let Some(current) = session.current() else {
221        return Ok(Vec::new());
222    };
223    let mut ended: Vec<AccountSession> = recorded(read, current).await?.into_iter().collect();
224    for record in &mut ended {
225        record.end(clock.now());
226    }
227    Ok(ended)
228}
229
230/// The impersonations in sessions just ended, ended with them, so the record says when.
231#[derive(Default)]
232pub(crate) struct Impersonations {
233    #[cfg(feature = "impersonation")]
234    ended: Vec<crate::impersonation::Impersonation>,
235}
236
237impl sofie::db::Outcome for Impersonations {
238    fn plan(&self, plan: &mut sofie::db::Plan<'_>) -> Result<()> {
239        #[cfg(feature = "impersonation")]
240        self.ended.plan(plan)?;
241        #[cfg(not(feature = "impersonation"))]
242        let _ = plan;
243        Ok(())
244    }
245
246    fn committed(&mut self, stamp: sofie::db::Stamp) {
247        #[cfg(feature = "impersonation")]
248        self.ended.committed(stamp);
249        #[cfg(not(feature = "impersonation"))]
250        let _ = stamp;
251    }
252}
253
254pub(crate) async fn impersonations_in(
255    read: &Read,
256    ended: &[AccountSession],
257    now: Instant,
258) -> Result<Impersonations> {
259    #[cfg(feature = "impersonation")]
260    {
261        let sessions: Vec<Uuid> = ended.iter().map(|record| record.session).collect();
262        let ended = crate::impersonation::ending_in_sessions(read, &sessions, now).await?;
263        Ok(Impersonations { ended })
264    }
265    #[cfg(not(feature = "impersonation"))]
266    {
267        let _ = (read, ended, now);
268        Ok(Impersonations::default())
269    }
270}
271
272/// The account's live sessions but `keep`, and those of the impersonations it started.
273pub(crate) async fn ending_all(
274    read: &Read,
275    account: AccountId,
276    keep: Option<Uuid>,
277    now: Instant,
278) -> Result<Ended> {
279    #[cfg_attr(not(feature = "impersonation"), expect(unused_mut))]
280    let mut owned = AccountSession::ACCOUNT.eq(account);
281    #[cfg(feature = "impersonation")]
282    {
283        let started = crate::impersonation::sessions_started_by(read, account).await?;
284        owned = owned.or(AccountSession::SESSION.in_(started));
285    }
286    let mut which = AccountSession::query().where_(owned);
287    if let Some(keep) = keep {
288        which = which.where_(AccountSession::SESSION.ne(keep));
289    }
290    ending(read, which, now).await
291}
292
293/// Each of the account's live sessions in `area`, and those of the impersonations it started,
294/// ended: for a membership of it removed, committed with the removal.
295pub async fn ending_in(read: &Read, account: AccountId, area: &str, now: Instant) -> Result<Ended> {
296    let which = AccountSession::query()
297        .where_(AccountSession::ACCOUNT.eq(account))
298        .where_(AccountSession::AREA.eq(area.to_owned()));
299    #[cfg_attr(not(feature = "impersonation"), expect(unused_mut))]
300    let mut ended = ending(read, which, now).await?;
301    #[cfg(feature = "impersonation")]
302    {
303        let started = crate::impersonation::sessions_started_by(read, account).await?;
304        let impersonated = AccountSession::query().where_(AccountSession::SESSION.in_(started));
305        ended.add(ending(read, impersonated, now).await?);
306    }
307    Ok(ended)
308}
309
310/// The accounts' live sessions in `area` signed in as `principal`, ended: for accounts no
311/// longer acting for that principal, such as the members of an organization deleted. One
312/// recorded without its principal ends too, as it may be that one.
313pub async fn ending_as(
314    read: &Read,
315    accounts: &[AccountId],
316    area: &str,
317    principal: Uuid,
318    now: Instant,
319) -> Result<Ended> {
320    let signed_in = AccountSession::PRINCIPAL.is_null();
321    let which = AccountSession::query()
322        .where_(AccountSession::ACCOUNT.in_(accounts.iter().copied()))
323        .where_(AccountSession::AREA.eq(area.to_owned()))
324        .where_(signed_in.or(AccountSession::PRINCIPAL.eq(Some(principal))));
325    ending(read, which, now).await
326}
327
328pub struct Ended {
329    sessions: Vec<AccountSession>,
330    impersonations: Impersonations,
331}
332
333#[cfg(feature = "impersonation")]
334impl Ended {
335    fn add(&mut self, other: Self) {
336        self.sessions.extend(other.sessions);
337        self.impersonations.ended.extend(other.impersonations.ended);
338    }
339}
340
341impl sofie::db::Outcome for Ended {
342    fn plan(&self, plan: &mut sofie::db::Plan<'_>) -> Result<()> {
343        self.sessions.plan(plan)?;
344        self.impersonations.plan(plan)
345    }
346
347    fn committed(&mut self, stamp: sofie::db::Stamp) {
348        self.sessions.committed(stamp);
349        self.impersonations.committed(stamp);
350    }
351}
352
353async fn ending(read: &Read, which: Query<AccountSession>, now: Instant) -> Result<Ended> {
354    let mut sessions = which
355        .where_(AccountSession::ENDED_AT.is_null())
356        .all(read)
357        .await?;
358    for record in &mut sessions {
359        record.end(now);
360    }
361    let impersonations = impersonations_in(read, &sessions, now).await?;
362    Ok(Ended {
363        sessions,
364        impersonations,
365    })
366}
367
368#[cfg(feature = "account-data")]
369pub(crate) async fn exported(read: &Read, account: AccountId) -> Result<sofie::serde_json::Value> {
370    let sessions = AccountSession::query()
371        .where_(AccountSession::ACCOUNT.eq(account))
372        .all(read)
373        .await?;
374    Ok(sofie::serde_json::json!(
375        sessions
376            .iter()
377            .map(|session| sofie::serde_json::json!({
378                "area": session.area,
379                "device": session.device,
380                "last_seen_at": session.last_seen_at,
381                "ended_at": session.ended_at,
382            }))
383            .collect::<Vec<_>>()
384    ))
385}
386
387/// Every session ends as signing out everywhere ends them, and no record keeps the device it
388/// was on.
389#[cfg(feature = "account-data")]
390pub(crate) async fn closing(read: &Read, account: AccountId, now: Instant) -> Result<Ended> {
391    let mut ended = ending_all(read, account, None, now).await?;
392    let earlier = AccountSession::query()
393        .where_(AccountSession::ACCOUNT.eq(account))
394        .where_(AccountSession::ENDED_AT.is_not_null())
395        .where_(AccountSession::DEVICE.is_not_null())
396        .all(read)
397        .await?;
398    ended.sessions.extend(earlier);
399    for session in &mut ended.sessions {
400        session.device = None;
401    }
402    Ok(ended)
403}