pinakes/packages/pinakes-server/src/routes/auth.rs
NotAShelf 00bab69598
meta: move public crates to packages/
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I928162008cb1ba02e1aa0e7aa971e8326a6a6964
2026-03-23 03:30:53 +03:00

400 lines
11 KiB
Rust

use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
};
use crate::{
dto::{LoginRequest, LoginResponse, UserInfoResponse},
state::AppState,
};
/// Dummy password hash to use for timing-safe comparison when user doesn't
/// exist. This is a valid argon2 hash that will always fail verification but
/// takes similar time to verify as a real hash, preventing timing attacks that
/// could reveal whether a username exists.
const DUMMY_HASH: &str =
"$argon2id$v=19$m=19456,t=2,\
p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk";
#[utoipa::path(
post,
path = "/api/v1/auth/login",
tag = "auth",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful", body = LoginResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Invalid credentials"),
(status = 500, description = "Internal server error"),
),
security()
)]
pub async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
// Limit input sizes to prevent DoS
if req.username.len() > 255 || req.password.len() > 1024 {
return Err(StatusCode::BAD_REQUEST);
}
let config = state.config.read().await;
if !config.accounts.enabled {
return Err(StatusCode::NOT_FOUND);
}
let user = config
.accounts
.users
.iter()
.find(|u| u.username == req.username);
// Always perform password verification to prevent timing attacks.
// If the user doesn't exist, we verify against a dummy hash to ensure
// consistent response times regardless of whether the username exists.
use argon2::password_hash::PasswordVerifier;
let (hash_to_verify, user_found) = match user {
Some(u) => (&u.password_hash as &str, true),
None => (DUMMY_HASH, false),
};
let parsed_hash = argon2::password_hash::PasswordHash::new(hash_to_verify)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let password_valid = argon2::Argon2::default()
.verify_password(req.password.as_bytes(), &parsed_hash)
.is_ok();
// Authentication fails if user wasn't found OR password was invalid
if !user_found || !password_valid {
// Log different messages for debugging but return same error
if user_found {
tracing::warn!(username = %req.username, "login failed: invalid password");
} else {
tracing::warn!(username = %req.username, "login failed: unknown user");
}
// Record failed login attempt in audit log
if let Err(e) = pinakes_core::audit::record_action(
&state.storage,
None,
pinakes_core::model::AuditAction::LoginFailed,
Some(format!("username: {}", req.username)),
)
.await
{
tracing::warn!(error = %e, "failed to record failed login audit");
}
return Err(StatusCode::UNAUTHORIZED);
}
// At this point we know the user exists and password is valid
let user = user.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
// Generate session token using unbiased uniform distribution
#[expect(clippy::expect_used)]
let token: String = {
use rand::seq::IndexedRandom;
const CHARSET: &[u8] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::rng();
(0..48)
.map(|_| *CHARSET.choose(&mut rng).expect("non-empty charset") as char)
.collect()
};
let role = user.role;
let username = user.username.clone();
// Create session in database
let now = chrono::Utc::now();
let session_data = pinakes_core::storage::SessionData {
session_token: token.clone(),
user_id: None, // Could be set if we had user IDs
username: username.clone(),
role: role.to_string(),
created_at: now,
expires_at: now
+ chrono::Duration::hours(config.accounts.session_expiry_hours as i64),
last_accessed: now,
};
if let Err(e) = state.storage.create_session(&session_data).await {
tracing::error!(error = %e, "failed to create session in database");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
tracing::info!(username = %username, role = %role, "login successful");
// Record successful login in audit log
if let Err(e) = pinakes_core::audit::record_action(
&state.storage,
None,
pinakes_core::model::AuditAction::LoginSuccess,
Some(format!("username: {username}, role: {role}")),
)
.await
{
tracing::warn!(error = %e, "failed to record login audit");
}
Ok(Json(LoginResponse {
token,
username,
role: role.to_string(),
}))
}
#[utoipa::path(
post,
path = "/api/v1/auth/logout",
tag = "auth",
responses(
(status = 200, description = "Logged out"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn logout(
State(state): State<AppState>,
headers: HeaderMap,
) -> StatusCode {
let Some(token) = extract_bearer_token(&headers) else {
return StatusCode::UNAUTHORIZED;
};
// Get username before deleting session
let username = match state.storage.get_session(token).await {
Ok(Some(session)) => Some(session.username),
_ => None,
};
// Delete session from database
if let Err(e) = state.storage.delete_session(token).await {
tracing::error!(error = %e, "failed to delete session from database");
return StatusCode::INTERNAL_SERVER_ERROR;
}
// Record logout in audit log
if let Some(user) = username
&& let Err(e) = pinakes_core::audit::record_action(
&state.storage,
None,
pinakes_core::model::AuditAction::Logout,
Some(format!("username: {user}")),
)
.await
{
tracing::warn!(error = %e, "failed to record logout audit");
}
StatusCode::OK
}
#[utoipa::path(
get,
path = "/api/v1/auth/me",
tag = "auth",
responses(
(status = 200, description = "Current user info", body = UserInfoResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn me(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<UserInfoResponse>, StatusCode> {
let config = state.config.read().await;
if !config.accounts.enabled {
// When accounts are not enabled, return a default admin user
return Ok(Json(UserInfoResponse {
username: "admin".to_string(),
role: "admin".to_string(),
}));
}
drop(config);
let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
let session = state
.storage
.get_session(token)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(Json(UserInfoResponse {
username: session.username.clone(),
role: session.role,
}))
}
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
}
/// Refresh the current session, extending its expiry by the configured
/// duration.
#[utoipa::path(
post,
path = "/api/v1/auth/refresh",
tag = "auth",
responses(
(status = 200, description = "Session refreshed"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn refresh(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, StatusCode> {
let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
let config = state.config.read().await;
let expiry_hours = config.accounts.session_expiry_hours as i64;
drop(config);
let new_expires_at =
chrono::Utc::now() + chrono::Duration::hours(expiry_hours);
match state.storage.extend_session(token, new_expires_at).await {
Ok(Some(expires)) => {
Ok(Json(serde_json::json!({
"expires_at": expires.to_rfc3339()
})))
},
Ok(None) => Err(StatusCode::UNAUTHORIZED),
Err(e) => {
tracing::error!(error = %e, "failed to extend session");
Err(StatusCode::INTERNAL_SERVER_ERROR)
},
}
}
/// Revoke all sessions for the current user
#[utoipa::path(
post,
path = "/api/v1/auth/revoke-all",
tag = "auth",
responses(
(status = 200, description = "All sessions revoked"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn revoke_all_sessions(
State(state): State<AppState>,
headers: HeaderMap,
) -> StatusCode {
let token = match extract_bearer_token(&headers) {
Some(t) => t,
None => return StatusCode::UNAUTHORIZED,
};
// Get current session to find username
let session = match state.storage.get_session(token).await {
Ok(Some(s)) => s,
Ok(None) => return StatusCode::UNAUTHORIZED,
Err(e) => {
tracing::error!(error = %e, "failed to get session");
return StatusCode::INTERNAL_SERVER_ERROR;
},
};
let username = session.username.clone();
// Delete all sessions for this user
match state.storage.delete_user_sessions(&username).await {
Ok(count) => {
tracing::info!(username = %username, count = count, "revoked all user sessions");
// Record in audit log
if let Err(e) = pinakes_core::audit::record_action(
&state.storage,
None,
pinakes_core::model::AuditAction::Logout,
Some(format!("revoked all sessions for username: {username}")),
)
.await
{
tracing::warn!(error = %e, "failed to record session revocation audit");
}
StatusCode::OK
},
Err(e) => {
tracing::error!(error = %e, "failed to revoke sessions");
StatusCode::INTERNAL_SERVER_ERROR
},
}
}
/// List all active sessions (admin only)
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SessionListResponse {
pub sessions: Vec<SessionInfo>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SessionInfo {
pub username: String,
pub role: String,
pub created_at: String,
pub last_accessed: String,
pub expires_at: String,
}
#[utoipa::path(
get,
path = "/api/v1/auth/sessions",
tag = "auth",
responses(
(status = 200, description = "Active sessions", body = SessionListResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_active_sessions(
State(state): State<AppState>,
) -> Result<Json<SessionListResponse>, StatusCode> {
// Get all active sessions
let sessions =
state
.storage
.list_active_sessions(None)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to list active sessions");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let session_infos = sessions
.into_iter()
.map(|s| {
SessionInfo {
username: s.username,
role: s.role,
created_at: s.created_at.to_rfc3339(),
last_accessed: s.last_accessed.to_rfc3339(),
expires_at: s.expires_at.to_rfc3339(),
}
})
.collect();
Ok(Json(SessionListResponse {
sessions: session_infos,
}))
}