refactor(gpodder): split repository for admin view
parent
669aa475ca
commit
2524eb5807
|
@ -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,
|
||||||
|
}
|
|
@ -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)
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
mod sessions;
|
mod sessions;
|
||||||
|
mod users;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Form, RequestExt, Router,
|
Form, RequestExt, Router,
|
||||||
|
|
Loading…
Reference in New Issue