Feat: Implement basic web authentication and UI
This commit introduces the foundation for web-based authentication and user interface. It includes: - **Session Management**: Securely handling user sessions using cryptographically generated tokens, cookies with appropriate security flags, and server-side storage. - **Login/Logout Functionality**: Endpoints for users to log in with their credentials and log out, clearing their session. - **User Interface**: Basic templates for the login page, a list of cases, and a detailed view of a single case, allowing users to navigate and view their data. - **Authorization**: An `AuthenticatedWebUser` extractor to ensure only logged-in users can access protected web routes. - **Configuration**: Added a `cookie_secure` option to the configuration to control the `Secure` flag on session cookies, useful for local development. - **Dependencies**: Added necessary dependencies for password hashing, time handling, and TOML file manipulation. - **Helper Binary**: Included a `hash-password` binary to simplify generating bcrypt hashes for user passwords.
This commit is contained in:
@@ -1,11 +1,22 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::extract::{FromRef, FromRequestParts};
|
||||
use axum::http::request::Parts;
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::web_session::SessionStore;
|
||||
|
||||
/// Truncate a session token to a non-sensitive prefix for logs.
|
||||
fn token_prefix(token: &str) -> String {
|
||||
token.chars().take(8).collect()
|
||||
}
|
||||
|
||||
const SESSION_COOKIE: &str = "session";
|
||||
|
||||
/// Extracted from the X-API-Key header on every authenticated request.
|
||||
pub struct AuthenticatedUser {
|
||||
@@ -52,3 +63,64 @@ where
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracted from the session cookie on web UI requests.
|
||||
/// On missing/expired session, returns `AppError::Redirect("/web/login")`.
|
||||
pub struct AuthenticatedWebUser {
|
||||
pub slug: String,
|
||||
pub role: String,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthenticatedWebUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
Arc<Config>: FromRef<S>,
|
||||
SessionStore: FromRef<S>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let config = Arc::<Config>::from_ref(state);
|
||||
let store = SessionStore::from_ref(state);
|
||||
|
||||
let jar = CookieJar::from_headers(&parts.headers);
|
||||
let token = jar
|
||||
.get(SESSION_COOKIE)
|
||||
.map(|c| c.value().to_owned())
|
||||
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
|
||||
|
||||
// Read lock first to check validity. If expired, upgrade to write and remove.
|
||||
let expired = {
|
||||
let r = store.read().await;
|
||||
match r.get(&token) {
|
||||
Some(s) => s.expires_at <= Instant::now(),
|
||||
None => {
|
||||
warn!(token_prefix = %token_prefix(&token), "session lookup failed (unknown or already expired)");
|
||||
return Err(AppError::Redirect("/web/login".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if expired {
|
||||
let mut w = store.write().await;
|
||||
let removed = w.remove(&token);
|
||||
if let Some(s) = removed {
|
||||
warn!(slug = %s.slug, "session expired");
|
||||
}
|
||||
return Err(AppError::Redirect("/web/login".into()));
|
||||
}
|
||||
|
||||
let r = store.read().await;
|
||||
let session = r
|
||||
.get(&token)
|
||||
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
|
||||
|
||||
let data_dir = config.data_path.join(&session.slug);
|
||||
Ok(Self {
|
||||
slug: session.slug.clone(),
|
||||
role: session.role.clone(),
|
||||
data_dir,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user