Config users refactor

This commit is contained in:
Antoine Gersant 2024-10-07 23:05:35 -07:00
parent c51ce59fba
commit 1555c784de
13 changed files with 680 additions and 673 deletions

View file

@ -1,12 +1,12 @@
use std::fs;
use std::path::{Path, PathBuf};
use config::AuthSecret;
use rand::rngs::OsRng;
use rand::RngCore;
use crate::paths::Paths;
pub mod auth;
pub mod config;
pub mod ddns;
pub mod formats;
@ -16,7 +16,6 @@ pub mod peaks;
pub mod playlist;
pub mod scanner;
pub mod thumbnail;
pub mod user;
#[cfg(test)]
pub mod test;
@ -116,6 +115,8 @@ pub enum Error {
EmptyUsername,
#[error("Cannot use empty password")]
EmptyPassword,
#[error("Username already exists")]
DuplicateUsername,
#[error("Username does not exist")]
IncorrectUsername,
#[error("Password does not match username")]
@ -164,9 +165,9 @@ impl App {
.map_err(|e| Error::Io(thumbnails_dir_path.clone(), e))?;
let auth_secret_file_path = paths.data_dir_path.join("auth.secret");
let auth_secret = Self::get_or_create_auth_secret(&auth_secret_file_path);
let auth_secret = Self::get_or_create_auth_secret(&auth_secret_file_path).await?;
let config_manager = config::Manager::new(&paths.config_file_path).await?;
let config_manager = config::Manager::new(&paths.config_file_path, auth_secret).await?;
let ndb_manager = ndb::Manager::new(&paths.data_dir_path)?;
let index_manager = index::Manager::new(&paths.data_dir_path).await?;
let scanner = scanner::Scanner::new(index_manager.clone(), config_manager.clone()).await?;
@ -187,15 +188,15 @@ impl App {
})
}
async fn get_or_create_auth_secret(path: &Path) -> Result<config::AuthSecret, Error> {
async fn get_or_create_auth_secret(path: &Path) -> Result<auth::Secret, Error> {
match tokio::fs::read(&path).await {
Ok(s) => Ok(config::AuthSecret {
Ok(s) => Ok(auth::Secret {
key: s
.try_into()
.map_err(|_| Error::AuthenticationSecretInvalid)?,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let mut secret = AuthSecret::default();
let mut secret = auth::Secret::default();
OsRng.fill_bytes(&mut secret.key);
tokio::fs::write(&path, &secret.key)
.await

85
src/app/auth.rs Normal file
View file

@ -0,0 +1,85 @@
use std::time::{SystemTime, UNIX_EPOCH};
use pbkdf2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use pbkdf2::Pbkdf2;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use crate::app::Error;
#[derive(Clone, Default)]
pub struct Secret {
pub key: [u8; 32],
}
#[derive(Debug)]
pub struct Token(pub String);
#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)]
pub enum Scope {
PolarisAuth,
}
#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct Authorization {
pub username: String,
pub scope: Scope,
}
pub fn hash_password(password: &str) -> Result<String, Error> {
if password.is_empty() {
return Err(Error::EmptyPassword);
}
let salt = SaltString::generate(&mut OsRng);
match Pbkdf2.hash_password(password.as_bytes(), &salt) {
Ok(h) => Ok(h.to_string()),
Err(_) => Err(Error::PasswordHashing),
}
}
pub fn verify_password(password_hash: &str, attempted_password: &str) -> bool {
match PasswordHash::new(password_hash) {
Ok(h) => Pbkdf2
.verify_password(attempted_password.as_bytes(), &h)
.is_ok(),
Err(_) => false,
}
}
pub fn generate_auth_token(
authorization: &Authorization,
auth_secret: &Secret,
) -> Result<Token, Error> {
let serialized_authorization =
serde_json::to_string(&authorization).or(Err(Error::AuthorizationTokenEncoding))?;
branca::encode(
serialized_authorization.as_bytes(),
&auth_secret.key,
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as u32,
)
.or(Err(Error::BrancaTokenEncoding))
.map(Token)
}
pub fn decode_auth_token(
auth_token: &Token,
scope: Scope,
auth_secret: &Secret,
) -> Result<Authorization, Error> {
let Token(data) = auth_token;
let ttl = match scope {
Scope::PolarisAuth => 0, // permanent
};
let authorization =
branca::decode(data, &auth_secret.key, ttl).map_err(|_| Error::InvalidAuthToken)?;
let authorization: Authorization =
serde_json::from_slice(&authorization[..]).map_err(|_| Error::InvalidAuthToken)?;
if authorization.scope != scope {
return Err(Error::IncorrectAuthorizationScope);
}
Ok(authorization)
}

View file

@ -1,65 +1,56 @@
use std::{
io::Read,
ops::Deref,
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use pbkdf2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use pbkdf2::Pbkdf2;
use rand::rngs::OsRng;
use regex::Regex;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use crate::app::Error;
#[derive(Clone, Default)]
pub struct AuthSecret {
pub key: [u8; 32],
}
mod mounts;
mod raw;
mod user;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct MountDir {
pub source: String,
pub name: String,
}
pub use mounts::*;
pub use user::*;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct User {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hashed_password: Option<String>,
}
use super::auth;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[derive(Default)]
pub struct Config {
#[serde(skip_serializing_if = "Option::is_none")]
pub reindex_every_n_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub album_art_pattern: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub mount_dirs: Vec<MountDir>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ddns_url: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub users: Vec<User>,
pub mount_dirs: Vec<MountDir>,
pub users: HashMap<String, User>,
}
impl Config {
pub fn from_path(path: &Path) -> Result<Config, Error> {
let mut config_file =
std::fs::File::open(path).map_err(|e| Error::Io(path.to_owned(), e))?;
let mut config_file_content = String::new();
config_file
.read_to_string(&mut config_file_content)
.map_err(|e| Error::Io(path.to_owned(), e))?;
let config = toml::de::from_str::<Self>(&config_file_content)?;
Ok(config)
impl TryFrom<raw::Config> for Config {
type Error = Error;
fn try_from(raw: raw::Config) -> Result<Self, Self::Error> {
let mut users: HashMap<String, User> = HashMap::new();
for user in raw.users {
if let Ok(user) = <raw::User as TryInto<User>>::try_into(user) {
users.insert(user.name.clone(), user);
}
}
let mount_dirs = raw
.mount_dirs
.into_iter()
.filter_map(|m| m.try_into().ok())
.collect();
Ok(Config {
reindex_every_n_seconds: raw.reindex_every_n_seconds, // TODO validate and warn
album_art_pattern: raw.album_art_pattern, // TODO validate and warn
ddns_url: raw.ddns_url, // TODO validate and warn
mount_dirs,
users,
})
}
}
@ -67,54 +58,37 @@ impl Config {
pub struct Manager {
config_file_path: PathBuf,
config: Arc<tokio::sync::RwLock<Config>>,
auth_secret: auth::Secret,
}
impl Manager {
pub async fn new(config_file_path: &Path) -> Result<Self, Error> {
let config = Config::default(); // TODO read from disk!!
pub async fn new(config_file_path: &Path, auth_secret: auth::Secret) -> Result<Self, Error> {
let raw_config = raw::Config::default(); // TODO read from disk!!
let config = raw_config.try_into()?;
let manager = Self {
config_file_path: config_file_path.to_owned(),
config: Arc::default(),
config: Arc::new(RwLock::new(config)),
auth_secret,
};
manager.apply(config);
Ok(manager)
}
pub async fn apply(&self, mut config: Config) -> Result<(), Error> {
config
.users
.retain(|u| u.initial_password.is_some() || u.hashed_password.is_some());
for user in &mut config.users {
if let (Some(password), None) = (&user.initial_password, &user.hashed_password) {
user.hashed_password = Some(hash_password(&password)?);
}
}
*self.config.write().await = config;
pub async fn apply(&self, raw_config: raw::Config) -> Result<(), Error> {
*self.config.write().await = raw_config.try_into()?;
// TODO persistence
Ok(())
}
pub async fn get_index_sleep_duration(&self) -> Duration {
let seconds = self
.config
.read()
.await
.reindex_every_n_seconds
.unwrap_or(1800);
let config = self.config.read().await;
let seconds = config.reindex_every_n_seconds.unwrap_or(1800);
Duration::from_secs(seconds)
}
pub async fn get_index_album_art_pattern(&self) -> String {
self.config
.read()
.await
.album_art_pattern
.clone()
.unwrap_or("Folder.(jpeg|jpg|png)".to_owned())
let config = self.config.read().await;
let pattern = config.album_art_pattern.clone();
pattern.unwrap_or("Folder.(jpeg|jpg|png)".to_owned())
}
pub async fn get_ddns_update_url(&self) -> Option<String> {
@ -122,18 +96,55 @@ impl Manager {
}
pub async fn get_users(&self) -> Vec<User> {
self.config.read().await.users.clone()
self.config.read().await.users.values().cloned().collect()
}
pub async fn get_user(&self, username: &str) -> Result<User, Error> {
let config = self.config.read().await;
let user = config.users.iter().find(|u| u.name == username);
let user = config.users.get(username);
user.cloned().ok_or(Error::UserNotFound)
}
pub async fn create_user(
&self,
username: &str,
password: &str,
admin: bool,
) -> Result<(), Error> {
let mut config = self.config.write().await;
config.create_user(username, password, admin)
// TODO persistence
}
pub async fn login(&self, username: &str, password: &str) -> Result<auth::Token, Error> {
let config = self.config.read().await;
config.login(username, password, &self.auth_secret)
}
pub async fn set_is_admin(&self, username: &str, is_admin: bool) -> Result<(), Error> {
let mut config = self.config.write().await;
config.set_is_admin(username, is_admin)
// TODO persistence
}
pub async fn set_password(&self, username: &str, password: &str) -> Result<(), Error> {
let mut config = self.config.write().await;
config.set_password(username, password)
// TODO persistence
}
pub async fn authenticate(
&self,
auth_token: &auth::Token,
scope: auth::Scope,
) -> Result<auth::Authorization, Error> {
let config = self.config.read().await;
config.authenticate(auth_token, scope, &self.auth_secret)
}
pub async fn delete_user(&self, username: &str) {
let mut config = self.config.write().await;
config.users.retain(|u| u.name != username);
config.delete_user(username);
// TODO persistence
}
@ -146,45 +157,15 @@ impl Manager {
virtual_path: P,
) -> Result<PathBuf, Error> {
let config = self.config.read().await;
for mount in &config.mount_dirs {
let mounth_source = sanitize_path(&mount.source);
let mount_path = Path::new(&mount.name);
if let Ok(p) = virtual_path.as_ref().strip_prefix(mount_path) {
return if p.components().count() == 0 {
Ok(mounth_source)
} else {
Ok(mounth_source.join(p))
};
}
}
Err(Error::CouldNotMapToRealPath(virtual_path.as_ref().into()))
config.resolve_virtual_path(virtual_path)
}
pub async fn set_mounts(&self, mount_dirs: Vec<MountDir>) {
self.config.write().await.mount_dirs = mount_dirs;
pub async fn set_mounts(&self, mount_dirs: Vec<raw::MountDir>) {
self.config.write().await.set_mounts(mount_dirs);
// TODO persistence
}
}
fn sanitize_path(source: &str) -> PathBuf {
let separator_regex = Regex::new(r"\\|/").unwrap();
let mut correct_separator = String::new();
correct_separator.push(std::path::MAIN_SEPARATOR);
let path_string = separator_regex.replace_all(source, correct_separator.as_str());
PathBuf::from(path_string.deref())
}
fn hash_password(password: &str) -> Result<String, Error> {
if password.is_empty() {
return Err(Error::EmptyPassword);
}
let salt = SaltString::generate(&mut OsRng);
match Pbkdf2.hash_password(password.as_bytes(), &salt) {
Ok(h) => Ok(h.to_string()),
Err(_) => Err(Error::PasswordHashing),
}
}
#[cfg(test)]
mod test {
@ -195,10 +176,10 @@ mod test {
#[tokio::test]
async fn can_apply_config() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_config = Config {
let new_config = raw::Config {
reindex_every_n_seconds: Some(100),
album_art_pattern: Some("cool_pattern".to_owned()),
mount_dirs: vec![MountDir {
mount_dirs: vec![raw::MountDir {
source: "/home/music".to_owned(),
name: "Library".to_owned(),
}],
@ -208,106 +189,4 @@ mod test {
ctx.config_manager.apply(new_config.clone()).await.unwrap();
assert_eq!(new_config, ctx.config_manager.config.read().await.clone(),);
}
#[tokio::test]
async fn applying_config_adds_or_preserves_password_hashes() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_config = Config {
users: vec![
User {
name: "walter".to_owned(),
initial_password: Some("super salmon 64".to_owned()),
..Default::default()
},
User {
name: "lara".to_owned(),
hashed_password: Some("hash".to_owned()),
..Default::default()
},
],
..Default::default()
};
ctx.config_manager.apply(new_config).await.unwrap();
let actual_config = ctx.config_manager.config.read().await.clone();
assert_eq!(actual_config.users[0].name, "walter");
assert_eq!(
actual_config.users[0].initial_password,
Some("super salmon 64".to_owned())
);
assert!(actual_config.users[0].hashed_password.is_some());
assert_eq!(
actual_config.users[1],
User {
name: "lara".to_owned(),
hashed_password: Some("hash".to_owned()),
..Default::default()
}
);
}
#[test]
fn converts_virtual_to_real() {
let vfs = VFS::new(vec![Mount {
name: "root".to_owned(),
source: Path::new("test_dir").to_owned(),
}]);
let real_path: PathBuf = ["test_dir", "somewhere", "something.png"].iter().collect();
let virtual_path: PathBuf = ["root", "somewhere", "something.png"].iter().collect();
let converted_path = vfs.virtual_to_real(virtual_path.as_path()).unwrap();
assert_eq!(converted_path, real_path);
}
#[test]
fn converts_virtual_to_real_top_level() {
let vfs = VFS::new(vec![Mount {
name: "root".to_owned(),
source: Path::new("test_dir").to_owned(),
}]);
let real_path = Path::new("test_dir");
let converted_path = vfs.virtual_to_real(Path::new("root")).unwrap();
assert_eq!(converted_path, real_path);
}
#[test]
fn cleans_path_string() {
let mut correct_path = path::PathBuf::new();
if cfg!(target_os = "windows") {
correct_path.push("C:\\");
} else {
correct_path.push("/usr");
}
correct_path.push("some");
correct_path.push("path");
let tests = if cfg!(target_os = "windows") {
vec![
r#"C:/some/path"#,
r#"C:\some\path"#,
r#"C:\some\path\"#,
r#"C:\some\path\\\\"#,
r#"C:\some/path//"#,
]
} else {
vec![
r#"/usr/some/path"#,
r#"/usr\some\path"#,
r#"/usr\some\path\"#,
r#"/usr\some\path\\\\"#,
r#"/usr\some/path//"#,
]
};
for test in tests {
let mount_dir = MountDir {
source: test.to_owned(),
name: "name".to_owned(),
};
let mount: Mount = mount_dir.into();
assert_eq!(mount.source, correct_path);
}
}
}

136
src/app/config/mounts.rs Normal file
View file

@ -0,0 +1,136 @@
use std::{
ops::Deref,
path::{Path, PathBuf},
};
use regex::Regex;
use crate::app::Error;
use super::raw;
use super::Config;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MountDir {
pub source: PathBuf,
pub name: String,
}
impl TryFrom<raw::MountDir> for MountDir {
type Error = Error;
fn try_from(mount_dir: raw::MountDir) -> Result<Self, Self::Error> {
// TODO validation
Ok(Self {
source: sanitize_path(&mount_dir.source),
name: mount_dir.name,
})
}
}
impl Config {
pub fn set_mounts(&mut self, mount_dirs: Vec<raw::MountDir>) {
self.mount_dirs = mount_dirs
.into_iter()
.filter_map(|m| m.try_into().ok())
.collect();
}
pub fn resolve_virtual_path<P: AsRef<Path>>(&self, virtual_path: P) -> Result<PathBuf, Error> {
for mount in &self.mount_dirs {
let mount_path = Path::new(&mount.name);
if let Ok(p) = virtual_path.as_ref().strip_prefix(mount_path) {
return if p.components().count() == 0 {
Ok(mount.source.clone())
} else {
Ok(mount.source.join(p))
};
}
}
Err(Error::CouldNotMapToRealPath(virtual_path.as_ref().into()))
}
}
fn sanitize_path(source: &str) -> PathBuf {
let separator_regex = Regex::new(r"\\|/").unwrap();
let mut correct_separator = String::new();
correct_separator.push(std::path::MAIN_SEPARATOR);
let path_string = separator_regex.replace_all(source, correct_separator.as_str());
PathBuf::from(path_string.deref())
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn can_resolve_virtual_paths() {
let raw_config = raw::Config {
mount_dirs: vec![raw::MountDir {
name: "root".to_owned(),
source: "test_dir".to_owned(),
}],
..Default::default()
};
let config: Config = raw_config.try_into().unwrap();
let test_cases = vec![
(vec!["root"], vec!["test_dir"]),
(
vec!["root", "somewhere", "something.png"],
vec!["test_dir", "somewhere", "something.png"],
),
];
for (r#virtual, real) in test_cases {
let real_path: PathBuf = real.iter().collect();
let virtual_path: PathBuf = r#virtual.iter().collect();
let converted_path = config.resolve_virtual_path(&virtual_path).unwrap();
assert_eq!(converted_path, real_path);
}
}
#[test]
fn sanitizes_paths() {
let mut correct_path = PathBuf::new();
if cfg!(target_os = "windows") {
correct_path.push("C:\\");
} else {
correct_path.push("/usr");
}
correct_path.push("some");
correct_path.push("path");
let tests = if cfg!(target_os = "windows") {
vec![
r#"C:/some/path"#,
r#"C:\some\path"#,
r#"C:\some\path\"#,
r#"C:\some\path\\\\"#,
r#"C:\some/path//"#,
]
} else {
vec![
r#"/usr/some/path"#,
r#"/usr\some\path"#,
r#"/usr\some\path\"#,
r#"/usr\some\path\\\\"#,
r#"/usr\some/path//"#,
]
};
for test in tests {
let raw_config = raw::Config {
mount_dirs: vec![raw::MountDir {
name: "root".to_owned(),
source: test.to_owned(),
}],
..Default::default()
};
let config: Config = raw_config.try_into().unwrap();
let converted_path = config.resolve_virtual_path(&PathBuf::from("root")).unwrap();
assert_eq!(converted_path, correct_path);
}
}
}

49
src/app/config/raw.rs Normal file
View file

@ -0,0 +1,49 @@
use std::{io::Read, path::Path};
use serde::{Deserialize, Serialize};
use crate::app::Error;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct User {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hashed_password: Option<String>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct MountDir {
pub source: String,
pub name: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct Config {
#[serde(skip_serializing_if = "Option::is_none")]
pub reindex_every_n_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub album_art_pattern: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub mount_dirs: Vec<MountDir>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ddns_url: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub users: Vec<User>,
}
impl Config {
pub fn from_path(path: &Path) -> Result<Self, Error> {
let mut config_file =
std::fs::File::open(path).map_err(|e| Error::Io(path.to_owned(), e))?;
let mut config_file_content = String::new();
config_file
.read_to_string(&mut config_file_content)
.map_err(|e| Error::Io(path.to_owned(), e))?;
let config = toml::de::from_str::<Self>(&config_file_content)?;
Ok(config)
}
}

282
src/app/config/user.rs Normal file
View file

@ -0,0 +1,282 @@
use crate::app::{auth, Error};
use super::raw;
use super::Config;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct User {
pub name: String,
pub admin: Option<bool>,
pub initial_password: Option<String>,
pub hashed_password: String,
}
impl TryFrom<raw::User> for User {
type Error = Error;
fn try_from(user: raw::User) -> Result<Self, Self::Error> {
let hashed_password = match (&user.initial_password, &user.hashed_password) {
(_, Some(p)) => p.clone(),
(Some(p), None) => auth::hash_password(p)?,
(None, None) => return Err(Error::EmptyPassword),
};
Ok(Self {
name: user.name,
admin: user.admin,
initial_password: user.initial_password,
hashed_password,
})
}
}
impl User {
pub fn is_admin(&self) -> bool {
self.admin == Some(true)
}
}
impl From<User> for raw::User {
fn from(user: User) -> Self {
Self {
name: user.name,
admin: user.admin,
initial_password: user.initial_password,
hashed_password: Some(user.hashed_password),
}
}
}
impl Config {
pub fn create_user(
&mut self,
username: &str,
password: &str,
admin: bool,
) -> Result<(), Error> {
if username.is_empty() {
return Err(Error::EmptyUsername);
}
if self.users.contains_key(username) {
return Err(Error::DuplicateUsername);
}
let password_hash = auth::hash_password(&password)?;
self.users.insert(
username.to_owned(),
User {
name: username.to_owned(),
admin: Some(admin),
initial_password: None,
hashed_password: password_hash,
},
);
Ok(())
}
pub fn authenticate(
&self,
auth_token: &auth::Token,
scope: auth::Scope,
auth_secret: &auth::Secret,
) -> Result<auth::Authorization, Error> {
let authorization = auth::decode_auth_token(auth_token, scope, auth_secret)?;
if self.users.contains_key(&authorization.username) {
Ok(authorization)
} else {
Err(Error::IncorrectUsername)
}
}
pub fn login(
&self,
username: &str,
password: &str,
auth_secret: &auth::Secret,
) -> Result<auth::Token, Error> {
let user = self.users.get(username).ok_or(Error::IncorrectUsername)?;
if auth::verify_password(&user.hashed_password, password) {
let authorization = auth::Authorization {
username: username.to_owned(),
scope: auth::Scope::PolarisAuth,
};
auth::generate_auth_token(&authorization, auth_secret)
} else {
Err(Error::IncorrectPassword)
}
}
pub fn set_is_admin(&mut self, username: &str, is_admin: bool) -> Result<(), Error> {
let user = self.users.get_mut(username).ok_or(Error::UserNotFound)?;
user.admin = Some(is_admin);
Ok(())
}
pub fn set_password(&mut self, username: &str, password: &str) -> Result<(), Error> {
let user = self.users.get_mut(username).ok_or(Error::UserNotFound)?;
user.hashed_password = auth::hash_password(password)?;
Ok(())
}
pub fn delete_user(&mut self, username: &str) {
self.users.remove(username);
}
}
#[cfg(test)]
mod test {
use crate::app::test;
use crate::test_name;
use super::*;
const TEST_USERNAME: &str = "Walter";
const TEST_PASSWORD: &str = "super_secret!";
fn adds_password_hashes() {
let user_in = raw::User {
name: TEST_USERNAME.to_owned(),
initial_password: Some(TEST_PASSWORD.to_owned()),
..Default::default()
};
let user: User = user_in.try_into().unwrap();
let user_out: raw::User = user.into();
assert_eq!(user_out.name, TEST_USERNAME);
assert_eq!(user_out.initial_password, Some(TEST_PASSWORD.to_owned()));
assert!(user_out.hashed_password.is_some());
}
fn preserves_password_hashes() {
let user_in = raw::User {
name: TEST_USERNAME.to_owned(),
hashed_password: Some("hash".to_owned()),
..Default::default()
};
let user: User = user_in.clone().try_into().unwrap();
let user_out: raw::User = user.into();
assert_eq!(user_out, user_in);
}
#[tokio::test]
async fn create_delete_user_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
ctx.config_manager
.create_user(TEST_USERNAME, TEST_PASSWORD, false)
.await
.unwrap();
assert!(ctx.config_manager.get_user(TEST_USERNAME).await.is_ok());
ctx.config_manager.delete_user(TEST_USERNAME).await;
assert!(ctx.config_manager.get_user(TEST_USERNAME).await.is_err());
}
#[tokio::test]
async fn cannot_create_user_with_blank_username() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let result = ctx.config_manager.create_user("", TEST_PASSWORD, false);
assert!(matches!(result.await.unwrap_err(), Error::EmptyUsername));
}
#[tokio::test]
async fn cannot_create_user_with_blank_password() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let result = ctx.config_manager.create_user(TEST_USERNAME, "", false);
assert!(matches!(result.await.unwrap_err(), Error::EmptyPassword));
}
#[tokio::test]
async fn cannot_create_duplicate_user() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let result = ctx.config_manager.create_user(TEST_USERNAME, "", false);
assert!(result.await.is_ok());
let result = ctx.config_manager.create_user(TEST_USERNAME, "", false);
assert!(matches!(
result.await.unwrap_err(),
Error::DuplicateUsername
));
}
#[tokio::test]
async fn login_rejects_bad_password() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
ctx.config_manager
.create_user(TEST_USERNAME, TEST_PASSWORD, false)
.await
.unwrap();
let result = ctx.config_manager.login(TEST_USERNAME, "not the password");
assert!(matches!(
result.await.unwrap_err(),
Error::IncorrectPassword
));
}
#[tokio::test]
async fn login_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
ctx.config_manager
.create_user(TEST_USERNAME, TEST_PASSWORD, false)
.await
.unwrap();
let result = ctx.config_manager.login(TEST_USERNAME, TEST_PASSWORD);
assert!(result.await.is_ok());
}
#[tokio::test]
async fn authenticate_rejects_bad_token() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
ctx.config_manager
.create_user(TEST_USERNAME, TEST_PASSWORD, false)
.await
.unwrap();
let fake_token = auth::Token("fake token".to_owned());
assert!(ctx
.config_manager
.authenticate(&fake_token, auth::Scope::PolarisAuth)
.await
.is_err())
}
#[tokio::test]
async fn authenticate_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
ctx.config_manager
.create_user(TEST_USERNAME, TEST_PASSWORD, false)
.await
.unwrap();
let token = ctx
.config_manager
.login(TEST_USERNAME, TEST_PASSWORD)
.await
.unwrap();
let authorization = ctx
.config_manager
.authenticate(&token, auth::Scope::PolarisAuth)
.await
.unwrap();
assert_eq!(
authorization,
auth::Authorization {
username: TEST_USERNAME.to_owned(),
scope: auth::Scope::PolarisAuth,
}
)
}
}

View file

@ -357,7 +357,7 @@ mod test {
let (directories_sender, directories_receiver) = channel();
let (songs_sender, songs_receiver) = channel();
let mounts = vec![config::MountDir {
source: "test-data/small-collection".to_owned(),
source: ["test-data", "small-collection"].iter().collect(),
name: "root".to_owned(),
}];
let artwork_regex = None;
@ -377,7 +377,7 @@ mod test {
let (directories_sender, _) = channel();
let (songs_sender, songs_receiver) = channel();
let mounts = vec![config::MountDir {
source: "test-data/small-collection".to_owned(),
source: ["test-data", "small-collection"].iter().collect(),
name: "root".to_owned(),
}];
let artwork_regex = None;
@ -400,7 +400,7 @@ mod test {
let (directories_sender, _) = channel();
let (songs_sender, songs_receiver) = channel();
let mounts = vec![config::MountDir {
source: "test-data/small-collection".to_owned(),
source: ["test-data", "small-collection"].iter().collect(),
name: "root".to_owned(),
}];
let artwork_regex = Some(Regex::new(pattern).unwrap());

View file

@ -1,10 +1,8 @@
use std::path::PathBuf;
use crate::app::{config, index, ndb, playlist, scanner};
use crate::app::{auth, config, index, ndb, playlist, scanner};
use crate::test::*;
use super::config::AuthSecret;
pub struct Context {
pub index_manager: index::Manager,
pub scanner: scanner::Scanner,
@ -42,11 +40,14 @@ impl ContextBuilder {
});
self
}
pub async fn build(self) -> Context {
let config_path = self.test_directory.join("polaris.toml");
let auth_secret = AuthSecret::default();
let config_manager = config::Manager::new(&config_path).await.unwrap();
let auth_secret = auth::Secret::default();
let config_manager = config::Manager::new(&config_path, auth_secret)
.await
.unwrap();
let ndb_manager = ndb::Manager::new(&self.test_directory).unwrap();
let index_manager = index::Manager::new(&self.test_directory).await.unwrap();
let scanner = scanner::Scanner::new(index_manager.clone(), config_manager.clone())

View file

@ -1,430 +0,0 @@
use pbkdf2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use pbkdf2::Pbkdf2;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::app::settings::AuthSecret;
use crate::app::Error;
use crate::db::DB;
#[derive(Debug)]
pub struct User {
pub name: String,
pub admin: i64,
}
impl User {
pub fn is_admin(&self) -> bool {
self.admin != 0
}
}
#[derive(Debug, Deserialize)]
pub struct NewUser {
pub name: String,
pub password: String,
pub admin: bool,
}
#[derive(Debug)]
pub struct AuthToken(pub String);
#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)]
pub enum AuthorizationScope {
PolarisAuth,
}
#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct Authorization {
pub username: String,
pub scope: AuthorizationScope,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Preferences {
pub web_theme_base: Option<String>,
pub web_theme_accent: Option<String>,
}
#[derive(Clone)]
pub struct Manager {
db: DB,
auth_secret: AuthSecret,
}
impl Manager {
pub fn new(db: DB, auth_secret: AuthSecret) -> Self {
Self { db, auth_secret }
}
pub async fn create(&self, new_user: &NewUser) -> Result<(), Error> {
if new_user.name.is_empty() {
return Err(Error::EmptyUsername);
}
let password_hash = hash_password(&new_user.password)?;
sqlx::query!(
"INSERT INTO users (name, password_hash, admin) VALUES($1, $2, $3)",
new_user.name,
password_hash,
new_user.admin
)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
pub async fn delete(&self, username: &str) -> Result<(), Error> {
sqlx::query!("DELETE FROM users WHERE name = $1", username)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
pub async fn set_password(&self, username: &str, password: &str) -> Result<(), Error> {
let hash = hash_password(password)?;
sqlx::query!(
"UPDATE users SET password_hash = $1 WHERE name = $2",
hash,
username
)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
pub async fn set_is_admin(&self, username: &str, is_admin: bool) -> Result<(), Error> {
sqlx::query!(
"UPDATE users SET admin = $1 WHERE name = $2",
is_admin,
username
)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
pub async fn login(&self, username: &str, password: &str) -> Result<AuthToken, Error> {
match sqlx::query_scalar!("SELECT password_hash FROM users WHERE name = $1", username)
.fetch_optional(self.db.connect().await?.as_mut())
.await?
{
None => Err(Error::IncorrectUsername),
Some(hash) => {
let hash: String = hash;
if verify_password(&hash, password) {
let authorization = Authorization {
username: username.to_owned(),
scope: AuthorizationScope::PolarisAuth,
};
self.generate_auth_token(&authorization)
} else {
Err(Error::IncorrectPassword)
}
}
}
}
pub async fn authenticate(
&self,
auth_token: &AuthToken,
scope: AuthorizationScope,
) -> Result<Authorization, Error> {
let authorization = self.decode_auth_token(auth_token, scope)?;
if self.exists(&authorization.username).await? {
Ok(authorization)
} else {
Err(Error::IncorrectUsername)
}
}
fn decode_auth_token(
&self,
auth_token: &AuthToken,
scope: AuthorizationScope,
) -> Result<Authorization, Error> {
let AuthToken(data) = auth_token;
let ttl = match scope {
AuthorizationScope::PolarisAuth => 0, // permanent
};
let authorization = branca::decode(data, &self.auth_secret.key, ttl)
.map_err(|_| Error::InvalidAuthToken)?;
let authorization: Authorization =
serde_json::from_slice(&authorization[..]).map_err(|_| Error::InvalidAuthToken)?;
if authorization.scope != scope {
return Err(Error::IncorrectAuthorizationScope);
}
Ok(authorization)
}
fn generate_auth_token(&self, authorization: &Authorization) -> Result<AuthToken, Error> {
let serialized_authorization =
serde_json::to_string(&authorization).or(Err(Error::AuthorizationTokenEncoding))?;
branca::encode(
serialized_authorization.as_bytes(),
&self.auth_secret.key,
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as u32,
)
.or(Err(Error::BrancaTokenEncoding))
.map(AuthToken)
}
pub async fn count(&self) -> Result<i64, Error> {
let count = sqlx::query_scalar!("SELECT COUNT(*) FROM users")
.fetch_one(self.db.connect().await?.as_mut())
.await?;
Ok(count)
}
pub async fn list(&self) -> Result<Vec<User>, Error> {
let listed_users = sqlx::query_as!(User, "SELECT name, admin FROM users")
.fetch_all(self.db.connect().await?.as_mut())
.await?;
Ok(listed_users)
}
pub async fn exists(&self, username: &str) -> Result<bool, Error> {
Ok(
0 < sqlx::query_scalar!("SELECT COUNT(*) FROM users WHERE name = $1", username)
.fetch_one(self.db.connect().await?.as_mut())
.await?,
)
}
pub async fn is_admin(&self, username: &str) -> Result<bool, Error> {
Ok(
0 < sqlx::query_scalar!("SELECT admin FROM users WHERE name = $1", username)
.fetch_one(self.db.connect().await?.as_mut())
.await?,
)
}
pub async fn read_preferences(&self, username: &str) -> Result<Preferences, Error> {
Ok(sqlx::query_as!(
Preferences,
"SELECT web_theme_base, web_theme_accent FROM users WHERE name = $1",
username
)
.fetch_one(self.db.connect().await?.as_mut())
.await?)
}
pub async fn write_preferences(
&self,
username: &str,
preferences: &Preferences,
) -> Result<(), Error> {
sqlx::query!(
"UPDATE users SET web_theme_base = $1, web_theme_accent = $2 WHERE name = $3",
preferences.web_theme_base,
preferences.web_theme_accent,
username
)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
}
fn hash_password(password: &str) -> Result<String, Error> {
if password.is_empty() {
return Err(Error::EmptyPassword);
}
let salt = SaltString::generate(&mut OsRng);
match Pbkdf2.hash_password(password.as_bytes(), &salt) {
Ok(h) => Ok(h.to_string()),
Err(_) => Err(Error::PasswordHashing),
}
}
fn verify_password(password_hash: &str, attempted_password: &str) -> bool {
match PasswordHash::new(password_hash) {
Ok(h) => Pbkdf2
.verify_password(attempted_password.as_bytes(), &h)
.is_ok(),
Err(_) => false,
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::app::test;
use crate::test_name;
const TEST_USERNAME: &str = "Walter";
const TEST_PASSWORD: &str = "super_secret!";
#[tokio::test]
async fn create_delete_user_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
password: TEST_PASSWORD.to_owned(),
admin: false,
};
ctx.user_manager.create(&new_user).await.unwrap();
assert_eq!(ctx.user_manager.list().await.unwrap().len(), 1);
ctx.user_manager.delete(&new_user.name).await.unwrap();
assert_eq!(ctx.user_manager.list().await.unwrap().len(), 0);
}
#[tokio::test]
async fn cannot_create_user_with_blank_username() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: "".to_owned(),
password: TEST_PASSWORD.to_owned(),
admin: false,
};
assert!(matches!(
ctx.user_manager.create(&new_user).await.unwrap_err(),
Error::EmptyUsername
));
}
#[tokio::test]
async fn cannot_create_user_with_blank_password() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
password: "".to_owned(),
admin: false,
};
assert!(matches!(
ctx.user_manager.create(&new_user).await.unwrap_err(),
Error::EmptyPassword
));
}
#[tokio::test]
async fn cannot_create_duplicate_user() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
password: TEST_PASSWORD.to_owned(),
admin: false,
};
ctx.user_manager.create(&new_user).await.unwrap();
ctx.user_manager.create(&new_user).await.unwrap_err();
}
#[tokio::test]
async fn can_read_write_preferences() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_preferences = Preferences {
web_theme_base: Some("very-dark-theme".to_owned()),
web_theme_accent: Some("#FF0000".to_owned()),
};
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
password: TEST_PASSWORD.to_owned(),
admin: false,
};
ctx.user_manager.create(&new_user).await.unwrap();
ctx.user_manager
.write_preferences(TEST_USERNAME, &new_preferences)
.await
.unwrap();
let read_preferences = ctx.user_manager.read_preferences("Walter").await.unwrap();
assert_eq!(new_preferences, read_preferences);
}
#[tokio::test]
async fn login_rejects_bad_password() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
password: TEST_PASSWORD.to_owned(),
admin: false,
};
ctx.user_manager.create(&new_user).await.unwrap();
assert!(matches!(
ctx.user_manager
.login(TEST_USERNAME, "not the password")
.await
.unwrap_err(),
Error::IncorrectPassword
));
}
#[tokio::test]
async fn login_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
password: TEST_PASSWORD.to_owned(),
admin: false,
};
ctx.user_manager.create(&new_user).await.unwrap();
assert!(ctx
.user_manager
.login(TEST_USERNAME, TEST_PASSWORD)
.await
.is_ok())
}
#[tokio::test]
async fn authenticate_rejects_bad_token() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
password: TEST_PASSWORD.to_owned(),
admin: false,
};
ctx.user_manager.create(&new_user).await.unwrap();
let fake_token = AuthToken("fake token".to_owned());
assert!(ctx
.user_manager
.authenticate(&fake_token, AuthorizationScope::PolarisAuth)
.await
.is_err())
}
#[tokio::test]
async fn authenticate_golden_path() {
let ctx = test::ContextBuilder::new(test_name!()).build().await;
let new_user = NewUser {
name: TEST_USERNAME.to_owned(),
password: TEST_PASSWORD.to_owned(),
admin: false,
};
ctx.user_manager.create(&new_user).await.unwrap();
let token = ctx
.user_manager
.login(TEST_USERNAME, TEST_PASSWORD)
.await
.unwrap();
let authorization = ctx
.user_manager
.authenticate(&token, AuthorizationScope::PolarisAuth)
.await
.unwrap();
assert_eq!(
authorization,
Authorization {
username: TEST_USERNAME.to_owned(),
scope: AuthorizationScope::PolarisAuth,
}
)
}
}

View file

@ -12,7 +12,7 @@ use axum_range::{KnownSize, Ranged};
use tower_http::{compression::CompressionLayer, CompressionLevel};
use crate::{
app::{config, index, peaks, playlist, scanner, thumbnail, App},
app::{auth, config, index, peaks, playlist, scanner, thumbnail, App},
server::{
dto, error::APIError, APIMajorVersion, API_ARRAY_SEPARATOR, API_MAJOR_VERSION,
API_MINOR_VERSION,
@ -148,10 +148,11 @@ async fn post_auth(
) -> Result<Json<dto::Authorization>, APIError> {
let username = credentials.username.clone();
let user::AuthToken(token) = user_manager
let auth::Token(token) = config_manager
.login(&credentials.username, &credentials.password)
.await?;
let is_admin = user_manager.is_admin(&credentials.username).await?;
let user = config_manager.get_user(&credentials.username).await?;
let is_admin = user.is_admin();
let authorization = dto::Authorization {
username: username.clone(),
@ -176,7 +177,9 @@ async fn post_user(
State(config_manager): State<config::Manager>,
Json(new_user): Json<dto::NewUser>,
) -> Result<(), APIError> {
user_manager.create(&new_user.into()).await?;
config_manager
.create_user(&new_user.name, &new_user.password, new_user.admin)
.await?;
Ok(())
}
@ -193,11 +196,11 @@ async fn put_user(
}
if let Some(password) = &user_update.new_password {
user_manager.set_password(&name, password).await?;
config_manager.set_password(&name, password).await?;
}
if let Some(is_admin) = &user_update.new_is_admin {
user_manager.set_is_admin(&name, *is_admin).await?;
config_manager.set_is_admin(&name, *is_admin).await?;
}
Ok(())

View file

@ -6,7 +6,7 @@ use headers::authorization::{Bearer, Credentials};
use http::request::Parts;
use crate::{
app::config,
app::{auth, config},
server::{dto, error::APIError},
};
@ -46,11 +46,8 @@ where
return Err(APIError::AuthenticationRequired);
};
let authorization = user_manager
.authenticate(
&user::AuthToken(token),
user::AuthorizationScope::PolarisAuth,
)
let authorization = config_manager
.authenticate(&auth::Token(token), auth::Scope::PolarisAuth)
.await?;
Ok(Auth {
@ -81,13 +78,13 @@ where
async fn from_request_parts(parts: &mut Parts, app: &S) -> Result<Self, Self::Rejection> {
let config_manager = config::Manager::from_ref(app);
let user_count = user_manager.count().await?;
let user_count = config_manager.get_users().await.len();
if user_count == 0 {
return Ok(AdminRights { auth: None });
}
let auth = Auth::from_request_parts(parts, app).await?;
if user_manager.is_admin(&auth.username).await? {
if config_manager.get_user(&auth.username).await?.is_admin() {
Ok(AdminRights { auth: Some(auth) })
} else {
Err(APIError::AdminPermissionRequired)

View file

@ -21,6 +21,7 @@ impl IntoResponse for APIError {
APIError::NativeDatabase(_) => StatusCode::INTERNAL_SERVER_ERROR,
APIError::DeletingOwnAccount => StatusCode::CONFLICT,
APIError::DirectoryNotFound(_) => StatusCode::NOT_FOUND,
APIError::DuplicateUsername => StatusCode::CONFLICT,
APIError::ArtistNotFound => StatusCode::NOT_FOUND,
APIError::AlbumNotFound => StatusCode::NOT_FOUND,
APIError::GenreNotFound => StatusCode::NOT_FOUND,

View file

@ -37,6 +37,8 @@ pub enum APIError {
DdnsUpdateQueryFailed(u16),
#[error("Cannot delete your own account")]
DeletingOwnAccount,
#[error("Username already exists")]
DuplicateUsername,
#[error("EmbeddedArtworkNotFound")]
EmbeddedArtworkNotFound,
#[error("EmptyUsername")]
@ -131,6 +133,7 @@ impl From<app::Error> for APIError {
app::Error::SearchQueryParseError => APIError::SearchQueryParseError,
app::Error::EmbeddedArtworkNotFound(_) => APIError::EmbeddedArtworkNotFound,
app::Error::DuplicateUsername => APIError::DuplicateUsername,
app::Error::EmptyUsername => APIError::EmptyUsername,
app::Error::EmptyPassword => APIError::EmptyPassword,
app::Error::IncorrectUsername => APIError::IncorrectCredentials,