Administrator privileges to get and delete subscribers
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled

This commit is contained in:
Alphonse Paix
2025-09-30 18:27:48 +02:00
parent b5b00152cd
commit 3e81c27ab3
12 changed files with 2790 additions and 54 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
CREATE TYPE user_role AS ENUM ('admin', 'writer');
ALTER TABLE users ADD COLUMN role user_role;
UPDATE users SET role = 'admin' WHERE role IS NULL;
ALTER TABLE users ALTER COLUMN role SET NOT NULL;

View File

@@ -1,12 +1,9 @@
use crate::{ use crate::telemetry::spawn_blocking_with_tracing;
routes::AdminError, session_state::TypedSession, telemetry::spawn_blocking_with_tracing,
};
use anyhow::Context; use anyhow::Context;
use argon2::{ use argon2::{
Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version, Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version,
password_hash::{SaltString, rand_core::OsRng}, password_hash::{SaltString, rand_core::OsRng},
}; };
use axum::{extract::Request, middleware::Next, response::Response};
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@@ -60,8 +57,9 @@ fn compute_pasword_hash(password: SecretString) -> Result<SecretString, anyhow::
pub async fn validate_credentials( pub async fn validate_credentials(
Credentials { username, password }: Credentials, Credentials { username, password }: Credentials,
connection_pool: &PgPool, connection_pool: &PgPool,
) -> Result<Uuid, AuthError> { ) -> Result<(Uuid, Role), AuthError> {
let mut user_id = None; let mut user_id = None;
let mut role = None;
let mut expected_password_hash = SecretString::from( let mut expected_password_hash = SecretString::from(
"$argon2id$v=19$m=15000,t=2,p=1$\ "$argon2id$v=19$m=15000,t=2,p=1$\
gZiV/M1gPc22ElAH/Jh1Hw$\ gZiV/M1gPc22ElAH/Jh1Hw$\
@@ -69,13 +67,14 @@ CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
.to_string(), .to_string(),
); );
if let Some((stored_user_id, stored_expected_password_hash)) = if let Some((stored_user_id, stored_expected_password_hash, stored_role)) =
get_stored_credentials(&username, connection_pool) get_stored_credentials(&username, connection_pool)
.await .await
.context("Failed to retrieve credentials from database.") .context("Failed to retrieve credentials from database.")
.map_err(AuthError::UnexpectedError)? .map_err(AuthError::UnexpectedError)?
{ {
user_id = Some(stored_user_id); user_id = Some(stored_user_id);
role = Some(stored_role);
expected_password_hash = stored_expected_password_hash; expected_password_hash = stored_expected_password_hash;
} }
@@ -86,12 +85,16 @@ CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
.ok_or_else(|| anyhow::anyhow!("Unknown username.")) .ok_or_else(|| anyhow::anyhow!("Unknown username."))
.map_err(AuthError::InvalidCredentials)?; .map_err(AuthError::InvalidCredentials)?;
let role = role
.ok_or_else(|| anyhow::anyhow!("Unknown role."))
.map_err(AuthError::UnexpectedError)?;
handle handle
.await .await
.context("Failed to spawn blocking task.") .context("Failed to spawn blocking task.")
.map_err(AuthError::UnexpectedError)? .map_err(AuthError::UnexpectedError)?
.map_err(AuthError::InvalidCredentials) .map_err(AuthError::InvalidCredentials)
.map(|_| uuid) .map(|_| (uuid, role))
} }
#[tracing::instrument(name = "Verify password", skip_all)] #[tracing::instrument(name = "Verify password", skip_all)]
@@ -113,10 +116,10 @@ fn verify_password_hash(
async fn get_stored_credentials( async fn get_stored_credentials(
username: &str, username: &str,
connection_pool: &PgPool, connection_pool: &PgPool,
) -> Result<Option<(Uuid, SecretString)>, sqlx::Error> { ) -> Result<Option<(Uuid, SecretString, Role)>, sqlx::Error> {
let row = sqlx::query!( let row = sqlx::query!(
r#" r#"
SELECT user_id, password_hash SELECT user_id, password_hash, role as "role: Role"
FROM users FROM users
WHERE username = $1 WHERE username = $1
"#, "#,
@@ -124,37 +127,26 @@ async fn get_stored_credentials(
) )
.fetch_optional(connection_pool) .fetch_optional(connection_pool)
.await? .await?
.map(|row| (row.user_id, SecretString::from(row.password_hash))); .map(|row| (row.user_id, SecretString::from(row.password_hash), row.role));
Ok(row) Ok(row)
} }
pub async fn require_auth( #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
session: TypedSession, #[sqlx(type_name = "user_role", rename_all = "lowercase")]
mut request: Request, pub enum Role {
next: Next, Admin,
) -> Result<Response, AdminError> { Writer,
let user_id = session
.get_user_id()
.await
.map_err(|e| AdminError::UnexpectedError(e.into()))?
.ok_or(AdminError::NotAuthenticated)?;
let username = session
.get_username()
.await
.map_err(|e| AdminError::UnexpectedError(e.into()))?
.ok_or(AdminError::UnexpectedError(anyhow::anyhow!(
"Could not find username in session."
)))?;
request
.extensions_mut()
.insert(AuthenticatedUser { user_id, username });
Ok(next.run(request).await)
} }
#[derive(Clone)] #[derive(Clone)]
pub struct AuthenticatedUser { pub struct AuthenticatedUser {
pub user_id: Uuid, pub user_id: Uuid,
pub username: String, pub username: String,
pub role: Role,
}
impl AuthenticatedUser {
pub fn is_admin(&self) -> bool {
matches!(self.role, Role::Admin)
}
} }

View File

@@ -6,11 +6,17 @@ mod posts;
mod subscribers; mod subscribers;
use crate::{ use crate::{
authentication::AuthenticatedUser, authentication::{AuthenticatedUser, Role},
routes::{AppError, error_chain_fmt}, routes::{AppError, error_chain_fmt},
session_state::TypedSession, session_state::TypedSession,
templates::{HtmlTemplate, MessageTemplate},
};
use anyhow::Context;
use axum::{
extract::Request,
middleware::Next,
response::{IntoResponse, Response},
}; };
use axum::{extract::Request, middleware::Next, response::Response};
pub use change_password::*; pub use change_password::*;
pub use dashboard::*; pub use dashboard::*;
pub use logout::*; pub use logout::*;
@@ -55,10 +61,37 @@ pub async fn require_auth(
.ok_or(AdminError::UnexpectedError(anyhow::anyhow!( .ok_or(AdminError::UnexpectedError(anyhow::anyhow!(
"Could not find username in session." "Could not find username in session."
)))?; )))?;
let role = session
.get_role()
.await
.context("Error retrieving user role in session.")?
.ok_or(anyhow::anyhow!("Could not find user role in session."))?;
request request.extensions_mut().insert(AuthenticatedUser {
.extensions_mut() user_id,
.insert(AuthenticatedUser { user_id, username }); username,
role,
});
Ok(next.run(request).await) Ok(next.run(request).await)
} }
pub async fn require_admin(
session: TypedSession,
request: Request,
next: Next,
) -> Result<Response, AppError> {
if let Role::Admin = session
.get_role()
.await
.context("Error retrieving user role in session.")?
.ok_or(anyhow::anyhow!("Could not find user role in session."))?
{
Ok(next.run(request).await)
} else {
Ok(HtmlTemplate(MessageTemplate::error(
"This action requires administrator privileges.".into(),
))
.into_response())
}
}

View File

@@ -20,7 +20,9 @@ pub struct PasswordFormData {
} }
pub async fn change_password( pub async fn change_password(
Extension(AuthenticatedUser { user_id, username }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser {
user_id, username, ..
}): Extension<AuthenticatedUser>,
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,

View File

@@ -31,7 +31,7 @@ pub async fn admin_dashboard(
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>, Extension(user): Extension<AuthenticatedUser>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let stats = get_stats(&connection_pool).await?; let stats = get_stats(&connection_pool).await?;
let idempotency_key_1 = Uuid::new_v4().to_string(); let idempotency_key_1 = Uuid::new_v4().to_string();
@@ -46,7 +46,7 @@ pub async fn admin_dashboard(
.context("Could not fetch total subscribers count from the database.")?; .context("Could not fetch total subscribers count from the database.")?;
let max_page = get_max_page(count); let max_page = get_max_page(count);
let template = DashboardTemplate { let template = DashboardTemplate {
username, user,
idempotency_key_1, idempotency_key_1,
idempotency_key_2, idempotency_key_2,
stats, stats,

View File

@@ -48,7 +48,7 @@ pub async fn post_login(
password: form.password, password: form.password,
}; };
tracing::Span::current().record("username", tracing::field::display(&credentials.username)); tracing::Span::current().record("username", tracing::field::display(&credentials.username));
let user_id = validate_credentials(credentials, &connection_pool).await?; let (user_id, role) = validate_credentials(credentials, &connection_pool).await?;
tracing::Span::current().record("user_id", tracing::field::display(&user_id)); tracing::Span::current().record("user_id", tracing::field::display(&user_id));
session.renew().await.context("Failed to renew session.")?; session.renew().await.context("Failed to renew session.")?;
@@ -60,6 +60,10 @@ pub async fn post_login(
.insert_username(form.username) .insert_username(form.username)
.await .await
.context("Failed to insert username in session data store.")?; .context("Failed to insert username in session data store.")?;
session
.insert_role(role)
.await
.context("Failed to insert role in session data store.")?;
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap()); headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());

View File

@@ -3,6 +3,8 @@ use std::result;
use tower_sessions::{Session, session::Error}; use tower_sessions::{Session, session::Error};
use uuid::Uuid; use uuid::Uuid;
use crate::authentication::Role;
pub struct TypedSession(Session); pub struct TypedSession(Session);
type Result<T> = result::Result<T, Error>; type Result<T> = result::Result<T, Error>;
@@ -10,6 +12,7 @@ type Result<T> = result::Result<T, Error>;
impl TypedSession { impl TypedSession {
const USER_ID_KEY: &'static str = "user_id"; const USER_ID_KEY: &'static str = "user_id";
const USERNAME_KEY: &'static str = "username"; const USERNAME_KEY: &'static str = "username";
const ROLE_KEY: &'static str = "role";
pub async fn renew(&self) -> Result<()> { pub async fn renew(&self) -> Result<()> {
self.0.cycle_id().await self.0.cycle_id().await
@@ -31,6 +34,14 @@ impl TypedSession {
self.0.get(Self::USERNAME_KEY).await self.0.get(Self::USERNAME_KEY).await
} }
pub async fn insert_role(&self, role: Role) -> Result<()> {
self.0.insert(Self::ROLE_KEY, role).await
}
pub async fn get_role(&self) -> Result<Option<Role>> {
self.0.get(Self::ROLE_KEY).await
}
pub async fn clear(&self) { pub async fn clear(&self) {
self.0.clear().await; self.0.clear().await;
} }

View File

@@ -85,14 +85,17 @@ pub fn app(
base_url, base_url,
}; };
let admin_routes = Router::new() let admin_routes = Router::new()
.route("/subscribers", get(get_subscribers_page))
.route("/subscribers/{subscriber_id}", delete(delete_subscriber))
.route("/posts/{post_id}", delete(delete_post))
.layer(middleware::from_fn(require_admin));
let auth_routes = Router::new()
.route("/dashboard", get(admin_dashboard)) .route("/dashboard", get(admin_dashboard))
.route("/password", post(change_password)) .route("/password", post(change_password))
.route("/newsletters", post(publish_newsletter)) .route("/newsletters", post(publish_newsletter))
.route("/posts", post(create_post)) .route("/posts", post(create_post))
.route("/posts/{post_id}", delete(delete_post))
.route("/logout", get(logout)) .route("/logout", get(logout))
.route("/subscribers", get(get_subscribers_page)) .merge(admin_routes)
.route("/subscribers/{subscriber_id}", delete(delete_subscriber))
.layer(middleware::from_fn(require_auth)); .layer(middleware::from_fn(require_auth));
Router::new() Router::new()
.route("/", get(home)) .route("/", get(home))
@@ -106,7 +109,7 @@ pub fn app(
.route("/posts/load_more", get(load_more)) .route("/posts/load_more", get(load_more))
.route("/posts/{post_id}", get(see_post)) .route("/posts/{post_id}", get(see_post))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
.nest("/admin", admin_routes) .nest("/admin", auth_routes)
.nest_service("/assets", ServeDir::new("assets")) .nest_service("/assets", ServeDir::new("assets"))
.layer( .layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {

View File

@@ -1,4 +1,5 @@
use crate::{ use crate::{
authentication::AuthenticatedUser,
domain::{PostEntry, SubscriberEntry}, domain::{PostEntry, SubscriberEntry},
routes::{AppError, DashboardStats}, routes::{AppError, DashboardStats},
}; };
@@ -49,7 +50,7 @@ pub struct LoginTemplate;
#[derive(Template)] #[derive(Template)]
#[template(path = "dashboard/dashboard.html")] #[template(path = "dashboard/dashboard.html")]
pub struct DashboardTemplate { pub struct DashboardTemplate {
pub username: String, pub user: AuthenticatedUser,
pub idempotency_key_1: String, pub idempotency_key_1: String,
pub idempotency_key_2: String, pub idempotency_key_2: String,
pub stats: DashboardStats, pub stats: DashboardStats,

View File

@@ -1,11 +1,16 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard{% endblock %} {% block title %}Dashboard{% endblock %}
{% block content %} {% block content %}
<div class="min-w-6/12 mx-auto p-4 sm:p-6"> <div class="max-w-5xl mx-auto p-4 sm:p-6">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1> <h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<p class="mt-2 text-gray-600"> <p class="mt-2 text-gray-600 items-start">
Connected as <span class="font-bold">{{ username }}</span> <span>Connected as <span class="font-bold">{{ user.username }}</span></span>
{% if user.is_admin() %}
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
admin
</span>
{% endif %}
</p> </p>
<button hx-get="/admin/logout" <button hx-get="/admin/logout"
type="submit" type="submit"
@@ -16,8 +21,13 @@
<span>Logout</span> <span>Logout</span>
</button> </button>
</div> </div>
{% include "stats.html" %} {% if user.is_admin() %}
{% include "subscribers/list.html" %} <div class="mb-8 p-6 bg-gradient-to-br from-blue-50 to-indigo-50 bg-blue-50 rounded-lg border border-blue-200">
<h2 class="text-lg font-semibold text-blue-900 mb-6">Administration</h2>
{% include "stats.html" %}
{% include "subscribers/list.html" %}
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
{% include "publish.html" %} {% include "publish.html" %}
{% include "send_email.html" %} {% include "send_email.html" %}

View File

@@ -64,7 +64,7 @@ impl TestUser {
.unwrap() .unwrap()
.to_string(); .to_string();
sqlx::query!( sqlx::query!(
"INSERT INTO users (user_id, username, password_hash) VALUES ($1, $2, $3)", "INSERT INTO users (user_id, username, password_hash, role) VALUES ($1, $2, $3, 'admin')",
self.user_id, self.user_id,
self.username, self.username,
password_hash password_hash