Skip to content

Commit 525364e

Browse files
committed
feat: Include apikey based authentication mechanism
1 parent c4f8f1a commit 525364e

4 files changed

Lines changed: 127 additions & 7 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,4 @@ log = "0.4.29"
4040
uuid = { version = "1.23.1", features = ["v4"] }
4141
dotenv = "0.15.0"
4242
url = "2.5.8"
43+
regex = "1.12.3"

src/api.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::qb;
22
use crate::settings;
33

4-
use actix_web::{web, HttpResponse, Responder};
4+
use actix_web::{web, HttpRequest, HttpResponse, Responder};
55
use reqwest::Client;
66
use serde_json::{json, Value};
77
use std::collections::HashMap;
@@ -16,6 +16,7 @@ use uuid::Uuid;
1616
#[utoipa::path(
1717
get,
1818
path = "/status",
19+
security(()),
1920
responses(
2021
(status = 200, description = "List of users", body = serde_json::Value),
2122
),
@@ -32,6 +33,7 @@ pub async fn status() -> impl Responder {
3233
#[utoipa::path(
3334
get,
3435
path = "/version",
36+
security(()),
3537
responses(
3638
(status = 200, description = "API version", body = serde_json::Value)
3739
)
@@ -40,6 +42,25 @@ pub async fn version() -> impl Responder {
4042
HttpResponse::Ok().json(json!({ "version": env!("CARGO_PKG_VERSION") }))
4143
}
4244

45+
/// Authenticates the `apikey` through incoming request headers.
46+
///
47+
/// # Arguments
48+
///
49+
/// - `request` - Reference to the `HttpRequest` object.
50+
/// * `config` - Reference to the `Config` object.
51+
///
52+
/// # Returns
53+
///
54+
/// Returns a boolean value to indicate the authentication status.
55+
fn authenticator(request: HttpRequest, config: &settings::Config) -> bool {
56+
if let Some(apikey) = request.headers().get("apikey") {
57+
if apikey.to_str().unwrap().to_string() == config.apikey {
58+
return true;
59+
}
60+
}
61+
false
62+
}
63+
4364
/// API endpoint to get download/copy status.
4465
///
4566
/// # Arguments
@@ -73,9 +94,13 @@ pub async fn version() -> impl Responder {
7394
)
7495
)]
7596
pub async fn get_torrents(
97+
request: HttpRequest,
7698
state: web::Data<settings::SharedState>,
7799
config: web::Data<settings::Config>,
78100
) -> impl Responder {
101+
if !authenticator(request, &config) {
102+
return HttpResponse::Unauthorized().json("Unauthorized");
103+
}
79104
let client = match qb::client(&config).await {
80105
Ok(c) => c,
81106
Err(e) => return e,
@@ -250,10 +275,14 @@ fn resolve_payload(body: &[settings::PutItem]) -> Vec<settings::PutItem> {
250275
)
251276
)]
252277
pub async fn put_torrent(
278+
request: HttpRequest,
253279
pending: web::Data<settings::PendingMap>,
254280
config: web::Data<settings::Config>,
255281
body: web::Json<Vec<settings::PutItem>>,
256282
) -> impl Responder {
283+
if !authenticator(request, &config) {
284+
return HttpResponse::Unauthorized().json("Unauthorized");
285+
}
257286
let client = match qb::client(&config).await {
258287
Ok(c) => c,
259288
Err(e) => return e,
@@ -357,9 +386,13 @@ pub async fn put_torrent(
357386
)
358387
)]
359388
pub async fn delete_torrent(
389+
request: HttpRequest,
360390
config: web::Data<settings::Config>,
361391
query: web::Query<HashMap<String, String>>,
362392
) -> impl Responder {
393+
if !authenticator(request, &config) {
394+
return HttpResponse::Unauthorized().json("Unauthorized");
395+
}
363396
let identifier = match query.get("name") {
364397
Some(i) => i,
365398
None => return HttpResponse::BadRequest().body("Missing name"),

src/settings.rs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub type PendingMap = Arc<RwLock<HashMap<String, PutItem>>>;
1818
pub struct Config {
1919
pub host: String,
2020
pub port: u16,
21+
pub apikey: String,
2122
pub workers: usize,
2223
pub qbit_api: String,
2324
pub username: String,
@@ -26,6 +27,10 @@ pub struct Config {
2627
pub log_level: log::LevelFilter,
2728
}
2829

30+
fn startup_error(msg: &str) {
31+
eprintln!("\nStartupError:\n\t{}\n", msg);
32+
}
33+
2934
/// ### Config::new
3035
/// Creates a new [`Config`] instance from environment variables.
3136
///
@@ -38,14 +43,34 @@ impl Config {
3843
let port = squire::get_env_var("port", Some("3000"))
3944
.parse::<u16>()
4045
.unwrap();
46+
let apikey = squire::get_env_var("apikey", None);
47+
if apikey.is_empty() {
48+
startup_error("'apikey' is empty");
49+
std::process::exit(1)
50+
}
51+
match squire::complexity_checker(&apikey, 32) {
52+
Ok(()) => (),
53+
Err(err) => {
54+
startup_error(format!("Invalid 'apikey': {}", err).as_str());
55+
std::process::exit(1)
56+
},
57+
}
4158

4259
let available_workers = std::thread::available_parallelism().map_or(2, NonZeroUsize::get);
4360
let default_workers =
4461
squire::get_env_var("workers", Some(available_workers.to_string().as_str()));
4562
let workers = match default_workers.parse::<usize>() {
4663
Ok(n) if n > 0 => n,
47-
Ok(_) => panic!("\nParseError:\n\t'workers' must be > 0, got {default_workers}\n"),
48-
Err(e) => panic!("\nParseError:\n\tInvalid 'workers' value '{default_workers}': {e}\n"),
64+
Ok(_) => {
65+
startup_error(format!("'workers' must be > 0, got {}", default_workers).as_str());
66+
std::process::exit(1)
67+
}
68+
Err(e) => {
69+
startup_error(
70+
format!("Invalid 'workers' value '{default_workers}': {e}\n").as_str(),
71+
);
72+
std::process::exit(1)
73+
}
4974
};
5075

5176
let qbit_api = squire::get_env_var("qbit_api", Some("http://localhost:8080"));
@@ -57,16 +82,19 @@ impl Config {
5782
let log_level = match default_log_level.parse::<log::LevelFilter>() {
5883
Ok(level) => level,
5984
Err(_) => {
60-
panic!(
61-
"\nParseError:\n\tInvalid 'log_level' value '{}'. Expected one of: off, error, warn, info, debug, trace\n",
62-
default_log_level
85+
startup_error(
86+
format!(
87+
"Invalid 'log_level' value '{default_log_level}'. Expected one of: off, error, warn, info, debug, trace"
88+
).as_str()
6389
);
90+
std::process::exit(1)
6491
}
6592
};
6693

6794
Self {
6895
host,
6996
port,
97+
apikey,
7098
workers,
7199
qbit_api,
72100
username,

src/squire.rs

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use regex::Regex;
12
use crate::{qb, rsync, settings};
23
use reqwest::Client;
34
use serde_json::Value;
@@ -161,7 +162,7 @@ pub fn spawn_worker(
161162

162163
let mut db = state.write().await;
163164

164-
// Remove entries that qBit no longer knows about (deleted via WebUI).
165+
// Remove entries that QBitAPI no longer knows about (deleted via WebUI).
165166
let returned: std::collections::HashSet<&str> =
166167
arr.iter().filter_map(|t| t["hash"].as_str()).collect();
167168
hashes.iter().for_each(|h| {
@@ -244,3 +245,60 @@ pub fn load_env_file() {
244245
let env_file_path = std::env::current_dir().unwrap_or_default().join(env_file);
245246
let _ = dotenv::from_path(env_file_path.as_path());
246247
}
248+
249+
/// Verifies the strength of a secret string.
250+
///
251+
/// # Description
252+
/// A secret is considered strong if it satisfies all the following:
253+
///
254+
/// - Has at least `min_length` characters
255+
/// - Contains at least 1 digit
256+
/// - Contains at least 1 symbol (non-alphanumeric, non-whitespace)
257+
/// - Contains at least 1 uppercase letter
258+
/// - Contains at least 1 lowercase letter
259+
///
260+
/// # Arguments
261+
///
262+
/// * `value` - The secret string to validate.
263+
/// * `min_length` - Minimum required length of the secret.
264+
///
265+
/// # Returns
266+
///
267+
/// * `Ok(())` if the secret meets all strength requirements.
268+
/// * `Err(String)` with a message describing the first failed condition.
269+
pub fn complexity_checker(value: &String, min_length: usize) -> Result<(), String> {
270+
if value.trim().is_empty() {
271+
return Err("Value cannot be empty".to_string());
272+
}
273+
274+
if value.len() < min_length {
275+
return Err(format!(
276+
"Minimum length is {}, received {}",
277+
min_length,
278+
value.len()
279+
));
280+
}
281+
282+
let digit_re = Regex::new(r"\d").unwrap();
283+
let upper_re = Regex::new(r"[A-Z]").unwrap();
284+
let lower_re = Regex::new(r"[a-z]").unwrap();
285+
let symbol_re = Regex::new(r#"[^\w\s]"#).unwrap();
286+
287+
if !digit_re.is_match(value) {
288+
return Err("Value must include an integer".to_string());
289+
}
290+
291+
if !upper_re.is_match(value) {
292+
return Err("Value must include at least one uppercase letter".to_string());
293+
}
294+
295+
if !lower_re.is_match(value) {
296+
return Err("Value must include at least one lowercase letter".to_string());
297+
}
298+
299+
if !symbol_re.is_match(value) {
300+
return Err("Value must contain at least one special character".to_string());
301+
}
302+
303+
Ok(())
304+
}

0 commit comments

Comments
 (0)