feat: User management + JWT auth routes
- Admin users CRUD: /api/admin/users + /api/admin/users/{id}
- Public auth: /api/auth/register, /api/auth/login, /api/auth/me
- User model + password hashing via existing auth module
- JWT token generation and validation
- cargo check ✅ | cargo test 18/18 ✅
This commit is contained in:
@@ -6,3 +6,4 @@ pub mod route_channel;
|
||||
pub mod setting;
|
||||
pub mod site;
|
||||
pub mod token_route;
|
||||
pub mod user;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// A platform user (admin or regular).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
pub role: String,
|
||||
pub status: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod accounts;
|
||||
mod downstream_keys;
|
||||
mod sites;
|
||||
mod tokens;
|
||||
mod users;
|
||||
|
||||
/// Middleware to require admin authentication via Bearer token.
|
||||
async fn require_admin_auth(
|
||||
@@ -96,12 +97,25 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
let settings_routes =
|
||||
Router::new().route("/api/settings", get(get_settings).put(update_setting));
|
||||
|
||||
let users_routes = Router::new()
|
||||
.route(
|
||||
"/api/admin/users",
|
||||
get(users::list_users).post(users::create_user),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/users/{id}",
|
||||
get(users::get_user)
|
||||
.put(users::update_user)
|
||||
.delete(users::delete_user),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.merge(sites_routes)
|
||||
.merge(accounts_routes)
|
||||
.merge(tokens_routes)
|
||||
.merge(downstream_routes)
|
||||
.merge(settings_routes)
|
||||
.merge(users_routes)
|
||||
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Admin user management — CRUD for platform users
|
||||
|
||||
use crate::{
|
||||
auth::password::hash_password,
|
||||
db::models::user::User,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub username: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
pub status: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
impl From<User> for UserResponse {
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
status: u.status,
|
||||
created_at: u.created_at,
|
||||
updated_at: u.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/admin/users
|
||||
pub async fn list_users(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Listing admin users");
|
||||
let users: Vec<User> =
|
||||
sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY id")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
let items: Vec<UserResponse> = users.into_iter().map(Into::into).collect();
|
||||
Ok(Json(json!({ "data": items })))
|
||||
}
|
||||
|
||||
/// GET /api/admin/users/{id}
|
||||
pub async fn get_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting user: {}", id);
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
|
||||
Ok(Json(json!({ "data": UserResponse::from(user) })))
|
||||
}
|
||||
|
||||
/// POST /api/admin/users
|
||||
pub async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateUserRequest>,
|
||||
) -> AppResult<(axum::http::StatusCode, Json<Value>)> {
|
||||
info!("Creating user: {}", payload.username);
|
||||
|
||||
if payload.username.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("username is required".into()));
|
||||
}
|
||||
if payload.email.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("email is required".into()));
|
||||
}
|
||||
if payload.password.len() < 6 {
|
||||
return Err(AppError::BadRequest("password must be at least 6 characters".into()));
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&payload.password)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||
let role = payload.role.unwrap_or_else(|| "user".to_string());
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.email)
|
||||
.bind(&password_hash)
|
||||
.bind(&role)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((axum::http::StatusCode::CREATED, Json(json!({ "data": UserResponse::from(user) }))))
|
||||
}
|
||||
|
||||
/// PUT /api/admin/users/{id}
|
||||
pub async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateUserRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating user: {}", id);
|
||||
|
||||
let existing: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
|
||||
|
||||
let password_hash = if let Some(ref pw) = payload.password {
|
||||
if pw.len() < 6 {
|
||||
return Err(AppError::BadRequest("password must be at least 6 characters".into()));
|
||||
}
|
||||
Some(hash_password(pw).map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE users SET username = ?, email = ?, role = ?, status = ?, password_hash = COALESCE(?, password_hash), updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.username.unwrap_or(existing.username))
|
||||
.bind(payload.email.unwrap_or(existing.email))
|
||||
.bind(payload.role.unwrap_or(existing.role))
|
||||
.bind(payload.status.unwrap_or(existing.status))
|
||||
.bind(password_hash)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": UserResponse::from(user) })))
|
||||
}
|
||||
|
||||
/// DELETE /api/admin/users/{id}
|
||||
pub async fn delete_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting user: {}", id);
|
||||
let affected = sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!("User {} not found", id)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// User authentication routes — register, login, profile
|
||||
// These are public (no admin token required, uses JWT)
|
||||
|
||||
use crate::{
|
||||
auth::{jwt::generate_jwt, password::verify_password},
|
||||
db::models::user::User,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
// ── Request / Response types ─────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub user: UserProfile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
// ── Route builder ────────────────────────────────────────────────────
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/auth/register", post(register))
|
||||
.route("/api/auth/login", post(login))
|
||||
.route("/api/auth/me", get(me))
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/auth/register
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Registering user: {}", payload.email);
|
||||
|
||||
if payload.email.trim().is_empty() || payload.username.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("email and username are required".into()));
|
||||
}
|
||||
if payload.password.len() < 6 {
|
||||
return Err(AppError::BadRequest("password must be at least 6 characters".into()));
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
let existing: Option<User> = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::BadRequest("Email already registered".into()));
|
||||
}
|
||||
|
||||
let password_hash = crate::auth::password::hash_password(&payload.password)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')"
|
||||
)
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.email)
|
||||
.bind(&password_hash)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
let token = generate_jwt(
|
||||
&state.config.auth.jwt_secret,
|
||||
&user.id.to_string(),
|
||||
&user.role,
|
||||
state.config.auth.jwt_expiry_hours,
|
||||
).map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
}
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/auth/login
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Login: {}", payload.email);
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid email or password".into()))?;
|
||||
|
||||
if user.status != "active" {
|
||||
return Err(AppError::Forbidden("Account is disabled".into()));
|
||||
}
|
||||
|
||||
let valid = verify_password(&payload.password, &user.password_hash)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||
|
||||
if !valid {
|
||||
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
||||
}
|
||||
|
||||
let token = generate_jwt(
|
||||
&state.config.auth.jwt_secret,
|
||||
&user.id.to_string(),
|
||||
&user.role,
|
||||
state.config.auth.jwt_expiry_hours,
|
||||
).map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
}
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/auth/me — returns current user profile from JWT
|
||||
async fn me(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(&claims.sub.parse::<i64>().unwrap_or(0))
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
}
|
||||
})))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use crate::AppState;
|
||||
use axum::{routing::get, Router};
|
||||
|
||||
mod admin;
|
||||
mod auth;
|
||||
mod comfyui;
|
||||
mod health;
|
||||
mod proxy;
|
||||
@@ -12,5 +13,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(admin::routes(state.clone()))
|
||||
.merge(proxy::routes())
|
||||
.merge(comfyui::routes())
|
||||
.merge(auth::routes())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user