Add user management system with JWT authentication
- Database schema: users, permissions, client_access tables - Auth: JWT tokens with Argon2 password hashing - API: login, user CRUD, permission management - Dashboard: login required, admin Users tab - Auto-creates initial admin user on first run
This commit is contained in:
317
server/src/api/auth.rs
Normal file
317
server/src/api/auth.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
//! Authentication API endpoints
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::{
|
||||
verify_password, AuthenticatedUser, JwtConfig,
|
||||
};
|
||||
use crate::db;
|
||||
use crate::AppState;
|
||||
|
||||
/// Login request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Login response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user: UserResponse,
|
||||
}
|
||||
|
||||
/// User info in response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Error response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// POST /api/auth/login
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get user by username
|
||||
let user = db::get_user_by_username(db.pool(), &request.username)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error during login: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid username or password".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Check if user is enabled
|
||||
if !user.enabled {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Account is disabled".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Verify password
|
||||
let password_valid = verify_password(&request.password, &user.password_hash)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Password verification error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !password_valid {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid username or password".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Get user permissions
|
||||
let permissions = db::get_user_permissions(db.pool(), user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Update last login
|
||||
let _ = db::update_last_login(db.pool(), user.id).await;
|
||||
|
||||
// Create JWT token
|
||||
let token = state.jwt_config.create_token(
|
||||
user.id,
|
||||
&user.username,
|
||||
&user.role,
|
||||
permissions.clone(),
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Token creation error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to create token".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!("User {} logged in successfully", user.username);
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
token,
|
||||
user: UserResponse {
|
||||
id: user.id.to_string(),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
permissions,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/auth/me - Get current user info
|
||||
pub async fn get_me(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<UserResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = uuid::Uuid::parse_str(&user.user_id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid user ID".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let db_user = db::get_user_by_id(db.pool(), user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let permissions = db::get_user_permissions(db.pool(), db_user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(UserResponse {
|
||||
id: db_user.id.to_string(),
|
||||
username: db_user.username,
|
||||
email: db_user.email,
|
||||
role: db_user.role,
|
||||
permissions,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Change password request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// POST /api/auth/change-password
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(request): Json<ChangePasswordRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = uuid::Uuid::parse_str(&user.user_id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid user ID".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get current user
|
||||
let db_user = db::get_user_by_id(db.pool(), user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Verify current password
|
||||
let password_valid = verify_password(&request.current_password, &db_user.password_hash)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Password verification error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !password_valid {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse {
|
||||
error: "Current password is incorrect".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if request.new_password.len() < 8 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Password must be at least 8 characters".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
let new_hash = crate::auth::hash_password(&request.new_password)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Password hashing error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to hash password".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Update password
|
||||
db::update_user_password(db.pool(), user_id, &new_hash)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to update password".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!("User {} changed their password", user.username);
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
Reference in New Issue
Block a user