From 8d68ac0b56fa6be0eb5215f346d513b9a2ef37b8 Mon Sep 17 00:00:00 2001 From: KunoiSayami <46131041+KunoiSayami@users.noreply.github.com> Date: Sat, 15 May 2021 02:38:31 +0800 Subject: feat(core): Set protect policy default enabled * fix(auth): Fix authenticate cookie failure in root page --- Cargo.lock | 7 +- Cargo.toml | 5 +- src/datastructures.rs | 196 +++++++++++++++++++++++--------------------------- src/main.rs | 148 +++++++++++++++++++++++--------------- 4 files changed, 188 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb44441..964e52e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -377,12 +377,13 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "cgit-simple-authentication" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "argon2", "base64", "clap", + "cpufeatures", "env_logger", "handlebars", "log", @@ -473,9 +474,9 @@ checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" [[package]] name = "cpufeatures" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec1028182c380cc45a2e2c5ec841134f2dfd0f8f5f0a5bcd68004f81b5efdf4" +checksum = "281f563b2c3a0e535ab12d81d3c5859045795256ad269afa7c19542585b68f93" dependencies = [ "libc", ] diff --git a/Cargo.toml b/Cargo.toml index 6f038c4..b0a36b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cgit-simple-authentication" -version = "0.4.0" +version = "0.4.1" authors = ["KunoiSayami <46131041+KunoiSayami@users.noreply.github.com>"] edition = "2018" @@ -29,4 +29,5 @@ tempdir = "0.3" regex = "1" [target.aarch64-unknown-linux-musl.dependencies] -openssl = { version = "0.10", features = ["vendored"] } \ No newline at end of file +openssl = { version = "0.10", features = ["vendored"] } +cpufeatures = "0.1.3" \ No newline at end of file diff --git a/src/datastructures.rs b/src/datastructures.rs index 81e04aa..2f5230c 100644 --- a/src/datastructures.rs +++ b/src/datastructures.rs @@ -78,11 +78,28 @@ pub(crate) trait TestSuite { pub struct Config { pub cookie_ttl: u64, database: String, - //access_node: hashmap, pub bypass_root: bool, - //secret: String, pub(crate) test: bool, - protected_repos: ProtectedRepo, + + /// To set specify repository protect, You should setup repo's protect attribute + /// First, set cgit-simple-auth-protect to none in /etc/cgitrc file + /// + /// # Examples + /// + /// In /etc/cgitrc: + /// ```conf + /// cgit-simple-auth-protect=none + /// ``` + /// + /// In repo.conf + /// ```conf + /// repo.url=test + /// repo.protected=true + /// ``` + /// + /// Default behavior is protect all repository + protect_all: bool, + protected_repos: Vec, } impl Default for Config { @@ -91,8 +108,8 @@ impl Default for Config { cookie_ttl: DEFAULT_COOKIE_TTL, database: DEFAULT_DATABASE_LOCATION.to_string(), bypass_root: false, - //secret: Default::default(), test: false, + protect_all: true, protected_repos: Default::default(), } } @@ -105,10 +122,12 @@ impl Config { pub fn load_from_path>(path: P) -> Self { let file = read_to_string(&path).unwrap_or_default(); + let mut cookie_ttl: u64 = DEFAULT_COOKIE_TTL; let mut database: &str = "/etc/cgit/auth.db"; let mut bypass_root: bool = false; - //let mut secret: &str = ""; + let mut protect_all: bool = true; + for line in file.lines() { let line = line.trim(); if !line.contains('=') || !line.starts_with("cgit-simple-auth-") { @@ -120,23 +139,74 @@ impl Config { } else { line.split_once('=').unwrap() }; + let value = value.trim(); let key_name = key.split_once("auth-").unwrap().1.trim(); match key_name { "cookie-ttl" => cookie_ttl = value.parse().unwrap_or(DEFAULT_COOKIE_TTL), "database" => database = value, "bypass-root" => bypass_root = value.to_lowercase().eq("true"), - //"secret" => secret = value, + "protect" => protect_all = !value.to_lowercase().eq("none"), _ => {} } } + + let protected_repos = if !protect_all { + Self::load_protect_repos_from_file(path) + } else { + Default::default() + }; + Self { cookie_ttl, database: database.to_string(), bypass_root, test: false, - //secret: secret.to_string(), - protected_repos: ProtectedRepo::load_from_file(path), + protect_all, + protected_repos, + } + } + + pub fn load_protect_repos_from_file>(path: P) -> Vec { + let context = read_to_string(path).unwrap(); + + let mut protect_repos = Vec::new(); + + let mut current_repo: &str = ""; + + for line in context.lines() { + let line = line.trim(); + if line.starts_with('#') || !line.contains('=') { + continue; + } + + let (key, value) = if line.contains('#') { + line.split_once('#') + .unwrap() + .0 + .trim() + .split_once('=') + .unwrap() + } else { + line.split_once('=').unwrap() + }; + + if key.eq("repo.url") { + current_repo = value.trim(); + continue; + } + + if key.eq("repo.protect") { + if value.trim().to_lowercase().eq("true") && !current_repo.is_empty() { + protect_repos.push(current_repo.to_string()); + } + } + + if key.eq("include") { + let r = Self::load_protect_repos_from_file(value); + protect_repos.extend(r) + } } + protect_repos } pub fn get_database_location(&self) -> &str { @@ -206,7 +276,15 @@ impl Config { } pub fn check_repo_protect(&self, repo: &str) -> bool { - self.protected_repos.query_is_protected(repo) + if self.protect_all { + return true; + } + self.protected_repos.iter().any(|x| x.eq(repo)) + } + + #[cfg(test)] + pub(crate) fn query_is_all_protected(&self) -> bool { + self.protect_all } } @@ -217,7 +295,8 @@ impl TestSuite for Config { bypass_root: false, cookie_ttl: DEFAULT_COOKIE_TTL, test: true, - protected_repos: Default::default(), + protect_all: false, + protected_repos: vec!["test".to_string()], } } } @@ -402,100 +481,3 @@ impl std::fmt::Display for Cookie { write!(f, "{}", base64::encode(s)) } } - - -/// To set specify repository protect, You should setup repo's protect attribute -/// -/// # Examples -/// -/// ```conf -/// repo.url=test -/// repo.protected=true -/// ``` -/// -/// OR: -/// -/// Set all repository under protected, by set cgit-simple-auth-protect=full - -#[derive(Debug, Clone)] -pub struct ProtectedRepo { - protect_all: bool, - protect_repos: Vec, -} - -impl ProtectedRepo { - - pub fn load_from_file>(path: P) -> Self { - let context = read_to_string(path).unwrap(); - - let mut protect_repos = Vec::new(); - - let mut protect_all = false; - let mut current_repo: &str = ""; - - for line in context.lines() { - let line = line.trim(); - if line.starts_with('#') || !line.contains('=') { - continue - } - - let (key, value) = if line.contains('#') { - line.split_once('#').unwrap().0.trim().split_once('=').unwrap() - } else { - line.split_once('=').unwrap() - }; - - if key.eq("repo.url") { - current_repo = value.trim(); - continue - } - - if key.eq("repo.protect") { - if value.trim().to_lowercase().eq("true") && !current_repo.is_empty() { - protect_repos.push(current_repo.to_string()); - } - } - - if key.eq("include") { - let r = Self::load_from_file(value); - if r.protect_all { - protect_all = true; - break - } - protect_repos.extend(r.protect_repos) - } - - if key.eq("cgit-simple-auth-protect") && value.trim().to_lowercase().eq("full") { - protect_all = true; - break - } - } - if protect_all { - protect_repos.clear(); - } - Self { - protect_all, - protect_repos, - } - } - - pub fn query_is_protected(&self, repo: &str) -> bool { - if self.protect_all { - return true - } - self.protect_repos.iter().any(|x| x.eq(repo)) - } - - pub fn query_is_all_protected(&self) -> bool { - self.protect_all - } -} - -impl Default for ProtectedRepo { - fn default() -> Self { - Self { - protect_all: true, - protect_repos: Default::default() - } - } -} diff --git a/src/main.rs b/src/main.rs index 7edd019..b422db1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,7 +55,6 @@ impl IOModule { //log::debug!("{}", buffer); let data = datastructures::FormData::from(buffer); - let ret = verify_login(&cfg, &data).await; if let Err(ref e) = ret { @@ -112,7 +111,7 @@ async fn cmd_authenticate_cookie(matches: &ArgMatches<'_>, cfg: Config) -> Resul bypass = true; } - if bypass || !cfg.check_repo_protect(repo){ + if bypass || (!repo.is_empty() && !cfg.check_repo_protect(repo)) { return Ok(true); } @@ -123,24 +122,23 @@ async fn cmd_authenticate_cookie(matches: &ArgMatches<'_>, cfg: Config) -> Resul let redis_conn = redis::Client::open("redis://127.0.0.1/")?; let mut conn = redis_conn.get_async_connection().await?; + let redis_key = format!("cgit_repo_{}", repo); if !repo.is_empty() { - let key = format!("cgit_repo_{}", repo); - if !conn.exists(&key).await? { + if !conn.exists(&redis_key).await? { let mut sql_conn = SqliteConnectOptions::from_str(cfg.get_database_location())? .read_only(true) .connect() .await?; - if let Some((users, )) = - sqlx::query_as::<_, (String, )>(r#"SELECT "users" FROM "repos" WHERE "repo" = ? "#) - .bind(repo) - .fetch_optional(&mut sql_conn) - .await? + if let Some((users,)) = + sqlx::query_as::<_, (String,)>(r#"SELECT "users" FROM "repos" WHERE "repo" = ? "#) + .bind(repo) + .fetch_optional(&mut sql_conn) + .await? { - let iter = users.split_whitespace().collect::>(); - conn.sadd(&key, iter).await?; + let users = users.split_whitespace().collect::>(); + conn.sadd(&redis_key, users).await?; } } - // TODO: redis repository ACL check should goes here } if let Ok(Some(cookie)) = Cookie::load_from_request(cookies) { @@ -149,7 +147,16 @@ async fn cmd_authenticate_cookie(matches: &ArgMatches<'_>, cfg: Config) -> Resul .await { if cookie.eq_body(r.as_str()) { - return Ok(true); + if repo.is_empty() { + return Ok(true); + } + if conn + .sismember::<_, _, i32>(&redis_key, cookie.get_user()) + .await? + == 1 + { + return Ok(true); + } } } log::debug!("{:?}", cookie); @@ -209,12 +216,11 @@ async fn verify_login(cfg: &Config, data: &FormData) -> Result { .connect() .await?; - let (passwd_hash,) = sqlx::query_as::<_, (String,)>( - r#"SELECT "password" FROM "accounts" WHERE "user" = ?"#, - ) - .bind(data.get_user()) - .fetch_one(&mut conn) - .await?; + let (passwd_hash,) = + sqlx::query_as::<_, (String,)>(r#"SELECT "password" FROM "accounts" WHERE "user" = ?"#) + .bind(data.get_user()) + .fetch_one(&mut conn) + .await?; let parsed_hash = PasswordHash::new(passwd_hash.as_str()).unwrap(); Ok(data.verify_password(&parsed_hash)) @@ -426,13 +432,20 @@ async fn cmd_upgrade_database(cfg: Config) -> Result<()> { Ok(()) } -async fn cmd_repo_user_control(matches: &ArgMatches<'_>, cfg: Config, is_delete: bool) -> Result<()> { +async fn cmd_repo_user_control( + matches: &ArgMatches<'_>, + cfg: Config, + is_delete: bool, +) -> Result<()> { let repo = matches.value_of("repo").unwrap_or(""); let user = matches.value_of("user").unwrap_or(""); let clear_all = matches.is_present("clear-all"); - if repo.is_empty() || (is_delete && !clear_all && user.is_empty()) || (!is_delete && user.is_empty()) { + if repo.is_empty() + || (is_delete && !clear_all && user.is_empty()) + || (!is_delete && user.is_empty()) + { return Err(anyhow::Error::msg("Invalid repository or username")); } @@ -445,10 +458,11 @@ async fn cmd_repo_user_control(matches: &ArgMatches<'_>, cfg: Config, is_delete: .bind(repo) .fetch_optional(&mut conn) .await? - .is_none() { + .is_none() + { if is_delete { println!("Row is empty."); - return Ok(()) + return Ok(()); } sqlx::query(r#"INSERT INTO "repos" VALUES (?, ?)"#) .bind(repo) @@ -488,7 +502,7 @@ async fn cmd_repo_user_control(matches: &ArgMatches<'_>, cfg: Config, is_delete: .await?; let redis_key = format!("cgit_repo_{}", repo); - if redis_conn.exists::<_, i32>(&redis_key).await? == 0{ + if redis_conn.exists::<_, i32>(&redis_key).await? == 0 { redis_conn.sadd::<_, _, i32>(&redis_key, users).await?; } else { if is_delete { @@ -503,11 +517,12 @@ async fn cmd_repo_user_control(matches: &ArgMatches<'_>, cfg: Config, is_delete: } if !clear_all { - println!("{} user {} {} repository {} ACL successful", - if is_delete { "Delete" } else { "Add" }, - user, - if is_delete { "from" } else { "to" }, - repo, + println!( + "{} user {} {} repository {} ACL successful", + if is_delete { "Delete" } else { "Add" }, + user, + if is_delete { "from" } else { "to" }, + repo, ); } else { println!("Clear all users from repository {} ACL", repo); @@ -562,9 +577,7 @@ async fn async_main(arg_matches: ArgMatches<'_>) -> Result { ("upgrade", Some(_matches)) => { cmd_upgrade_database(cfg).await?; } - ("repoadd", Some(matches)) => { - cmd_repo_user_control(matches, cfg, false).await? - } + ("repoadd", Some(matches)) => cmd_repo_user_control(matches, cfg, false).await?, ("repodel", Some(matches)) => { cmd_repo_user_control(matches, cfg, true).await?; } @@ -713,8 +726,8 @@ fn main() -> Result<()> { #[cfg(test)] mod test { - use crate::datastructures::{rand_str, Config, TestSuite, ProtectedRepo}; - use crate::{cmd_add_user, cmd_authenticate_cookie, cmd_init}; + use crate::datastructures::{rand_str, Config, TestSuite}; + use crate::{cmd_add_user, cmd_authenticate_cookie, cmd_init, cmd_repo_user_control}; use crate::{get_arg_matches, IOModule}; use argon2::{ password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, @@ -870,12 +883,24 @@ mod test { #[test] fn test_03_insert_repo() { lock(&PathBuf::from("test/USER_WRITTEN"), 5); - let matches = crate::get_arg_matches(Some(vec!["a", "repoadd", "hunter2", "hunter2"])); + let matches = crate::get_arg_matches(Some(vec!["a", "repoadd", "test", "hunter2"])); + match matches.subcommand() { + ("repoadd", Some(matches)) => { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(cmd_repo_user_control(matches, Config::generate_test_config(), false)) + .unwrap(); + std::fs::File::create("test/REPO_USER_ADDED").unwrap(); + } + _ => unreachable!() + } } #[test] fn test_91_auth_pass() { - lock(&PathBuf::from("test/USER_WRITTEN"), 7); + lock(&PathBuf::from("test/REPO_USER_ADDED"), 7); let s = test_auth_post(); @@ -893,6 +918,16 @@ mod test { #[test] fn test_92_authenticate_cookie() { + test_authenticate_cookie("test"); + } + + #[test] + #[should_panic] + fn test_93_authenticate_cookie() { + test_authenticate_cookie("repo"); + } + + fn test_authenticate_cookie(repo: &str) { lock(&PathBuf::from("test/RESPONSE"), 15); let mut buffer = String::new(); @@ -923,7 +958,7 @@ mod test { "/", "git.example.com", "on", - "", + repo, "", "/", "/?p=login", @@ -958,31 +993,32 @@ mod test { fn test_02_protected_repo_parser() { let tmpdir = tempdir::TempDir::new("test").unwrap(); - let another_file_path = format!("include={}/REPO_SETTING # TEST", tmpdir.path().to_str().unwrap()); + let another_file_path = format!( + "include={}/REPO_SETTING # TEST\ncgit-simple-auth-protect=none", + tmpdir.path().to_str().unwrap() + ); write_to_specify_file(&tmpdir.path().join("CFG"), another_file_path.as_bytes()).unwrap(); + write_to_specify_file( + &tmpdir.path().join("REPO_SETTING"), + b"repo.url=test\nrepo.protect=true", + ) + .unwrap(); + let cfg = Config::load_from_path(tmpdir.path().join("CFG")); - write_to_specify_file(&tmpdir.path().join("REPO_SETTING"), b"repo.url=test\nrepo.protect=true").unwrap(); - - - let result = ProtectedRepo::load_from_file(tmpdir.path().join("CFG")); - - assert!(result.query_is_protected("test")); - assert!(!result.query_is_all_protected()); - - write_to_specify_file(&tmpdir.path().join("REPO_SETTING"), b"repo.protect=true\nrepo.url=test").unwrap(); - - let result = ProtectedRepo::load_from_file(tmpdir.path().join("CFG")); - - assert!(!result.query_is_protected("test")); - assert!(!result.query_is_all_protected()); + assert!(cfg.check_repo_protect("test")); + assert!(!cfg.query_is_all_protected()); - write_to_specify_file(&tmpdir.path().join("REPO_SETTING"), b"repo.protect=true\nrepo.url=test\n\ncgit-simple-auth-protect=full").unwrap(); + write_to_specify_file( + &tmpdir.path().join("REPO_SETTING"), + b"repo.protect=true\nrepo.url=test", + ) + .unwrap(); - let result = ProtectedRepo::load_from_file(tmpdir.path().join("CFG")); + let cfg = Config::load_from_path(tmpdir.path().join("CFG")); - assert!(result.query_is_all_protected()); - assert!(result.query_is_protected("test")); + assert!(!cfg.check_repo_protect("test")); + assert!(!cfg.query_is_all_protected()); tmpdir.close().unwrap(); } -- cgit v1.2.3