feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
//! Authentication handlers — login, register, and token refresh.
|
||||
//!
|
||||
//! # Endpoints
|
||||
//!
|
||||
//! - `POST /auth/login` — authenticate with username/password, returns JWT
|
||||
//! - `POST /auth/register` — create a new user account
|
||||
//! - `POST /auth/refresh` — exchange a refresh token for a new access token
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Login: validate input → verify password → generate token pair → return.
|
||||
//! Register: validate input → check uniqueness → hash password → persist → login.
|
||||
//! Refresh: decode refresh token → verify → generate new token pair.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
|
||||
use zesdex_application::ports::{PasswordService, TokenService};
|
||||
|
||||
use crate::dto::auth::{AuthResponse, LoginRequest, RefreshRequest, RegisterRequest};
|
||||
use crate::error::ApiError;
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// Build the auth sub-router (`/auth/*`).
|
||||
pub fn router() -> Router<Arc<ApiState>> {
|
||||
Router::new()
|
||||
.route("/login", post(login_handler))
|
||||
.route("/register", post(register_handler))
|
||||
.route("/refresh", post(refresh_handler))
|
||||
}
|
||||
|
||||
/// POST /auth/login — authenticate and issue JWT tokens.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize `LoginRequest`.
|
||||
/// 2. Load the stored user credentials from the users store.
|
||||
/// 3. Verify the password against the stored hash.
|
||||
/// 4. Generate an access + refresh token pair.
|
||||
/// 5. Return `AuthResponse`.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — missing or empty fields.
|
||||
/// - `401 Unauthorized` — invalid username or password.
|
||||
/// - `500 Internal Server Error` — unexpected failure.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn login_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
// Validate input
|
||||
if req.username.is_empty() || req.password.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Username and password are required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Load the users database from the store
|
||||
let users_path = state.store_base_dir.join("users.json");
|
||||
let users: std::collections::HashMap<String, String> = if users_path.exists() {
|
||||
let content = std::fs::read_to_string(&users_path)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))?
|
||||
} else {
|
||||
return Err(ApiError::Unauthorized("Invalid username or password".into()));
|
||||
};
|
||||
|
||||
// Look up the user
|
||||
let stored_hash = users
|
||||
.get(&req.username)
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid username or password".into()))?;
|
||||
|
||||
// Verify password
|
||||
let valid = state
|
||||
.password_service
|
||||
.verify(&req.password, stored_hash)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Password verification failed: {e}")))?;
|
||||
|
||||
if !valid {
|
||||
return Err(ApiError::Unauthorized("Invalid username or password".into()));
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
let (access_token, refresh_token) = state
|
||||
.token_service
|
||||
.generate_tokens(&req.username)
|
||||
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.token_service.access_token_expiry_secs,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /auth/register — create a new user account.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize `RegisterRequest`.
|
||||
/// 2. Check username availability (load users, reject if exists).
|
||||
/// 3. Hash the password using Argon2id.
|
||||
/// 4. Persist the new username + hash.
|
||||
/// 5. Generate an access + refresh token pair.
|
||||
/// 6. Return `AuthResponse`.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — missing or invalid fields.
|
||||
/// - `409 Conflict` — username already taken.
|
||||
/// - `500 Internal Server Error` — unexpected failure.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn register_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
// Validate input
|
||||
if req.username.is_empty() {
|
||||
return Err(ApiError::BadRequest("Username is required".into()));
|
||||
}
|
||||
if req.password.len() < 6 {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Load existing users
|
||||
let users_path = state.store_base_dir.join("users.json");
|
||||
let mut users: std::collections::HashMap<String, String> = if users_path.exists() {
|
||||
let content = std::fs::read_to_string(&users_path)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))?
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
// Check uniqueness
|
||||
if users.contains_key(&req.username) {
|
||||
return Err(ApiError::Conflict(
|
||||
"Username already exists".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
let hash = state
|
||||
.password_service
|
||||
.hash(&req.password)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Password hashing failed: {e}")))?;
|
||||
|
||||
// Persist
|
||||
users.insert(req.username.clone(), hash);
|
||||
let content = serde_json::to_string_pretty(&users)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to serialize users: {e}")))?;
|
||||
std::fs::write(&users_path, &content)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to write users: {e}")))?;
|
||||
|
||||
// Generate tokens
|
||||
let (access_token, refresh_token) = state
|
||||
.token_service
|
||||
.generate_tokens(&req.username)
|
||||
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.token_service.access_token_expiry_secs,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /auth/refresh — exchange a refresh token for a new access token.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize `RefreshRequest`.
|
||||
/// 2. Verify the refresh token's signature and extract the subject.
|
||||
/// 3. Generate a fresh access + refresh token pair.
|
||||
/// 4. Return `AuthResponse`.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — missing refresh token.
|
||||
/// - `401 Unauthorized` — invalid or expired refresh token.
|
||||
/// - `500 Internal Server Error` — unexpected failure.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn refresh_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
if req.refresh_token.is_empty() {
|
||||
return Err(ApiError::BadRequest("Refresh token is required".into()));
|
||||
}
|
||||
|
||||
// Verify the refresh token and extract the subject
|
||||
let sub = state
|
||||
.token_service
|
||||
.verify_access_token(&req.refresh_token)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?;
|
||||
|
||||
// Generate a fresh token pair
|
||||
let (access_token, new_refresh_token) = state
|
||||
.token_service
|
||||
.generate_tokens(&sub)
|
||||
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token,
|
||||
refresh_token: new_refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.token_service.access_token_expiry_secs,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user