From 2c56c686370f84bb7bbe9266390c5b8134387542 Mon Sep 17 00:00:00 2001 From: Solninja A Date: Tue, 31 Dec 2024 16:19:20 +1000 Subject: [PATCH] Improves API functionality --- .gitignore | 1 + Dockerfile | 2 +- README.md | 2 +- actix/Cargo.lock | 37 ++++++++++- actix/Cargo.toml | 3 + actix/src/auth.rs | 47 ++++++++++++++ actix/src/database.rs | 3 +- actix/src/main.rs | 12 ++++ actix/src/services.rs | 148 +++++++++++++++++++++++++++++++++--------- actix/src/utils.rs | 42 +++++++++++- compose.yaml | 12 ++-- 11 files changed, 265 insertions(+), 44 deletions(-) diff --git a/.gitignore b/.gitignore index 1dbf157..5afbeb2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ urls.sqlite **/.directory .env cookie* +.idea/ diff --git a/Dockerfile b/Dockerfile index e8ac570..16bc272 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ RUN cargo chef cook --release --target=$target --recipe-path recipe.json COPY ./actix/Cargo.toml ./actix/Cargo.lock ./ COPY ./actix/src ./src # Build application -RUN cargo build --release --target=$target --locked --bin chhoto-url +RUN cargo build --release --target=$target --offline --bin chhoto-url RUN cp /chhoto-url/target/$target/release/chhoto-url /chhoto-url/release FROM scratch diff --git a/README.md b/README.md index a023051..0474cfd 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ below, replace `http://localhost:4567` with where your instance of `chhoto-url` If you have set up a password, first do the following to get an authentication cookie and store it in a file. ```bash -curl -X post -d "" -c cookie.txt http://localhost:4567/api/login +curl -X POST -d "" -c cookie.txt http://localhost:4567/api/login ``` You should receive "Correct password!" if the provided password was correct. For any subsequent request, please add `-b cookie.txt` to provide authentication. diff --git a/actix/Cargo.lock b/actix/Cargo.lock index 0217de1..69d364f 100644 --- a/actix/Cargo.lock +++ b/actix/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "actix-codec" @@ -19,6 +19,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "actix-cors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e772b3bcafe335042b5db010ab7c09013dad6eac4915c91d8d50902769f331" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more 0.99.18", + "futures-util", + "log", + "once_cell", + "smallvec", +] + [[package]] name = "actix-files" version = "0.6.6" @@ -225,6 +240,21 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-web-httpauth" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456348ed9dcd72a13a1f4a660449fafdecee9ac8205552e286809eb5b0b29bd3" +dependencies = [ + "actix-utils", + "actix-web", + "base64 0.22.1", + "futures-core", + "futures-util", + "log", + "pin-project-lite", +] + [[package]] name = "addr2line" version = "0.24.2" @@ -476,11 +506,13 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chhoto-url" -version = "5.4.5" +version = "5.4.6" dependencies = [ + "actix-cors", "actix-files", "actix-session", "actix-web", + "actix-web-httpauth", "env_logger", "nanoid", "rand", @@ -736,6 +768,7 @@ dependencies = [ "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] diff --git a/actix/Cargo.toml b/actix/Cargo.toml index c3d9e7e..a762fa1 100644 --- a/actix/Cargo.toml +++ b/actix/Cargo.toml @@ -29,9 +29,12 @@ categories = ["web-programming"] [dependencies] actix-web = "4.5.1" actix-files = "0.6.5" +actix-cors = "0.7.0" +actix-web-httpauth = "0.8.2" rusqlite = { version = "0.32.0", features = ["bundled"] } regex = "1.10.3" rand = "0.8.5" +passwords = "3.1.16" actix-session = { version = "0.10.0", features = ["cookie-session"] } env_logger = "0.11.1" nanoid = "0.4.0" diff --git a/actix/src/auth.rs b/actix/src/auth.rs index 3ca4495..fd8d8b8 100644 --- a/actix/src/auth.rs +++ b/actix/src/auth.rs @@ -3,6 +3,53 @@ use actix_session::Session; use std::{env, time::SystemTime}; +use actix_web::{HttpRequest}; + +// API key generation and scoring +use passwords::{PasswordGenerator, scorer, analyzer}; + +// Validate API key +pub fn validate_key(key: String) -> bool { + if let Ok(api_key) = env::var("api_key") { + if api_key != key { + eprintln!("Incorrect API key was provided when connecting to Chhoto URL."); + false + } else { + eprintln!("Server accessed with API key."); + true + } + } else { + eprintln!("API was accessed with API key validation but no API key was specified. Set the 'api_key' environment variable."); + false + } +} + +// Generate an API key if the user doesn't specify a secure key +// Called in main.rs +pub fn gen_key() -> String { + let key = PasswordGenerator { + length: 128, + numbers: true, + lowercase_letters: true, + uppercase_letters: true, + symbols: false, + spaces: false, + exclude_similar_characters: false, + strict: true, + }; + key.generate_one().unwrap() +} + +// Check if the API key header exists +pub fn api_header(req: &HttpRequest) -> Option<&str> { + req.headers().get("Chhoto-Api-Key")?.to_str().ok() +} + +// Determine whether the inputted API key is sufficiently secure +pub fn is_key_secure() -> bool { + let score = scorer::score(&analyzer::analyze(env::var("api_key").unwrap())); + if score < 90.0 { false } else { true } +} // Validate a given password pub fn validate(session: Session) -> bool { diff --git a/actix/src/database.rs b/actix/src/database.rs index 87ddec9..95d4a6e 100644 --- a/actix/src/database.rs +++ b/actix/src/database.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2023 Sayantan Santra // SPDX-License-Identifier: MIT -use rusqlite::Connection; +use rusqlite::{Connection}; use serde::Serialize; // Struct for encoding a DB row @@ -91,5 +91,6 @@ pub fn open_db(path: String) -> Connection { [], ) .expect("Unable to initialize empty database."); + db } diff --git a/actix/src/main.rs b/actix/src/main.rs index 70d069e..ef9a618 100644 --- a/actix/src/main.rs +++ b/actix/src/main.rs @@ -24,11 +24,13 @@ async fn main() -> Result<()> { // Generate session key in runtime so that restart invalidates older logins let secret_key = Key::generate(); + let db_location = env::var("db_url") .ok() .filter(|s| !s.trim().is_empty()) .unwrap_or(String::from("urls.sqlite")); + let port = env::var("port") .unwrap_or(String::from("4567")) .parse::() @@ -38,6 +40,16 @@ async fn main() -> Result<()> { .ok() .filter(|s| !s.trim().is_empty()); + // If an API key is set, check the security + if let Ok(key) = env::var("api_key") { + if !auth::is_key_secure() { + eprintln!("API key is insecure! Please change it. Current key is: {}. Generated secure key which you may use: {}", key, auth::gen_key()) + } + } + + // Tell the user that the server has started, and where it is listening to, rather than simply outputting nothing + eprintln!("Server has started at 0.0.0.0 on port {}. Configured Site URL is: {}", port, env::var("site_url").unwrap_or(String::from("http://localhost"))); + // Actually start the server HttpServer::new(move || { App::new() diff --git a/actix/src/services.rs b/actix/src/services.rs index b01516f..172612c 100644 --- a/actix/src/services.rs +++ b/actix/src/services.rs @@ -3,14 +3,11 @@ use actix_files::NamedFile; use actix_session::Session; -use actix_web::{ - delete, get, - http::StatusCode, - post, - web::{self, Redirect}, - Either, HttpResponse, Responder, -}; -use std::env; +use actix_web::{delete, get, http::StatusCode, post, web::{self, Redirect}, Either, HttpRequest, HttpResponse, Responder}; +use std::{env}; + +// Serialize JSON data +use serde::Serialize; use crate::auth; use crate::database; @@ -20,35 +17,68 @@ use crate::AppState; // Store the version number const VERSION: &str = env!("CARGO_PKG_VERSION"); +// Define JSON struct for returning JSON data +#[derive(Serialize)] +struct Response { + success: bool, + error: bool, + reason: String, +} + // Define the routes // Add new links #[post("/api/new")] -pub async fn add_link(req: String, data: web::Data, session: Session) -> HttpResponse { - if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) { +pub async fn add_link(req: String, data: web::Data, session: Session, http: HttpRequest) -> HttpResponse { + // Call is_api_ok() function, pass HttpRequest + let result = utils::is_api_ok(http); + // If success, add new link + if result.success { let out = utils::add_link(req, &data.db); if out.0 { HttpResponse::Created().body(out.1) } else { HttpResponse::Conflict().body(out.1) } + } else if result.error { + HttpResponse::Unauthorized().json(result) + // If "pass" is true - keeps backwards compatibility } else { - HttpResponse::Unauthorized().body("Not logged in!") + if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) { + let out = utils::add_link(req, &data.db); + if out.0 { + HttpResponse::Created().body(out.1) + } else { + HttpResponse::Conflict().body(out.1) + } + } else { + HttpResponse::Unauthorized().body("Not logged in!") + } } } // Return all active links #[get("/api/all")] -pub async fn getall(data: web::Data, session: Session) -> HttpResponse { - if auth::validate(session) { +pub async fn getall(data: web::Data, session: Session, http: HttpRequest) -> HttpResponse { + // Call is_api_ok() function, pass HttpRequest + let result = utils::is_api_ok(http); + // If success, return all links + if result.success { HttpResponse::Ok().body(utils::getall(&data.db)) + } else if result.error { + HttpResponse::Unauthorized().json(result) + // If "pass" is true - keeps backwards compatibility } else { - let body = if env::var("public_mode") == Ok(String::from("Enable")) { - "Using public mode." + if auth::validate(session){ + HttpResponse::Ok().body(utils::getall(&data.db)) } else { - "Not logged in!" - }; - HttpResponse::Unauthorized().body(body) + let body = if env::var("public_mode") == Ok(String::from("Enable")) { + "Using public mode." + } else { + "Not logged in!" + }; + HttpResponse::Unauthorized().body(body) + } } } @@ -105,20 +135,51 @@ pub async fn link_handler( // Handle login #[post("/api/login")] pub async fn login(req: String, session: Session) -> HttpResponse { - if let Ok(password) = env::var("password") { - if password != req { - eprintln!("Failed login attempt!"); - return HttpResponse::Unauthorized().body("Wrong password!"); + // Someone's API may be listening for the plain HTML body response of "Correct password!" + // rather than a 200 OK HTTP response. Because of that, a check is performed to see whether + // the api_key environment variable is set. If it is set, then it is assumed the user will expect a JSON response for all API routes. + // *If this is not a concern, this can be removed.* + if let Ok(_) = env::var("api_key") { + if let Ok(password) = env::var("password") { + if password != req { + eprintln!("Failed login attempt!"); + let response = Response { + success: false, + error: true, + reason: "Wrong password!".to_string() + }; + return HttpResponse::Unauthorized().json(response); + } } + // Return Ok if no password was set on the server side + session + .insert("chhoto-url-auth", auth::gen_token()) + .expect("Error inserting auth token."); + + let response = Response { + success: true, + error: false, + reason: "Correct password!".to_string() + }; + HttpResponse::Ok().json(response) + } else { + if let Ok(password) = env::var("password") { + if password != req { + eprintln!("Failed login attempt!"); + return HttpResponse::Unauthorized().body("Wrong password!"); + } + } + // Return Ok if no password was set on the server side + session + .insert("chhoto-url-auth", auth::gen_token()) + .expect("Error inserting auth token."); + + HttpResponse::Ok().body("Correct password!") } - // Return Ok if no password was set on the server side - session - .insert("chhoto-url-auth", auth::gen_token()) - .expect("Error inserting auth token."); - HttpResponse::Ok().body("Correct password!") } // Handle logout +// There's no reason to be calling this route with an API key, so it is not necessary to check if the api_key env variable is set. #[delete("/api/logout")] pub async fn logout(session: Session) -> HttpResponse { if session.remove("chhoto-url-auth").is_some() { @@ -134,14 +195,39 @@ pub async fn delete_link( shortlink: web::Path, data: web::Data, session: Session, + http: HttpRequest, ) -> HttpResponse { - if auth::validate(session) { + // Call is_api_ok() function, pass HttpRequest + let result = utils::is_api_ok(http); + // If success, delete shortlink + if result.success { if utils::delete_link(shortlink.to_string(), &data.db) { - HttpResponse::Ok().body(format!("Deleted {shortlink}")) + let response = Response { + success: true, + error: false, + reason: format!("Deleted {}", shortlink) + }; + HttpResponse::Ok().json(response) } else { - HttpResponse::NotFound().body("Not found!") + let response = Response { + success: false, + error: true, + reason: "The short link was not found, and could not be deleted.".to_string() + }; + HttpResponse::NotFound().json(response) } + } else if result.error { + HttpResponse::Unauthorized().json(result) + // If "pass" is true - keeps backwards compatibility } else { - HttpResponse::Unauthorized().body("Not logged in!") + if auth::validate(session) { + if utils::delete_link(shortlink.to_string(), &data.db) { + HttpResponse::Ok().body(format!("Deleted {shortlink}")) + } else { + HttpResponse::NotFound().body("Not found!") + } + } else { + HttpResponse::Unauthorized().body("Not logged in!") + } } } diff --git a/actix/src/utils.rs b/actix/src/utils.rs index a72f36e..bd05325 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -5,10 +5,10 @@ use nanoid::nanoid; use rand::seq::SliceRandom; use regex::Regex; use rusqlite::Connection; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::env; - -use crate::database; +use actix_web::HttpRequest; +use crate::{auth, database}; // Struct for reading link pairs sent during API call #[derive(Deserialize)] @@ -17,6 +17,42 @@ struct URLPair { longlink: String, } +// Define JSON struct for response +#[derive(Serialize)] +pub struct Response { + pub(crate) success: bool, + pub(crate) error: bool, + reason: String, + pass: bool, +} + +// If the api_key environment variable eists +pub fn is_api_ok(http: HttpRequest) -> Response { + // If the api_key environment variable exists + if let Ok(_) = env::var("api_key") { + // If the header exists + if let Some(header) = auth::api_header(&http) { + // If the header is correct + if auth::validate_key(header.to_string()) { + let result = Response { success: true, error: false, reason: "".to_string(), pass: false }; + result + } else { + let result = Response { success: false, error: true, reason: "Incorrect API key".to_string(), pass: false }; + result + } + // The header may not exist when the user logs in through the web interface, so allow a request with no header. + // Further authentication checks will be conducted in services.rs + } else { + let result = Response { success: false, error: false, reason: "Chhoto-Api-Key header not found".to_string(), pass: true }; + result + } + } else { + let result = Response {success: false, error: false, reason: "".to_string(), pass: true}; + result + } +} + + // Request the DB for searching an URL pub fn get_longurl(shortlink: String, db: &Connection) -> Option { if validate_link(&shortlink) { diff --git a/compose.yaml b/compose.yaml index 75443e4..256b19d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,7 +3,7 @@ services: chhoto-url: - image: sintan1729/chhoto-url:latest + image: chhoto-url restart: unless-stopped container_name: chhoto-url ports: @@ -24,7 +24,9 @@ services: # - site_url=https://www.example.com - password=TopSecretPass - + + - api_key=test + # Pass the redirect method, if needed. TEMPORARY and PERMANENT # are accepted values, defaults to PERMANENT. # - redirect_method=TEMPORARY @@ -33,12 +35,12 @@ services: # If you want UIDs, please change slug_style to UID. # Supported values for slug_style are Pair and UID. # The length is 8 by default, and a minimum of 4 is allowed. - # - slug_style=Pair - # - slug_length=8 + - slug_style=Pair + - slug_length=8 # In case you want to provide public access to adding links (and not # delete, or listing), change the following option to Enable. - # - public_mode=Disable + - public_mode=Disable # By default, the server sends no Cache-Control headers. You can supply a # comma separated list of valid header as per RFC 7234 ยง5.2 to send those