OXYGENHUB
◆ v0.9.24 oxygenhub / crates / hub / src / db / sessions.rs
5.5 KiB · Rust · last changed A phone as the second factor: Keystore keys approve sign-ins by the number the browser shows, and the sudo step asks a stale session again before sensitive changes (closes #356) 3 days ago source blame history raw
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`.
29
    pub fn live(&self, salt: &str, now: i64) -> bool {
30
        self.salt == salt && now - self.seen_ms < IDLE_MS && now - self.created_ms < ABSOLUTE_MS
31
    }
32
}
33
34
const COLUMNS: &str = "id, account_id, salt, created_ms, seen_ms, address, agent, second_factor_ms";
35
36
pub async fn create(pool: &Pool, account_id: i64, hash: &str, salt: &str) -> sqlx::Result<i64> {
37
    let now = now_ms();
38
    // Signing in is as fresh as proof gets (oxygenhub#356).
39
    sqlx::query_scalar(
40
        "INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms, second_factor_ms) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
41
    )
42
    .bind(account_id)
43
    .bind(hash)
44
    .bind(salt)
45
    .bind(now)
46
    .bind(now)
47
    .bind(now)
48
    .fetch_one(pool)
49
    .await
50
}
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.
54
pub async fn mark_second_factor(pool: &Pool, session_id: i64, now: i64) -> sqlx::Result<()> {
55
    sqlx::query("UPDATE sessions SET second_factor_ms = $1 WHERE id = $2").bind(now).bind(session_id).execute(pool).await?;
56
    Ok(())
57
}
58
59
pub async fn by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<Session>> {
60
    sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE id = $1")).bind(id).fetch_optional(pool).await
61
}
62
63
pub async fn by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Session>> {
64
    sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE hash = $1")).bind(hash).fetch_optional(pool).await
65
}
66
67
/// Note a use: the time (at most once a minute) and, the first time, the
68
/// address and browser it came from.
69
pub async fn touch(pool: &Pool, s: &Session, address: &str, agent: &str, now: i64) -> sqlx::Result<()> {
70
    if s.agent.is_empty() && s.address.is_empty() && (!agent.is_empty() || !address.is_empty()) {
71
        sqlx::query("UPDATE sessions SET seen_ms = $1, address = $2, agent = $3 WHERE id = $4")
72
            .bind(now)
73
            .bind(address)
74
            .bind(agent)
75
            .bind(s.id)
76
            .execute(pool)
77
            .await?;
78
    } else if now - s.seen_ms >= TOUCH_EVERY_MS {
79
        sqlx::query("UPDATE sessions SET seen_ms = $1 WHERE id = $2").bind(now).bind(s.id).execute(pool).await?;
80
    }
81
    Ok(())
82
}
83
84
/// An account's live sessions, most recently used first.
85
pub async fn of_account(pool: &Pool, account_id: i64, salt: &str) -> sqlx::Result<Vec<Session>> {
86
    let now = now_ms();
87
    let all: Vec<Session> = sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE account_id = $1 ORDER BY seen_ms DESC"))
88
        .bind(account_id)
89
        .fetch_all(pool)
90
        .await?;
91
    Ok(all.into_iter().filter(|s| s.live(salt, now)).collect())
92
}
93
94
/// End one session of an account's; whether there was one.
95
pub async fn end(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<bool> {
96
    let r = sqlx::query("DELETE FROM sessions WHERE id = $1 AND account_id = $2").bind(id).bind(account_id).execute(pool).await?;
97
    Ok(r.rows_affected() > 0)
98
}
99
100
pub async fn end_hash(pool: &Pool, hash: &str) -> sqlx::Result<()> {
101
    sqlx::query("DELETE FROM sessions WHERE hash = $1").bind(hash).execute(pool).await?;
102
    Ok(())
103
}
104
105
/// Every session of an account's but `keep`.
106
pub async fn end_others(pool: &Pool, account_id: i64, keep: i64) -> sqlx::Result<()> {
107
    sqlx::query("DELETE FROM sessions WHERE account_id = $1 AND id != $2").bind(account_id).bind(keep).execute(pool).await?;
108
    Ok(())
109
}
110
111
/// Forget sessions that can no longer be used; how many.
112
pub async fn sweep(pool: &Pool) -> sqlx::Result<u64> {
113
    let now = now_ms();
114
    let r = sqlx::query(
115
        "DELETE FROM sessions WHERE seen_ms < $1 OR created_ms < $2 \
116
         OR salt != (SELECT session_salt FROM accounts WHERE accounts.id = sessions.account_id)",
117
    )
118
    .bind(now - IDLE_MS)
119
    .bind(now - ABSOLUTE_MS)
120
    .execute(pool)
121
    .await?;
122
    Ok(r.rows_affected())
123
}
124
125
#[cfg(test)]
126
mod tests {
127
    use super::*;
128
129
    #[test]
130
    fn a_session_ends_on_a_new_salt_when_idle_or_when_old() {
131
        let t0 = 1_000_000_000_000;
132
        let s = Session {
133
            id: 1,
134
            account_id: 1,
135
            salt: "a".into(),
136
            created_ms: t0,
137
            seen_ms: t0,
138
            address: String::new(),
139
            agent: String::new(),
140
            second_factor_ms: t0,
141
        };
142
        assert!(s.live("a", t0 + 1000));
143
        assert!(!s.live("b", t0 + 1000), "a new salt: signed out everywhere");
144
        assert!(!s.live("a", t0 + IDLE_MS), "unused for 14 days");
145
        let busy = Session { seen_ms: t0 + ABSOLUTE_MS - 1000, ..s.clone() };
146
        assert!(busy.live("a", t0 + ABSOLUTE_MS - 1));
147
        assert!(!busy.live("a", t0 + ABSOLUTE_MS), "30 days, however busy");
148
    }
149
}