1use 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#[derive(Clone)]
21pub struct ProxyAuth {
22 header: HeaderName,
23 from: Arc<Vec<IpNet>>,
24}
25
26impl ProxyAuth {
27 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 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
112fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub(super) enum Vouch<'a> {
129 Absent,
132 Unusable,
136 Named(&'a str),
137}
138
139pub(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
160fn 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
172pub(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 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
244pub(super) const PROXY_RESUME: &str = "/ui/resume";
249
250pub(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
280async 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
297pub(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
323pub(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
347pub(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
368pub(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
377fn 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 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}