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 0474cfd..a31b59f 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,17 @@ docker run -p 4567:4567 \ -e site_url="https://www.example.com" \ -d chhoto-url:latest ``` +1.c Optionally, set an API key to activate JSON result mode (optional) + +``` +docker run -p 4567:4567 \ + -e password="password" \ + -e api_key="SECURE_API_KEY" \ + -v ./urls.sqlite:/urls.sqlite \ + -e db_url=/urls.sqlite \ + -e site_url="https://www.example.com" \ + -d chhoto-url:latest +``` You can set the redirect method to Permanent 308 (default) or Temporary 307 by setting the `redirect_method` variable to `TEMPORARY` or `PERMANENT` (it's matched exactly). By @@ -148,6 +159,7 @@ served through a proxy. The application can be used from the terminal using something like `curl`. In all the examples below, replace `http://localhost:4567` with where your instance of `chhoto-url` is accessible. +### Cookie validation If you have set up a password, first do the following to get an authentication cookie and store it in a file. ```bash @@ -173,6 +185,33 @@ curl -X DELETE http://localhost:4567/api/del/ ``` The server will send a confirmation. +### API key validation +**This is required for programs that rely on a JSON response from Chhoto URL** + +In order to use API key validation, set the `api_key` environment variable. If this is not set, the API will default to cookie validation (see section above). +If the API key is insecure, a warning will be outputted along with a generated API key which may be used. + +To add a link: +``` bash +curl -X POST -H "Chhoto-Api-Key: " -d '{"shortlink":"", "longlink":""}' http://localhost:4567/api/new +``` + +To get a list of all the currently available links: +``` bash +curl -H "Chhoto-Api-Key: " http://localhost:4567/api/all +``` + +To delete a link: +``` bash +curl -X DELETE -H "Chhoto-Api-Key: " http://localhost:4567/api/del/ +``` +Where `` is name of the shortened link you would like to delete. For example, if the shortened link is `http://localhost:4567/example`, `` would be `example`. + +The server will output when the instance is accessed over API, when an incorrect API key is received, etc. + + +In both modes, these routes are accessible: + You can get the version of `chhoto-url` the server is running using `curl http://localhost:4567/api/version` and get the siteurl using `curl http://localhost:4567/api/siteurl`. diff --git a/actix/Cargo.lock b/actix/Cargo.lock index 0217de1..29c7760 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" @@ -476,13 +476,14 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chhoto-url" -version = "5.4.5" +version = "5.4.6" dependencies = [ "actix-files", "actix-session", "actix-web", "env_logger", "nanoid", + "passwords", "rand", "regex", "rusqlite", @@ -1227,6 +1228,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "passwords" +version = "3.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11407193a7c2bd14ec6b0ec3394da6fdcf7a4d5dcbc8c3cc38dfb17802c8d59c" +dependencies = [ + "random-pick", +] + [[package]] name = "paste" version = "1.0.15" @@ -1284,6 +1294,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.89" @@ -1332,6 +1348,37 @@ dependencies = [ "getrandom", ] +[[package]] +name = "random-number" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc8cdd49be664772ffc3dbfa743bb8c34b78f9cc6a9f50e56ae878546796067" +dependencies = [ + "proc-macro-hack", + "rand", + "random-number-macro-impl", +] + +[[package]] +name = "random-number-macro-impl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5135143cb48d14289139e4615bffec0d59b4cbfd4ea2398a3770bd2abfc4aa2" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", +] + +[[package]] +name = "random-pick" +version = "1.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c179499072da789afe44127d5f4aa6012de2c2f96ef759990196b37387a2a0f8" +dependencies = [ + "random-number", +] + [[package]] name = "redox_syscall" version = "0.5.7" diff --git a/actix/Cargo.toml b/actix/Cargo.toml index c3d9e7e..7d09eeb 100644 --- a/actix/Cargo.toml +++ b/actix/Cargo.toml @@ -32,6 +32,7 @@ actix-files = "0.6.5" 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..c6ab4bd 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())); + score >= 90.0 +} // Validate a given password pub fn validate(session: Session) -> bool { diff --git a/actix/src/database.rs b/actix/src/database.rs index 87ddec9..ab0277a 100644 --- a/actix/src/database.rs +++ b/actix/src/database.rs @@ -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..79dcfa7 100644 --- a/actix/src/main.rs +++ b/actix/src/main.rs @@ -24,6 +24,7 @@ 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()) @@ -38,6 +39,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..4b4a573 100644 --- a/actix/src/services.rs +++ b/actix/src/services.rs @@ -3,15 +3,12 @@ use actix_files::NamedFile; use actix_session::Session; -use actix_web::{ - delete, get, - http::StatusCode, - post, - web::{self, Redirect}, - Either, HttpResponse, Responder, -}; +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; use crate::utils; @@ -20,35 +17,91 @@ 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, +} + +// Needs to return the short URL to make it easier for programs leveraging the API +#[derive(Serialize)] +struct CreatedURL { + success: bool, + error: bool, + shorturl: 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) + let port = env::var("port") + .unwrap_or(String::from("4567")) + .parse::() + .expect("Supplied port is not an integer"); + let url = format!("{}:{}", env::var("site_url").unwrap_or(String::from("http://localhost")), port); + let response = CreatedURL { + success: true, + error: false, + shorturl: format!("{}/{}", url, out.1) + }; + HttpResponse::Created().json(response) } else { - HttpResponse::Conflict().body(out.1) + let response = Response { + success: false, + error: true, + reason: out.1 + }; + HttpResponse::Conflict().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 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 +158,48 @@ 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!"); + // Keep this function backwards compatible + if env::var("api_key").is_ok() { + 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 +215,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..c596603 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,43 @@ 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 env::var("api_key").is_ok() { + // If the header exists + if let Some(header) = auth::api_header(&http) { + // If the header is correct + if auth::validate_key(header.to_string()) { + Response { success: true, error: false, reason: "".to_string(), pass: false } + } else { + Response { success: false, error: true, reason: "Incorrect API key".to_string(), pass: false } + } + // 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 { + Response { success: false, error: false, reason: "Chhoto-Api-Key header not found".to_string(), pass: true } + } + } else { + // If the API key isn't set, but an API Key header is provided + if auth::api_header(&http).is_some() { + Response {success: false, error: true, reason: "API key access was attempted, but no API key is configured".to_string(), pass: false} + } else { + Response {success: false, error: false, reason: "".to_string(), pass: true} + } + } +} + + // 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..78a51d6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,7 +24,12 @@ services: # - site_url=https://www.example.com - password=TopSecretPass - + + # This needs to be set in order to use programs that use the JSON interface of Chhoto URL. + # You will get a warning if this is insecure, and a generated value will be outputted + # You may use that value if you can't think of a secure key + # - api_key=SECURE_API_KEY + # Pass the redirect method, if needed. TEMPORARY and PERMANENT # are accepted values, defaults to PERMANENT. # - redirect_method=TEMPORARY