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    // Nobody to sign in as yet: make the admin first.
206    if super::setup::open_for_setup(&s).await {
207        return see_other("/setup");
208    }
209    html(StatusCode::OK, pages::login(next, None))
210}
211
212pub(super) async fn login(
213    State(s): State<UiState>,
214    ClientIp(from): ClientIp,
215    headers: HeaderMap,
216    ext: Extensions,
217    Form(f): Form<LoginForm>,
218) -> Response {
219    if !same_origin(&headers) {
220        return cross_site();
221    }
222    let next = local_path(&f.next);
223    // Through the proxy, the proxy says who is signed in, not a password.
224    if vouched(&s, &headers, &ext) != Vouch::Absent {
225        return see_other(&format!("{PROXY_RESUME}?next={}", encode(next)));
226    }
227    match authenticate(&s.auth, &f.username, &f.password, from).await {
228        Ok((_, access, refresh)) => (
229            StatusCode::SEE_OTHER,
230            [(header::LOCATION, next.to_owned())],
231            s.auth.session_cookies(&access, &refresh),
232        )
233            .into_response(),
234        Err(resp) => {
235            let status = resp.status();
236            let message = match status {
237                StatusCode::UNAUTHORIZED => "Wrong username or password.",
238                StatusCode::TOO_MANY_REQUESTS => {
239                    "Too many failed sign-ins for this account. Try again in a minute."
240                }
241                _ => "Signing in failed. Try again.",
242            };
243            html(status, pages::login(next, Some(message)))
244        }
245    }
246}
247
248/// Where a page load goes for a session when an authenticating proxy is
249/// trusted. A UI path, so the proxy covers it: the paths operators exempt
250/// from the proxy (`/auth/login`, `/oauth/token`…) never read the header, and
251/// a client's own header reaching them through an exemption signs in no one.
252pub(super) const PROXY_RESUME: &str = "/ui/resume";
253
254/// Sign in the account the proxy names, over whatever session the browser
255/// held. Without the header, on to the refresh cookie as usual.
256pub(super) async fn proxy_resume(
257    State(s): State<UiState>,
258    Query(q): Query<NextParam>,
259    headers: HeaderMap,
260    ext: Extensions,
261) -> Response {
262    let next = local_path(&q.next).to_owned();
263    if !s.auth_enabled {
264        return see_other(&next);
265    }
266    let vouch = vouched(&s, &headers, &ext);
267    if vouch == Vouch::Absent {
268        return see_other(&format!("/auth/resume?next={}", encode(&next)));
269    }
270    match proxied(&s, vouch).await {
271        Ok(access) => (
272            StatusCode::SEE_OTHER,
273            [
274                (header::LOCATION, next),
275                (header::CACHE_CONTROL, "no-store".to_owned()),
276            ],
277            s.auth.proxied_cookies(&access),
278        )
279            .into_response(),
280        Err(refused) => *refused,
281    }
282}
283
284/// An access token for the account the proxy names, or a refusal that signs
285/// the browser out. Never a refresh token: the session is derived again from
286/// the header on the next page load, so it cannot outlast the proxy's say-so,
287/// and a switch of account at the proxy needs nothing revoked.
288async fn proxied(s: &UiState, vouch: Vouch<'_>) -> Result<String, Box<Response>> {
289    let Vouch::Named(name) = vouch else {
290        return Err(Box::new(unusable_header(s)));
291    };
292    proxied_access(&s.auth, name).await.ok_or_else(|| {
293        log::info!("web UI: the sign-in proxy named {name:?}, who has no account");
294        Box::new(refused_by_proxy(
295            s,
296            "Your sign-in proxy names an account this server does not have. Ask an admin to create it.",
297        ))
298    })
299}
300
301/// Spend the refresh cookie for a new session. A page load lands here when its
302/// access cookie has lapsed.
303pub(super) async fn resume(
304    State(s): State<UiState>,
305    ClientIp(from): ClientIp,
306    Query(q): Query<NextParam>,
307    headers: HeaderMap,
308) -> Response {
309    let next = local_path(&q.next).to_owned();
310    if !s.auth_enabled {
311        return see_other(&next);
312    }
313    match rotate_from(&s, &headers, from).await {
314        Some((access, refresh)) => (
315            StatusCode::SEE_OTHER,
316            [
317                (header::LOCATION, next),
318                (header::CACHE_CONTROL, "no-store".to_owned()),
319            ],
320            s.auth.session_cookies(&access, &refresh),
321        )
322            .into_response(),
323        None => see_other(&format!("/login?next={}", encode(&next))),
324    }
325}
326
327/// Keep an open page's session alive past the access token's lifetime. Unlike
328/// `/auth/refresh` it answers with cookies only, so the tokens stay out of
329/// page script.
330pub(super) async fn renew(
331    State(s): State<UiState>,
332    ClientIp(from): ClientIp,
333    headers: HeaderMap,
334) -> Response {
335    if !same_origin(&headers) {
336        return cross_site();
337    }
338    if !s.auth_enabled {
339        return StatusCode::NO_CONTENT.into_response();
340    }
341    match rotate_from(&s, &headers, from).await {
342        Some((access, refresh)) => (
343            StatusCode::NO_CONTENT,
344            s.auth.session_cookies(&access, &refresh),
345        )
346            .into_response(),
347        None => StatusCode::UNAUTHORIZED.into_response(),
348    }
349}
350
351/// Keep an open page's session alive behind an authenticating proxy, which
352/// issues no refresh cookie: a fresh access cookie for the account the header
353/// names. The page asks here when `/auth/renew` cannot renew. A UI path, like
354/// `PROXY_RESUME`, so nothing under `/auth` reads the header.
355pub(super) async fn proxy_renew(
356    State(s): State<UiState>,
357    headers: HeaderMap,
358    ext: Extensions,
359) -> Response {
360    if !same_origin(&headers) {
361        return cross_site();
362    }
363    match vouched(&s, &headers, &ext) {
364        Vouch::Absent => StatusCode::UNAUTHORIZED.into_response(),
365        vouch => match proxied(&s, vouch).await {
366            Ok(access) => (StatusCode::NO_CONTENT, s.auth.proxied_cookies(&access)).into_response(),
367            Err(refused) => *refused,
368        },
369    }
370}
371
372/// The proxy sent its header but named no one account in it.
373pub(super) fn unusable_header(s: &UiState) -> Response {
374    log::warn!("web UI: the sign-in proxy sent a header that names no one account");
375    refused_by_proxy(
376        s,
377        "Your sign-in proxy did not name one account. Ask an admin to check its configuration.",
378    )
379}
380
381/// The proxy signed in no one this server has an account for: a name it does
382/// not know (accounts are made by an admin; a proxy cannot create one), or a
383/// header that names no one. The browser's own session goes too.
384fn refused_by_proxy(s: &UiState, message: &'static str) -> Response {
385    (
386        StatusCode::FORBIDDEN,
387        [(header::CACHE_CONTROL, "no-store")],
388        s.auth.cleared_cookies(),
389        message,
390    )
391        .into_response()
392}
393
394async fn rotate_from(s: &UiState, headers: &HeaderMap, from: IpAddr) -> Option<(String, String)> {
395    let supplied = refresh_token_from(None, headers)?;
396    let auth = s.auth.clone();
397    tokio::task::spawn_blocking(move || rotate(&auth, &supplied, Some(from)).ok())
398        .await
399        .ok()
400        .flatten()
401}
402
403pub(super) async fn signout(
404    State(s): State<UiState>,
405    headers: HeaderMap,
406    Form(q): Form<NextParam>,
407) -> Response {
408    if !same_origin(&headers) {
409        return cross_site();
410    }
411    if let Some(token) = refresh_token_from(None, &headers) {
412        let pool = s.pool.clone();
413        let _ = tokio::task::spawn_blocking(move || {
414            let db = super::open(&pool)?;
415            auth_queries::revoke_refresh_token(&db.conn, &token).ok()
416        })
417        .await;
418    }
419    // Back to sign in, and then to where the user was: the consent page signs
420    // out to let another account approve.
421    let to = match local_path(&q.next) {
422        "/" => "/login".to_owned(),
423        next => format!("/login?next={}", encode(next)),
424    };
425    (
426        StatusCode::SEE_OTHER,
427        [(header::LOCATION, to)],
428        s.auth.cleared_cookies(),
429    )
430        .into_response()
431}