use hotaru::prelude::*;
use hotaru_lib::ende::aes;
use hotaru_lib::random::random_alphanumeric_string;
use std::num::NonZeroU32;
use std::time::Duration;
use std::collections::HashMap;
use tokio::sync::RwLock;
use std::sync::Arc;
use tokio::time;
const DEFAULT_ITER: NonZeroU32 = NonZeroU32::new(100_000).unwrap();
#[derive(Clone, Debug)]
pub struct UserStorage {
pub username: String,
pub email: String,
pub password_hash: String,
pub password_salt: String,
pub profile: Value,
pub is_active: bool,
}
impl UserStorage {
fn from_json(value: Value) -> Self {
UserStorage {
username: value.get("username").string(),
email: value.get("email").string(),
password_hash: value.get("password_hash").string(),
password_salt: value.get("password_salt").string(),
profile: value.get("profile").clone(),
is_active: value.try_get("is_active").map(|v| v.boolean()).unwrap_or(true),
}
}
fn into_json(&self) -> Value {
object!({
username: &self.username,
email: &self.email,
password_hash: &self.password_hash,
password_salt: &self.password_salt,
profile: self.profile.clone(),
is_active: self.is_active,
})
}
fn into_json_without_password(&self, uid: u32) -> Value {
object!({
uid: uid,
username: &self.username,
email: &self.email,
profile: self.profile.clone(),
is_active: self.is_active,
})
}
}
pub struct TokenList(RwLock<HashMap<String, (u32, u64)>>);
impl TokenList {
pub fn new() -> Self {
TokenList(RwLock::new(HashMap::new()))
}
pub async fn add(&self, token: String, uid: u32, expires: u64) {
self.0.write().await.insert(token, (uid, expires));
}
pub async fn remove(&self, token: &str) {
self.0.write().await.remove(token);
}
pub async fn authenticate_user(&self, token: &str) -> Option<u32> {
let guard = self.0.read().await;
if let Some(&(uid, expires)) = guard.get(token) {
if expires > std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() {
return Some(uid);
}
}
None
}
pub async fn cleanup_expired(&self) {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
let mut guard = self.0.write().await;
guard.retain(|_, &mut (_, expires)| expires > now);
}
}
#[cfg(test)]
mod tests {
use super::TokenList;
use std::{
collections::HashMap,
time::{SystemTime, UNIX_EPOCH},
};
use tokio::sync::RwLock;
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_secs()
}
#[tokio::test]
async fn test_add_and_authenticate() {
let list = TokenList(RwLock::new(HashMap::new()));
let token = "token123".to_string();
let uid = 42;
let expires = now_secs() + 100;
list.add(token.clone(), uid, expires).await;
assert_eq!(list.authenticate_user(&token).await, Some(uid));
}
#[tokio::test]
async fn test_expired_token() {
let list = TokenList(RwLock::new(HashMap::new()));
let token = "token_exp".to_string();
let uid = 7;
let expires = now_secs() - 1; list.add(token.clone(), uid, expires).await;
assert_eq!(list.authenticate_user(&token).await, None);
}
#[tokio::test]
async fn test_cleanup_expired() {
let list = TokenList(RwLock::new(HashMap::new()));
let good = "good".to_string();
let bad = "bad".to_string();
let uid1 = 1;
let uid2 = 2;
let expires1 = now_secs() + 50;
let expires2 = now_secs() - 50;
list.add(good.clone(), uid1, expires1).await;
list.add(bad.clone(), uid2, expires2).await;
assert_eq!(list.authenticate_user(&good).await, Some(uid1));
assert_eq!(list.authenticate_user(&bad).await, None);
list.cleanup_expired().await;
let guard = list.0.read().await;
assert!(guard.contains_key(&good));
assert!(!guard.contains_key(&bad));
}
#[tokio::test]
async fn test_remove_token() {
let list = TokenList(RwLock::new(HashMap::new()));
let token = "toremove".to_string();
let uid = 3;
let expires = now_secs() + 100;
list.add(token.clone(), uid, expires).await;
assert_eq!(list.authenticate_user(&token).await, Some(uid));
list.remove(&token).await;
assert_eq!(list.authenticate_user(&token).await, None);
}
}
pub struct AuthManager {
users: Arc<RwLock<HashMap<u32, UserStorage>>>,
username_map: Arc<RwLock<HashMap<String, u32>>>,
email_map: Arc<RwLock<HashMap<String, u32>>>,
token_list: Arc<TokenList>,
path: String,
max_uid: Arc<RwLock<u32>>
}
impl AuthManager {
pub fn new(users_file: impl Into<String>, interval: Duration) -> Self {
let path = users_file.into();
let mut user_map: HashMap<u32, UserStorage> = HashMap::new();
let mut username_map: HashMap<String, u32> = HashMap::new();
let mut email_map: HashMap<String, u32> = HashMap::new();
let mut max_uid = 0_u32;
if let Ok(Value::Dict(initial)) = Value::from_jsonf(&path) {
initial.into_iter().for_each(|(uid, value)| {
if let Ok(uid) = uid.parse::<u32>(){
let user_storage: UserStorage = UserStorage::from_json(value);
username_map.insert(user_storage.username.clone(), uid);
email_map.insert(user_storage.email.clone(), uid);
user_map.insert(uid, user_storage);
if max_uid < uid {
max_uid = uid
}
};
});
}
let users = Arc::new(RwLock::new(user_map));
let username_map = Arc::new(RwLock::new(username_map));
let email_map = Arc::new(RwLock::new(email_map));
let token_list = Arc::new(TokenList::new());
let users_clone = Arc::clone(&users);
let token_clone = Arc::clone(&token_list);
let path_clone = path.clone();
let _flush_task = tokio::spawn(async move {
let mut ticker = time::interval(interval);
loop {
ticker.tick().await;
let guard = users_clone.read().await;
let list = Value::Dict(guard.iter().map(|(uid, value)| (uid.to_string(), value.into_json())).collect());
if let Err(err) = list.into_jsonf(&path_clone) {
eprintln!("Failed to flush users to {}: {}", &path_clone, err);
}
token_clone.cleanup_expired().await; }
});
AuthManager { users, username_map, email_map, token_list, path, max_uid: Arc::new(RwLock::new(max_uid)) }
}
pub async fn check_password(&self, uid: u32, password: &str) -> bool {
let guard = self.users.read().await;
if let Some(user) = guard.get(&uid) {
if !user.is_active {
return false;
}
if aes::decrypt(&user.password_hash, &user.password_salt) == Ok(password.to_string()) {
return true
}
false
} else {
false
}
}
pub async fn authenticate_user(&self, token: &str) -> Result<Value, FopError> {
if let Some(uid) = self.token_list.authenticate_user(token).await {
let guard = self.users.read().await;
if let Some(user) = guard.get(&uid) {
if !user.is_active {
return Err(FopError::UserInactive);
}
Ok(user.into_json())
} else {
Err(FopError::UserNotFound)
}
} else {
Err(FopError::TokenInvalid)
}
}
pub async fn login_user(&self, uid: u32, password: &str) -> Result<String, FopError> {
println!("[AuthManager::login_user] Checking password for uid: {}", uid);
if self.check_password(uid, password).await {
let token = random_alphanumeric_string(32);
let expires = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 3600; println!("[AuthManager::login_user] Generated token: {}, expires: {}", token, expires);
self.token_list.add(token.clone(), uid, expires).await;
println!("[AuthManager::login_user] Token added to token_list");
Ok(token)
} else {
println!("[AuthManager::login_user] Password mismatch");
Err(FopError::PasswordMismatch)
}
}
pub async fn logout_user(&self, token: &str) -> Result<(), FopError> {
if self.token_list.authenticate_user(token).await.is_some() {
self.token_list.remove(token).await;
Ok(())
} else {
Err(FopError::TokenInvalid)
}
}
pub async fn get_uid_by_email(&self, email: &str) -> Option<u32> {
let guard = self.email_map.read().await;
guard.get(email).cloned()
}
pub async fn refresh_token(&self, old_token: &str) -> Result<String, FopError> {
if let Some(uid) = self.token_list.authenticate_user(old_token).await {
let users = self.users.read().await;
match users.get(&uid) {
Some(user) if user.is_active => {}
Some(_) => return Err(FopError::UserInactive),
None => return Err(FopError::UserNotFound),
}
drop(users);
let new_token = random_alphanumeric_string(32);
let expires = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 3600; self.token_list.add(new_token.clone(), uid, expires).await;
Ok(new_token)
} else {
Err(FopError::TokenInvalid)
}
}
pub async fn get_uid_by_username(&self, username: &str) -> Option<u32> {
let guard = self.username_map.read().await;
guard.get(username).cloned()
}
pub async fn uid_from_username_or_email_or_uid(&self, string: String) -> Result<u32, FopError> {
if let Ok(uid) = string.parse::<u32>() {
return Ok(uid);
}
if let Some(uid) = self.get_uid_by_email(&string).await {
return Ok(uid);
}
if let Some(uid) = self.get_uid_by_username(&string).await {
return Ok(uid);
}
Err(FopError::UserNotFound)
}
pub async fn validate_username(&self, username: &str) -> bool {
if !Self::validate_username_format(username) {
return false;
}
let usernames = self.username_map.read().await;
println!("Checking against existing usernames: {:?}", usernames);
!usernames.contains_key(username)
}
fn validate_username_format(username: &str) -> bool {
println!("Validating username: {}/", username);
let mut chars = username.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
for c in username.chars() {
if c.is_ascii_alphanumeric() {
continue;
}
match c {
',' | '.' | '_' | '+' | '-' |
'(' | ')' | '[' | ']' | '{' |
'}' | '|' => continue,
_ => return false,
}
}
true
}
pub async fn validate_email(&self, email: &str) -> bool {
if !Self::validate_email_format(email) {
return false;
}
let emails = self.email_map.read().await;
!emails.contains_key(email)
}
fn validate_email_format(email: &str) -> bool {
let mut chars = email.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
let parts: Vec<&str> = email.split('@').collect();
if parts.len() != 2 {
println!("Length of parts: {}, {:?}", parts.len(), parts);
return false;
}
for part in parts.iter() {
if part.is_empty() {
return false;
}
for c in part.chars() {
if c.is_ascii_alphanumeric() {
continue;
}
match c {
',' | '.' | '_' | '+' | '-' |
'(' | ')' | '[' | ']' | '{' |
'}' | '|' => continue,
_ => return false,
}
}
}
true
}
pub async fn new_uid(&self) -> u32 {
let mut max_uid = self.max_uid.write().await;
*max_uid += 1;
*max_uid
}
pub async fn change_username(&self, token: &str, new_username: &str) -> Result<(), FopError> {
let uid = match self.token_list.authenticate_user(token).await {
Some(uid) => uid,
None => return Err(FopError::TokenInvalid),
};
if !self.validate_username(new_username).await {
return Err(FopError::UserNameNotValid);
}
let mut username_map = self.username_map.write().await;
if let Some(old_username) = username_map.iter().find(|(_, v)| v == &&uid).map(|(k, _)| k.clone()) {
username_map.remove(&old_username);
username_map.insert(new_username.to_string(), uid);
} else {
return Err(FopError::UserNotFound)
}
let mut users = self.users.write().await;
if let Some(user) = users.get_mut(&uid) {
user.username = new_username.to_string();
Ok(())
} else {
Err(FopError::UserNotFound)
}
}
pub async fn change_email(&self, token: &str, new_email: &str) -> Result<(), FopError> {
let uid = match self.token_list.authenticate_user(token).await {
Some(uid) => uid,
None => return Err(FopError::TokenInvalid),
};
if !self.validate_email(new_email).await {
return Err(FopError::EmailNotValid);
}
let mut email_map = self.email_map.write().await;
if let Some(old_email) = email_map.iter().find(|(_, v)| v == &&uid).map(|(k, _)| k.clone()) {
email_map.remove(&old_email);
email_map.insert(new_email.to_string(), uid);
} else {
return Err(FopError::UserNotFound);
}
let mut users = self.users.write().await;
if let Some(user) = users.get_mut(&uid) {
user.email = new_email.to_string();
Ok(())
} else {
Err(FopError::UserNotFound)
}
}
pub async fn change_password(&self, token: &str, old_password: &str, new_password: &str) -> Result<(), FopError> {
let uid = match self.token_list.authenticate_user(token).await {
Some(uid) => uid,
None => return Err(FopError::TokenInvalid),
};
if self.check_password(uid, old_password).await {
return Err(FopError::PasswordMismatch);
}
let mut users = self.users.write().await;
if let Some(user) = users.get_mut(&uid) {
user.password_hash = aes::encrypt(new_password, &user.password_salt).unwrap(); Ok(())
} else {
Err(FopError::UserNotFound)
}
}
pub async fn register_user(&self, username: &str, email: &str, password: &str) -> Result<(), FopError> {
if !self.validate_username(username).await {
return Err(FopError::UserNameNotValid)
};
if !self.validate_email(email).await {
return Err(FopError::EmailNotValid)
};
let new_uid = self.new_uid().await;
self.username_map.write().await.insert(username.to_string(), new_uid);
self.email_map.write().await.insert(email.to_string(), new_uid);
let salt = random_alphanumeric_string(16); let user = UserStorage {
username: username.to_string(),
email: email.to_string(),
password_hash: aes::encrypt(password, &salt).unwrap(), password_salt: salt,
profile: object!({}),
is_active: true,
};
self.users.write().await.insert(new_uid, user);
Ok(())
}
pub async fn edit_user(&mut self, token: String, user: UserStorage) -> Result<(), FopError> {
match self.token_list.authenticate_user(&token).await {
Some(uid) => {
if !self.validate_username(&user.username).await {
return Err(FopError::UserNameNotValid)
};
if !self.validate_email(&user.email).await {
return Err(FopError::EmailNotValid)
};
let mut users = self.users.write().await;
if let Some(existing_user) = users.get_mut(&uid) {
existing_user.username = user.username;
existing_user.email = user.email;
existing_user.password_hash = user.password_hash;
existing_user.password_salt = user.password_salt;
existing_user.profile = user.profile;
existing_user.is_active = user.is_active;
Ok(())
} else {
Err(FopError::UserTooBig)
}
},
None => return Err(FopError::TokenInvalid),
}
}
pub async fn get_user_profile(&mut self, token: String) -> Result<Value, FopError> {
match self.token_list.authenticate_user(&token).await {
Some(auth_uid) => {
let users = self.users.read().await;
if let Some(user) = users.get(&auth_uid) {
if !user.is_active {
return Err(FopError::UserInactive);
}
Ok(user.profile.clone())
} else {
Err(FopError::UserTooBig)
}
},
_ => Err(FopError::TokenInvalid),
}
}
pub async fn get_userstorage_instance(self, token: String) -> Result<UserStorage, FopError> {
match self.token_list.authenticate_user(&token).await {
Some(auth_uid) => {
let users = self.users.read().await;
if let Some(user) = users.get(&auth_uid) {
if !user.is_active {
return Err(FopError::UserInactive);
}
Ok(user.clone())
} else {
Err(FopError::UserTooBig)
}
},
_ => Err(FopError::TokenInvalid),
}
}
pub async fn get_user_info(&self, token: String) -> Result<Value, FopError> {
println!("[AuthManager::get_user_info] Looking up token: {}", token);
match self.token_list.authenticate_user(&token).await {
Some(auth_uid) => {
println!("[AuthManager::get_user_info] Token valid, uid: {}", auth_uid);
let users = self.users.read().await;
if let Some(user) = users.get(&auth_uid) {
if !user.is_active {
return Err(FopError::UserInactive);
}
println!("[AuthManager::get_user_info] Found user: {}", user.username);
Ok(object!({
username: &user.username,
email: &user.email,
uid: auth_uid,
is_active: user.is_active,
}))
} else {
println!("[AuthManager::get_user_info] User not found for uid: {}", auth_uid);
Err(FopError::UserTooBig)
}
},
_ => {
println!("[AuthManager::get_user_info] Token not found in token_list");
Err(FopError::TokenInvalid)
},
}
}
pub async fn list_users(&self) -> Vec<Value> {
self.admin_list_users()
.await
.into_iter()
.map(|(uid, user)| user.into_json_without_password(uid))
.collect()
}
pub async fn admin_list_users(&self) -> Vec<(u32, UserStorage)> {
let users = self.users.read().await;
let mut users: Vec<(u32, UserStorage)> =
users.iter().map(|(uid, user)| (*uid, user.clone())).collect();
users.sort_by_key(|(uid, _)| *uid);
users
}
pub async fn admin_get_user(&self, uid: u32) -> Option<UserStorage> {
self.users.read().await.get(&uid).cloned()
}
pub async fn admin_edit_user(
&self,
uid: u32,
new_username: Option<String>,
new_email: Option<String>,
new_is_active: Option<bool>,
) -> Result<(), FopError> {
if let Some(username) = &new_username {
if !Self::validate_username_format(username) {
return Err(FopError::UserNameNotValid);
}
}
if let Some(email) = &new_email {
if !Self::validate_email_format(email) {
return Err(FopError::EmailNotValid);
}
}
let mut username_map = self.username_map.write().await;
let mut email_map = self.email_map.write().await;
let mut users = self.users.write().await;
let user = users.get_mut(&uid).ok_or(FopError::UserNotFound)?;
if let Some(username) = &new_username {
if username_map.get(username).is_some_and(|owner| *owner != uid) {
return Err(FopError::UserNameConflict);
}
}
if let Some(email) = &new_email {
if email_map.get(email).is_some_and(|owner| *owner != uid) {
return Err(FopError::EmailConflict);
}
}
if let Some(username) = &new_username {
username_map.remove(&user.username);
username_map.insert(username.clone(), uid);
}
if let Some(email) = &new_email {
email_map.remove(&user.email);
email_map.insert(email.clone(), uid);
}
if let Some(username) = new_username {
user.username = username;
}
if let Some(email) = new_email {
user.email = email;
}
if let Some(is_active) = new_is_active {
user.is_active = is_active;
}
Ok(())
}
pub async fn admin_reset_password(&self, uid: u32, new_password: &str) -> Result<(), FopError> {
if new_password.is_empty() {
return Err(FopError::PasswordMismatch);
}
let mut users = self.users.write().await;
let user = users.get_mut(&uid).ok_or(FopError::UserNotFound)?;
user.password_hash = aes::encrypt(new_password, &user.password_salt).unwrap();
Ok(())
}
pub async fn admin_delete_user(&self, uid: u32) -> Result<(), FopError> {
let mut username_map = self.username_map.write().await;
let mut email_map = self.email_map.write().await;
let mut users = self.users.write().await;
let removed = users.remove(&uid).ok_or(FopError::UserNotFound)?;
username_map.remove(&removed.username);
email_map.remove(&removed.email);
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum FopError {
TooManyRequest,
UserNameNotValid,
UserNameConflict,
EmailNotValid,
EmailConflict,
PasswordMismatch,
UserTooBig,
UserNotFound,
UserInactive,
TokenInvalid,
Other(Box<str>)
}
impl ToString for FopError {
fn to_string(&self) -> String {
match self {
FopError::TooManyRequest => "Too many requests".to_string(),
FopError::UserNameNotValid => "Username is not valid".to_string(),
FopError::UserNameConflict => "Username already exists".to_string(),
FopError::EmailNotValid => "Email is not valid".to_string(),
FopError::EmailConflict => "Email already exists".to_string(),
FopError::PasswordMismatch => "Password mismatch".to_string(),
FopError::UserTooBig => "User data too big".to_string(),
FopError::UserNotFound => "User not found".to_string(),
FopError::UserInactive => "User is inactive".to_string(),
FopError::TokenInvalid => "Token is invalid".to_string(),
FopError::Other(msg) => msg.to_string(),
}
}
}
#[cfg(test)]
mod test {
use std::collections::HashMap;
use tokio::sync::RwLock;
use hotaru::prelude::*;
use hotaru_lib::ende::aes;
use crate::local_auth::fop::AuthManager;
use crate::local_auth::fop::TokenList;
use crate::local_auth::fop::UserStorage;
#[test]
pub fn test_user_from_json() {
let user = UserStorage::from_json(object!({
username: "Admin",
email: "redstone@fds.moe",
password_hash: "js",
password_salt: "suki"
}));
assert_eq!(user.username, "Admin");
assert_eq!(user.email, "redstone@fds.moe");
assert_eq!(user.password_hash, "js");
assert_eq!(user.password_salt, "suki");
}
#[test]
pub fn test_user_into_json() {
let user = UserStorage {
username: "Admin".to_string(),
email: "redstone@fds.moe".to_string(),
password_hash: "123456".to_string(),
password_salt: "Aa333333".to_string(),
profile: object!({}),
is_active: true,
};
let value = user.into_json();
println!("{}, {}", value.to_string(), value.into_json())
}
#[tokio::test]
pub async fn test_auth_user() {
let mut users = HashMap::new();
users.insert(1_u32, UserStorage::from_json(object!({
username: "Admin",
email: "redstone@fds.moe",
password_hash: aes::encrypt("js", "suki").unwrap(),
password_salt: "suki"
})));
users.insert(2_u32, UserStorage::from_json(object!({
username: "App",
email: "Sabi",
password_hash: aes::encrypt("ustc", "aes").unwrap(),
password_salt: "aes"
})));
let mut username_map = HashMap::new();
username_map.insert("Admin".to_string(), 1_u32);
username_map.insert("App".to_string(),2_u32);
let mut email_map = HashMap::new();
email_map.insert("redstone@fds.moe".to_string(), 1_u32);
email_map.insert("Sabi".to_string(), 2_u32);
let auth = AuthManager {
users: Arc::new(RwLock::new(users)),
username_map: Arc::new(RwLock::new(username_map)),
email_map: Arc::new(RwLock::new(email_map)),
token_list: Arc::new(TokenList::new()),
path: "test.json".to_string(),
max_uid: Arc::new(RwLock::new(2_u32))
};
assert!(auth.check_password(1, "js").await);
}
}
#[cfg(test)]
mod password_verification_tests {
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use hotaru::prelude::*;
use hotaru_lib::ende::aes;
use crate::local_auth::fop::{AuthManager, FopError, TokenList, UserStorage};
async fn manager_with_one_user(
username: &str,
password: &str,
is_active: bool,
) -> AuthManager {
let salt = "test-salt-16char".to_string();
let hash = aes::encrypt(password, &salt).unwrap();
let mut users = HashMap::new();
users.insert(
1_u32,
UserStorage {
username: username.to_string(),
email: format!("{}@test.example", username),
password_hash: hash,
password_salt: salt,
profile: object!({}),
is_active,
},
);
let mut username_map = HashMap::new();
username_map.insert(username.to_string(), 1_u32);
let mut email_map = HashMap::new();
email_map.insert(format!("{}@test.example", username), 1_u32);
AuthManager {
users: Arc::new(RwLock::new(users)),
username_map: Arc::new(RwLock::new(username_map)),
email_map: Arc::new(RwLock::new(email_map)),
token_list: Arc::new(TokenList::new()),
path: "test.json".to_string(),
max_uid: Arc::new(RwLock::new(1_u32)),
}
}
#[tokio::test]
async fn step1_aes_roundtrip_for_default_admin_shape() {
let salt = "sfx-default-pwd1";
let hash = aes::encrypt("Aa333333", salt).unwrap();
assert_eq!(aes::decrypt(&hash, salt).unwrap(), "Aa333333");
}
#[tokio::test]
async fn step2_check_password_accepts_correct() {
let auth = manager_with_one_user("Alice", "secret123", true).await;
assert!(auth.check_password(1, "secret123").await);
}
#[tokio::test]
async fn step3_check_password_rejects_wrong() {
let auth = manager_with_one_user("Alice", "secret123", true).await;
assert!(!auth.check_password(1, "wrong").await);
}
#[tokio::test]
async fn step4_check_password_rejects_unknown_uid() {
let auth = manager_with_one_user("Alice", "secret123", true).await;
assert!(!auth.check_password(999, "secret123").await);
}
#[tokio::test]
async fn step5_check_password_rejects_inactive_user() {
let auth = manager_with_one_user("Alice", "secret123", false).await;
assert!(!auth.check_password(1, "secret123").await);
}
#[tokio::test]
async fn step6_login_user_issues_token_on_success() {
let auth = manager_with_one_user("Alice", "secret123", true).await;
let token = auth
.login_user(1, "secret123")
.await
.expect("login should succeed");
assert!(!token.is_empty());
}
#[tokio::test]
async fn step7_login_user_returns_mismatch_on_wrong_password() {
let auth = manager_with_one_user("Alice", "secret123", true).await;
assert_eq!(
auth.login_user(1, "wrong").await.unwrap_err(),
FopError::PasswordMismatch
);
}
#[tokio::test]
async fn step8_admin_reset_password_round_trip() {
let auth = manager_with_one_user("Alice", "old_password", true).await;
auth.admin_reset_password(1, "new_password").await.unwrap();
assert!(!auth.check_password(1, "old_password").await);
assert!(auth.check_password(1, "new_password").await);
let token = auth
.login_user(1, "new_password")
.await
.expect("post-reset login should succeed");
assert!(!token.is_empty());
}
}
#[cfg(test)]
mod default_admin_fixture_test {
use hotaru::prelude::*;
use hotaru_lib::ende::aes;
#[test]
fn shipped_admin_password_decrypts_to_advertised_value() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/default/programfiles/local_auth/users"
);
let value = Value::from_jsonf(path)
.expect("default/programfiles/local_auth/users is missing or unreadable");
let admin = value.get("1");
let password_hash = admin.get("password_hash").string();
let password_salt = admin.get("password_salt").string();
let plaintext = aes::decrypt(&password_hash, &password_salt).expect(
"shipped admin password_hash failed to decrypt — \
regenerate the fixture with the current aes::encrypt",
);
assert_eq!(
plaintext, "Aa333333",
"shipped admin password must match the value `sfx new` prints (Aa333333)"
);
}
}