Skip to main content

sofie_identity/sessions/
mod.rs

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