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;