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 impersonated(&mut self, by: &Email, until: Instant) {
65        self.device = Some(format!("Impersonated by {}", by.as_str()));
66        self.expires_at = Some(until);
67    }
68}
69
70pub(crate) fn started(
71    session: Uuid,
72    account: AccountId,
73    area: &str,
74    principal: Uuid,
75    user_agent: Option<&str>,
76    now: Instant,
77) -> Result<AccountSession> {
78    Ok(AccountSession {
79        meta: Meta::new()?,
80        account,
81        session,
82        area: area.to_owned(),
83        principal: Some(principal),
84        device: user_agent.map(device),
85        last_seen_at: now,
86        ended_at: None,
87        expires_at: None,
88    })
89}
90
91fn device(user_agent: &str) -> String {
92    user_agent
93        .chars()
94        .filter(|character| !sofie::core::misleads_display(*character))
95        .take(DEVICE_CHARACTERS)
96        .collect()
97}
98
99/// A session the store never recorded is live: only an ended one is refused.
100pub(crate) async fn record(
101    db: &Db,
102    session: Uuid,
103    seen: Option<sofie::core::Timestamp>,
104) -> Result<SessionRecord> {
105    let read = db.read();
106    let Some(mut record) = recorded(&read, session).await? else {
107        return Ok(SessionRecord::Unrecorded);
108    };
109    if record.ended_at.is_some() {
110        return Ok(SessionRecord::Ended);
111    }
112    // Only an impersonation's session ends by itself, so no other is looked up.
113    #[cfg(feature = "impersonation")]
114    let impersonator = match record.expires_at {
115        Some(_) => match crate::impersonation::checked(&read, session).await? {
116            crate::impersonation::Checked::Absent => None,
117            crate::impersonation::Checked::By(impersonator) => Some(impersonator),
118            crate::impersonation::Checked::Beyond(impersonation) => {
119                return ending_impersonated(db, record, *impersonation).await;
120            }
121        },
122        None => None,
123    };
124    #[cfg(not(feature = "impersonation"))]
125    let impersonator = None;
126    let held = SessionRecord::Live {
127        account: record.account.uuid(),
128        expires_at: record.expires_at,
129        impersonator,
130    };
131    let Some(seen) = seen.map(Instant::from) else {
132        return Ok(held);
133    };
134    if !held.is_live(seen) || seen < record.last_seen_at.checked_add(LAST_SEEN_PRECISION)? {
135        return Ok(held);
136    }
137    record.last_seen_at = seen;
138    match db.commit(&mut record).await {
139        // Ended, or seen by a concurrent request, in between: the next request tells which.
140        Err(Error::Conflict) | Ok(()) => Ok(held),
141        Err(error) => Err(error),
142    }
143}
144
145#[cfg(feature = "impersonation")]
146async fn ending_impersonated(
147    db: &Db,
148    mut record: AccountSession,
149    mut impersonation: crate::impersonation::Impersonation,
150) -> Result<SessionRecord> {
151    let now = db.clock().now();
152    impersonation.end(now)?;
153    record.end(now);
154    match db.commit(&mut (record, impersonation)).await {
155        // Ended by a concurrent request already.
156        Err(Error::Conflict) | Ok(()) => Ok(SessionRecord::Ended),
157        Err(error) => Err(error),
158    }
159}
160
161/// A record unseen for longer than a cookie lives can refuse no cookie, ended or not.
162pub(crate) async fn prune(prune: &sofie::db::Prune, clock: &Clock) -> Result<()> {
163    let kept = sofie::auth::SessionClaims::ABSOLUTE_LIFETIME + PRUNE_MARGIN;
164    let before = clock.now().checked_sub(kept)?;
165    prune
166        .before(AccountSession::LAST_SEEN_AT, before)
167        .await
168        .map(drop)
169}
170
171/// The account a live session signed in, read beyond the request's reach.
172pub async fn account_of(read: &Read, session: Uuid) -> Result<Option<AccountId>> {
173    let record = recorded(read, session).await?;
174    Ok(record
175        .filter(|record| record.ended_at.is_none())
176        .map(|record| record.account))
177}
178
179async fn recorded(read: &Read, session: Uuid) -> Result<Option<AccountSession>> {
180    AccountSession::query()
181        .where_(AccountSession::SESSION.eq(session))
182        .first(read)
183        .await
184}
185
186#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
187pub(crate) async fn signed_in_recently(read: &Read, session: Uuid, now: Instant) -> Result<bool> {
188    AccountSession::query()
189        .where_(AccountSession::SESSION.eq(session))
190        .where_(AccountSession::ENDED_AT.is_null())
191        .where_(AccountSession::CREATED_AT.gt(now.checked_sub(RECENT_SIGN_IN)?))
192        .exists(read)
193        .await
194}
195
196pub(crate) async fn ending_current(
197    read: &Read,
198    session: &Session,
199    clock: &Clock,
200) -> Result<Vec<AccountSession>> {
201    let Some(current) = session.current() else {
202        return Ok(Vec::new());
203    };
204    let mut ended: Vec<AccountSession> = recorded(read, current).await?.into_iter().collect();
205    for record in &mut ended {
206        record.end(clock.now());
207    }
208    Ok(ended)
209}
210
211/// The impersonations in sessions just ended, ended with them, so the record says when.
212#[derive(Default)]
213pub(crate) struct Impersonations {
214    #[cfg(feature = "impersonation")]
215    ended: Vec<crate::impersonation::Impersonation>,
216}
217
218impl sofie::db::Outcome for Impersonations {
219    fn plan(&self, plan: &mut sofie::db::Plan<'_>) -> Result<()> {
220        #[cfg(feature = "impersonation")]
221        self.ended.plan(plan)?;
222        #[cfg(not(feature = "impersonation"))]
223        let _ = plan;
224        Ok(())
225    }
226
227    fn committed(&mut self, stamp: sofie::db::Stamp) {
228        #[cfg(feature = "impersonation")]
229        self.ended.committed(stamp);
230        #[cfg(not(feature = "impersonation"))]
231        let _ = stamp;
232    }
233}
234
235pub(crate) async fn impersonations_in(
236    read: &Read,
237    ended: &[AccountSession],
238    now: Instant,
239) -> Result<Impersonations> {
240    #[cfg(feature = "impersonation")]
241    {
242        let sessions: Vec<Uuid> = ended.iter().map(|record| record.session).collect();
243        let ended = crate::impersonation::ending_in_sessions(read, &sessions, now).await?;
244        Ok(Impersonations { ended })
245    }
246    #[cfg(not(feature = "impersonation"))]
247    {
248        let _ = (read, ended, now);
249        Ok(Impersonations::default())
250    }
251}
252
253/// The account's live sessions but `keep`, and those of the impersonations it started.
254pub(crate) async fn ending_all(
255    read: &Read,
256    account: AccountId,
257    keep: Option<Uuid>,
258    now: Instant,
259) -> Result<Ended> {
260    #[cfg_attr(not(feature = "impersonation"), expect(unused_mut))]
261    let mut owned = AccountSession::ACCOUNT.eq(account);
262    #[cfg(feature = "impersonation")]
263    {
264        let started = crate::impersonation::sessions_started_by(read, account).await?;
265        owned = owned.or(AccountSession::SESSION.in_(started));
266    }
267    let mut which = AccountSession::query().where_(owned);
268    if let Some(keep) = keep {
269        which = which.where_(AccountSession::SESSION.ne(keep));
270    }
271    ending(read, which, now).await
272}
273
274/// Each of the account's live sessions in `area`, ended: for a membership of it removed,
275/// committed with the removal.
276pub async fn ending_in(read: &Read, account: AccountId, area: &str, now: Instant) -> Result<Ended> {
277    let which = AccountSession::query()
278        .where_(AccountSession::ACCOUNT.eq(account))
279        .where_(AccountSession::AREA.eq(area.to_owned()));
280    ending(read, which, now).await
281}
282
283/// The accounts' live sessions in `area` signed in as `principal`, ended: for accounts no
284/// longer acting for that principal, such as the members of an organization deleted. One
285/// recorded without its principal ends too, as it may be that one.
286pub async fn ending_as(
287    read: &Read,
288    accounts: &[AccountId],
289    area: &str,
290    principal: Uuid,
291    now: Instant,
292) -> Result<Ended> {
293    let signed_in = AccountSession::PRINCIPAL.is_null();
294    let which = AccountSession::query()
295        .where_(AccountSession::ACCOUNT.in_(accounts.iter().copied()))
296        .where_(AccountSession::AREA.eq(area.to_owned()))
297        .where_(signed_in.or(AccountSession::PRINCIPAL.eq(Some(principal))));
298    ending(read, which, now).await
299}
300
301pub struct Ended {
302    sessions: Vec<AccountSession>,
303    impersonations: Impersonations,
304}
305
306impl sofie::db::Outcome for Ended {
307    fn plan(&self, plan: &mut sofie::db::Plan<'_>) -> Result<()> {
308        self.sessions.plan(plan)?;
309        self.impersonations.plan(plan)
310    }
311
312    fn committed(&mut self, stamp: sofie::db::Stamp) {
313        self.sessions.committed(stamp);
314        self.impersonations.committed(stamp);
315    }
316}
317
318async fn ending(read: &Read, which: Query<AccountSession>, now: Instant) -> Result<Ended> {
319    let mut sessions = which
320        .where_(AccountSession::ENDED_AT.is_null())
321        .all(read)
322        .await?;
323    for record in &mut sessions {
324        record.end(now);
325    }
326    let impersonations = impersonations_in(read, &sessions, now).await?;
327    Ok(Ended {
328        sessions,
329        impersonations,
330    })
331}
332
333#[cfg(feature = "account-data")]
334pub(crate) async fn exported(read: &Read, account: AccountId) -> Result<sofie::serde_json::Value> {
335    let sessions = AccountSession::query()
336        .where_(AccountSession::ACCOUNT.eq(account))
337        .all(read)
338        .await?;
339    Ok(sofie::serde_json::json!(
340        sessions
341            .iter()
342            .map(|session| sofie::serde_json::json!({
343                "area": session.area,
344                "device": session.device,
345                "last_seen_at": session.last_seen_at,
346                "ended_at": session.ended_at,
347            }))
348            .collect::<Vec<_>>()
349    ))
350}
351
352/// Every session ends, and no record keeps the device it was on.
353#[cfg(feature = "account-data")]
354pub(crate) async fn closing(
355    read: &Read,
356    account: AccountId,
357    now: Instant,
358) -> Result<Vec<AccountSession>> {
359    let mut sessions = AccountSession::query()
360        .where_(AccountSession::ACCOUNT.eq(account))
361        .all(read)
362        .await?;
363    for session in &mut sessions {
364        session.end(now);
365        session.device = None;
366    }
367    Ok(sessions)
368}