feat: refactor and refine authentication system with decoupled user management and admin console
This commit is contained in:
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user