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/Makefile b/Makefile index 85ae80d..8f5f8ae 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,7 @@ docker-test: docker-local docker-stop docker run -p ${PORT}:${PORT} --name chhoto-url -e password="${PASSWORD}" -e public_mode="${PUBLIC_MODE}" \ -e site_url="${SITE_URL}" -e db_url="${DB_URL}" -e redirect_method="${REDIRECT_METHOD}" -e port="${PORT}"\ -e slug_style="${SLUG_STYLE}" -e slug_length="${SLUG_LENGTH}" -e cache_control_header="${CACHE_CONTROL_HEADER}"\ + -e api_key="${API_KEY}"\ -d chhoto-url docker logs chhoto-url -f diff --git a/README.md b/README.md index 0474cfd..3fc90ee 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 Further, 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,8 +159,11 @@ 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. -If you have set up -a password, first do the following to get an authentication cookie and store it in a file. +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`. These routes are accessible without any authentication. + +### 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 curl -X POST -d "" -c cookie.txt http://localhost:4567/api/login ``` @@ -173,8 +187,33 @@ curl -X DELETE http://localhost:4567/api/del/ ``` The server will send a confirmation. -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`. +### 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. + +Example Linux command for generating a secure API key: `tr -dc A-Za-z0-9 " -d '{"shortlink":"", "longlink":""}' http://localhost:4567/api/new +``` +Send an empty `` if you want it to be auto-generated. The server will reply with the generated shortlink. + +To get a list of all the currently available links: +``` bash +curl -H "X-API-Key: " http://localhost:4567/api/all +``` + +To delete a link: +``` bash +curl -X DELETE -H "X-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. ## Disable authentication If you do not define a password environment variable when starting the docker image, authentication 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..611d93d 100644 --- a/actix/src/auth.rs +++ b/actix/src/auth.rs @@ -2,8 +2,55 @@ // SPDX-License-Identifier: MIT use actix_session::Session; +use actix_web::HttpRequest; use std::{env, time::SystemTime}; +// API key generation and scoring +use passwords::{analyzer, scorer, PasswordGenerator}; + +// 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("X-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 { // If there's no password provided, just return true 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..825ab4a 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,21 @@ 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()) + } else { + eprintln!("Secure API key was provided.") + } + } + + // 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 {port}."); + if let Some(site_url) = env::var("site_url").ok().filter(|s| !s.trim().is_empty()) { + eprintln!("Configured Site URL is: {site_url}."); + } + // Actually start the server HttpServer::new(move || { App::new() diff --git a/actix/src/services.rs b/actix/src/services.rs index b01516f..a5c80db 100644 --- a/actix/src/services.rs +++ b/actix/src/services.rs @@ -8,10 +8,13 @@ use actix_web::{ http::StatusCode, post, web::{self, Redirect}, - Either, HttpResponse, Responder, + Either, HttpRequest, HttpResponse, Responder, }; use std::env; +// Serialize JSON data +use serde::Serialize; + use crate::auth; use crate::database; use crate::utils; @@ -20,12 +23,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, +} + +// 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 { + 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") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or(String::from("http://localhost")), + port + ); + let response = CreatedURL { + success: true, + error: false, + shorturl: format!("{}/{}", url, out.1), + }; + HttpResponse::Created().json(response) + } else { + let response = Response { + success: false, + error: true, + reason: out.1, + }; + HttpResponse::Conflict().json(response) + } + } else if result.error { + HttpResponse::Unauthorized().json(result) + // If password authentication or public mode is used - keeps backwards compatibility + } else 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) @@ -39,8 +98,20 @@ pub async fn add_link(req: String, data: web::Data, session: Session) // 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 password authentication is used - keeps backwards compatibility + } else if auth::validate(session) { HttpResponse::Ok().body(utils::getall(&data.db)) } else { let body = if env::var("public_mode") == Ok(String::from("Enable")) { @@ -105,20 +176,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,8 +233,31 @@ 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) { + let response = Response { + success: true, + error: false, + reason: format!("Deleted {}", shortlink), + }; + HttpResponse::Ok().json(response) + } else { + 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 if auth::validate(session) { if utils::delete_link(shortlink.to_string(), &data.db) { HttpResponse::Ok().body(format!("Deleted {shortlink}")) } else { diff --git a/actix/src/utils.rs b/actix/src/utils.rs index a72f36e..c548907 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -1,15 +1,15 @@ // SPDX-FileCopyrightText: 2023 Sayantan Santra // SPDX-License-Identifier: MIT +use crate::{auth, database}; +use actix_web::HttpRequest; 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; - // Struct for reading link pairs sent during API call #[derive(Deserialize)] struct URLPair { @@ -17,6 +17,68 @@ 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 exists +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: "Correct API key".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 { + // Due to the implementation of this result in services.rs, this JSON object will not be outputted. + Response { + success: false, + error: false, + reason: "X-API-Key header was 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: "An API key was provided, but the 'api_key' environment variable is not configured in the Chhoto URL instance".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..7236653 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 output + # 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