feat: refactor and refine authentication system with decoupled user management and admin console

This commit is contained in:
2026-05-30 14:05:11 +05:00
parent f35908095c
commit f6ea8a99d9
17 changed files with 816 additions and 191 deletions
+3
View File
@@ -12,6 +12,7 @@ use crate::auth::models::Claims;
pub struct AuthenticatedUser {
pub user_id: ObjectId,
pub username: String,
pub is_admin: bool,
}
impl<S> FromRequestParts<S> for AuthenticatedUser
@@ -46,6 +47,7 @@ where
Ok(AuthenticatedUser {
user_id,
username: token_data.claims.username,
is_admin: token_data.claims.is_admin,
})
}
}
@@ -82,6 +84,7 @@ where
Ok(Some(AuthenticatedUser {
user_id,
username: token_data.claims.username,
is_admin: token_data.claims.is_admin,
}))
}
}
+300 -18
View File
@@ -1,6 +1,6 @@
use askama::Template;
use axum::{
extract::{Form, State},
extract::{Form, State, Path},
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
};
@@ -117,6 +117,7 @@ pub async fn post_login(
let claims = Claims {
sub: user.id.expect("User document must have ID").to_hex(),
username: user.username,
is_admin: user.is_admin,
exp,
};
@@ -139,24 +140,36 @@ pub async fn post_login(
}
pub async fn get_register(
user_opt: Option<AuthenticatedUser>,
) -> impl IntoResponse {
if user_opt.is_some() {
return Redirect::to("/tasks").into_response();
user: AuthenticatedUser,
) -> Result<Response, AppError> {
if !user.is_admin {
return Err(AppError::Forbidden("Only administrators can access registration".to_string()));
}
HtmlTemplate(RegisterTemplate {
Ok(HtmlTemplate(RegisterTemplate {
error: None,
success: None,
authenticated: false,
username: "".to_string(),
authenticated: true,
username: user.username,
})
.into_response()
.into_response())
}
#[derive(Deserialize)]
pub struct RegisterPayload {
pub username: String,
pub password: String,
pub is_admin: Option<String>,
}
pub async fn post_register(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
Form(payload): Form<AuthPayload>,
Form(payload): Form<RegisterPayload>,
) -> Result<Response, AppError> {
if !user.is_admin {
return Err(AppError::Forbidden("Only administrators can register new users".to_string()));
}
let username = payload.username.trim();
let password = payload.password.trim();
@@ -164,8 +177,8 @@ pub async fn post_register(
return Ok(HtmlTemplate(RegisterTemplate {
error: Some("Username and password cannot be empty".to_string()),
success: None,
authenticated: false,
username: "".to_string(),
authenticated: true,
username: user.username,
})
.into_response());
}
@@ -176,8 +189,8 @@ pub async fn post_register(
return Ok(HtmlTemplate(RegisterTemplate {
error: Some("Username already taken".to_string()),
success: None,
authenticated: false,
username: "".to_string(),
authenticated: true,
username: user.username,
})
.into_response());
}
@@ -186,13 +199,14 @@ pub async fn post_register(
let hashed_password = hash(password, DEFAULT_COST)?;
// Create user
user_repo.create(username, &hashed_password).await?;
let is_admin_val = payload.is_admin.is_some();
user_repo.create(username, &hashed_password, is_admin_val).await?;
Ok(HtmlTemplate(RegisterTemplate {
error: None,
success: Some("Registration successful! You can now log in.".to_string()),
authenticated: false,
username: "".to_string(),
success: Some(format!("User '{}' registered successfully!", username)),
authenticated: true,
username: user.username,
})
.into_response())
}
@@ -205,3 +219,271 @@ pub async fn post_logout(jar: CookieJar) -> impl IntoResponse {
let updated_jar = jar.add(cookie);
(updated_jar, Redirect::to("/")).into_response()
}
#[derive(Template)]
#[template(path = "auth/password.html")]
struct PasswordTemplate {
error: Option<String>,
success: Option<String>,
authenticated: bool,
username: String,
is_admin: bool,
}
#[derive(Deserialize)]
pub struct PasswordPayload {
pub current_password: String,
pub new_password: String,
pub confirm_password: String,
}
pub async fn get_password(
user: AuthenticatedUser,
) -> impl IntoResponse {
HtmlTemplate(PasswordTemplate {
error: None,
success: None,
authenticated: true,
username: user.username.clone(),
is_admin: user.is_admin,
})
}
pub async fn post_password(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
Form(payload): Form<PasswordPayload>,
) -> Result<Response, AppError> {
let current_password = payload.current_password.trim();
let new_password = payload.new_password.trim();
let confirm_password = payload.confirm_password.trim();
if current_password.is_empty() || new_password.is_empty() || confirm_password.is_empty() {
return Ok(HtmlTemplate(PasswordTemplate {
error: Some("All fields are required".to_string()),
success: None,
authenticated: true,
username: user.username.clone(),
is_admin: user.is_admin,
})
.into_response());
}
if new_password != confirm_password {
return Ok(HtmlTemplate(PasswordTemplate {
error: Some("New passwords do not match".to_string()),
success: None,
authenticated: true,
username: user.username.clone(),
is_admin: user.is_admin,
})
.into_response());
}
// Fetch user details to verify current password
let user_db = user_repo.find_by_username(&user.username).await?;
let Some(user_db) = user_db else {
return Err(AppError::Unauthorized("User not found".to_string()));
};
// Verify current password
match verify(current_password, &user_db.password_hash) {
Ok(true) => {}
_ => {
return Ok(HtmlTemplate(PasswordTemplate {
error: Some("Current password is incorrect".to_string()),
success: None,
authenticated: true,
username: user.username.clone(),
is_admin: user.is_admin,
})
.into_response());
}
}
// Hash new password
let hashed_password = hash(new_password, DEFAULT_COST)?;
// Update password in database
user_repo.update_password(&user.user_id, &hashed_password).await?;
Ok(HtmlTemplate(PasswordTemplate {
error: None,
success: Some("Password updated successfully!".to_string()),
authenticated: true,
username: user.username.clone(),
is_admin: user.is_admin,
})
.into_response())
}
// User Management Templates and Handlers
#[derive(Template)]
#[template(path = "auth/users.html")]
pub struct UserListTemplate {
pub authenticated: bool,
pub username: String,
pub users: Vec<crate::auth::models::User>,
}
#[derive(Template)]
#[template(path = "auth/edit_user.html")]
pub struct UserEditTemplate {
pub error: Option<String>,
pub success: Option<String>,
pub authenticated: bool,
pub username: String,
pub user_to_edit: crate::auth::models::User,
}
pub async fn get_users(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
) -> Result<Response, AppError> {
if !user.is_admin {
return Err(AppError::Forbidden("Only administrators can access user management".to_string()));
}
let users = user_repo.find_all().await?;
Ok(HtmlTemplate(UserListTemplate {
authenticated: true,
username: user.username,
users,
})
.into_response())
}
pub async fn get_edit_user(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
Path(id_str): Path<String>,
) -> Result<Response, AppError> {
if !user.is_admin {
return Err(AppError::Forbidden("Only administrators can edit users".to_string()));
}
let oid = mongodb::bson::oid::ObjectId::parse_str(&id_str)
.map_err(|_| AppError::BadRequest("Invalid user ID format".to_string()))?;
let user_to_edit = user_repo.find_by_id(&oid).await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
Ok(HtmlTemplate(UserEditTemplate {
error: None,
success: None,
authenticated: true,
username: user.username,
user_to_edit,
})
.into_response())
}
#[derive(Deserialize)]
pub struct EditUserPayload {
pub username: String,
pub password: Option<String>,
pub is_admin: Option<String>,
}
pub async fn post_edit_user(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
Path(id_str): Path<String>,
Form(payload): Form<EditUserPayload>,
) -> Result<Response, AppError> {
if !user.is_admin {
return Err(AppError::Forbidden("Only administrators can edit users".to_string()));
}
let oid = mongodb::bson::oid::ObjectId::parse_str(&id_str)
.map_err(|_| AppError::BadRequest("Invalid user ID format".to_string()))?;
let user_to_edit = user_repo.find_by_id(&oid).await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
let new_username = payload.username.trim();
if new_username.is_empty() {
return Ok(HtmlTemplate(UserEditTemplate {
error: Some("Username cannot be empty".to_string()),
success: None,
authenticated: true,
username: user.username.clone(),
user_to_edit,
})
.into_response());
}
if new_username != user_to_edit.username {
let existing = user_repo.find_by_username(new_username).await?;
if existing.is_some() {
return Ok(HtmlTemplate(UserEditTemplate {
error: Some("Username already taken".to_string()),
success: None,
authenticated: true,
username: user.username.clone(),
user_to_edit,
})
.into_response());
}
}
let is_admin_val = payload.is_admin.is_some();
// Safety check: Don't allow removing own admin status
if oid == user.user_id && !is_admin_val {
return Ok(HtmlTemplate(UserEditTemplate {
error: Some("You cannot revoke your own administrator privileges".to_string()),
success: None,
authenticated: true,
username: user.username.clone(),
user_to_edit: user_to_edit.clone(),
})
.into_response());
}
let new_password = payload.password.as_deref().unwrap_or("").trim();
let password_hash_opt = if !new_password.is_empty() {
let hash = bcrypt::hash(new_password, bcrypt::DEFAULT_COST)?;
Some(hash)
} else {
None
};
user_repo.update(&oid, new_username, password_hash_opt.as_deref(), is_admin_val).await?;
let updated_user = user_repo.find_by_id(&oid).await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
Ok(HtmlTemplate(UserEditTemplate {
error: None,
success: Some("User updated successfully!".to_string()),
authenticated: true,
username: user.username.clone(),
user_to_edit: updated_user,
})
.into_response())
}
pub async fn post_delete_user(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
Path(id_str): Path<String>,
) -> Result<Response, AppError> {
if !user.is_admin {
return Err(AppError::Forbidden("Only administrators can delete users".to_string()));
}
let oid = mongodb::bson::oid::ObjectId::parse_str(&id_str)
.map_err(|_| AppError::BadRequest("Invalid user ID format".to_string()))?;
// Safety: Cannot delete self
if oid == user.user_id {
return Err(AppError::BadRequest("You cannot delete your own account while logged in".to_string()));
}
user_repo.delete(&oid).await?;
Ok(Redirect::to("/auth/users").into_response())
}
+4
View File
@@ -20,4 +20,8 @@ where
.route("/auth/login", get(handlers::get_login).post(handlers::post_login))
.route("/auth/register", get(handlers::get_register).post(handlers::post_register))
.route("/auth/logout", post(handlers::post_logout))
.route("/auth/password", get(handlers::get_password).post(handlers::post_password))
.route("/auth/users", get(handlers::get_users))
.route("/auth/users/{id}/edit", get(handlers::get_edit_user).post(handlers::post_edit_user))
.route("/auth/users/{id}/delete", post(handlers::post_delete_user))
}
+2
View File
@@ -9,6 +9,7 @@ pub struct User {
pub id: Option<ObjectId>,
pub username: String,
pub password_hash: String,
pub is_admin: bool,
#[serde_as(as = "mongodb::bson::serde_helpers::datetime::FromChrono04DateTime")]
pub created_at: chrono::DateTime<chrono::Utc>,
}
@@ -17,5 +18,6 @@ pub struct User {
pub struct Claims {
pub sub: String,
pub username: String,
pub is_admin: bool,
pub exp: usize,
}
+53 -2
View File
@@ -8,7 +8,12 @@ use crate::auth::models::User;
#[async_trait::async_trait]
pub trait UserRepository {
async fn find_by_username(&self, username: &str) -> Result<Option<User>, AppError>;
async fn create(&self, username: &str, password_hash: &str) -> Result<User, AppError>;
async fn find_by_id(&self, id: &mongodb::bson::oid::ObjectId) -> Result<Option<User>, AppError>;
async fn find_all(&self) -> Result<Vec<User>, AppError>;
async fn create(&self, username: &str, password_hash: &str, is_admin: bool) -> Result<User, AppError>;
async fn update(&self, id: &mongodb::bson::oid::ObjectId, username: &str, password_hash: Option<&str>, is_admin: bool) -> Result<(), AppError>;
async fn update_password(&self, user_id: &mongodb::bson::oid::ObjectId, new_password_hash: &str) -> Result<(), AppError>;
async fn delete(&self, id: &mongodb::bson::oid::ObjectId) -> Result<(), AppError>;
}
#[derive(Clone)]
@@ -32,13 +37,29 @@ impl UserRepository for MongoUserRepository {
Ok(user)
}
async fn create(&self, username: &str, password_hash: &str) -> Result<User, AppError> {
async fn find_by_id(&self, id: &mongodb::bson::oid::ObjectId) -> Result<Option<User>, AppError> {
let collection = self.db.collection::<User>("users");
let filter = doc! { "_id": id };
let user = collection.find_one(filter).await?;
Ok(user)
}
async fn find_all(&self) -> Result<Vec<User>, AppError> {
use futures::TryStreamExt;
let collection = self.db.collection::<User>("users");
let cursor = collection.find(doc! {}).await?;
let users = cursor.try_collect::<Vec<User>>().await?;
Ok(users)
}
async fn create(&self, username: &str, password_hash: &str, is_admin: bool) -> Result<User, AppError> {
let collection = self.db.collection::<User>("users");
let new_user = User {
id: None,
username: username.to_string(),
password_hash: password_hash.to_string(),
is_admin,
created_at: chrono::Utc::now(),
};
@@ -49,4 +70,34 @@ impl UserRepository for MongoUserRepository {
Ok(user)
}
async fn update(&self, id: &mongodb::bson::oid::ObjectId, username: &str, password_hash: Option<&str>, is_admin: bool) -> Result<(), AppError> {
let collection = self.db.collection::<User>("users");
let filter = doc! { "_id": id };
let mut update_doc = doc! {
"username": username.to_string(),
"is_admin": is_admin,
};
if let Some(hash) = password_hash {
update_doc.insert("password_hash", hash.to_string());
}
let update = doc! { "$set": update_doc };
collection.update_one(filter, update).await?;
Ok(())
}
async fn update_password(&self, user_id: &mongodb::bson::oid::ObjectId, new_password_hash: &str) -> Result<(), AppError> {
let collection = self.db.collection::<User>("users");
let filter = doc! { "_id": user_id };
let update = doc! { "$set": { "password_hash": new_password_hash } };
collection.update_one(filter, update).await?;
Ok(())
}
async fn delete(&self, id: &mongodb::bson::oid::ObjectId) -> Result<(), AppError> {
let collection = self.db.collection::<User>("users");
let filter = doc! { "_id": id };
collection.delete_one(filter).await?;
Ok(())
}
}
+8
View File
@@ -18,9 +18,15 @@ pub enum AppError {
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Bad Request: {0}")]
BadRequest(String),
#[error("Not Found: {0}")]
NotFound(String),
#[error("Internal Server Error: {0}")]
#[allow(dead_code)]
Internal(String),
@@ -42,7 +48,9 @@ impl IntoResponse for AppError {
(StatusCode::UNAUTHORIZED, "Your session has expired or is invalid. Please log in again.")
}
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.as_str()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.as_str()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.as_str()),
AppError::Internal(msg) => {
tracing::error!("Internal Error: {}", msg);
(StatusCode::INTERNAL_SERVER_ERROR, msg.as_str())
+36
View File
@@ -75,6 +75,42 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let task_repo = MongoTaskRepository::new(db.clone());
let dev_repo = MongoDeveloperRepository::new(db.clone());
// Auto-provision initial administrator if users collection is empty
let users_count = db.collection::<crate::auth::models::User>("users")
.count_documents(mongodb::bson::doc! {})
.await?;
if users_count == 0 {
use rand::{distributions::Alphanumeric, Rng};
let random_password: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
let password_hash = bcrypt::hash(&random_password, bcrypt::DEFAULT_COST)?;
let admin_username = "admin";
let admin_user = crate::auth::models::User {
id: None,
username: admin_username.to_string(),
password_hash,
is_admin: true,
created_at: chrono::Utc::now(),
};
db.collection::<crate::auth::models::User>("users")
.insert_one(admin_user)
.await?;
info!("\n\n\
======================================================\n\
CREATED INITIAL ADMINISTRATOR ACCOUNT:\n\
Username: {}\n\
Password: {}\n\
======================================================\n\n",
admin_username, random_password);
}
// 5. Initialize shared AppState
let state = AppState {
config: config.clone(),