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 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 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
248pub(super) const PROXY_RESUME: &str = "/ui/resume";
253
254pub(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
284async 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
301pub(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
327pub(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
351pub(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
372pub(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
381fn 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 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}