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