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:
boos
2026-05-17 13:24:37 +08:00
parent 7987833ba9
commit 8568859da6
6 changed files with 409 additions and 0 deletions
+1
View File
@@ -6,3 +6,4 @@ pub mod route_channel;
pub mod setting;
pub mod site;
pub mod token_route;
pub mod user;
+16
View File
@@ -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>,
}
+14
View File
@@ -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))
}
+183
View File
@@ -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 } })))
}
+193
View File
@@ -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
View File
@@ -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)
}