OXYGENHUB

Coverage / crates/hub/src/db/sessions.rs 70.9%

73 of 103 lines run, 18 of 45 functions · run #537 on main · run missed

1
//! Browser sessions as server-side records (oxygenhub#223).
2
3
use super::{now_ms, Pool};
4
use sqlx::FromRow;
5
6
/// A session ends after this long unused.
7
pub const IDLE_MS: i64 = 14 * 24 * 60 * 60_000;
8
/// And after this long whatever happens.
9
pub const ABSOLUTE_MS: i64 = 30 * 24 * 60 * 60_000;
10
/// `seen_ms` is written at most this often per session.
11
const TOUCH_EVERY_MS: i64 = 60_000;
12
13
#[derive(Clone, Debug, FromRow)]
14
pub struct Session {
15
    pub id: i64,
16
    pub account_id: i64,
17
    pub salt: String,
18
    pub created_ms: i64,
19
    pub seen_ms: i64,
20
    pub address: String,
21
    pub agent: String,
22
    /// When it last proved a second factor, or signed in (oxygenhub#356):
23
    /// the sudo step asks again once this is older than its window.
24
    pub second_factor_ms: i64,
25
}
26
27
impl Session {
28
    /// Whether it is still good at `now`, for an account whose salt is `salt`.
291331×
    pub fn live(&self, salt: &str, now: i64) -> bool {
301331×
        self.salt == salt && now - self.seen_ms < IDLE_MS && now - self.created_ms < ABSOLUTE_MS
311331×
    }
32
}
33
34
const COLUMNS: &str = "id, account_id, salt, created_ms, seen_ms, address, agent, second_factor_ms";
35
36148×
pub async fn create(pool: &Pool, account_id: i64, hash: &str, salt: &str) -> sqlx::Result<i64> {
37148×
    let now = now_ms();
38
    // Signing in is as fresh as proof gets (oxygenhub#356).
39148×
    sqlx::query_scalar(
40148×
        "INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms, second_factor_ms) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
41148×
    )
42148×
    .bind(account_id)
43148×
    .bind(hash)
44148×
    .bind(salt)
45148×
    .bind(now)
46148×
    .bind(now)
47148×
    .bind(now)
48148×
    .fetch_one(pool)
49148×
    .await
50148×
}
51
52
/// The session proved a second factor (or the password, for an account
53
/// without one) at `now`: the sudo step lets it through for a while.
542×
pub async fn mark_second_factor(pool: &Pool, session_id: i64, now: i64) -> sqlx::Result<()> {
552×
    sqlx::query("UPDATE sessions SET second_factor_ms = $1 WHERE id = $2").bind(now).bind(session_id).execute(pool).await?;
562×
    Ok(())
572×
}
58
590×
pub async fn by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<Session>> {
600×
    sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE id = $1")).bind(id).fetch_optional(pool).await
610×
}
62
631315×
pub async fn by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Session>> {
641315×
    sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE hash = $1")).bind(hash).fetch_optional(pool).await
651315×
}
66
67
/// Note a use: the time (at most once a minute) and, the first time, the
68
/// address and browser it came from.
691136×
pub async fn touch(pool: &Pool, s: &Session, address: &str, agent: &str, now: i64) -> sqlx::Result<()> {
701136×
    if s.agent.is_empty() && s.address.is_empty() && (!agent.is_empty() || !address.is_empty()) {
710×
        sqlx::query("UPDATE sessions SET seen_ms = $1, address = $2, agent = $3 WHERE id = $4")
720×
            .bind(now)
730×
            .bind(address)
740×
            .bind(agent)
750×
            .bind(s.id)
760×
            .execute(pool)
770×
            .await?;
781136×
    } else if now - s.seen_ms >= TOUCH_EVERY_MS {
790×
        sqlx::query("UPDATE sessions SET seen_ms = $1 WHERE id = $2").bind(now).bind(s.id).execute(pool).await?;
801136×
    }
811136×
    Ok(())
821136×
}
83
84
/// An account's live sessions, most recently used first.
8510×
pub async fn of_account(pool: &Pool, account_id: i64, salt: &str) -> sqlx::Result<Vec<Session>> {
8610×
    let now = now_ms();
8710×
    let all: Vec<Session> = sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE account_id = $1 ORDER BY seen_ms DESC"))
8810×
        .bind(account_id)
8910×
        .fetch_all(pool)
9010×
        .await?;
9116×
    Ok(all.into_iter().filter(|s| s.live(salt, now)).collect())
9210×
}
93
94
/// End one session of an account's; whether there was one.
951×
pub async fn end(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<bool> {
961×
    let r = sqlx::query("DELETE FROM sessions WHERE id = $1 AND account_id = $2").bind(id).bind(account_id).execute(pool).await?;
971×
    Ok(r.rows_affected() > 0)
981×
}
99
1001×
pub async fn end_hash(pool: &Pool, hash: &str) -> sqlx::Result<()> {
1011×
    sqlx::query("DELETE FROM sessions WHERE hash = $1").bind(hash).execute(pool).await?;
1021×
    Ok(())
1031×
}
104
105
/// Every session of an account's but `keep`.
1060×
pub async fn end_others(pool: &Pool, account_id: i64, keep: i64) -> sqlx::Result<()> {
1070×
    sqlx::query("DELETE FROM sessions WHERE account_id = $1 AND id != $2").bind(account_id).bind(keep).execute(pool).await?;
1080×
    Ok(())
1090×
}
110
111
/// Forget sessions that can no longer be used; how many.
1120×
pub async fn sweep(pool: &Pool) -> sqlx::Result<u64> {
1130×
    let now = now_ms();
1140×
    let r = sqlx::query(
1150×
        "DELETE FROM sessions WHERE seen_ms < $1 OR created_ms < $2 \
1160×
         OR salt != (SELECT session_salt FROM accounts WHERE accounts.id = sessions.account_id)",
1170×
    )
1180×
    .bind(now - IDLE_MS)
1190×
    .bind(now - ABSOLUTE_MS)
1200×
    .execute(pool)
1210×
    .await?;
1220×
    Ok(r.rows_affected())
1230×
}
124
125
#[cfg(test)]
126
mod tests {
127
    use super::*;
128
129
    #[test]
1301×
    fn a_session_ends_on_a_new_salt_when_idle_or_when_old() {
1311×
        let t0 = 1_000_000_000_000;
1321×
        let s = Session {
1331×
            id: 1,
1341×
            account_id: 1,
1351×
            salt: "a".into(),
1361×
            created_ms: t0,
1371×
            seen_ms: t0,
1381×
            address: String::new(),
1391×
            agent: String::new(),
1401×
            second_factor_ms: t0,
1411×
        };
1421×
        assert!(s.live("a", t0 + 1000));
1431×
        assert!(!s.live("b", t0 + 1000), "a new salt: signed out everywhere");
1441×
        assert!(!s.live("a", t0 + IDLE_MS), "unused for 14 days");
1451×
        let busy = Session { seen_ms: t0 + ABSOLUTE_MS - 1000, ..s.clone() };
1461×
        assert!(busy.live("a", t0 + ABSOLUTE_MS - 1));
1471×
        assert!(!busy.live("a", t0 + ABSOLUTE_MS), "30 days, however busy");
1481×
    }
149
}