The only API change affecting us is that PasswordHash moved to password_hash::phc; use the argon2 crate's top-level re-export instead. Existing argon2id hashes in password files still verify. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kmrgkpp9YBJ6AMaWYPPZuD
207 lines
6.0 KiB
Rust
207 lines
6.0 KiB
Rust
use common::request::Request;
|
|
|
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
|
|
|
use std::fs::File;
|
|
use std::io::{BufRead, BufReader};
|
|
use std::path::PathBuf;
|
|
use log::info;
|
|
|
|
#[derive(Clone)]
|
|
pub struct PasswordDirectory {
|
|
dir_path: PathBuf,
|
|
}
|
|
|
|
impl PasswordDirectory {
|
|
pub fn new(path: &str) -> PasswordDirectory {
|
|
PasswordDirectory {
|
|
dir_path: PathBuf::from(path),
|
|
}
|
|
}
|
|
|
|
/// Read all the non-blank and non-comment lines in a password file
|
|
///
|
|
fn read_password_lines(&self, request: &Request) -> Vec<String> {
|
|
let mut file_name = self.dir_path.clone();
|
|
file_name.push(&request.userid);
|
|
|
|
let mut result = Vec::new();
|
|
|
|
if let Ok(file) = File::open(file_name) {
|
|
for line in BufReader::new(file).lines().map_while(Result::ok) {
|
|
if !line.starts_with('#') {
|
|
let trimmed = line.trim();
|
|
if !trimmed.is_empty() {
|
|
result.push(line);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// The password sent by the user is like "[{id}]{plain-text-password}"
|
|
/// so try to find a line with a matching "[{id}]" and see if the hash stored
|
|
/// there corresponds to the {plain-text-password}
|
|
///
|
|
fn check_hashed_password(&self, lines: &Vec<String>, password: &str) -> bool {
|
|
let (key, split_password) = password.strip_prefix('[').unwrap().split_once(']').unwrap();
|
|
info!(" looking for password-key: [{}]", key);
|
|
|
|
let mut result = false;
|
|
|
|
for line in lines {
|
|
if line.starts_with('[') && line.contains(']') {
|
|
let (line_key, line_hash) =
|
|
line.strip_prefix('[').unwrap().split_once(']').unwrap();
|
|
|
|
if line_key == key
|
|
&& let Ok(parsed_hash) = PasswordHash::new(line_hash)
|
|
&& Argon2::default()
|
|
.verify_password(split_password.as_bytes(), &parsed_hash)
|
|
.is_ok()
|
|
{
|
|
result = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
fn check_plain_password(&self, lines: &Vec<String>, password: &str) -> bool {
|
|
info!(" looking for plain password");
|
|
|
|
let mut result = false;
|
|
|
|
for line in lines {
|
|
// Ignore lines that are the hashed passwords with ids, but still take
|
|
// lines that contain ']' if they don't also start with '['
|
|
//
|
|
if !(line.starts_with('[') && line.contains(']')) && line == password {
|
|
result = true;
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
pub fn check_auth(&self, request: &Request) -> bool {
|
|
// Don't allow empty userids or test_passwords
|
|
//
|
|
if request.userid.is_empty() || request.userid.contains('/') || request.password.is_empty()
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let lines = self.read_password_lines(request);
|
|
|
|
if request.password.starts_with('[') && request.password.contains("]") {
|
|
self.check_hashed_password(&lines, &request.password)
|
|
} else {
|
|
self.check_plain_password(&lines, &request.password)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::repository::PasswordDirectory;
|
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
|
use common::request::Request;
|
|
|
|
const TEST_PATH: &str = "../test_passwords";
|
|
|
|
#[test]
|
|
|
|
/// Make sure hash checking is working as expected
|
|
///
|
|
fn it_checks_argon2() {
|
|
let password = b"hunter2";
|
|
|
|
// Generated with
|
|
// echo -n "hunter2" | argon2 "$(openssl rand -base64 16)" -id
|
|
let password_hash = "$argon2id$v=19$m=4096,t=3,p=1$VTMrc2wyaW93L01ibVFPOGNQcHcxQT09$UxX86sGpknkc45CnXq+4CZ0coiTYDvSWIN7JgbeAZUs";
|
|
|
|
let parsed_hash = PasswordHash::new(password_hash).unwrap();
|
|
assert!(
|
|
Argon2::default()
|
|
.verify_password(password, &parsed_hash)
|
|
.is_ok()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn it_checks_good_password() {
|
|
let repository = PasswordDirectory::new(TEST_PATH);
|
|
|
|
let request = Request::new("bp", "some-rad-password", "ignore-this", "also-ignore-this");
|
|
|
|
assert!(repository.check_auth(&request));
|
|
}
|
|
|
|
#[test]
|
|
fn it_checks_good_hashed_password() {
|
|
let repository = PasswordDirectory::new(TEST_PATH);
|
|
|
|
let request = Request::new("bp", "[id-1]hunter2", "ignore-this", "also-ignore-this");
|
|
|
|
assert!(repository.check_auth(&request));
|
|
}
|
|
|
|
/// The key [id-1] is present in the test file, but the hash shouldn't match
|
|
///
|
|
#[test]
|
|
fn it_checks_bad_hashed_password() {
|
|
let repository = PasswordDirectory::new(TEST_PATH);
|
|
|
|
let request = Request::new("bp", "[id-1]hunter-bad", "ignore-this", "also-ignore-this");
|
|
|
|
assert!(!repository.check_auth(&request));
|
|
}
|
|
|
|
/// The key [id-2] is not present in the test file
|
|
///
|
|
#[test]
|
|
fn it_checks_missing_hashed_password() {
|
|
let repository = PasswordDirectory::new(TEST_PATH);
|
|
|
|
let request = Request::new("bp", "[id-2]hunter", "ignore-this", "also-ignore-this");
|
|
|
|
assert!(!repository.check_auth(&request));
|
|
}
|
|
|
|
#[test]
|
|
fn it_fails_bad_password() {
|
|
let repository = PasswordDirectory::new(TEST_PATH);
|
|
|
|
let request = Request::new("bp", "not-correct", "ignore-this", "also-ignore-this");
|
|
|
|
assert!(!repository.check_auth(&request));
|
|
}
|
|
|
|
#[test]
|
|
fn it_fails_bad_userid() {
|
|
let repository = PasswordDirectory::new(TEST_PATH);
|
|
|
|
let request = Request::new(
|
|
"bp-xxx",
|
|
"some-rad-password",
|
|
"ignore-this",
|
|
"also-ignore-this",
|
|
);
|
|
|
|
assert!(!repository.check_auth(&request));
|
|
}
|
|
|
|
#[test]
|
|
fn it_ignores_comments() {
|
|
let repository = PasswordDirectory::new(TEST_PATH);
|
|
|
|
let request = Request::new("bp-xxx", "commented-out", "ignore-this", "also-ignore-this");
|
|
|
|
assert!(!repository.check_auth(&request));
|
|
}
|
|
}
|