Skip to content

Commit f3005cc

Browse files
committed
feat: [#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 f3005cc

File tree

3 files changed

+201
-100
lines changed

3 files changed

+201
-100
lines changed

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

+46-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};
@@ -43,16 +62,26 @@ pub struct State {
4362
pub access_tokens: Arc<AccessTokens>,
4463
}
4564

46-
/// Middleware for authentication using a "token" GET param.
65+
/// Middleware for authentication.
66+
///
4767
/// The token must be one of the tokens in the tracker [HTTP API configuration](torrust_tracker_configuration::HttpApi).
4868
pub async fn auth(
4969
extract::State(state): extract::State<State>,
5070
extract::Query(params): extract::Query<QueryParams>,
5171
request: Request<axum::body::Body>,
5272
next: Next,
5373
) -> Response {
54-
let Some(token) = params.token else {
55-
return AuthError::Unauthorized.into_response();
74+
let token_from_header = extract_token_from_header(&request);
75+
76+
let token_from_get_param = params.token.clone();
77+
78+
let provided_tokens = (token_from_header, token_from_get_param);
79+
80+
let token = match provided_tokens {
81+
(Some(token_from_header), Some(_token_from_get_param)) => token_from_header,
82+
(Some(token_from_header), None) => token_from_header,
83+
(None, Some(token_from_get_param)) => token_from_get_param,
84+
(None, None) => return AuthError::Unauthorized.into_response(),
5685
};
5786

5887
if !authenticate(&token, &state.access_tokens) {
@@ -62,9 +91,21 @@ pub async fn auth(
6291
next.run(request).await
6392
}
6493

94+
fn extract_token_from_header(request: &Request<axum::body::Body>) -> Option<String> {
95+
let headers = request.headers();
96+
headers
97+
.get(axum::http::header::AUTHORIZATION)
98+
.and_then(|header_value| header_value.to_str().ok())
99+
.and_then(|header_str| {
100+
// Check if the header starts with "Bearer " and extract the token
101+
header_str.strip_prefix("Bearer ").map(std::string::ToString::to_string)
102+
})
103+
}
104+
65105
enum AuthError {
66106
/// Missing token for authentication.
67107
Unauthorized,
108+
68109
/// Token was provided but it is not valid.
69110
TokenNotValid,
70111
}
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,169 @@
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 torrust_axum_rest_tracker_api_server::environment::Started;
3+
use torrust_rest_tracker_api_client::common::http::Query;
4+
use torrust_rest_tracker_api_client::v1::client::{headers_with_auth_token, Client};
5+
use torrust_tracker_test_helpers::{configuration, logging};
76

8-
use crate::server::v1::asserts::{assert_token_not_valid, assert_unauthorized};
7+
#[tokio::test]
8+
async fn it_should_authenticate_requests_when_the_token_is_provided_in_the_authentication_header() {
9+
logging::setup();
910

10-
#[tokio::test]
11-
async fn should_authenticate_requests_by_using_a_token_query_param() {
12-
logging::setup();
11+
let env = Started::new(&configuration::ephemeral().into()).await;
1312

14-
let env = Started::new(&configuration::ephemeral().into()).await;
13+
let token = env.get_connection_info().api_token.unwrap();
1514

16-
let token = env.get_connection_info().api_token.unwrap();
15+
let response = Client::new(env.get_connection_info())
16+
.unwrap()
17+
.get_request_with_query("stats", Query::default(), Some(headers_with_auth_token(&token)))
18+
.await;
1719

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;
20+
assert_eq!(response.status(), 200);
2221

23-
assert_eq!(response.status(), 200);
24-
25-
env.stop().await;
22+
env.stop().await;
23+
}
2624
}
25+
mod given_that_the_token_is_only_provided_in_the_query_param {
2726

28-
#[tokio::test]
29-
async fn should_not_authenticate_requests_when_the_token_is_missing() {
30-
logging::setup();
27+
use torrust_axum_rest_tracker_api_server::environment::Started;
28+
use torrust_rest_tracker_api_client::common::http::{Query, QueryParam};
29+
use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, Client};
30+
use torrust_tracker_test_helpers::logging::logs_contains_a_line_with;
31+
use torrust_tracker_test_helpers::{configuration, logging};
32+
use uuid::Uuid;
3133

32-
let env = Started::new(&configuration::ephemeral().into()).await;
34+
use crate::server::v1::asserts::assert_token_not_valid;
3335

34-
let request_id = Uuid::new_v4();
36+
#[tokio::test]
37+
async fn it_should_authenticate_requests_when_the_token_is_provided_as_a_query_param() {
38+
logging::setup();
3539

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;
40+
let env = Started::new(&configuration::ephemeral().into()).await;
4041

41-
assert_unauthorized(response).await;
42+
let token = env.get_connection_info().api_token.unwrap();
4243

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-
);
44+
let response = Client::new(env.get_connection_info())
45+
.unwrap()
46+
.get_request_with_query("stats", Query::params([QueryParam::new("token", &token)].to_vec()), None)
47+
.await;
4748

48-
env.stop().await;
49-
}
49+
assert_eq!(response.status(), 200);
5050

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

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

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

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;
60+
let request_id = Uuid::new_v4();
6761

68-
assert_token_not_valid(response).await;
62+
let response = Client::new(env.get_connection_info())
63+
.unwrap()
64+
.get_request_with_query(
65+
"stats",
66+
Query::params([QueryParam::new("token", "")].to_vec()),
67+
Some(headers_with_request_id(request_id)),
68+
)
69+
.await;
6970

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-
);
71+
assert_token_not_valid(response).await;
7472

75-
env.stop().await;
76-
}
73+
assert!(
74+
logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]),
75+
"Expected logs to contain: ERROR ... API ... request_id={request_id}"
76+
);
77+
78+
env.stop().await;
79+
}
80+
81+
#[tokio::test]
82+
async fn it_should_not_authenticate_requests_when_the_token_is_invalid() {
83+
logging::setup();
84+
85+
let env = Started::new(&configuration::ephemeral().into()).await;
86+
87+
let request_id = Uuid::new_v4();
7788

78-
#[tokio::test]
79-
async fn should_not_authenticate_requests_when_the_token_is_invalid() {
80-
logging::setup();
89+
let response = Client::new(env.get_connection_info())
90+
.unwrap()
91+
.get_request_with_query(
92+
"stats",
93+
Query::params([QueryParam::new("token", "INVALID TOKEN")].to_vec()),
94+
Some(headers_with_request_id(request_id)),
95+
)
96+
.await;
8197

82-
let env = Started::new(&configuration::ephemeral().into()).await;
98+
assert_token_not_valid(response).await;
8399

84-
let request_id = Uuid::new_v4();
100+
assert!(
101+
logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]),
102+
"Expected logs to contain: ERROR ... API ... request_id={request_id}"
103+
);
85104

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;
105+
env.stop().await;
106+
}
94107

95-
assert_token_not_valid(response).await;
108+
#[tokio::test]
109+
async fn it_should_allow_the_token_query_param_to_be_at_any_position_in_the_url_query() {
110+
logging::setup();
96111

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-
);
112+
let env = Started::new(&configuration::ephemeral().into()).await;
101113

102-
env.stop().await;
114+
let token = env.get_connection_info().api_token.unwrap();
115+
116+
// At the beginning of the query component
117+
let response = Client::new(env.get_connection_info())
118+
.unwrap()
119+
.get_request(&format!("torrents?token={token}&limit=1"))
120+
.await;
121+
122+
assert_eq!(response.status(), 200);
123+
124+
// At the end of the query component
125+
let response = Client::new(env.get_connection_info())
126+
.unwrap()
127+
.get_request(&format!("torrents?limit=1&token={token}"))
128+
.await;
129+
130+
assert_eq!(response.status(), 200);
131+
132+
env.stop().await;
133+
}
103134
}
104135

105-
#[tokio::test]
106-
async fn should_allow_the_token_query_param_to_be_at_any_position_in_the_url_query() {
107-
logging::setup();
136+
mod given_that_not_token_is_provided {
137+
138+
use torrust_axum_rest_tracker_api_server::environment::Started;
139+
use torrust_rest_tracker_api_client::common::http::Query;
140+
use torrust_rest_tracker_api_client::v1::client::{headers_with_request_id, Client};
141+
use torrust_tracker_test_helpers::logging::logs_contains_a_line_with;
142+
use torrust_tracker_test_helpers::{configuration, logging};
143+
use uuid::Uuid;
144+
145+
use crate::server::v1::asserts::assert_unauthorized;
108146

109-
let env = Started::new(&configuration::ephemeral().into()).await;
147+
#[tokio::test]
148+
async fn it_should_not_authenticate_requests_when_the_token_is_missing() {
149+
logging::setup();
110150

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

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;
153+
let request_id = Uuid::new_v4();
118154

119-
assert_eq!(response.status(), 200);
155+
let response = Client::new(env.get_connection_info())
156+
.unwrap()
157+
.get_request_with_query("stats", Query::default(), Some(headers_with_request_id(request_id)))
158+
.await;
120159

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;
160+
assert_unauthorized(response).await;
126161

127-
assert_eq!(response.status(), 200);
162+
assert!(
163+
logs_contains_a_line_with(&["ERROR", "API", &format!("{request_id}")]),
164+
"Expected logs to contain: ERROR ... API ... request_id={request_id}"
165+
);
128166

129-
env.stop().await;
167+
env.stop().await;
168+
}
130169
}

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

+24-3
Original file line numberDiff line numberDiff line change
@@ -180,15 +180,36 @@ pub async fn get(path: Url, query: Option<Query>, headers: Option<HeaderMap>) ->
180180
builder.send().await.unwrap()
181181
}
182182

183-
/// Returns a `HeaderMap` with a request id header
183+
/// Returns a `HeaderMap` with a request id header.
184184
///
185185
/// # Panics
186186
///
187-
/// Will panic if the request ID can't be parsed into a string.
187+
/// Will panic if the request ID can't be parsed into a `HeaderValue`.
188188
#[must_use]
189189
pub fn headers_with_request_id(request_id: Uuid) -> HeaderMap {
190190
let mut headers = HeaderMap::new();
191-
headers.insert("x-request-id", request_id.to_string().parse().unwrap());
191+
headers.insert(
192+
"x-request-id",
193+
request_id
194+
.to_string()
195+
.parse()
196+
.expect("the request ID is not a valid header value"),
197+
);
198+
headers
199+
}
200+
201+
/// Returns a `HeaderMap` with an authorization token.
202+
///
203+
/// # Panics
204+
///
205+
/// Will panic if the token can't be parsed into a `HeaderValue`.
206+
#[must_use]
207+
pub fn headers_with_auth_token(token: &str) -> HeaderMap {
208+
let mut headers = HeaderMap::new();
209+
headers.insert(
210+
"Authorization: Bearer",
211+
token.to_string().parse().expect("the auth token is not a valid header value"),
212+
);
192213
headers
193214
}
194215

0 commit comments

Comments
 (0)