Skip to main content

lfsx_server/console/
session.rs

1use std::time::Duration;
2
3use axum::Json;
4use axum::extract::State;
5use axum::http::header::{AUTHORIZATION, COOKIE, SET_COOKIE};
6use axum::http::{HeaderMap, HeaderValue, StatusCode};
7use axum::response::{IntoResponse, Response};
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use hmac::{Hmac, KeyInit, Mac};
11use serde::{Deserialize, Serialize};
12use sha2::Sha256;
13
14use super::{Refused, tokens};
15use crate::config::Auth;
16use crate::error::Error;
17use crate::state::Shared;
18
19const KEY: &str = "session.key";
20const COOKIE_NAME: &str = "lfsx_session";
21const LIFETIME: Duration = Duration::from_secs(12 * 60 * 60);
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(tag = "via", content = "name", rename_all = "lowercase")]
25pub enum Viewer {
26    Token(String),
27    Forge(String),
28}
29
30#[derive(Debug, Serialize, Deserialize)]
31struct Claims {
32    viewer: Viewer,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    issued: Option<String>,
35    expires: i64,
36}
37
38#[derive(Debug, Deserialize)]
39pub struct SignIn {
40    token: String,
41}
42
43async fn key(state: &Shared) -> Result<&[u8], Error> {
44    state
45        .session_key
46        .get_or_try_init(|| kept_key(state))
47        .await
48        .map(Vec::as_slice)
49}
50
51async fn kept_key(state: &Shared) -> Result<Vec<u8>, Error> {
52    if let Some(key) = state.store.read_meta(KEY).await? {
53        return Ok(key);
54    }
55
56    let mut key = vec![0u8; 32];
57    getrandom::fill(&mut key).expect("the operating system has a random number generator");
58    state.store.write_meta(KEY, key).await?;
59
60    state
61        .store
62        .read_meta(KEY)
63        .await?
64        .ok_or(Error::Misconfigured(
65            "the session key could not be kept in the store",
66        ))
67}
68
69fn mac(key: &[u8]) -> Hmac<Sha256> {
70    Hmac::<Sha256>::new_from_slice(key).expect("HMAC takes a key of any length")
71}
72
73fn now() -> i64 {
74    time::OffsetDateTime::now_utc().unix_timestamp()
75}
76
77fn seal(key: &[u8], viewer: Viewer, issued: Option<String>) -> Result<String, Error> {
78    let claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&Claims {
79        viewer,
80        issued,
81        expires: now() + LIFETIME.as_secs() as i64,
82    })?);
83    let mut mac = mac(key);
84    mac.update(claims.as_bytes());
85
86    Ok(format!(
87        "{claims}.{}",
88        URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
89    ))
90}
91
92fn open(key: &[u8], sealed: &str) -> Option<Claims> {
93    let (claims, signature) = sealed.split_once('.')?;
94    let mut mac = mac(key);
95    mac.update(claims.as_bytes());
96    mac.verify_slice(&URL_SAFE_NO_PAD.decode(signature).ok()?)
97        .ok()?;
98
99    let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(claims).ok()?).ok()?;
100    (claims.expires > now()).then_some(claims)
101}
102
103fn presented(headers: &HeaderMap) -> Option<&str> {
104    headers
105        .get_all(COOKIE)
106        .iter()
107        .filter_map(|value| value.to_str().ok())
108        .flat_map(|value| value.split(';'))
109        .find_map(|pair| pair.trim().strip_prefix(COOKIE_NAME)?.strip_prefix('='))
110}
111
112pub(crate) async fn viewer(state: &Shared, headers: &HeaderMap) -> Result<Option<Viewer>, Error> {
113    let Some(sealed) = presented(headers) else {
114        return Ok(None);
115    };
116
117    let Some(claims) = open(key(state).await?, sealed) else {
118        return Ok(None);
119    };
120
121    match (&claims.viewer, &claims.issued) {
122        (Viewer::Forge(_), _) => Ok(Some(claims.viewer)),
123        (Viewer::Token(_), Some(hash)) if tokens::still_issued(&state.store, hash).await? => {
124            Ok(Some(claims.viewer))
125        }
126        (Viewer::Token(_), _) => Ok(None),
127    }
128}
129
130fn cookie(state: &Shared, value: &str, max_age: u64) -> HeaderValue {
131    let secure = state
132        .config
133        .public_url
134        .as_deref()
135        .is_some_and(|url| url.starts_with("https://"));
136
137    HeaderValue::from_str(&format!(
138        "{COOKIE_NAME}={value}; Path=/-/; Max-Age={max_age}; HttpOnly; SameSite=Strict{}",
139        if secure { "; Secure" } else { "" }
140    ))
141    .expect("a cookie built from base64 and fixed text is a valid header")
142}
143
144async fn admitted(state: &Shared, token: &str) -> Result<(Viewer, Option<String>), Error> {
145    if let Some(issued) = tokens::holder(&state.store, token).await? {
146        let hash = issued.hash().to_owned();
147        return Ok((Viewer::Token(issued.name), Some(hash)));
148    }
149
150    let (Auth::Forge { .. }, Some(admins)) = (
151        &state.config.auth,
152        state
153            .config
154            .dashboard
155            .as_ref()
156            .and_then(|dashboard| dashboard.admins.as_ref()),
157    ) else {
158        return Err(Error::Unauthenticated);
159    };
160
161    let mut headers = HeaderMap::new();
162    headers.insert(
163        AUTHORIZATION,
164        HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| Error::Unauthenticated)?,
165    );
166    state
167        .authorizer
168        .forge_permission(&headers, admins)
169        .await?
170        .require_admin()?;
171
172    Ok((
173        Viewer::Forge(state.authorizer.actor(&headers).await?.0),
174        None,
175    ))
176}
177
178pub(crate) async fn sign_in(
179    State(state): State<Shared>,
180    Json(SignIn { token }): Json<SignIn>,
181) -> Result<Response, Refused> {
182    if state.config.dashboard.is_none() {
183        return Err(Error::NotServed.into());
184    }
185
186    let (viewer, issued) = admitted(&state, token.trim()).await?;
187    let sealed = seal(key(&state).await?, viewer.clone(), issued)?;
188    tracing::info!(?viewer, "signed in to the dashboard");
189
190    Ok((
191        [(SET_COOKIE, cookie(&state, &sealed, LIFETIME.as_secs()))],
192        Json(viewer),
193    )
194        .into_response())
195}
196
197pub(crate) async fn current(
198    State(state): State<Shared>,
199    headers: HeaderMap,
200) -> Result<Json<Viewer>, Refused> {
201    if state.config.dashboard.is_none() {
202        return Err(Error::NotServed.into());
203    }
204
205    Ok(Json(
206        viewer(&state, &headers)
207            .await?
208            .ok_or(Error::Unauthenticated)?,
209    ))
210}
211
212pub(crate) async fn sign_out(State(state): State<Shared>) -> Response {
213    (
214        StatusCode::NO_CONTENT,
215        [(SET_COOKIE, cookie(&state, "", 0))],
216    )
217        .into_response()
218}
219
220#[cfg(test)]
221mod tests;