OXYGENHUB
# de505df5cf8d oxygenhub / crates / hub / src / auth / viewer.rs
2.8 KiB · Rust · last changed Milestone 0.2: accounts and access 1 month ago source blame history raw
1
//! Who is asking: the session cookie resolved to an account, as an axum
2
//! extractor every page handler takes.
3
4
use super::{csrf_token, mac_equal, session_value, SESSION_COOKIE};
5
use crate::db::{self, accounts::Account};
6
use crate::Shared;
7
use axum::extract::FromRequestParts;
8
use axum::http::request::Parts;
9
use axum::http::{header, HeaderMap};
10
use std::convert::Infallible;
11
12
#[derive(Clone, Debug)]
13
pub struct ViewerAccount {
14
    pub account: Account,
15
    /// The cookie value, which CSRF tokens are bound to.
16
    pub session: String,
17
    pub csrf: String,
18
}
19
20
/// The signed-in account, if any. Never fails: anonymous is a valid viewer.
21
#[derive(Clone, Debug, Default)]
22
pub struct Viewer(pub Option<ViewerAccount>);
23
24
impl Viewer {
25
    pub fn account(&self) -> Option<&Account> {
26
        self.0.as_ref().map(|v| &v.account)
27
    }
28
29
    pub fn signed_in(&self) -> bool {
30
        self.0.is_some()
31
    }
32
33
    pub fn is_admin(&self) -> bool {
34
        self.account().is_some_and(|a| a.is_admin)
35
    }
36
37
    pub fn csrf(&self) -> &str {
38
        self.0.as_ref().map(|v| v.csrf.as_str()).unwrap_or("")
39
    }
40
41
    /// Whether a submitted CSRF token belongs to this session.
42
    pub fn csrf_ok(&self, token: &str) -> bool {
43
        self.0.as_ref().is_some_and(|v| mac_equal(&v.csrf, token))
44
    }
45
}
46
47
/// The value of a named cookie in the request.
48
pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
49
    headers.get_all(header::COOKIE).iter().filter_map(|v| v.to_str().ok()).flat_map(|line| line.split(';')).find_map(|kv| {
50
        let (k, v) = kv.trim().split_once('=')?;
51
        (k == name).then(|| v.trim().to_string())
52
    })
53
}
54
55
/// Resolve a session cookie value to its account.
56
pub async fn resolve(app: &Shared, value: &str) -> Option<ViewerAccount> {
57
    let (username, mac) = value.rsplit_once('.')?;
58
    let account = db::accounts::account_by_name(&app.db, username).await.ok()??;
59
    if account.banned {
60
        return None;
61
    }
62
    let expected = session_value(&app.cookie_key, &account.username, &account.session_salt);
63
    let (_, expected_mac) = expected.rsplit_once('.')?;
64
    if !mac_equal(expected_mac, mac) {
65
        return None;
66
    }
67
    let csrf = csrf_token(&app.cookie_key, &expected);
68
    Some(ViewerAccount { account, session: expected, csrf })
69
}
70
71
impl FromRequestParts<Shared> for Viewer {
72
    type Rejection = Infallible;
73
74
    async fn from_request_parts(parts: &mut Parts, app: &Shared) -> Result<Viewer, Infallible> {
75
        let Some(value) = cookie(&parts.headers, SESSION_COOKIE) else { return Ok(Viewer(None)) };
76
        let viewer = resolve(app, &value).await;
77
        if let Some(v) = &viewer {
78
            let pool = app.db.clone();
79
            let id = v.account.id;
80
            tokio::spawn(async move {
81
                let _ = db::accounts::touch_seen(&pool, id).await;
82
            });
83
        }
84
        Ok(Viewer(viewer))
85
    }
86
}