From 7ff86b3e02d6cb509e38aca12f9ed83279bfdee8 Mon Sep 17 00:00:00 2001 From: minoplhy Date: Fri, 1 Nov 2024 13:45:13 +0700 Subject: [PATCH] add: simple api-key authentication --- actix/src/auth.rs | 13 +++++++++++ actix/src/database.rs | 25 +++++++++++++++++++++ actix/src/main.rs | 1 + actix/src/services.rs | 46 +++++++++++++++++++++++++++++++------- actix/src/utils.rs | 19 +++++++++++++++- resources/index.html | 5 ++++- resources/static/script.js | 20 +++++++++++++++++ 7 files changed, 119 insertions(+), 10 deletions(-) diff --git a/actix/src/auth.rs b/actix/src/auth.rs index 3ca4495..abf2446 100644 --- a/actix/src/auth.rs +++ b/actix/src/auth.rs @@ -2,8 +2,12 @@ // SPDX-License-Identifier: MIT use actix_session::Session; +use actix_web::{web, HttpRequest}; use std::{env, time::SystemTime}; +use crate::database::get_api_key; +use crate::AppState; + // Validate a given password pub fn validate(session: Session) -> bool { // If there's no password provided, just return true @@ -22,6 +26,15 @@ pub fn validate(session: Session) -> bool { } } +// Validate x-api-header to match the key in database +pub fn apikey_validate(httprequest: HttpRequest, data: web::Data) -> bool { + httprequest.headers() + .get("x-api-key") + .and_then(|h| h.to_str().ok()) + .map(|key| key == get_api_key(&data.db)) + .unwrap_or(false) +} + // Check a token cryptographically fn check(token: Option) -> bool { if let Some(token_body) = token { diff --git a/actix/src/database.rs b/actix/src/database.rs index c694c49..f3be580 100644 --- a/actix/src/database.rs +++ b/actix/src/database.rs @@ -86,6 +86,23 @@ pub fn edit_link(shortlink: String, longlink: String, db: &Connection) -> bool { .is_ok() } +pub fn add_api_key(api_key: String, db: &Connection) -> bool { + db.execute( + "INSERT OR REPLACE INTO api (id, api_key) VALUES (0, ?1);", + [api_key] + ) + .is_ok() +} + +pub fn get_api_key(db: &Connection) -> String { + let query = db.query_row( + "SELECT api_key FROM api WHERE id = 0", + [], + |row| row.get::<_, String>(0), + ); + query.expect("Failed to fetch API key") +} + // Open the DB, and create schema if missing pub fn open_db(path: String) -> Connection { let db = Connection::open(path).expect("Unable to open database!"); @@ -100,5 +117,13 @@ pub fn open_db(path: String) -> Connection { [], ) .expect("Unable to initialize empty database."); + // create table if doesn't exist. For API key! + db.execute("CREATE TABLE IF NOT EXISTS api ( + id INTEGER PRIMARY KEY CHECK (id = 0), + api_key TEXT NOT NULL + )", + [] + ) + .expect("Unable to initialize empty database."); db } diff --git a/actix/src/main.rs b/actix/src/main.rs index 27f9ac5..66e74b6 100644 --- a/actix/src/main.rs +++ b/actix/src/main.rs @@ -66,6 +66,7 @@ async fn main() -> Result<()> { .service(services::edit_link) .service(services::delete_link) .service(services::login) + .service(services::gen_api_key) .service(services::logout) .service(Files::new("/", "./resources/").index_file("index.html")) .default_service(actix_web::web::get().to(services::error404)) diff --git a/actix/src/services.rs b/actix/src/services.rs index e435cd0..921a749 100644 --- a/actix/src/services.rs +++ b/actix/src/services.rs @@ -4,11 +4,11 @@ use actix_files::NamedFile; use actix_session::Session; use actix_web::{ - delete, get, http::StatusCode, post, put, web::{self, Redirect}, Either, HttpResponse, Responder + delete, get, http::StatusCode, post, put, web::{self, Redirect}, Either, HttpRequest, HttpResponse, Responder }; use std::env; -use crate::auth; +use crate::auth::{self, apikey_validate}; use crate::database; use crate::utils; use crate::AppState; @@ -20,8 +20,13 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); // 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, + httprequest: HttpRequest) + -> HttpResponse { + if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) || apikey_validate(httprequest, data.clone()) { let out = utils::add_link(req, &data.db); if out.0 { HttpResponse::Created().body(out.1) @@ -35,8 +40,12 @@ 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, + httprequest: HttpRequest +) -> HttpResponse { + if auth::validate(session) || apikey_validate(httprequest, data.clone()) { HttpResponse::Ok().body(utils::getall(&data.db)) } else { let body = if env::var("public_mode") == Ok(String::from("Enable")) { @@ -114,6 +123,25 @@ pub async fn login(req: String, session: Session) -> HttpResponse { HttpResponse::Ok().body("Correct password!") } +// Create API Key, Will be disabled on public mode +#[post("/api/key")] +pub async fn gen_api_key(session: Session, httprequest: HttpRequest, data: web::Data) -> HttpResponse { + if env::var("public_mode") == Ok(String::from("Enable")) { + return HttpResponse::Forbidden().body("Public mode is enabled!"); + } + + if auth::validate(session) || apikey_validate(httprequest, data.clone()) { + let key = utils::gen_api_key(&data.db); + if key.0 { + HttpResponse::Ok().body(key.1) + } else { + HttpResponse::Conflict().body("Generate Api Key Error!") + } + } else { + HttpResponse::Unauthorized().body("Not logged in!") + } +} + // Handle logout #[delete("/api/logout")] pub async fn logout(session: Session) -> HttpResponse { @@ -131,8 +159,9 @@ pub async fn edit_link( shortlink: web::Path, data: web::Data, session: Session, + httprequest: HttpRequest, ) -> HttpResponse { - if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) { + if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) || apikey_validate(httprequest, data.clone()) { let out = utils::edit_link(req, shortlink.to_string(), &data.db); if out.0 { HttpResponse::Created().body(out.1) @@ -150,8 +179,9 @@ pub async fn delete_link( shortlink: web::Path, data: web::Data, session: Session, + httprequest: HttpRequest, ) -> HttpResponse { - if auth::validate(session) { + if auth::validate(session) || apikey_validate(httprequest, data.clone()) { 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 a68a54d..8a538b3 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -6,8 +6,9 @@ use rand::seq::SliceRandom; use regex::Regex; use rusqlite::Connection; use serde::Deserialize; -use std::env; +use std::{env, iter}; use once_cell::sync::Lazy; +use rand::Rng; use crate::database; @@ -144,6 +145,22 @@ pub fn delete_link(shortlink: String, db: &Connection) -> bool { } } +// Generate a simple API Key(totally not secured!) +pub fn gen_api_key(db: &Connection) -> (bool, String) { + let generated_key: String = generate_string(32); + (database::add_api_key(generated_key.clone(), db), + generated_key) +} + +// Generate Random String +// From: https://stackoverflow.com/a/74953997 +fn generate_string(len: usize) -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let mut rng = rand::thread_rng(); + let one_char = || CHARSET[rng.gen_range(0..CHARSET.len())] as char; + iter::repeat_with(one_char).take(len).collect() +} + // Generate a random link using either adjective-name pair (default) of a slug or a-z, 0-9 fn gen_link(style: String, len: usize) -> String { #[rustfmt::skip] diff --git a/resources/index.html b/resources/index.html index 7b37db2..be08ed4 100644 --- a/resources/index.html +++ b/resources/index.html @@ -69,11 +69,14 @@
  + +   -
+
+

Please enter password to access this website

diff --git a/resources/static/script.js b/resources/static/script.js index fb3f898..19534fd 100644 --- a/resources/static/script.js +++ b/resources/static/script.js @@ -48,6 +48,7 @@ const refreshData = async () => { console.log(errorMsg); if (errorMsg == "Using public mode.") { document.getElementById("admin-button").hidden = false; + document.getElementById("api-key-button").hidden = true; // Hide initially loading_text = document.getElementById("loading-text"); loading_text.hidden = true; showVersion(); @@ -57,6 +58,7 @@ const refreshData = async () => { } else { let data = await res.json(); displayData(data); + document.getElementById("api-key-button").hidden = false; // Show API Key button when logged in } } @@ -68,6 +70,10 @@ const displayData = async (data) => { admin_button.href = "javascript:logOut()"; admin_button.hidden = false; + apikey_button = document.getElementById("api-key-button"); + apikey_button.innerText = "API Key" + apikey_button.hidden = false; + table_box = document.getElementById("table-box"); loading_text = document.getElementById("loading-text"); const table = document.getElementById("url-table"); @@ -289,6 +295,20 @@ const submitLogin = () => { }) } +const fetchApiKey = async () => { + const response = await fetch(prepSubdir("/api/key"), { + method: "POST" + }); + + if (response.ok) { + const apiKey = await response.text(); + prompt("API Key:", apiKey); + } else { + const error_text = await response.text(); + alert("Failed to fetch API Key: " + error_text); + } +} + const logOut = async () => { let reply = await fetch(prepSubdir("/api/logout"), {method: "DELETE"}).then(res => res.text()); console.log(reply);