Config users refactor
This commit is contained in:
parent
c51ce59fba
commit
1555c784de
13 changed files with 680 additions and 673 deletions
15
src/app.rs
15
src/app.rs
|
@ -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
85
src/app/auth.rs
Normal 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)
|
||||
}
|
|
@ -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
136
src/app/config/mounts.rs
Normal 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
49
src/app/config/raw.rs
Normal 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
282
src/app/config/user.rs
Normal 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,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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())
|
||||
|
|
430
src/app/user.rs
430
src/app/user.rs
|
@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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(())
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue