v0.34.14
This commit is contained in:
1 parent
5c2e50ec06
commit
62b7574553
19 files changed
+563
-139
No files matched your search
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.34.13",
|
||||
"version": "0.34.14",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
},
|
||||
"servers": [
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>502 - Tinyauth connection error - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>502 - Tinyauth connection error</h1>
|
||||
<p>Unable to connect to the Tinyauth authentication service.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>504 - Tinyauth timeout - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>504 - Tinyauth timeout</h1>
|
||||
<p>The Tinyauth authentication service did not respond in time.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>502 - Tinyauth verification error - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>502 - Tinyauth verification error</h1>
|
||||
<p>The Tinyauth authentication service returned an invalid response.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+16
-1
@@ -86,12 +86,27 @@ mod tests {
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"500 - Internal server error",
|
||||
),
|
||||
(StatusCode::BAD_GATEWAY, "502 - Bad gateway"),
|
||||
] {
|
||||
let page = builtin_gateway_error(code).unwrap();
|
||||
assert!(page.contains(label));
|
||||
assert!(page.contains("background: #131615"));
|
||||
}
|
||||
let gateway_502 = builtin_gateway_error(StatusCode::BAD_GATEWAY).unwrap();
|
||||
assert!(gateway_502.contains("<h1>502 - Bad gateway</h1>"));
|
||||
assert!(gateway_502.contains("The upstream service returned an invalid response."));
|
||||
assert!(gateway_502.contains("RustProxy"));
|
||||
assert!(gateway_502.contains("#131615"));
|
||||
assert!(!gateway_502.contains("Tinyauth"));
|
||||
// The standard HTTP error registry and its stock pages predate the
|
||||
// Tinyauth-specific diagnostic pages and must remain unchanged.
|
||||
assert_eq!(
|
||||
ERROR_CODES,
|
||||
[400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]
|
||||
);
|
||||
assert_eq!(
|
||||
BUILTIN_ERROR_CODES,
|
||||
[400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504]
|
||||
);
|
||||
assert!(builtin_gateway_error(StatusCode::OK).is_none());
|
||||
assert!(builtin_application_page(StatusCode::OK).contains("200 - OK"));
|
||||
assert!(builtin_application_page(StatusCode::CREATED).contains("background: #131615"));
|
||||
|
||||
+101
-23
@@ -63,6 +63,51 @@ pub struct Compiled {
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct AuthenticationFailure;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum CheckError {
|
||||
Connection(anyhow::Error),
|
||||
InvalidResponse(anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum GatewayFailure {
|
||||
Connection,
|
||||
Timeout,
|
||||
Verification,
|
||||
}
|
||||
|
||||
fn gateway_failure(kind: GatewayFailure) -> Response {
|
||||
let (status, html) = match kind {
|
||||
GatewayFailure::Connection => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
include_str!("../admin/web/error_tinyauth_connection.html"),
|
||||
),
|
||||
GatewayFailure::Timeout => (
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
include_str!("../admin/web/error_tinyauth_timeout.html"),
|
||||
),
|
||||
GatewayFailure::Verification => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
include_str!("../admin/web/error_tinyauth_verification.html"),
|
||||
),
|
||||
};
|
||||
let mut response = (status, html).into_response();
|
||||
response.headers_mut().insert(
|
||||
"content-type",
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
response.extensions_mut().insert(AuthenticationFailure);
|
||||
response
|
||||
}
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Identity {
|
||||
headers: HeaderMap,
|
||||
@@ -220,6 +265,31 @@ fn failure(status: StatusCode) -> Response {
|
||||
response.extensions_mut().insert(AuthenticationFailure);
|
||||
response
|
||||
}
|
||||
|
||||
fn apply_nginx_auth_context(
|
||||
headers: &mut HeaderMap,
|
||||
method: &Method,
|
||||
context: &forwarding::Context,
|
||||
) -> Result<()> {
|
||||
context.apply(headers);
|
||||
|
||||
let non_nginx_headers: Vec<_> = headers
|
||||
.keys()
|
||||
.filter(|name| {
|
||||
name.as_str() == "forwarded"
|
||||
|| (name.as_str().starts_with("x-forwarded-") && name.as_str() != "x-forwarded-for")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for name in non_nginx_headers {
|
||||
headers.remove(name);
|
||||
}
|
||||
|
||||
headers.insert("x-original-url", context.original_url.parse()?);
|
||||
headers.insert("x-original-method", method.as_str().parse()?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Compiled {
|
||||
pub fn strip_identity(&self, headers: &mut HeaderMap) {
|
||||
let names: Vec<_> = headers
|
||||
@@ -290,11 +360,18 @@ impl Compiled {
|
||||
};
|
||||
let (status, headers) = match result {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(error)) => {
|
||||
tracing::debug!(%error,"Tinyauth verification failed");
|
||||
return Err(failure(StatusCode::BAD_GATEWAY));
|
||||
Ok(Err(CheckError::Connection(error))) => {
|
||||
tracing::warn!(%error, "Tinyauth backend connection failed");
|
||||
return Err(gateway_failure(GatewayFailure::Connection));
|
||||
}
|
||||
Ok(Err(CheckError::InvalidResponse(error))) => {
|
||||
tracing::warn!(%error, "Tinyauth returned an invalid verification response");
|
||||
return Err(gateway_failure(GatewayFailure::Verification));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("Tinyauth verification timed out");
|
||||
return Err(gateway_failure(GatewayFailure::Timeout));
|
||||
}
|
||||
Err(_) => return Err(failure(StatusCode::GATEWAY_TIMEOUT)),
|
||||
};
|
||||
if status.is_success() {
|
||||
let mut identity = Identity::default();
|
||||
@@ -303,7 +380,8 @@ impl Compiled {
|
||||
for value in headers.get_all(name) {
|
||||
size += value.as_bytes().len();
|
||||
if size > 8192 {
|
||||
return Err(failure(StatusCode::BAD_GATEWAY));
|
||||
tracing::warn!("Tinyauth identity headers exceed the configured limit");
|
||||
return Err(gateway_failure(GatewayFailure::Verification));
|
||||
}
|
||||
identity.headers.append(name.clone(), value.clone());
|
||||
}
|
||||
@@ -323,24 +401,26 @@ impl Compiled {
|
||||
return Err(response);
|
||||
}
|
||||
}
|
||||
Err(failure(if status == StatusCode::FORBIDDEN {
|
||||
StatusCode::FORBIDDEN
|
||||
} else if status == StatusCode::UNAUTHORIZED {
|
||||
StatusCode::UNAUTHORIZED
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
}))
|
||||
if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {
|
||||
return Err(failure(status));
|
||||
}
|
||||
tracing::warn!(
|
||||
status = status.as_u16(),
|
||||
"Tinyauth returned an unexpected HTTP status"
|
||||
);
|
||||
Err(gateway_failure(GatewayFailure::Verification))
|
||||
}
|
||||
async fn check(
|
||||
&self,
|
||||
incoming: &HeaderMap,
|
||||
method: &Method,
|
||||
context: &forwarding::Context,
|
||||
) -> Result<(StatusCode, HeaderMap)> {
|
||||
) -> std::result::Result<(StatusCode, HeaderMap), CheckError> {
|
||||
let mut request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(self.uri.clone())
|
||||
.body(Body::empty())?;
|
||||
.body(Body::empty())
|
||||
.map_err(|error| CheckError::InvalidResponse(error.into()))?;
|
||||
for name in &self.request_headers {
|
||||
for value in incoming.get_all(name) {
|
||||
request.headers_mut().append(name.clone(), value.clone());
|
||||
@@ -349,27 +429,25 @@ impl Compiled {
|
||||
crate::proxy::headers::strip_hop_by_hop(request.headers_mut());
|
||||
crate::proxy::headers::strip_admin_cookie(request.headers_mut());
|
||||
self.strip_identity(request.headers_mut());
|
||||
context.apply(request.headers_mut());
|
||||
apply_nginx_auth_context(request.headers_mut(), method, context)
|
||||
.map_err(CheckError::InvalidResponse)?;
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("host", self.backend.host_header.clone());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("x-original-url", context.original_url.parse()?);
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("x-original-method", method.as_str().parse()?);
|
||||
let response = self
|
||||
.backend
|
||||
.client
|
||||
.request(request)
|
||||
.await
|
||||
.context("Tinyauth request failed")?;
|
||||
.context("Tinyauth request failed")
|
||||
.map_err(CheckError::Connection)?;
|
||||
let (parts, body) = response.into_parts();
|
||||
Limited::new(body, 16 * 1024)
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Tinyauth response body: {e}"))?;
|
||||
.map_err(|error| {
|
||||
CheckError::InvalidResponse(anyhow::anyhow!("Tinyauth response body: {error}"))
|
||||
})?;
|
||||
Ok((parts.status, parts.headers))
|
||||
}
|
||||
}
|
||||
|
||||
+202
-2
@@ -9,8 +9,8 @@ mod tests {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn compiled() -> Compiled {
|
||||
settings()
|
||||
fn compile_settings(settings: Settings) -> Compiled {
|
||||
settings
|
||||
.compile(
|
||||
&ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
@@ -24,6 +24,23 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
fn compiled() -> Compiled {
|
||||
compile_settings(settings())
|
||||
}
|
||||
fn request_context(incoming: &HeaderMap) -> forwarding::Context {
|
||||
forwarding::Settings::default()
|
||||
.compile()
|
||||
.unwrap()
|
||||
.context(
|
||||
incoming,
|
||||
"192.0.2.5:34567".parse().unwrap(),
|
||||
"10.0.0.1:443".parse().unwrap(),
|
||||
true,
|
||||
&"app.example:443".parse().unwrap(),
|
||||
&"/private?next=%2Fhome".parse().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn origin_adds_nginx_endpoint() {
|
||||
assert_eq!(
|
||||
@@ -32,6 +49,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn nginx_auth_context_has_no_conflicting_forward_auth_headers() {
|
||||
let context = request_context(&HeaderMap::new());
|
||||
let mut headers = HeaderMap::new();
|
||||
apply_nginx_auth_context(&mut headers, &Method::POST, &context).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
headers["x-original-url"],
|
||||
"https://app.example:443/private?next=%2Fhome"
|
||||
);
|
||||
assert_eq!(headers["x-original-method"], "POST");
|
||||
assert_eq!(headers["x-forwarded-for"], "192.0.2.5");
|
||||
assert_eq!(headers["x-real-ip"], "192.0.2.5");
|
||||
for name in [
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-uri",
|
||||
"x-forwarded-proto",
|
||||
"x-forwarded-port",
|
||||
"forwarded",
|
||||
] {
|
||||
assert!(!headers.contains_key(name), "{name}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn nginx_auth_context_does_not_forward_spoofed_client_metadata() {
|
||||
let mut headers = HeaderMap::new();
|
||||
for (name, value) in [
|
||||
("x-original-url", "https://evil.example/"),
|
||||
("x-original-method", "DELETE"),
|
||||
("x-forwarded-host", "evil.example"),
|
||||
("x-forwarded-uri", "/admin"),
|
||||
("x-forwarded-for", "203.0.113.1"),
|
||||
("x-real-ip", "203.0.113.1"),
|
||||
("forwarded", "for=203.0.113.1"),
|
||||
] {
|
||||
headers.insert(name, value.parse().unwrap());
|
||||
}
|
||||
let context = request_context(&headers);
|
||||
apply_nginx_auth_context(&mut headers, &Method::GET, &context).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
headers["x-original-url"],
|
||||
"https://app.example:443/private?next=%2Fhome"
|
||||
);
|
||||
assert_eq!(headers["x-original-method"], "GET");
|
||||
assert_eq!(headers["x-forwarded-for"], "192.0.2.5");
|
||||
assert_eq!(headers["x-real-ip"], "192.0.2.5");
|
||||
assert!(!headers.contains_key("x-forwarded-host"));
|
||||
assert!(!headers.contains_key("x-forwarded-uri"));
|
||||
assert!(!headers.contains_key("forwarded"));
|
||||
}
|
||||
#[test]
|
||||
fn rejects_transport_header_injection_and_unsafe_urls() {
|
||||
for name in [
|
||||
"host",
|
||||
@@ -118,4 +186,136 @@ mod tests {
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tinyauth_gateway_pages_are_distinct_and_fail_closed() {
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
for (kind, status, text) in [
|
||||
(
|
||||
GatewayFailure::Connection,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Tinyauth connection error",
|
||||
),
|
||||
(
|
||||
GatewayFailure::Verification,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Tinyauth verification error",
|
||||
),
|
||||
(
|
||||
GatewayFailure::Timeout,
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"Tinyauth timeout",
|
||||
),
|
||||
] {
|
||||
let response = gateway_failure(kind);
|
||||
assert_eq!(response.status(), status);
|
||||
assert_eq!(
|
||||
response.headers()["content-type"],
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
assert_eq!(response.headers()["cache-control"], "private, no-store");
|
||||
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
|
||||
assert!(response
|
||||
.extensions()
|
||||
.get::<AuthenticationFailure>()
|
||||
.is_some());
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let html = std::str::from_utf8(&body).unwrap();
|
||||
assert!(html.contains(text));
|
||||
assert!(html.contains("Access") && html.contains("protected"));
|
||||
assert!(html.contains("<style>"));
|
||||
assert!(!html.contains("<script") && !html.contains("href=\"http"));
|
||||
assert!(!html.contains("127.0.0.1") && !html.contains("/api/auth/nginx"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreachable_tinyauth_returns_branded_connection_error() {
|
||||
use http_body_util::BodyExt;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
let mut config = settings();
|
||||
config.url = format!("http://{address}");
|
||||
config.connect_timeout_seconds = 1;
|
||||
config.timeout_seconds = 2;
|
||||
let auth = compile_settings(config);
|
||||
let headers = HeaderMap::new();
|
||||
let response = auth
|
||||
.authorize(
|
||||
&headers,
|
||||
&Method::GET,
|
||||
&request_context(&headers),
|
||||
&tokio_util::sync::CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let html = std::str::from_utf8(&body).unwrap();
|
||||
assert!(html.contains("Tinyauth connection error"));
|
||||
assert!(!html.contains(&address.to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unresponsive_tinyauth_returns_branded_timeout() {
|
||||
use http_body_util::BodyExt;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut config = settings();
|
||||
config.url = format!("http://{}", listener.local_addr().unwrap());
|
||||
config.connect_timeout_seconds = 1;
|
||||
config.timeout_seconds = 1;
|
||||
let auth = compile_settings(config);
|
||||
let headers = HeaderMap::new();
|
||||
let response = auth
|
||||
.authorize(
|
||||
&headers,
|
||||
&Method::GET,
|
||||
&request_context(&headers),
|
||||
&tokio_util::sync::CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
assert!(
|
||||
std::str::from_utf8(&response.into_body().collect().await.unwrap().to_bytes())
|
||||
.unwrap()
|
||||
.contains("Tinyauth timeout")
|
||||
);
|
||||
drop(listener);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tinyauth_http_503_is_not_misdiagnosed_as_connection_error() {
|
||||
use http_body_util::BodyExt;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut config = settings();
|
||||
config.url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = [0_u8; 4096];
|
||||
let read = socket.read(&mut request).await.unwrap();
|
||||
assert!(read > 0, "auth request must be sent to Tinyauth");
|
||||
socket.write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap();
|
||||
});
|
||||
let auth = compile_settings(config);
|
||||
let headers = HeaderMap::new();
|
||||
let response = auth
|
||||
.authorize(
|
||||
&headers,
|
||||
&Method::GET,
|
||||
&request_context(&headers),
|
||||
&tokio_util::sync::CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
server.await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(std::str::from_utf8(&body)
|
||||
.unwrap()
|
||||
.contains("Tinyauth verification error"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user