Skip to content

Commit

Permalink
Merge #975: New API endpoint to upload pre-existing keys
Browse files Browse the repository at this point in the history
04f50e4 docs: [#974] update add key endpoint doc (Jose Celano)
583b305 test: [#874] new key generation endpoint (Jose Celano)
09beb52 feat: [#974] new API endpoint to upload pre-existing keys (Jose Celano)

Pull request description:

  You can test it with:

  ```console
  curl -X POST http://localhost:1212/api/v1/keys?token=MyAccessToken \
       -H "Content-Type: application/json" \
       -d '{
             "key": "Xc1L4PbQJSFGlrgSRZl8wxSFAuMa21z7",
             "seconds_valid": 7200
           }'
  ```

  The `key` field is optional. If it's not provided, a random key will be generated.

  ### Subtasks

  - [x] New endpoint to upload a pre-existing key.
  - [x] Add E2E test.
  - [x] Add new endpoint to rustdoc.
  - [x] Mark the current endpoint to generate random keys as deprecated.

ACKs for top commit:
  josecelano:
    ACK 04f50e4

Tree-SHA512: 6c788f428dbaa5960b87c6ca8c0cc3229e62a3f18de4e5a0e66e5a498e3911cb1ee553251962b6ba022e8a82ebc9fe4d78849119b28abbe5027c207987fddd3c
  • Loading branch information
josecelano committed Jul 30, 2024
2 parents a964659 + 04f50e4 commit 1f5de8c
Show file tree
Hide file tree
Showing 11 changed files with 412 additions and 56 deletions.
2 changes: 1 addition & 1 deletion src/core/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ pub struct Key(String);
/// ```
///
/// If the string does not contains a valid key, the parser function will return this error.
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, PartialEq, Eq, Display)]
pub struct ParseKeyError;

impl FromStr for Key {
Expand Down
38 changes: 33 additions & 5 deletions src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,16 +453,17 @@ use std::panic::Location;
use std::sync::Arc;
use std::time::Duration;

use auth::ExpiringKey;
use databases::driver::Driver;
use derive_more::Constructor;
use tokio::sync::mpsc::error::SendError;
use torrust_tracker_clock::clock::Time;
use torrust_tracker_configuration::v2::database;
use torrust_tracker_configuration::{AnnouncePolicy, Core, TORRENT_PEERS_LIMIT};
use torrust_tracker_primitives::info_hash::InfoHash;
use torrust_tracker_primitives::peer;
use torrust_tracker_primitives::swarm_metadata::SwarmMetadata;
use torrust_tracker_primitives::torrent_metrics::TorrentsMetrics;
use torrust_tracker_primitives::{peer, DurationSinceUnixEpoch};
use torrust_tracker_torrent_repository::entry::EntrySync;
use torrust_tracker_torrent_repository::repository::Repository;
use tracing::debug;
Expand Down Expand Up @@ -804,6 +805,37 @@ impl Tracker {
/// Will return a `database::Error` if unable to add the `auth_key` to the database.
pub async fn generate_auth_key(&self, lifetime: Duration) -> Result<auth::ExpiringKey, databases::error::Error> {
let auth_key = auth::generate(lifetime);

self.database.add_key_to_keys(&auth_key)?;
self.keys.write().await.insert(auth_key.key.clone(), auth_key.clone());
Ok(auth_key)
}

/// It adds a pre-generated authentication key.
///
/// Authentication keys are used by HTTP trackers.
///
/// # Context: Authentication
///
/// # Errors
///
/// Will return a `database::Error` if unable to add the `auth_key` to the
/// database. For example, if the key already exist.
///
/// # Arguments
///
/// * `lifetime` - The duration in seconds for the new key. The key will be
/// no longer valid after `lifetime` seconds.
pub async fn add_auth_key(
&self,
key: Key,
valid_until: DurationSinceUnixEpoch,
) -> Result<auth::ExpiringKey, databases::error::Error> {
let auth_key = ExpiringKey { key, valid_until };

// code-review: should we return a friendly error instead of the DB
// constrain error when the key already exist? For now, it's returning
// the specif error for each DB driver when a UNIQUE constrain fails.
self.database.add_key_to_keys(&auth_key)?;
self.keys.write().await.insert(auth_key.key.clone(), auth_key.clone());
Ok(auth_key)
Expand All @@ -816,10 +848,6 @@ impl Tracker {
/// # Errors
///
/// Will return a `database::Error` if unable to remove the `key` to the database.
///
/// # Panics
///
/// Will panic if key cannot be converted into a valid `Key`.
pub async fn remove_auth_key(&self, key: &Key) -> Result<(), databases::error::Error> {
self.database.remove_key_from_keys(key)?;
self.keys.write().await.remove(key);
Expand Down
8 changes: 8 additions & 0 deletions src/servers/apis/v1/context/auth_key/forms.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
pub struct AddKeyForm {
#[serde(rename = "key")]
pub opt_key: Option<String>,
pub seconds_valid: u64,
}
55 changes: 53 additions & 2 deletions src/servers/apis/v1/context/auth_key/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,66 @@ use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;

use axum::extract::{Path, State};
use axum::extract::{self, Path, State};
use axum::response::Response;
use serde::Deserialize;
use torrust_tracker_clock::clock::Time;

use super::forms::AddKeyForm;
use super::responses::{
auth_key_response, failed_to_delete_key_response, failed_to_generate_key_response, failed_to_reload_keys_response,
auth_key_response, failed_to_add_key_response, failed_to_delete_key_response, failed_to_generate_key_response,
failed_to_reload_keys_response, invalid_auth_key_duration_response, invalid_auth_key_response,
};
use crate::core::auth::Key;
use crate::core::Tracker;
use crate::servers::apis::v1::context::auth_key::resources::AuthKey;
use crate::servers::apis::v1::responses::{invalid_auth_key_param_response, ok_response};
use crate::CurrentClock;

/// It handles the request to add a new authentication key.
///
/// It returns these types of responses:
///
/// - `200` with a json [`AuthKey`]
/// resource. If the key was generated successfully.
/// - `400` with an error if the key couldn't been added because of an invalid
/// request.
/// - `500` with serialized error in debug format. If the key couldn't be
/// generated.
///
/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::auth_key#generate-a-new-authentication-key)
/// for more information about this endpoint.
pub async fn add_auth_key_handler(
State(tracker): State<Arc<Tracker>>,
extract::Json(add_key_form): extract::Json<AddKeyForm>,
) -> Response {
match add_key_form.opt_key {
Some(pre_existing_key) => {
let Some(valid_until) = CurrentClock::now_add(&Duration::from_secs(add_key_form.seconds_valid)) else {
return invalid_auth_key_duration_response(add_key_form.seconds_valid);
};

let key = pre_existing_key.parse::<Key>();

match key {
Ok(key) => match tracker.add_auth_key(key, valid_until).await {
Ok(auth_key) => auth_key_response(&AuthKey::from(auth_key)),
Err(e) => failed_to_add_key_response(e),
},
Err(e) => invalid_auth_key_response(&pre_existing_key, &e),
}
}
None => {
match tracker
.generate_auth_key(Duration::from_secs(add_key_form.seconds_valid))
.await
{
Ok(auth_key) => auth_key_response(&AuthKey::from(auth_key)),
Err(e) => failed_to_generate_key_response(e),
}
}
}
}

/// It handles the request to generate a new authentication key.
///
Expand All @@ -26,6 +75,8 @@ use crate::servers::apis::v1::responses::{invalid_auth_key_param_response, ok_re
///
/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::auth_key#generate-a-new-authentication-key)
/// for more information about this endpoint.
///
/// This endpoint has been deprecated. Use [`add_auth_key_handler`].
pub async fn generate_auth_key_handler(State(tracker): State<Arc<Tracker>>, Path(seconds_valid_or_key): Path<u64>) -> Response {
let seconds_valid = seconds_valid_or_key;
match tracker.generate_auth_key(Duration::from_secs(seconds_valid)).await {
Expand Down
22 changes: 16 additions & 6 deletions src/servers/apis/v1/context/auth_key/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
//! Authentication keys are used to authenticate HTTP tracker `announce` and
//! `scrape` requests.
//!
//! When the tracker is running in `private` or `private_listed` mode, the
//! authentication keys are required to announce and scrape torrents.
//! When the tracker is running in `private` mode, the authentication keys are
//! required to announce and scrape torrents.
//!
//! A sample `announce` request **without** authentication key:
//!
Expand All @@ -22,22 +22,31 @@
//!
//! # Generate a new authentication key
//!
//! `POST /key/:seconds_valid`
//! `POST /keys`
//!
//! It generates a new authentication key.
//! It generates a new authentication key or upload a pre-generated key.
//!
//! > **NOTICE**: keys expire after a certain amount of time.
//!
//! **Path parameters**
//! **POST parameters**
//!
//! Name | Type | Description | Required | Example
//! ---|---|---|---|---
//! `key` | 32-char string (0-9, a-z, A-Z) | The optional pre-generated key. | No | `Xc1L4PbQJSFGlrgSRZl8wxSFAuMa21z7`
//! `seconds_valid` | positive integer | The number of seconds the key will be valid. | Yes | `3600`
//!
//! > **NOTICE**: the `key` field is optional. If is not provided the tracker
//! > will generated a random one.
//!
//! **Example request**
//!
//! ```bash
//! curl -X POST "http://127.0.0.1:1212/api/v1/key/120?token=MyAccessToken"
//! curl -X POST http://localhost:1212/api/v1/keys?token=MyAccessToken \
//! -H "Content-Type: application/json" \
//! -d '{
//! "key": "xqD6NWH9TcKrOCwDmqcdH5hF5RrbL0A6",
//! "seconds_valid": 7200
//! }'
//! ```
//!
//! **Example response** `200`
Expand Down Expand Up @@ -119,6 +128,7 @@
//! "status": "ok"
//! }
//! ```
pub mod forms;
pub mod handlers;
pub mod resources;
pub mod responses;
Expand Down
21 changes: 20 additions & 1 deletion src/servers/apis/v1/context/auth_key/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ use std::error::Error;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};

use crate::core::auth::ParseKeyError;
use crate::servers::apis::v1::context::auth_key::resources::AuthKey;
use crate::servers::apis::v1::responses::unhandled_rejection_response;
use crate::servers::apis::v1::responses::{bad_request_response, unhandled_rejection_response};

/// `200` response that contains the `AuthKey` resource as json.
///
Expand All @@ -22,12 +23,20 @@ pub fn auth_key_response(auth_key: &AuthKey) -> Response {
.into_response()
}

// Error responses

/// `500` error response when a new authentication key cannot be generated.
#[must_use]
pub fn failed_to_generate_key_response<E: Error>(e: E) -> Response {
unhandled_rejection_response(format!("failed to generate key: {e}"))
}

/// `500` error response when the provide key cannot be added.
#[must_use]
pub fn failed_to_add_key_response<E: Error>(e: E) -> Response {
unhandled_rejection_response(format!("failed to add key: {e}"))
}

/// `500` error response when an authentication key cannot be deleted.
#[must_use]
pub fn failed_to_delete_key_response<E: Error>(e: E) -> Response {
Expand All @@ -40,3 +49,13 @@ pub fn failed_to_delete_key_response<E: Error>(e: E) -> Response {
pub fn failed_to_reload_keys_response<E: Error>(e: E) -> Response {
unhandled_rejection_response(format!("failed to reload keys: {e}"))
}

#[must_use]
pub fn invalid_auth_key_response(auth_key: &str, error: &ParseKeyError) -> Response {
bad_request_response(&format!("Invalid URL: invalid auth key: string \"{auth_key}\", {error}"))
}

#[must_use]
pub fn invalid_auth_key_duration_response(duration: u64) -> Response {
bad_request_response(&format!("Invalid URL: invalid auth key duration: \"{duration}\""))
}
16 changes: 12 additions & 4 deletions src/servers/apis/v1/context/auth_key/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::sync::Arc;
use axum::routing::{get, post};
use axum::Router;

use super::handlers::{delete_auth_key_handler, generate_auth_key_handler, reload_keys_handler};
use super::handlers::{add_auth_key_handler, delete_auth_key_handler, generate_auth_key_handler, reload_keys_handler};
use crate::core::Tracker;

/// It adds the routes to the router for the [`auth_key`](crate::servers::apis::v1::context::auth_key) API context.
Expand All @@ -21,14 +21,22 @@ pub fn add(prefix: &str, router: Router, tracker: Arc<Tracker>) -> Router {
.route(
// code-review: Axum does not allow two routes with the same path but different path variable name.
// In the new major API version, `seconds_valid` should be a POST form field so that we will have two paths:
// POST /key
// DELETE /key/:key
//
// POST /keys
// DELETE /keys/:key
//
// The POST /key/:seconds_valid has been deprecated and it will removed in the future.
// Use POST /keys
&format!("{prefix}/key/:seconds_valid_or_key"),
post(generate_auth_key_handler)
.with_state(tracker.clone())
.delete(delete_auth_key_handler)
.with_state(tracker.clone()),
)
// Keys command
.route(&format!("{prefix}/keys/reload"), get(reload_keys_handler).with_state(tracker))
.route(
&format!("{prefix}/keys/reload"),
get(reload_keys_handler).with_state(tracker.clone()),
)
.route(&format!("{prefix}/keys"), post(add_auth_key_handler).with_state(tracker))
}
3 changes: 2 additions & 1 deletion src/servers/apis/v1/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ pub fn invalid_auth_key_param_response(invalid_key: &str) -> Response {
bad_request_response(&format!("Invalid auth key id param \"{invalid_key}\""))
}

fn bad_request_response(body: &str) -> Response {
#[must_use]
pub fn bad_request_response(body: &str) -> Response {
(
StatusCode::BAD_REQUEST,
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
Expand Down
35 changes: 34 additions & 1 deletion tests/servers/api/v1/asserts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ pub async fn assert_bad_request(response: Response, body: &str) {
assert_eq!(response.text().await.unwrap(), body);
}

pub async fn assert_unprocessable_content(response: Response, text: &str) {
assert_eq!(response.status(), 422);
assert_eq!(response.headers().get("content-type").unwrap(), "text/plain; charset=utf-8");
assert!(response.text().await.unwrap().contains(text));
}

pub async fn assert_not_found(response: Response) {
assert_eq!(response.status(), 404);
// todo: missing header in the response
Expand All @@ -82,10 +88,37 @@ pub async fn assert_invalid_infohash_param(response: Response, invalid_infohash:
.await;
}

pub async fn assert_invalid_auth_key_param(response: Response, invalid_auth_key: &str) {
pub async fn assert_invalid_auth_key_get_param(response: Response, invalid_auth_key: &str) {
assert_bad_request(response, &format!("Invalid auth key id param \"{}\"", &invalid_auth_key)).await;
}

pub async fn assert_invalid_auth_key_post_param(response: Response, invalid_auth_key: &str) {
assert_bad_request(
response,
&format!(
"Invalid URL: invalid auth key: string \"{}\", ParseKeyError",
&invalid_auth_key
),
)
.await;
}

pub async fn _assert_unprocessable_auth_key_param(response: Response, _invalid_value: &str) {
assert_unprocessable_content(
response,
"Failed to deserialize the JSON body into the target type: seconds_valid: invalid type",
)
.await;
}

pub async fn assert_unprocessable_auth_key_duration_param(response: Response, _invalid_value: &str) {
assert_unprocessable_content(
response,
"Failed to deserialize the JSON body into the target type: seconds_valid: invalid type",
)
.await;
}

pub async fn assert_invalid_key_duration_param(response: Response, invalid_key_duration: &str) {
assert_bad_request(
response,
Expand Down
Loading

0 comments on commit 1f5de8c

Please sign in to comment.