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
222 lines
7.4 KiB
Rust
222 lines
7.4 KiB
Rust
//! 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,
|
|
}))
|
|
}
|