Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 3f915a2

Browse files
committedMar 10, 2025··
feat: [torrust#727] allow to authenticate API via authentication header
The API allos client authentication via a `token` parameter in the URL query: ```console curl http://0.0.0.0:1212/api/v1/stats?token=MyAccessToken | jq ``` Now it's also possible to do it via Authentication Header: ```console curl -H "Authorization: Bearer MyAccessToken" http://0.0.0.0:1212/api/v1/stats | jq ``` This is to avoid leaking the token in logs, proxies, etc. For now, it's only optional and recommendable. It could be mandatory in future major API versions.
1 parent 6a22b1e commit 3f915a2

File tree

3 files changed

+327
-100
lines changed

3 files changed

+327
-100
lines changed
 

‎packages/axum-rest-tracker-api-server/src/v1/middlewares/auth.rs

+62-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
//! Authentication middleware for the API.
22
//!
3-
//! It uses a "token" GET param to authenticate the user. URLs must be of the
4-
//! form:
3+
//! It uses a "token" to authenticate the user. The token must be one of the
4+
//! `access_tokens` in the tracker [HTTP API configuration](torrust_tracker_configuration::HttpApi).
5+
//!
6+
//! There are two ways to provide the token:
7+
//!
8+
//! 1. As a `Bearer` token in the `Authorization` header.
9+
//! 2. As a `token` GET param in the URL.
10+
//!
11+
//! Using the `Authorization` header:
12+
//!
13+
//! ```console
14+
//! curl -H "Authorization: Bearer MyAccessToken" http://<host>:<port>/api/v1/<context>
15+
//! ```
16+
//!
17+
//! Using the `token` GET param:
518
//!
619
//! `http://<host>:<port>/api/v1/<context>?token=<token>`.
720
//!
@@ -21,6 +34,12 @@
2134
//! All the tokes have the same permissions, so it is not possible to have
2235
//! different permissions for different tokens. The label is only used to
2336
//! identify the token.
37+
//!
38+
//! NOTICE: The token is not encrypted, so it is recommended to use HTTPS to
39+
//! protect the token from being intercepted.
40+
//!
41+
//! NOTICE: If both the `Authorization` header and the `token` GET param are
42+
//! provided, the `Authorization` header will be used.
2443
use std::sync::Arc;
2544

2645
use axum::extract::{self};
@@ -32,6 +51,8 @@ use torrust_tracker_configuration::AccessTokens;
3251

3352
use crate::v1::responses::unhandled_rejection_response;
3453

54+
pub const AUTH_BEARER_TOKEN_HEADER_PREFIX: &str = "Bearer";
55+
3556
/// Container for the `token` extracted from the query params.
3657
#[derive(Deserialize, Debug)]
3758
pub struct QueryParams {
@@ -43,16 +64,28 @@ pub struct State {
4364
pub access_tokens: Arc<AccessTokens>,
4465
}
4566

46-
/// Middleware for authentication using a "token" GET param.
67+
/// Middleware for authentication.
68+
///
4769
/// The token must be one of the tokens in the tracker [HTTP API configuration](torrust_tracker_configuration::HttpApi).
4870
pub async fn auth(
4971
extract::State(state): extract::State<State>,
5072
extract::Query(params): extract::Query<QueryParams>,
5173
request: Request<axum::body::Body>,
5274
next: Next,
5375
) -> Response {
54-
let Some(token) = params.token else {
55-
return AuthError::Unauthorized.into_response();
76+
let token_from_header = extract_bearer_token_from_header(&request);
77+
78+
let token_from_get_param = params.token.clone();
79+
80+
let provided_tokens = (token_from_header, token_from_get_param);
81+
82+
println!("Provided tokens: {provided_tokens:?}");
83+
84+
let token = match provided_tokens {
85+
(Some(token_from_header), Some(_token_from_get_param)) => token_from_header,
86+
(Some(token_from_header), None) => token_from_header,
87+
(None, Some(token_from_get_param)) => token_from_get_param,
88+
(None, None) => return AuthError::Unauthorized.into_response(),
5689
};
5790

5891
if !authenticate(&token, &state.access_tokens) {
@@ -62,9 +95,33 @@ pub async fn auth(
6295
next.run(request).await
6396
}
6497

98+
fn extract_bearer_token_from_header(request: &Request<axum::body::Body>) -> Option<String> {
99+
let headers = request.headers();
100+
headers
101+
.get(axum::http::header::AUTHORIZATION)
102+
.and_then(|header_value| header_value.to_str().ok())
103+
.and_then(|header_str| {
104+
if (header_str) == AUTH_BEARER_TOKEN_HEADER_PREFIX {
105+
// Empty token
106+
return Some(String::new());
107+
}
108+
109+
if !header_str.starts_with(&format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} ").to_string()) {
110+
// Invalid token type. Missing "Bearer" prefix.
111+
// We return the header as is, so the caller can decide what to do.
112+
return Some(header_str.to_owned());
113+
}
114+
115+
header_str
116+
.strip_prefix(&format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} ").to_string())
117+
.map(std::string::ToString::to_string)
118+
})
119+
}
120+
65121
enum AuthError {
66122
/// Missing token for authentication.
67123
Unauthorized,
124+
68125
/// Token was provided but it is not valid.
69126
TokenNotValid,
70127
}
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,275 @@
1-
use torrust_axum_rest_tracker_api_server::environment::Started;
2-
use torrust_rest_tracker_api_client::common::http::{Query, QueryParam};
3-
use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, Client};
4-
use torrust_tracker_test_helpers::logging::logs_contains_a_line_with;
5-
use torrust_tracker_test_helpers::{configuration, logging};
6-
use uuid::Uuid;
1+
mod given_that_the_token_is_only_provided_in_the_authentication_header {
2+
use hyper::header;
3+
use torrust_axum_rest_tracker_api_server::environment::Started;
4+
use torrust_rest_tracker_api_client::common::http::Query;
5+
use torrust_rest_tracker_api_client::v1::client::{
6+
headers_with_auth_token, headers_with_request_id, Client, AUTH_BEARER_TOKEN_HEADER_PREFIX,
7+
};
8+
use torrust_tracker_test_helpers::logging::logs_contains_a_line_with;
9+
use torrust_tracker_test_helpers::{configuration, logging};
10+
use uuid::Uuid;
711

8-
use crate::server::v1::asserts::{assert_token_not_valid, assert_unauthorized};
12+
use crate::server::v1::asserts::assert_token_not_valid;
913

10-
#[tokio::test]
11-
async fn should_authenticate_requests_by_using_a_token_query_param() {
12-
logging::setup();
14+
#[tokio::test]
15+
async fn it_should_authenticate_requests_when_the_token_is_provided_in_the_authentication_header() {
16+
logging::setup();
1317

14-
let env = Started::new(&configuration::ephemeral().into()).await;
18+
let env = Started::new(&configuration::ephemeral().into()).await;
1519

16-
let token = env.get_connection_info().api_token.unwrap();
20+
let token = env.get_connection_info().api_token.unwrap();
1721

18-
let response = Client::new(env.get_connection_info())
19-
.unwrap()
20-
.get_request_with_query("stats", Query::params([QueryParam::new("token", &token)].to_vec()), None)
21-
.await;
22+
let response = Client::new(env.get_connection_info())
23+
.unwrap()
24+
.get_request_with_query("stats", Query::default(), Some(headers_with_auth_token(&token)))
25+
.await;
2226

23-
assert_eq!(response.status(), 200);
27+
assert_eq!(response.status(), 200);
2428

25-
env.stop().await;
26-
}
29+
env.stop().await;
30+
}
31+
32+
#[tokio::test]
33+
async fn it_should_not_authenticate_requests_when_the_token_is_empty() {
34+
logging::setup();
35+
36+
let env = Started::new(&configuration::ephemeral().into()).await;
37+
38+
let request_id = Uuid::new_v4();
39+
40+
let mut headers = headers_with_request_id(request_id);
41+
42+
// Send the header with an empty token
43+
headers.insert(
44+
header::AUTHORIZATION,
45+
format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} ")
46+
.parse()
47+
.expect("the auth token is not a valid header value"),
48+
);
49+
50+
let response = Client::new(env.get_connection_info())
51+
.unwrap()
52+
.get_request_with_query("stats", Query::default(), Some(headers))
53+
.await;
54+
55+
assert_token_not_valid(response).await;
56+
57+
assert!(
58+
logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]),
59+
"Expected logs to contain: ERROR ... API ... request_id={request_id}"
60+
);
61+
62+
env.stop().await;
63+
}
2764

28-
#[tokio::test]
29-
async fn should_not_authenticate_requests_when_the_token_is_missing() {
30-
logging::setup();
65+
#[tokio::test]
66+
async fn it_should_not_authenticate_requests_when_the_token_is_invalid() {
67+
logging::setup();
3168

32-
let env = Started::new(&configuration::ephemeral().into()).await;
69+
let env = Started::new(&configuration::ephemeral().into()).await;
3370

34-
let request_id = Uuid::new_v4();
71+
let request_id = Uuid::new_v4();
3572

36-
let response = Client::new(env.get_connection_info())
37-
.unwrap()
38-
.get_request_with_query("stats", Query::default(), Some(headers_with_request_id(request_id)))
39-
.await;
73+
let mut headers = headers_with_request_id(request_id);
4074

41-
assert_unauthorized(response).await;
75+
// Send the header with an empty token
76+
headers.insert(
77+
header::AUTHORIZATION,
78+
"Bearer INVALID TOKEN"
79+
.parse()
80+
.expect("the auth token is not a valid header value"),
81+
);
4282

43-
assert!(
44-
logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]),
45-
"Expected logs to contain: ERROR ... API ... request_id={request_id}"
46-
);
83+
let response = Client::new(env.get_connection_info())
84+
.unwrap()
85+
.get_request_with_query("stats", Query::default(), Some(headers))
86+
.await;
4787

48-
env.stop().await;
88+
assert_token_not_valid(response).await;
89+
90+
assert!(
91+
logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]),
92+
"Expected logs to contain: ERROR ... API ... request_id={request_id}"
93+
);
94+
95+
env.stop().await;
96+
}
4997
}
98+
mod given_that_the_token_is_only_provided_in_the_query_param {
99+
100+
use torrust_axum_rest_tracker_api_server::environment::Started;
101+
use torrust_rest_tracker_api_client::common::http::{Query, QueryParam};
102+
use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, Client};
103+
use torrust_tracker_test_helpers::logging::logs_contains_a_line_with;
104+
use torrust_tracker_test_helpers::{configuration, logging};
105+
use uuid::Uuid;
106+
107+
use crate::server::v1::asserts::assert_token_not_valid;
108+
109+
#[tokio::test]
110+
async fn it_should_authenticate_requests_when_the_token_is_provided_as_a_query_param() {
111+
logging::setup();
112+
113+
let env = Started::new(&configuration::ephemeral().into()).await;
114+
115+
let token = env.get_connection_info().api_token.unwrap();
116+
117+
let response = Client::new(env.get_connection_info())
118+
.unwrap()
119+
.get_request_with_query("stats", Query::params([QueryParam::new("token", &token)].to_vec()), None)
120+
.await;
121+
122+
assert_eq!(response.status(), 200);
50123

51-
#[tokio::test]
52-
async fn should_not_authenticate_requests_when_the_token_is_empty() {
53-
logging::setup();
124+
env.stop().await;
125+
}
54126

55-
let env = Started::new(&configuration::ephemeral().into()).await;
127+
#[tokio::test]
128+
async fn it_should_not_authenticate_requests_when_the_token_is_empty() {
129+
logging::setup();
56130

57-
let request_id = Uuid::new_v4();
131+
let env = Started::new(&configuration::ephemeral().into()).await;
58132

59-
let response = Client::new(env.get_connection_info())
60-
.unwrap()
61-
.get_request_with_query(
62-
"stats",
63-
Query::params([QueryParam::new("token", "")].to_vec()),
64-
Some(headers_with_request_id(request_id)),
65-
)
66-
.await;
133+
let request_id = Uuid::new_v4();
67134

68-
assert_token_not_valid(response).await;
135+
let response = Client::new(env.get_connection_info())
136+
.unwrap()
137+
.get_request_with_query(
138+
"stats",
139+
Query::params([QueryParam::new("token", "")].to_vec()),
140+
Some(headers_with_request_id(request_id)),
141+
)
142+
.await;
69143

70-
assert!(
71-
logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]),
72-
"Expected logs to contain: ERROR ... API ... request_id={request_id}"
73-
);
144+
assert_token_not_valid(response).await;
74145

75-
env.stop().await;
146+
assert!(
147+
logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]),
148+
"Expected logs to contain: ERROR ... API ... request_id={request_id}"
149+
);
150+
151+
env.stop().await;
152+
}
153+
154+
#[tokio::test]
155+
async fn it_should_not_authenticate_requests_when_the_token_is_invalid() {
156+
logging::setup();
157+
158+
let env = Started::new(&configuration::ephemeral().into()).await;
159+
160+
let request_id = Uuid::new_v4();
161+
162+
let response = Client::new(env.get_connection_info())
163+
.unwrap()
164+
.get_request_with_query(
165+
"stats",
166+
Query::params([QueryParam::new("token", "INVALID TOKEN")].to_vec()),
167+
Some(headers_with_request_id(request_id)),
168+
)
169+
.await;
170+
171+
assert_token_not_valid(response).await;
172+
173+
assert!(
174+
logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]),
175+
"Expected logs to contain: ERROR ... API ... request_id={request_id}"
176+
);
177+
178+
env.stop().await;
179+
}
180+
181+
#[tokio::test]
182+
async fn it_should_allow_the_token_query_param_to_be_at_any_position_in_the_url_query() {
183+
logging::setup();
184+
185+
let env = Started::new(&configuration::ephemeral().into()).await;
186+
187+
let token = env.get_connection_info().api_token.unwrap();
188+
189+
// At the beginning of the query component
190+
let response = Client::new(env.get_connection_info())
191+
.unwrap()
192+
.get_request(&format!("torrents?token={token}&limit=1"))
193+
.await;
194+
195+
assert_eq!(response.status(), 200);
196+
197+
// At the end of the query component
198+
let response = Client::new(env.get_connection_info())
199+
.unwrap()
200+
.get_request(&format!("torrents?limit=1&token={token}"))
201+
.await;
202+
203+
assert_eq!(response.status(), 200);
204+
205+
env.stop().await;
206+
}
76207
}
77208

78-
#[tokio::test]
79-
async fn should_not_authenticate_requests_when_the_token_is_invalid() {
80-
logging::setup();
209+
mod given_that_not_token_is_provided {
210+
211+
use torrust_axum_rest_tracker_api_server::environment::Started;
212+
use torrust_rest_tracker_api_client::common::http::Query;
213+
use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, Client};
214+
use torrust_tracker_test_helpers::logging::logs_contains_a_line_with;
215+
use torrust_tracker_test_helpers::{configuration, logging};
216+
use uuid::Uuid;
217+
218+
use crate::server::v1::asserts::assert_unauthorized;
219+
220+
#[tokio::test]
221+
async fn it_should_not_authenticate_requests_when_the_token_is_missing() {
222+
logging::setup();
81223

82-
let env = Started::new(&configuration::ephemeral().into()).await;
224+
let env = Started::new(&configuration::ephemeral().into()).await;
83225

84-
let request_id = Uuid::new_v4();
226+
let request_id = Uuid::new_v4();
85227

86-
let response = Client::new(env.get_connection_info())
87-
.unwrap()
88-
.get_request_with_query(
89-
"stats",
90-
Query::params([QueryParam::new("token", "INVALID TOKEN")].to_vec()),
91-
Some(headers_with_request_id(request_id)),
92-
)
93-
.await;
228+
let response = Client::new(env.get_connection_info())
229+
.unwrap()
230+
.get_request_with_query("stats", Query::default(), Some(headers_with_request_id(request_id)))
231+
.await;
94232

95-
assert_token_not_valid(response).await;
233+
assert_unauthorized(response).await;
96234

97-
assert!(
98-
logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]),
99-
"Expected logs to contain: ERROR ... API ... request_id={request_id}"
100-
);
235+
assert!(
236+
logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]),
237+
"Expected logs to contain: ERROR ... API ... request_id={request_id}"
238+
);
101239

102-
env.stop().await;
240+
env.stop().await;
241+
}
103242
}
104243

105-
#[tokio::test]
106-
async fn should_allow_the_token_query_param_to_be_at_any_position_in_the_url_query() {
107-
logging::setup();
244+
mod given_that_token_is_provided_via_get_param_and_authentication_header {
245+
use torrust_axum_rest_tracker_api_server::environment::Started;
246+
use torrust_rest_tracker_api_client::common::http::{Query, QueryParam};
247+
use torrust_rest_tracker_api_client::v1::client::{headers_with_auth_token, Client, TOKEN_PARAM_NAME};
248+
use torrust_tracker_test_helpers::{configuration, logging};
108249

109-
let env = Started::new(&configuration::ephemeral().into()).await;
250+
#[tokio::test]
251+
async fn it_should_authenticate_requests_using_the_token_provided_in_the_authentication_header() {
252+
logging::setup();
110253

111-
let token = env.get_connection_info().api_token.unwrap();
254+
let env = Started::new(&configuration::ephemeral().into()).await;
112255

113-
// At the beginning of the query component
114-
let response = Client::new(env.get_connection_info())
115-
.unwrap()
116-
.get_request(&format!("torrents?token={token}&limit=1"))
117-
.await;
256+
let authorized_token = env.get_connection_info().api_token.unwrap();
118257

119-
assert_eq!(response.status(), 200);
258+
let non_authorized_token = "NonAuthorizedToken";
120259

121-
// At the end of the query component
122-
let response = Client::new(env.get_connection_info())
123-
.unwrap()
124-
.get_request(&format!("torrents?limit=1&token={token}"))
125-
.await;
260+
let response = Client::new(env.get_connection_info())
261+
.unwrap()
262+
.get_request_with_query(
263+
"stats",
264+
Query::params([QueryParam::new(TOKEN_PARAM_NAME, non_authorized_token)].to_vec()),
265+
Some(headers_with_auth_token(&authorized_token)),
266+
)
267+
.await;
126268

127-
assert_eq!(response.status(), 200);
269+
// The token provided in the query param should be ignored and the token
270+
// in the authentication header should be used.
271+
assert_eq!(response.status(), 200);
128272

129-
env.stop().await;
273+
env.stop().await;
274+
}
130275
}

‎packages/rest-tracker-api-client/src/v1/client.rs

+30-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::time::Duration;
22

3-
use hyper::HeaderMap;
3+
use hyper::{header, HeaderMap};
44
use reqwest::{Error, Response};
55
use serde::Serialize;
66
use url::Url;
@@ -9,7 +9,9 @@ use uuid::Uuid;
99
use crate::common::http::{Query, QueryParam, ReqwestQuery};
1010
use crate::connection_info::ConnectionInfo;
1111

12-
const TOKEN_PARAM_NAME: &str = "token";
12+
pub const TOKEN_PARAM_NAME: &str = "token";
13+
pub const AUTH_BEARER_TOKEN_HEADER_PREFIX: &str = "Bearer";
14+
1315
const API_PATH: &str = "api/v1/";
1416
const DEFAULT_REQUEST_TIMEOUT_IN_SECS: u64 = 5;
1517

@@ -180,15 +182,38 @@ pub async fn get(path: Url, query: Option<Query>, headers: Option<HeaderMap>) ->
180182
builder.send().await.unwrap()
181183
}
182184

183-
/// Returns a `HeaderMap` with a request id header
185+
/// Returns a `HeaderMap` with a request id header.
184186
///
185187
/// # Panics
186188
///
187-
/// Will panic if the request ID can't be parsed into a string.
189+
/// Will panic if the request ID can't be parsed into a `HeaderValue`.
188190
#[must_use]
189191
pub fn headers_with_request_id(request_id: Uuid) -> HeaderMap {
190192
let mut headers = HeaderMap::new();
191-
headers.insert("x-request-id", request_id.to_string().parse().unwrap());
193+
headers.insert(
194+
"x-request-id",
195+
request_id
196+
.to_string()
197+
.parse()
198+
.expect("the request ID is not a valid header value"),
199+
);
200+
headers
201+
}
202+
203+
/// Returns a `HeaderMap` with an authorization token.
204+
///
205+
/// # Panics
206+
///
207+
/// Will panic if the token can't be parsed into a `HeaderValue`.
208+
#[must_use]
209+
pub fn headers_with_auth_token(token: &str) -> HeaderMap {
210+
let mut headers = HeaderMap::new();
211+
headers.insert(
212+
header::AUTHORIZATION,
213+
format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} {token}")
214+
.parse()
215+
.expect("the auth token is not a valid header value"),
216+
);
192217
headers
193218
}
194219

0 commit comments

Comments
 (0)
Please sign in to comment.