From 2524eb58075c907ea3c7dd6409f6599727732e45 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Tue, 24 Jun 2025 13:30:17 +0200 Subject: [PATCH] refactor(gpodder): split repository for admin view --- gpodder/src/repository/admin.rs | 7 + .../authenticated.rs} | 107 ++-------------- gpodder/src/repository/mod.rs | 120 ++++++++++++++++++ gpodder/src/store.rs | 2 + otter/src/server/gpodder/mod.rs | 3 +- otter/src/server/web/mod.rs | 1 + 6 files changed, 140 insertions(+), 100 deletions(-) create mode 100644 gpodder/src/repository/admin.rs rename gpodder/src/{repository.rs => repository/authenticated.rs} (69%) create mode 100644 gpodder/src/repository/mod.rs diff --git a/gpodder/src/repository/admin.rs b/gpodder/src/repository/admin.rs new file mode 100644 index 0000000..98fb08a --- /dev/null +++ b/gpodder/src/repository/admin.rs @@ -0,0 +1,7 @@ +use crate::models; + +/// Admin view of the repository, providing methods only allowed by admins +pub struct AdminRepository<'a> { + pub(crate) store: &'a (dyn super::GpodderStore + Send + Sync), + pub(crate) user: &'a models::User, +} diff --git a/gpodder/src/repository.rs b/gpodder/src/repository/authenticated.rs similarity index 69% rename from gpodder/src/repository.rs rename to gpodder/src/repository/authenticated.rs index 173785d..9dd05ee 100644 --- a/gpodder/src/repository.rs +++ b/gpodder/src/repository/authenticated.rs @@ -1,108 +1,15 @@ -use std::{collections::HashSet, sync::Arc}; +use std::collections::HashSet; -use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; use chrono::{DateTime, TimeDelta, Utc}; -use rand::{Rng, rngs::OsRng}; +use rand::Rng; -use crate::{ - models, - store::{AuthErr, GpodderStore}, -}; - -const MAX_SESSION_AGE: TimeDelta = TimeDelta::seconds(60 * 60 * 24 * 7); - -/// Main abstraction over the database that provides API-compatible methods for querying and -/// modifying the underlying database -#[derive(Clone)] -pub struct GpodderRepository { - store: Arc, -} +use crate::{AuthErr, GpodderStore, models}; /// Authenticated view of the repository, providing methods that take the authenticated user /// explicitely into account pub struct AuthenticatedRepository<'a> { - store: &'a (dyn GpodderStore + Send + Sync), - user: &'a models::User, -} - -impl GpodderRepository { - pub fn new(store: impl GpodderStore + Send + Sync + 'static) -> Self { - Self { - store: Arc::new(store), - } - } - - /// Return an authenticated view of the repository for the given user - pub fn user<'a>(&'a self, user: &'a models::User) -> AuthenticatedRepository<'a> { - AuthenticatedRepository { - store: self.store.as_ref(), - user: user, - } - } - - pub fn get_session(&self, session_id: i64) -> Result { - let session = self - .store - .get_session(session_id)? - .ok_or(AuthErr::UnknownSession)?; - - // Expired sessions still in the database are considered removed - if Utc::now() - session.last_seen > MAX_SESSION_AGE { - Err(AuthErr::UnknownSession) - } else { - Ok(session) - } - } - - pub fn get_user(&self, username: &str) -> Result { - self.store.get_user(username)?.ok_or(AuthErr::UnknownUser) - } - - pub fn create_user(&self, username: &str, password: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - - let password_hash = Argon2::default() - .hash_password(password.as_bytes(), &salt) - .unwrap() - .to_string(); - - self.store.insert_user(username, &password_hash) - } - - pub fn validate_credentials( - &self, - username: &str, - password: &str, - ) -> Result { - let user = self.store.get_user(username)?.ok_or(AuthErr::UnknownUser)?; - - let password_hash = PasswordHash::new(&user.password_hash).unwrap(); - - if Argon2::default() - .verify_password(password.as_bytes(), &password_hash) - .is_ok() - { - Ok(user) - } else { - Err(AuthErr::InvalidPassword) - } - } - - pub fn refresh_session(&self, session: &models::Session) -> Result<(), AuthErr> { - let now = Utc::now(); - - self.store.refresh_session(session, now) - } - - pub fn remove_session(&self, session_id: i64) -> Result<(), AuthErr> { - self.store.remove_session(session_id) - } - - pub fn remove_old_sessions(&self) -> Result { - let min_last_seen = Utc::now() - MAX_SESSION_AGE; - - self.store.remove_old_sessions(min_last_seen) - } + pub(crate) store: &'a (dyn GpodderStore + Send + Sync), + pub(crate) user: &'a models::User, } impl<'a> AuthenticatedRepository<'a> { @@ -115,7 +22,9 @@ impl<'a> AuthenticatedRepository<'a> { // Users can't see sessions from other users, and expired sessions still in the database // are considered removed - if session.user.id != self.user.id || Utc::now() - session.last_seen > MAX_SESSION_AGE { + if session.user.id != self.user.id + || Utc::now() - session.last_seen > super::MAX_SESSION_AGE + { Err(AuthErr::UnknownSession) } else { Ok(session) diff --git a/gpodder/src/repository/mod.rs b/gpodder/src/repository/mod.rs new file mode 100644 index 0000000..ef87237 --- /dev/null +++ b/gpodder/src/repository/mod.rs @@ -0,0 +1,120 @@ +mod admin; +mod authenticated; + +use std::sync::Arc; + +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; +use chrono::{TimeDelta, Utc}; +use rand::rngs::OsRng; + +use crate::{ + models, + store::{AuthErr, GpodderStore}, +}; + +const MAX_SESSION_AGE: TimeDelta = TimeDelta::seconds(60 * 60 * 24 * 7); + +/// Main abstraction over the database that provides API-compatible methods for querying and +/// modifying the underlying database +#[derive(Clone)] +pub struct GpodderRepository { + store: Arc, +} + +impl GpodderRepository { + pub fn new(store: impl GpodderStore + Send + Sync + 'static) -> Self { + Self { + store: Arc::new(store), + } + } + + /// Return an authenticated view of the repository for the given user + pub fn user<'a>( + &'a self, + user: &'a models::User, + ) -> authenticated::AuthenticatedRepository<'a> { + authenticated::AuthenticatedRepository { + store: self.store.as_ref(), + user, + } + } + + /// Return an admin view of the repository, if the user is an admin + pub fn admin<'a>( + &'a self, + user: &'a models::User, + ) -> Result, AuthErr> { + if user.admin { + Ok(admin::AdminRepository { + store: self.store.as_ref(), + user, + }) + } else { + Err(AuthErr::NotAnAdmin) + } + } + + pub fn get_session(&self, session_id: i64) -> Result { + let session = self + .store + .get_session(session_id)? + .ok_or(AuthErr::UnknownSession)?; + + // Expired sessions still in the database are considered removed + if Utc::now() - session.last_seen > MAX_SESSION_AGE { + Err(AuthErr::UnknownSession) + } else { + Ok(session) + } + } + + pub fn get_user(&self, username: &str) -> Result { + self.store.get_user(username)?.ok_or(AuthErr::UnknownUser) + } + + pub fn create_user(&self, username: &str, password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + + let password_hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .unwrap() + .to_string(); + + self.store.insert_user(username, &password_hash) + } + + pub fn validate_credentials( + &self, + username: &str, + password: &str, + ) -> Result { + let user = self.store.get_user(username)?.ok_or(AuthErr::UnknownUser)?; + + let password_hash = PasswordHash::new(&user.password_hash).unwrap(); + + if Argon2::default() + .verify_password(password.as_bytes(), &password_hash) + .is_ok() + { + Ok(user) + } else { + Err(AuthErr::InvalidPassword) + } + } + + pub fn refresh_session(&self, session: &models::Session) -> Result<(), AuthErr> { + let now = Utc::now(); + + self.store.refresh_session(session, now) + } + + pub fn remove_session(&self, session_id: i64) -> Result<(), AuthErr> { + self.store.remove_session(session_id) + } + + pub fn remove_old_sessions(&self) -> Result { + let min_last_seen = Utc::now() - MAX_SESSION_AGE; + + self.store.remove_old_sessions(min_last_seen) + } +} diff --git a/gpodder/src/store.rs b/gpodder/src/store.rs index 4b60b24..0cc45ae 100644 --- a/gpodder/src/store.rs +++ b/gpodder/src/store.rs @@ -8,6 +8,7 @@ pub enum AuthErr { UnknownSession, UnknownUser, InvalidPassword, + NotAnAdmin, Other(Box), } @@ -17,6 +18,7 @@ impl Display for AuthErr { Self::UnknownUser => write!(f, "unknown user"), Self::UnknownSession => write!(f, "unknown session"), Self::InvalidPassword => write!(f, "invalid password"), + Self::NotAnAdmin => write!(f, "not an admin"), Self::Other(err) => err.fmt(f), } } diff --git a/otter/src/server/gpodder/mod.rs b/otter/src/server/gpodder/mod.rs index e8f77e3..69e86f2 100644 --- a/otter/src/server/gpodder/mod.rs +++ b/otter/src/server/gpodder/mod.rs @@ -123,7 +123,8 @@ impl From for AppError { match value { gpodder::AuthErr::UnknownUser | gpodder::AuthErr::UnknownSession - | gpodder::AuthErr::InvalidPassword => Self::Unauthorized, + | gpodder::AuthErr::InvalidPassword + | gpodder::AuthErr::NotAnAdmin => Self::Unauthorized, gpodder::AuthErr::Other(err) => Self::Other(err), } } diff --git a/otter/src/server/web/mod.rs b/otter/src/server/web/mod.rs index 9bbd11f..0cc1eb3 100644 --- a/otter/src/server/web/mod.rs +++ b/otter/src/server/web/mod.rs @@ -1,4 +1,5 @@ mod sessions; +mod users; use axum::{ Form, RequestExt, Router,