Skip to main content

koan_server/ui/
session.rs

1//! Signing in and out of the web UI, on koan's own login and refresh tokens.
2
3use std::net::{IpAddr, SocketAddr};
4use std::sync::Arc;
5
6use axum::Form;
7use axum::extract::{ConnectInfo, Query, State};
8use axum::http::{Extensions, HeaderMap, HeaderName, StatusCode, header};
9use axum::response::{IntoResponse, Response};
10use ipnet::IpNet;
11use serde::Deserialize;
12
13use koan_core::db::queries::auth as auth_queries;
14
15use super::{UiState, encode, html, pages, see_other};
16use crate::auth::routes::{ClientIp, authenticate, proxied_access, refresh_token_from, rotate};
17
18/// An authenticating reverse proxy in front of the web UI, whose header names
19/// the account signed in to it.
20#[derive(Clone)]
21pub struct ProxyAuth {
22    header: HeaderName,
23    from: Arc<Vec<IpNet>>,
24}
25
26impl ProxyAuth {
27    /// From `graphql.proxy_auth_header` and `graphql.proxy_auth_from`. Off
28    /// when neither is set; on when both are. Anything between is refused
29    /// rather than read as either: a header with no proxy to believe it from,
30    /// a proxy with no header, a header name or entry that does not parse, or
31    /// a range covering every address, which would let any client name any
32    /// account.
33    pub fn from_config(header: &str, from: &[String]) -> Result<Option<Self>, String> {
34        let header = header.trim();
35        match (header.is_empty(), from.is_empty()) {
36            (true, true) => return Ok(None),
37            (false, true) => {
38                return Err(
39                    "graphql.proxy_auth_header is set but graphql.proxy_auth_from is empty; \
40                     name the addresses the authenticating proxy connects from"
41                        .into(),
42                );
43            }
44            (true, false) => {
45                return Err(
46                    "graphql.proxy_auth_from is set but graphql.proxy_auth_header is empty; \
47                     name the header the authenticating proxy sets"
48                        .into(),
49                );
50            }
51            (false, false) => {}
52        }
53        let header = HeaderName::from_bytes(header.as_bytes())
54            .map_err(|_| format!("graphql.proxy_auth_header {header:?} is not a header name"))?;
55        let from = from
56            .iter()
57            .map(|entry| {
58                let entry = entry.trim();
59                let net = entry
60                    .parse::<IpNet>()
61                    .or_else(|_| entry.parse::<IpAddr>().map(IpNet::from))
62                    .map_err(|_| {
63                        format!("graphql.proxy_auth_from: {entry:?} is not an address or range")
64                    })?;
65                let net = canonical(net);
66                if net.prefix_len() == 0 {
67                    return Err(format!(
68                        "graphql.proxy_auth_from: {entry:?} covers every address, so any client \
69                         could name any account; name the proxy's own address"
70                    ));
71                }
72                Ok(net)
73            })
74            .collect::<Result<Vec<_>, String>>()?;
75        let ranges = from.iter().map(ToString::to_string).collect::<Vec<_>>();
76        log::info!(
77            "web UI: proxy sign-in on, believing {header} from {}",
78            ranges.join(", ")
79        );
80        Ok(Some(Self {
81            header,
82            from: Arc::new(from),
83        }))
84    }
85
86    /// What the proxy says about who is signed in. Only a connection from
87    /// the proxy itself is heard; a header from anywhere else is absent. From
88    /// the proxy, one value naming one account is believed, and anything else
89    /// it sent is unusable: a proxy that appended to or merged with a client's
90    /// own header would otherwise let the client name the account.
91    pub(super) fn user<'a>(&self, headers: &'a HeaderMap, ext: &Extensions) -> Vouch<'a> {
92        let Some(ConnectInfo(peer)) = ext.get::<ConnectInfo<SocketAddr>>() else {
93            return Vouch::Absent;
94        };
95        let peer = peer.ip().to_canonical();
96        if !self.from.iter().any(|net| net.contains(&peer)) {
97            return Vouch::Absent;
98        }
99        let mut values = headers.get_all(&self.header).iter();
100        let Some(first) = values.next() else {
101            return Vouch::Absent;
102        };
103        match std::str::from_utf8(first.as_bytes()).map(str::trim) {
104            Ok(name) if values.next().is_none() && !name.is_empty() && !name.contains(',') => {
105                Vouch::Named(name)
106            }
107            _ => Vouch::Unusable,
108        }
109    }
110}
111
112/// An IPv4-mapped IPv6 range as the IPv4 range it maps, since a peer's
113/// address is compared in that form.
114fn canonical(net: IpNet) -> IpNet {
115    match net {
116        IpNet::V6(v6) if v6.prefix_len() >= 96 => match v6.addr().to_ipv4_mapped() {
117            Some(v4) => ipnet::Ipv4Net::new(v4, v6.prefix_len() - 96)
118                .map_or(net, IpNet::V4)
119                .trunc(),
120            None => net,
121        },
122        _ => net,
123    }
124}
125
126/// What a request's proxy says about who is signed in.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub(super) enum Vouch<'a> {
129    /// Proxy sign-in is off, the request did not come from the proxy, or the
130    /// proxy sent no header.
131    Absent,
132    /// The proxy sent the header, but not as one account's name. Never read
133    /// as absent: the browser's own session would then stand in for whoever
134    /// the proxy signed in.
135    Unusable,
136    Named(&'a str),
137}
138
139/// What a request's proxy says about who is signed in.
140pub(super) fn vouched<'a>(s: &UiState, headers: &'a HeaderMap, ext: &Extensions) -> Vouch<'a> {
141    s.proxy_auth
142        .as_ref()
143        .map_or(Vouch::Absent, |proxy| proxy.user(headers, ext))
144}
145
146#[derive(Deserialize, Default)]
147#[serde(default)]
148pub(super) struct NextParam {
149    next: String,
150}
151
152#[derive(Deserialize)]
153pub(super) struct LoginForm {
154    username: String,
155    password: String,
156    #[serde(default)]
157    next: String,
158}
159
160/// Where to go after signing in: a path on this server, or the albums. Anything
161/// else (`//evil.example`, `https://…`) would make the form an open redirect,
162/// and the auth routes themselves would loop.
163fn local_path(next: &str) -> &str {
164    let local = next.starts_with('/')
165        && !next.starts_with("//")
166        && !next.starts_with("/auth/")
167        && !next.starts_with("/login")
168        && next.bytes().all(|b| b.is_ascii_graphic() && b != b'\\');
169    if local { next } else { "/" }
170}
171
172/// A form cannot carry Datastar's header, so these POSTs prove they came from
173/// this origin the way a browser does: `Sec-Fetch-Site`, or an `Origin` naming
174/// the host the request was sent to.
175pub(super) fn same_origin(headers: &HeaderMap) -> bool {
176    let get = |name| headers.get(name).and_then(|v| v.to_str().ok());
177    if get(header::HeaderName::from_static("sec-fetch-site")) == Some("same-origin") {
178        return true;
179    }
180    match (get(header::ORIGIN), get(header::HOST)) {
181        (Some(origin), Some(host)) => origin
182            .split_once("://")
183            .is_some_and(|(_, authority)| authority.eq_ignore_ascii_case(host)),
184        _ => false,
185    }
186}
187
188fn cross_site() -> Response {
189    (StatusCode::FORBIDDEN, "cross-site request refused").into_response()
190}
191
192pub(super) async fn login_form(
193    State(s): State<UiState>,
194    Query(q): Query<NextParam>,
195    headers: HeaderMap,
196    ext: Extensions,
197) -> Response {
198    let next = local_path(&q.next);
199    if !s.auth_enabled {
200        return see_other(next);
201    }
202    if vouched(&s, &headers, &ext) != Vouch::Absent {
203        return see_other(&format!("{PROXY_RESUME}?next={}", encode(next)));
204    }
205    html(StatusCode::OK, pages::login(next, None))
206}
207
208pub(super) async fn login(
209    State(s): State<UiState>,
210    ClientIp(from): ClientIp,
211    headers: HeaderMap,
212    ext: Extensions,
213    Form(f): Form<LoginForm>,
214) -> Response {
215    if !same_origin(&headers) {
216        return cross_site();
217    }
218    let next = local_path(&f.next);
219    // Through the proxy, the proxy says who is signed in, not a password.
220    if vouched(&s, &headers, &ext) != Vouch::Absent {
221        return see_other(&format!("{PROXY_RESUME}?next={}", encode(next)));
222    }
223    match authenticate(&s.auth, &f.username, &f.password, from).await {
224        Ok((_, access, refresh)) => (
225            StatusCode::SEE_OTHER,
226            [(header::LOCATION, next.to_owned())],
227            s.auth.session_cookies(&access, &refresh),
228        )
229            .into_response(),
230        Err(resp) => {
231            let status = resp.status();
232            let message = match status {
233                StatusCode::UNAUTHORIZED => "Wrong username or password.",
234                StatusCode::TOO_MANY_REQUESTS => {
235                    "Too many failed sign-ins for this account. Try again in a minute."
236                }
237                _ => "Signing in failed. Try again.",
238            };
239            html(status, pages::login(next, Some(message)))
240        }
241    }
242}
243
244/// Where a page load goes for a session when an authenticating proxy is
245/// trusted. A UI path, so the proxy covers it: the paths operators exempt
246/// from the proxy (`/auth/login`, `/oauth/token`…) never read the header, and
247/// a client's own header reaching them through an exemption signs in no one.
248pub(super) const PROXY_RESUME: &str = "/ui/resume";
249
250/// Sign in the account the proxy names, over whatever session the browser
251/// held. Without the header, on to the refresh cookie as usual.
252pub(super) async fn proxy_resume(
253    State(s): State<UiState>,
254    Query(q): Query<NextParam>,
255    headers: HeaderMap,
256    ext: Extensions,
257) -> Response {
258    let next = local_path(&q.next).to_owned();
259    if !s.auth_enabled {
260        return see_other(&next);
261    }
262    let vouch = vouched(&s, &headers, &ext);
263    if vouch == Vouch::Absent {
264        return see_other(&format!("/auth/resume?next={}", encode(&next)));
265    }
266    match proxied(&s, vouch).await {
267        Ok(access) => (
268            StatusCode::SEE_OTHER,
269            [
270                (header::LOCATION, next),
271                (header::CACHE_CONTROL, "no-store".to_owned()),
272            ],
273            s.auth.proxied_cookies(&access),
274        )
275            .into_response(),
276        Err(refused) => *refused,
277    }
278}
279
280/// An access token for the account the proxy names, or a refusal that signs
281/// the browser out. Never a refresh token: the session is derived again from
282/// the header on the next page load, so it cannot outlast the proxy's say-so,
283/// and a switch of account at the proxy needs nothing revoked.
284async fn proxied(s: &UiState, vouch: Vouch<'_>) -> Result<String, Box<Response>> {
285    let Vouch::Named(name) = vouch else {
286        return Err(Box::new(unusable_header(s)));
287    };
288    proxied_access(&s.auth, name).await.ok_or_else(|| {
289        log::info!("web UI: the sign-in proxy named {name:?}, who has no account");
290        Box::new(refused_by_proxy(
291            s,
292            "Your sign-in proxy names an account this server does not have. Ask an admin to create it.",
293        ))
294    })
295}
296
297/// Spend the refresh cookie for a new session. A page load lands here when its
298/// access cookie has lapsed.
299pub(super) async fn resume(
300    State(s): State<UiState>,
301    ClientIp(from): ClientIp,
302    Query(q): Query<NextParam>,
303    headers: HeaderMap,
304) -> Response {
305    let next = local_path(&q.next).to_owned();
306    if !s.auth_enabled {
307        return see_other(&next);
308    }
309    match rotate_from(&s, &headers, from).await {
310        Some((access, refresh)) => (
311            StatusCode::SEE_OTHER,
312            [
313                (header::LOCATION, next),
314                (header::CACHE_CONTROL, "no-store".to_owned()),
315            ],
316            s.auth.session_cookies(&access, &refresh),
317        )
318            .into_response(),
319        None => see_other(&format!("/login?next={}", encode(&next))),
320    }
321}
322
323/// Keep an open page's session alive past the access token's lifetime. Unlike
324/// `/auth/refresh` it answers with cookies only, so the tokens stay out of
325/// page script.
326pub(super) async fn renew(
327    State(s): State<UiState>,
328    ClientIp(from): ClientIp,
329    headers: HeaderMap,
330) -> Response {
331    if !same_origin(&headers) {
332        return cross_site();
333    }
334    if !s.auth_enabled {
335        return StatusCode::NO_CONTENT.into_response();
336    }
337    match rotate_from(&s, &headers, from).await {
338        Some((access, refresh)) => (
339            StatusCode::NO_CONTENT,
340            s.auth.session_cookies(&access, &refresh),
341        )
342            .into_response(),
343        None => StatusCode::UNAUTHORIZED.into_response(),
344    }
345}
346
347/// Keep an open page's session alive behind an authenticating proxy, which
348/// issues no refresh cookie: a fresh access cookie for the account the header
349/// names. The page asks here when `/auth/renew` cannot renew. A UI path, like
350/// `PROXY_RESUME`, so nothing under `/auth` reads the header.
351pub(super) async fn proxy_renew(
352    State(s): State<UiState>,
353    headers: HeaderMap,
354    ext: Extensions,
355) -> Response {
356    if !same_origin(&headers) {
357        return cross_site();
358    }
359    match vouched(&s, &headers, &ext) {
360        Vouch::Absent => StatusCode::UNAUTHORIZED.into_response(),
361        vouch => match proxied(&s, vouch).await {
362            Ok(access) => (StatusCode::NO_CONTENT, s.auth.proxied_cookies(&access)).into_response(),
363            Err(refused) => *refused,
364        },
365    }
366}
367
368/// The proxy sent its header but named no one account in it.
369pub(super) fn unusable_header(s: &UiState) -> Response {
370    log::warn!("web UI: the sign-in proxy sent a header that names no one account");
371    refused_by_proxy(
372        s,
373        "Your sign-in proxy did not name one account. Ask an admin to check its configuration.",
374    )
375}
376
377/// The proxy signed in no one this server has an account for: a name it does
378/// not know (accounts are made by an admin; a proxy cannot create one), or a
379/// header that names no one. The browser's own session goes too.
380fn refused_by_proxy(s: &UiState, message: &'static str) -> Response {
381    (
382        StatusCode::FORBIDDEN,
383        [(header::CACHE_CONTROL, "no-store")],
384        s.auth.cleared_cookies(),
385        message,
386    )
387        .into_response()
388}
389
390async fn rotate_from(s: &UiState, headers: &HeaderMap, from: IpAddr) -> Option<(String, String)> {
391    let supplied = refresh_token_from(None, headers)?;
392    let auth = s.auth.clone();
393    tokio::task::spawn_blocking(move || rotate(&auth, &supplied, Some(from)).ok())
394        .await
395        .ok()
396        .flatten()
397}
398
399pub(super) async fn signout(
400    State(s): State<UiState>,
401    headers: HeaderMap,
402    Form(q): Form<NextParam>,
403) -> Response {
404    if !same_origin(&headers) {
405        return cross_site();
406    }
407    if let Some(token) = refresh_token_from(None, &headers) {
408        let pool = s.pool.clone();
409        let _ = tokio::task::spawn_blocking(move || {
410            let db = super::open(&pool)?;
411            auth_queries::revoke_refresh_token(&db.conn, &token).ok()
412        })
413        .await;
414    }
415    // Back to sign in, and then to where the user was: the consent page signs
416    // out to let another account approve.
417    let to = match local_path(&q.next) {
418        "/" => "/login".to_owned(),
419        next => format!("/login?next={}", encode(next)),
420    };
421    (
422        StatusCode::SEE_OTHER,
423        [(header::LOCATION, to)],
424        s.auth.cleared_cookies(),
425    )
426        .into_response()
427}