Administrator privileges to get and delete subscribers
This commit is contained in:
2675
assets/css/main.css
2675
assets/css/main.css
File diff suppressed because one or more lines are too long
7
migrations/20250930145931_add_role_to_users.sql
Normal file
7
migrations/20250930145931_add_role_to_users.sql
Normal 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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<_>| {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{% if user.is_admin() %}
|
||||||
|
<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 "stats.html" %}
|
||||||
{% include "subscribers/list.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" %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user