refactor(gpodder): split repository for admin view

signup-links
Jef Roosens 2025-06-24 13:30:17 +02:00
parent 669aa475ca
commit 2524eb5807
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
6 changed files with 140 additions and 100 deletions

View File

@ -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,
}

View File

@ -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 chrono::{DateTime, TimeDelta, Utc};
use rand::{Rng, rngs::OsRng}; use rand::Rng;
use crate::{ use crate::{AuthErr, GpodderStore, models};
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<dyn GpodderStore + Send + Sync>,
}
/// Authenticated view of the repository, providing methods that take the authenticated user /// Authenticated view of the repository, providing methods that take the authenticated user
/// explicitely into account /// explicitely into account
pub struct AuthenticatedRepository<'a> { pub struct AuthenticatedRepository<'a> {
store: &'a (dyn GpodderStore + Send + Sync), pub(crate) store: &'a (dyn GpodderStore + Send + Sync),
user: &'a models::User, pub(crate) 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<models::Session, AuthErr> {
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<models::User, AuthErr> {
self.store.get_user(username)?.ok_or(AuthErr::UnknownUser)
}
pub fn create_user(&self, username: &str, password: &str) -> Result<models::User, AuthErr> {
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<models::User, AuthErr> {
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<usize, AuthErr> {
let min_last_seen = Utc::now() - MAX_SESSION_AGE;
self.store.remove_old_sessions(min_last_seen)
}
} }
impl<'a> AuthenticatedRepository<'a> { 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 // Users can't see sessions from other users, and expired sessions still in the database
// are considered removed // 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) Err(AuthErr::UnknownSession)
} else { } else {
Ok(session) Ok(session)

View File

@ -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<dyn GpodderStore + Send + Sync>,
}
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<admin::AdminRepository<'a>, 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<models::Session, AuthErr> {
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<models::User, AuthErr> {
self.store.get_user(username)?.ok_or(AuthErr::UnknownUser)
}
pub fn create_user(&self, username: &str, password: &str) -> Result<models::User, AuthErr> {
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<models::User, AuthErr> {
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<usize, AuthErr> {
let min_last_seen = Utc::now() - MAX_SESSION_AGE;
self.store.remove_old_sessions(min_last_seen)
}
}

View File

@ -8,6 +8,7 @@ pub enum AuthErr {
UnknownSession, UnknownSession,
UnknownUser, UnknownUser,
InvalidPassword, InvalidPassword,
NotAnAdmin,
Other(Box<dyn std::error::Error + Sync + Send>), Other(Box<dyn std::error::Error + Sync + Send>),
} }
@ -17,6 +18,7 @@ impl Display for AuthErr {
Self::UnknownUser => write!(f, "unknown user"), Self::UnknownUser => write!(f, "unknown user"),
Self::UnknownSession => write!(f, "unknown session"), Self::UnknownSession => write!(f, "unknown session"),
Self::InvalidPassword => write!(f, "invalid password"), Self::InvalidPassword => write!(f, "invalid password"),
Self::NotAnAdmin => write!(f, "not an admin"),
Self::Other(err) => err.fmt(f), Self::Other(err) => err.fmt(f),
} }
} }

View File

@ -123,7 +123,8 @@ impl From<gpodder::AuthErr> for AppError {
match value { match value {
gpodder::AuthErr::UnknownUser gpodder::AuthErr::UnknownUser
| gpodder::AuthErr::UnknownSession | gpodder::AuthErr::UnknownSession
| gpodder::AuthErr::InvalidPassword => Self::Unauthorized, | gpodder::AuthErr::InvalidPassword
| gpodder::AuthErr::NotAnAdmin => Self::Unauthorized,
gpodder::AuthErr::Other(err) => Self::Other(err), gpodder::AuthErr::Other(err) => Self::Other(err),
} }
} }

View File

@ -1,4 +1,5 @@
mod sessions; mod sessions;
mod users;
use axum::{ use axum::{
Form, RequestExt, Router, Form, RequestExt, Router,