diff options
-rw-r--r-- | .github/workflows/build.yml | 3 | ||||
-rw-r--r-- | Cargo.lock | 59 | ||||
-rw-r--r-- | Cargo.toml | 5 | ||||
-rw-r--r-- | src/authentication_page.html | 10 | ||||
-rw-r--r-- | src/datastructures.rs | 195 | ||||
-rw-r--r-- | src/main.rs | 131 |
6 files changed, 312 insertions, 91 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ae8f4a0..ef5e2be 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,9 +39,6 @@ jobs: elif [ "$RUNNER_OS" == "macOS" ]; then BIN='cgit-simple-authentication_darwin_amd64' mv target/release/cgit-simple-authentication target/release/$BIN - else - BIN='cgit-simple-authentication_windows_amd64.exe' - mv target/release/cgit-simple-authentication.exe target/release/$BIN fi echo "::set-output name=bin::target/release/$BIN" - uses: actions/upload-artifact@v2 @@ -60,6 +60,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dabe5a181f83789739c194cbe5a897dde195078fac08568d09221fd6137a7ba8" [[package]] +name = "argon2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa6ffe98a5aacd627ea719b7295646e6c457ff78bc87dff0a8d1e1a00c80557" +dependencies = [ + "blake2", + "password-hash", +] + +[[package]] name = "arrayvec" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -227,6 +237,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" [[package]] +name = "base64ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d27fb6b6f1e43147af148af49d49329413ba781aa0d5e10979831c210173b5" + +[[package]] name = "bitflags" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -245,6 +261,17 @@ dependencies = [ ] [[package]] +name = "blake2" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a5720225ef5daecf08657f23791354e1685a8c91a4c60c7f3d3b2892f978f4" +dependencies = [ + "crypto-mac", + "digest 0.9.0", + "opaque-debug 0.3.0", +] + +[[package]] name = "block-buffer" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -350,9 +377,10 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "cgit-simple-authentication" -version = "0.1.5" +version = "0.2.0" dependencies = [ "anyhow", + "argon2", "base64", "clap", "env_logger", @@ -361,11 +389,11 @@ dependencies = [ "log4rs", "openssl", "rand 0.7.3", + "rand_core 0.6.2", "redis", "serde", "serde_derive", "serde_json", - "sha2", "sqlx", "tokio 1.5.0", "tokio-stream", @@ -490,6 +518,16 @@ dependencies = [ ] [[package]] +name = "crypto-mac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" +dependencies = [ + "generic-array 0.14.4", + "subtle", +] + +[[package]] name = "ctor" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1288,6 +1326,17 @@ dependencies = [ ] [[package]] +name = "password-hash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1a5d4e9c205d2c1ae73b84aab6240e98218c0e72e63b50422cfb2d1ca952282" +dependencies = [ + "base64ct", + "rand_core 0.6.2", + "subtle", +] + +[[package]] name = "percent-encoding" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1846,6 +1895,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" [[package]] +name = "subtle" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2" + +[[package]] name = "syn" version = "1.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1,6 +1,6 @@ [package] name = "cgit-simple-authentication" -version = "0.1.5" +version = "0.2.0" authors = ["KunoiSayami <[email protected]>"] edition = "2018" @@ -19,10 +19,11 @@ clap = "2.33" handlebars = "3.4" url = "2.1" redis = { version = "0.17", features = ["tokio-comp"] } -sha2 = "0.9" base64 = "0.13" log4rs = "1" tokio-stream = "0.1" +argon2 = "0.2" +rand_core = "0.6" [target.aarch64-unknown-linux-musl.dependencies] openssl = { version = "0.10", features = ["vendored"] }
\ No newline at end of file diff --git a/src/authentication_page.html b/src/authentication_page.html index 73ec85a..c62dce0 100644 --- a/src/authentication_page.html +++ b/src/authentication_page.html @@ -1,9 +1,9 @@ <h2>Authentication Required</h2> -<form method='post' action='{{action}}'> - <input type='hidden' name='redirect' value='{{redirect}}' /> +<form method="post" action="{{action}}"> + <input type="hidden" name="redirect" value={{redirect}}" /> <table> - <tr><td><label for='username'>Username:</label></td><td><input id='username' name='username' autofocus /></td></tr> - <tr><td><label for='password'>Password:</label></td><td><input id='password' name='password' type='password' /></td></tr> - <tr><td colspan='2'><input value='Login' type='submit' /></td></tr> + <tr><td><label for="username">Username:</label></td><td><input id="username" name="username" autofocus /></td></tr> + <tr><td><label for="password">Password:</label></td><td><input id="password" name="password" type="password" /></td></tr> + <tr><td colspan="2"><input value="Login" type="submit" /></td></tr> </table> </form>
\ No newline at end of file diff --git a/src/datastructures.rs b/src/datastructures.rs index 2a19087..bb91cfb 100644 --- a/src/datastructures.rs +++ b/src/datastructures.rs @@ -19,16 +19,56 @@ */ use anyhow::Result; -use sha2::Digest; use std::borrow::Cow; use std::fs::read_to_string; use std::path::Path; use url::form_urlencoded; +use std::fmt::Formatter; +use rand::Rng; +use serde::{Serialize, Deserialize}; +use argon2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2 +}; +use rand_core::OsRng; const DEFAULT_CONFIG_LOCATION: &str = "/etc/cgitrc"; const DEFAULT_COOKIE_TTL: u64 = 1200; const DEFAULT_DATABASE_LOCATION: &str = "/etc/cgit/auth.db"; pub const CACHE_DIR: &str = "/var/cache/cgit"; +pub type RandIntType = u32; +pub const MINIMUM_SECRET_LENGTH: usize = 8; + +pub fn get_current_timestamp() -> u64 { + let start = std::time::SystemTime::now(); + let since_the_epoch = start + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards"); + since_the_epoch.as_secs() +} + +pub fn rand_int() -> RandIntType { + let mut rng = rand::thread_rng(); + rng.gen() +} + +pub fn rand_str(len: usize) -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + abcdefghijklmnopqrstuvwxyz\ + 0123456789"; + let mut rng = rand::thread_rng(); + + let password: String = (0..len) + .map(|_| { + let idx = rng.gen_range(0, CHARSET.len()); + CHARSET[idx] as char + }) + .collect(); + + password +} + + #[derive(Debug, Clone)] pub struct Config { @@ -36,6 +76,7 @@ pub struct Config { database: String, //access_node: hashmap, pub bypass_root: bool, + //secret: String, } impl Default for Config { @@ -44,6 +85,7 @@ impl Default for Config { cookie_ttl: DEFAULT_COOKIE_TTL, database: DEFAULT_DATABASE_LOCATION.to_string(), bypass_root: false, + //secret: Default::default(), } } } @@ -58,6 +100,7 @@ impl Config { 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 = ""; for line in file.lines() { let line = line.trim(); if !line.contains('=') || !line.starts_with("cgit-simple-auth-") { @@ -74,6 +117,7 @@ impl Config { "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, _ => {} } } @@ -81,12 +125,23 @@ impl Config { cookie_ttl, database: database.to_string(), bypass_root, + //secret: secret.to_string(), } } pub fn get_database_location(&self) -> &str { self.database.as_str() } + +/* pub fn get_secret_warning(&self) -> &str { + if self.secret.is_empty() { + r#"<span color="red">Warning: You should specify secret in your cgitrc file.</span>"# + } else if self.secret.len() < MINIMUM_SECRET_LENGTH { + r#"<span color="yellow">Warning: You should set key length more than MINIMUM_SECRET_LENGTH.</span>"# + } else { + "" + } + }*/ } #[derive(Debug, Clone, Default)] @@ -103,10 +158,13 @@ impl FormData { } } - pub fn get_string_sha256_value(s: &str) -> Result<String> { - let mut hasher = sha2::Sha256::new(); - hasher.update(s.as_bytes()); - Ok(format!("{:x}", hasher.finalize())) + pub fn get_string_argon2_hash(s: &str) -> Result<String> { + let passwd = s.as_bytes(); + let salt = SaltString::generate(&mut OsRng); + + let argon2_alg = Argon2::default(); + + Ok(argon2_alg.hash_password_simple(passwd, salt.as_ref()).unwrap().to_string()) } pub fn set_password(&mut self, password: String) { @@ -114,6 +172,11 @@ impl FormData { self.hash = Default::default(); } + pub fn verify_password(&self, password_hash: &PasswordHash) -> bool { + let argon2_alg = Argon2::default(); + argon2_alg.verify_password(self.password.as_bytes(), password_hash).is_ok() + } + pub fn set_user(&mut self, user: String) { self.user = user } @@ -122,20 +185,20 @@ impl FormData { &self.user } - pub fn get_password_sha256(&self) -> Result<String> { - Self::get_string_sha256_value(&self.password) + pub fn get_password_argon2(&self) -> Result<String> { + Self::get_string_argon2_hash(&self.password) } #[allow(dead_code)] - pub fn get_password_sha256_cache(&mut self) -> Result<String> { + pub fn get_password_argon2_cache(&mut self) -> Result<String> { if self.hash.is_empty() { - self.hash = self.get_password_sha256()?; + self.hash = self.get_password_argon2()?; } Ok(self.hash.clone()) } #[allow(dead_code)] - pub fn get_sha256_without_calc(&self) -> &String { + pub fn get_argon2_without_calc(&self) -> &String { &self.hash } } @@ -151,7 +214,6 @@ impl From<&[u8]> for FormData { } Cow::Borrowed("password") => { data.set_password(f.1.to_string()); - data.get_password_sha256_cache().unwrap(); } _ => {} } @@ -171,3 +233,114 @@ impl From<String> for FormData { Self::from(&s) } } + +#[derive(Serialize, Deserialize)] +struct IvFile { + iv: String, + timestamp: u64, +} + +pub struct Cookie { + timestamp: u64, + randint: RandIntType, + body: String, +} + +impl Cookie { + fn new(randint: RandIntType, body: &String) -> Self { + Self { + timestamp: get_current_timestamp(), + randint, + body: body.clone() + } + } + + pub fn load_from_request(cookies: &str) -> Result<Option<Self>> { + let mut cookie_self = None; + for cookie in cookies.split(';').map(|x| x.trim()) { + let (key, value) = cookie.split_once('=').unwrap(); + if key.eq("cgit_auth") { + let value = base64::decode(value).unwrap_or_default(); + let value = std::str::from_utf8(&value).unwrap_or(""); + + if !value.contains(';') { + break; + } + + let (key, value) = value.split_once(';').unwrap(); + + let (timestamp, randint) = key.split_once("_").unwrap_or(("0", "")); + + cookie_self = Some(Self{ + timestamp: timestamp.parse()?, + randint: randint.parse()?, + body: value.to_string(), + }); + break + } + } + Ok(cookie_self) + } + + pub fn eq_body(&self, s: &str) -> bool { + self.body.eq(s) + } + + pub fn get_key(&self) -> String { + format!("{}_{}", self.timestamp, self.randint) + } +} + + +impl std::fmt::Display for Cookie { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let s = format!("{}_{}; {}", self.timestamp, self.randint, self.body); + write!(f, "{}", base64::encode(s)) + } +} + +/* +pub struct CookieOrigin { + timestamp: u64, + randint: RandIntType, + user: String, + rand_str: String, +} + +impl CookieOrigin { + pub fn new(randint: RandIntType, user: &String, rand_str: &String) -> Self { + Self { + timestamp: get_current_timestamp(), + randint, + user: user.clone(), + rand_str: rand_str.clone(), + } + } + + pub async fn to_cookie(&self, cfg: &Config) -> Result<Cookie> { + let mut file = File::open(Path::new(CACHE_DIR).join(DEFAULT_IV_FILE_NAME)).await?; + let mut context = String::new(); + file.read_to_string(&mut context).await?; + let iv_file: IvFile = serde_json::from_str(&context)?; + let key = cfg.secret.to_hex(); + let iv = iv_file.iv.to_hex(); + + let mut cipher = Blowfish::new(key); + + cipher.write_all().awa + + let mut buffer = [0u8, 128]; + + let plain_text = format!("{}, {}", self.user, self.rand_str); + + let pos = plain_text.len(); + + buffer[..pos].copy_from_slice(plain_text.as_bytes()); + + let b = Block::from_mut_slice(&mut buffer); + + + Ok(()) + } +} +*/
\ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 89be8e3..0f5fd48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +#![feature(array_methods)] /* ** Copyright (C) 2021 KunoiSayami ** @@ -21,14 +22,13 @@ mod database; mod datastructures; -use crate::datastructures::{Config, FormData}; +use crate::datastructures::{Config, FormData, get_current_timestamp, Cookie, rand_int, rand_str}; use anyhow::Result; use clap::{App, Arg, ArgMatches, SubCommand}; use handlebars::Handlebars; use log4rs::append::file::FileAppender; use log4rs::config::{Appender, Root}; use log4rs::encode::pattern::PatternEncoder; -use rand::Rng; use redis::AsyncCommands; use serde::Serialize; use sqlx::Connection; @@ -36,43 +36,12 @@ use std::env; use std::io::{stdin, Read}; use std::result::Result::Ok; use tokio_stream::StreamExt as _; +use argon2::{ + password_hash::{PasswordHash}, +}; const COOKIE_LENGTH: usize = 45; -fn get_current_timestamp() -> u64 { - let start = std::time::SystemTime::now(); - let since_the_epoch = start - .duration_since(std::time::UNIX_EPOCH) - .expect("Time went backwards"); - since_the_epoch.as_secs() -} - -fn rand_str(len: usize) -> String { - const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ - abcdefghijklmnopqrstuvwxyz\ - 0123456789"; - let mut rng = rand::thread_rng(); - - let password: String = (0..len) - .map(|_| { - let idx = rng.gen_range(0, CHARSET.len()); - CHARSET[idx] as char - }) - .collect(); - - password -} - -fn rand_int() -> i32 { - let mut rng = rand::thread_rng(); - rng.gen() -} - -#[derive(Serialize)] -struct Meta<'a> { - action: &'a str, - redirect: &'a str, -} // Processing the `authenticate-cookie` called by cgit. async fn cmd_authenticate_cookie(matches: &ArgMatches<'_>, cfg: Config) -> Result<bool> { @@ -95,30 +64,11 @@ 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?; - for cookie in cookies.split(';').map(|x| x.trim()) { - let (key, value) = cookie.split_once('=').unwrap(); - if key.eq("cgit_auth") { - let value = base64::decode(value).unwrap_or_default(); - let value = std::str::from_utf8(&value).unwrap_or(""); - - if !value.contains(';') { - break; - } - - let (key, value) = value.split_once(';').unwrap(); //.unwrap_or(("0_0", "0")); - - let (timestamp, _) = key.split_once("_").unwrap_or(("0", "")); - - if get_current_timestamp() - timestamp.parse::<u64>().unwrap_or(0) > cfg.cookie_ttl { - break; + if let Ok(Some(cookie)) = Cookie::load_from_request(cookies) { + if let Ok(r) = conn.get::<_, String>(format!("cgit_auth_{}", cookie.get_key())).await{ + if cookie.eq_body(r.as_str()) { + return Ok(true); } - - if let Ok(r) = conn.get::<_, String>(format!("cgit_auth_{}", key)).await { - if r == value { - return Ok(true); - } - } - break; } } @@ -150,6 +100,8 @@ async fn cmd_init(cfg: Config) -> Result<()> { } async fn verify_login(cfg: &Config, data: &FormData) -> Result<bool> { + // TODO: use timestamp to mark file diff + // or copy in init process let database_file_name = std::path::Path::new(datastructures::CACHE_DIR).join( std::path::Path::new(cfg.get_database_location()) .file_name() @@ -157,14 +109,12 @@ async fn verify_login(cfg: &Config, data: &FormData) -> Result<bool> { ); std::fs::copy(cfg.get_database_location(), database_file_name.clone())?; let mut conn = sqlx::SqliteConnection::connect(database_file_name.to_str().unwrap()).await?; - let password_sha = data.get_password_sha256()?; - log::debug!("password: {}", password_sha); - let ret = sqlx::query(r#"SELECT 1 FROM "accounts" WHERE "user" = ? AND "password" = ? "#) + let (passwd_hash,) = sqlx::query_as::<_, (String, )>(r#"SELECT "password" FROM "accounts" WHERE "user" = ?"#) .bind(data.get_user()) - .bind(password_sha) - .fetch_all(&mut conn) + .fetch_one(&mut conn) .await?; - Ok(!ret.is_empty()) + let parsed_hash = PasswordHash::new(passwd_hash.as_str()).unwrap(); + Ok(data.verify_password(&parsed_hash)) } // Processing the `authenticate-post` called by cgit. @@ -218,13 +168,22 @@ async fn cmd_authenticate_post(matches: &ArgMatches<'_>, cfg: Config) -> Result< Ok(()) } +#[derive(Serialize)] +pub struct Meta<'a> { + action: &'a str, + redirect: &'a str, + //custom_warning: &'a str, +} + + // Processing the `body` called by cgit. -async fn cmd_body(matches: &ArgMatches<'_>, _cfg: Config) { +async fn cmd_body(matches: &ArgMatches<'_>, cfg: Config) { let source = include_str!("authentication_page.html"); let handlebars = Handlebars::new(); let meta = Meta { action: matches.value_of("login-url").unwrap_or(""), redirect: matches.value_of("current-url").unwrap_or(""), + //custom_warning: cfg.get_secret_warning() }; handlebars .render_template_to_write(source, &meta, std::io::stdout()) @@ -237,6 +196,11 @@ async fn cmd_add_user(matches: &ArgMatches<'_>, cfg: Config) -> Result<()> { if user.is_empty() || passwd.is_empty() { return Err(anyhow::Error::msg("Invalid user or password")); } + + if user.len() > 20 { + return Err(anyhow::Error::msg("Username length should less than 20")) + } + let mut conn = sqlx::SqliteConnection::connect(cfg.get_database_location()).await?; let items = sqlx::query(r#"SELECT 1 FROM "accounts" WHERE "user" = ? "#) @@ -250,7 +214,7 @@ async fn cmd_add_user(matches: &ArgMatches<'_>, cfg: Config) -> Result<()> { sqlx::query(r#"INSERT INTO "accounts" ("user", "password") VALUES (?, ?) "#) .bind(user) - .bind(FormData::get_string_sha256_value(&passwd)?) + .bind(FormData::get_string_argon2_hash(&passwd)?) .execute(&mut conn) .await?; println!("Insert {} to database", user); @@ -348,7 +312,7 @@ fn main() -> Result<()> { .encoder(Box::new(PatternEncoder::new( "{d(%Y-%m-%d %H:%M:%S)}- {h({l})} - {m}{n}", ))) - .build("/tmp/output.log")?; + .build(option_env!("RUST_LOG_FILE").unwrap_or("/tmp/auth.log"))?; let config = log4rs::Config::builder() .appender(Appender::builder().build("logfile", Box::new(logfile))) @@ -438,3 +402,34 @@ fn main() -> Result<()> { Ok(()) } + +mod test { + const PASSWORD: &str = "hunter2"; + const ARGON2_HASH: &str = "$argon2id$v=19$m=4096,t=3,p=1$szYDnoQSVPmXq+RD2LneBw$fRETH//iCQuIX+SgjYPdZ9iIbM8gEy9fBjTJ/KFFJNM"; + use argon2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2 + }; + use rand_core::OsRng; + + + #[test] + fn test_argon2() { + let passwd = PASSWORD.as_bytes(); + let salt = SaltString::generate(&mut OsRng); + + let argon2 = Argon2::default(); + + argon2.hash_password_simple(passwd, salt.as_ref()).unwrap(); + + } + + #[test] + fn test_argon2_verify() { + let passwd = PASSWORD.as_bytes(); + let parsed_hash = PasswordHash::new(ARGON2_HASH).unwrap(); + let argon2 = Argon2::default(); + assert!(argon2.verify_password(passwd, &parsed_hash).is_ok()) + } +} + |