polaris-mirror/src/app/user.rs
2024-08-03 13:57:03 -07:00

509 lines
13 KiB
Rust

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,
LastFMLink,
}
#[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 lastfm_username: Option<String>,
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
AuthorizationScope::LastFMLink => 10 * 60, // 10 minutes
};
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, lastfm_username 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(())
}
pub async fn lastfm_link(
&self,
username: &str,
lastfm_login: &str,
session_key: &str,
) -> Result<(), Error> {
sqlx::query!(
"UPDATE users SET lastfm_username = $1, lastfm_session_key = $2 WHERE name = $3",
lastfm_login,
session_key,
username
)
.execute(self.db.connect().await?.as_mut())
.await?;
Ok(())
}
pub fn generate_lastfm_link_token(&self, username: &str) -> Result<AuthToken, Error> {
self.generate_auth_token(&Authorization {
username: username.to_owned(),
scope: AuthorizationScope::LastFMLink,
})
}
pub async fn get_lastfm_session_key(&self, username: &str) -> Result<String, Error> {
let token: Option<String> = sqlx::query_scalar!(
"SELECT lastfm_session_key FROM users WHERE name = $1",
username
)
.fetch_one(self.db.connect().await?.as_mut())
.await?;
token.ok_or(Error::MissingLastFMSessionKey)
}
pub async fn is_lastfm_linked(&self, username: &str) -> bool {
self.get_lastfm_session_key(username).await.is_ok()
}
pub async fn lastfm_unlink(&self, username: &str) -> Result<(), Error> {
let null: Option<String> = None;
sqlx::query!(
"UPDATE users SET lastfm_session_key = $1, lastfm_username = $1 WHERE name = $2",
null,
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()),
lastfm_username: None,
};
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,
}
)
}
#[tokio::test]
async fn authenticate_validates_scope() {
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
.generate_lastfm_link_token(TEST_USERNAME)
.unwrap();
let authorization = ctx
.user_manager
.authenticate(&token, AuthorizationScope::PolarisAuth)
.await;
assert!(matches!(
authorization.unwrap_err(),
Error::IncorrectAuthorizationScope
));
}
}