feat: complete Phase 0 Enterprise IAM

This commit is contained in:
thakares committed 2026-07-21 15:26:16 +05:30
1 parent 3d2d291006
commit c2f5ba3f54
202 files changed
+21371 -1360

No files matched your search

+194 -82
View File
@@ -1,14 +1,19 @@
use crate::db::repository::traits::AuditRepositoryExt;
// Authentication endpoints.
//
// Login is POST-only with a JSON body. Credentials must never appear in
// query strings, path segments, or server access logs of request URIs.
use axum::{Json, extract::State};
use axum_extra::extract::{CookieJar, cookie::Cookie};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
audit::{self, AuditEvent},
audit::AuditEvent,
db::models::AuditSeverity,
db::repository::users as user_repo,
error::{AppError, Result},
identity::{permissions, roles},
middleware::{audit::AuditContext, auth::AuthUser},
security::{passwords, sessions},
state::AppState,
@@ -16,30 +21,64 @@ use crate::{
// ── Login ─────────────────────────────────────────────────────────────────────
/// Login request body. Deserialized from JSON only (never from query params).
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct LoginUserView {
pub id: String,
pub username: String,
pub status: String,
pub last_login_at: Option<String>,
pub created_at: String,
pub roles: Vec<String>,
pub permissions: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
/// Opaque access token (session). Send as `Authorization: Bearer …`.
pub access_token: String,
/// Opaque refresh token. Longer-lived; used to obtain a new access token.
pub refresh_token: String,
/// Access token lifetime in seconds (idle TTL).
pub expires_in: u64,
pub token_type: &'static str,
pub user: LoginUserView,
}
/// POST /api/v1/auth/login
///
/// Accepts JSON `{ "username", "password" }` only. No GET handler exists.
pub async fn login(
State(state): State<AppState>,
ctx: AuditContext,
jar: CookieJar,
Json(body): Json<LoginRequest>,
) -> Result<(CookieJar, Json<Value>)> {
) -> Result<(CookieJar, Json<LoginResponse>)> {
let ip = ctx.ip_address.as_deref();
// Rate limit check
// Reject empty credentials early without revealing which field failed.
if body.username.trim().is_empty() || body.password.is_empty() {
return Err(AppError::InvalidCredentials);
}
// Rate limit check (per IP)
if let Some(ip_str) = &ctx.ip_address {
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
state.rate_limiter.check(ip_addr)?;
}
}
// Look up user
let user_opt = user_repo::find_by_username(&state.pool, &body.username)
// Look up user — always run comparable work on failure paths (timing).
let user_opt = state
.provider
.users()
.find_by_username(body.username.trim())
.await
.map_err(AppError::Database)?;
@@ -47,27 +86,33 @@ pub async fn login(
let mut final_user = None;
if let Some(user) = user_opt {
// Constant-time Argon2id verify (argon2 crate).
let password_ok = passwords::verify_password(&body.password, &user.password_hash)?;
if password_ok && user.is_active() {
is_authed = true;
final_user = Some(user);
}
} else {
// Run dummy verify to take same execution time
// Dummy verify to reduce username enumeration via timing.
passwords::verify_dummy(&state.config.security)?;
}
// Zeroize is best-effort; String drop is immediate after this function.
// Do not log body.password anywhere.
let _ = &body.password;
if !is_authed {
record_login_failure(&state, &body.username, ip, ctx.user_agent.as_deref()).await;
record_login_failure(&state, body.username.trim(), ip, ctx.user_agent.as_deref()).await;
if let Some(ip_str) = &ctx.ip_address {
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
state.rate_limiter.record_failure(ip_addr);
}
}
return Err(AppError::Unauthorized);
// Non-enumerating error for both unknown user and bad password.
return Err(AppError::InvalidCredentials);
}
let user = final_user.unwrap();
let user = final_user.expect("authenticated user");
// Clear rate limit on success
if let Some(ip_str) = &ctx.ip_address {
@@ -76,37 +121,78 @@ pub async fn login(
}
}
// Create session
let (session, raw_token) = sessions::create_session(
&state.pool,
&user.id,
ip,
ctx.user_agent.as_deref(),
&state.config.security,
)
.await?;
// Update last_login_at and audit in the same transaction
if let Ok(mut tx) = state.pool.begin().await {
let _ = user_repo::set_last_login(&mut tx, &user.id).await;
let _ = audit::log(
&mut tx,
AuditEvent {
actor_id: Some(&user.id),
target_id: Some(&user.id),
action: "login_success",
resource_type: "session",
resource_id: Some(&session.id),
severity: AuditSeverity::Info,
ip,
ua: ctx.user_agent.as_deref(),
metadata: None,
},
)
// Session fixation mitigation: revoke prior sessions + refresh tokens.
let _ = state
.provider
.sessions()
.revoke_all_for_user(&user.id)
.await;
let _ = state
.provider
.refresh_tokens()
.revoke_all_for_user(&user.id)
.await;
let _ = tx.commit().await;
}
// Create new session (new ID + new token) — rotation on every login.
let session_id = uuid::Uuid::new_v4().to_string();
let access_token = crate::security::sessions::generate_session_token();
let token_hash = crate::security::sessions::hash_session_token(&access_token);
let ttl_mins = (state.config.security.session_ttl_hours * 60) as i64;
let expires = chrono::Utc::now() + chrono::Duration::minutes(ttl_mins);
let expires_str = expires.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let session = state
.provider
.sessions()
.create(
&session_id,
&user.id,
&token_hash,
ip,
ctx.user_agent.as_deref(),
&expires_str,
)
.await
.map_err(AppError::Database)?;
// Refresh token (opaque, BLAKE3-hashed at rest). Longer absolute lifetime.
let refresh_raw = sessions::generate_session_token();
let refresh_hash = sessions::hash_session_token(&refresh_raw);
let refresh_id = uuid::Uuid::new_v4().to_string();
let refresh_ttl_days = state.config.security.session_absolute_ttl_days.max(1) as i64;
let refresh_expires = chrono::Utc::now() + chrono::Duration::days(refresh_ttl_days);
let refresh_expires_str = refresh_expires.format("%Y-%m-%dT%H:%M:%SZ").to_string();
state
.provider
.refresh_tokens()
.create(&refresh_id, &user.id, &refresh_hash, &refresh_expires_str)
.await
.map_err(AppError::Database)?;
let user_roles = state.provider.roles().list_for_user(&user.id).await?;
let user_perms = state.provider.permissions().list_for_user(&user.id).await?;
let role_names: Vec<String> = user_roles.into_iter().map(|r| r.name).collect();
// Update last_login_at and audit (never log password / tokens).
let _ = state.provider.users().set_last_login(&user.id).await;
let _ = state
.provider
.audit()
.log(AuditEvent {
actor_id: Some(&user.id),
target_id: Some(&user.id),
action: "login_success",
resource_type: "session",
resource_id: Some(&session.id),
severity: AuditSeverity::Info,
ip,
ua: ctx.user_agent.as_deref(),
metadata: None,
})
.await;
// Structured log: identity + outcome only (no secrets).
tracing::info!(
event = "login_success",
user_id = %user.id,
@@ -114,16 +200,33 @@ pub async fn login(
ip = ip.unwrap_or("unknown"),
);
// Build secure session cookie using time::Duration for max_age
let expires_in = (state.config.security.session_ttl_hours as u64).saturating_mul(3600);
let max_age_secs = state.config.security.session_absolute_ttl_days as i64 * 86400;
let mut cookie = Cookie::new(sessions::SESSION_COOKIE, raw_token);
let mut cookie = Cookie::new(sessions::SESSION_COOKIE, access_token.clone());
cookie.set_http_only(true);
cookie.set_secure(true);
cookie.set_secure(state.config.server.cookie_secure);
cookie.set_same_site(axum_extra::extract::cookie::SameSite::Lax);
cookie.set_path("/");
cookie.set_max_age(time::Duration::seconds(max_age_secs));
Ok((jar.add(cookie), Json(json!({ "success": true }))))
let response = LoginResponse {
access_token,
refresh_token: refresh_raw,
expires_in,
token_type: "Bearer",
user: LoginUserView {
id: user.id.clone(),
username: user.username.clone(),
status: user.status().to_string(),
last_login_at: user.last_login_at.clone(),
created_at: user.created_at.clone(),
roles: role_names,
permissions: user_perms,
},
};
Ok((jar.add(cookie), Json(response)))
}
async fn record_login_failure(
@@ -132,24 +235,26 @@ async fn record_login_failure(
ip: Option<&str>,
ua: Option<&str>,
) {
if let Ok(mut tx) = state.pool.begin().await {
let _ = audit::log(
&mut tx,
AuditEvent {
actor_id: None,
target_id: None,
action: "login_failed",
resource_type: "session",
resource_id: None,
severity: AuditSeverity::Warning,
ip,
ua,
metadata: Some(&format!(r#"{{"username":"{}"}}"#, username)),
},
)
// Audit: username + outcome only — never password.
let metadata = format!(
r#"{{"username":{}}}"#,
serde_json::to_string(username).unwrap_or_else(|_| "\"\"".into())
);
let _ = state
.provider
.audit()
.log(AuditEvent {
actor_id: None,
target_id: None,
action: "login_failed",
resource_type: "session",
resource_id: None,
severity: AuditSeverity::Warning,
ip,
ua,
metadata: Some(&metadata),
})
.await;
let _ = tx.commit().await;
}
tracing::warn!(
event = "login_failed",
@@ -167,29 +272,32 @@ pub async fn logout(
jar: CookieJar,
) -> Result<(CookieJar, Json<Value>)> {
if let Some(session_id) = &auth.session_id {
sessions::revoke_session(&state.pool, session_id).await?;
state.provider.sessions().revoke(session_id).await?;
// Audit log for logout
if let Ok(mut tx) = state.pool.begin().await {
let _ = audit::log(
&mut tx,
AuditEvent {
actor_id: Some(&auth.user.id),
target_id: Some(&auth.user.id),
action: "logout",
resource_type: "session",
resource_id: Some(session_id),
severity: AuditSeverity::Info,
ip: None,
ua: None,
metadata: None,
},
)
let _ = state
.provider
.audit()
.log(AuditEvent {
actor_id: Some(&auth.user.id),
target_id: Some(&auth.user.id),
action: "logout",
resource_type: "session",
resource_id: Some(session_id),
severity: AuditSeverity::Info,
ip: None,
ua: None,
metadata: None,
})
.await;
let _ = tx.commit().await;
}
}
// Revoke refresh tokens for this user on logout (full session end).
let _ = state
.provider
.refresh_tokens()
.revoke_all_for_user(&auth.user.id)
.await;
let mut removal = Cookie::from(sessions::SESSION_COOKIE);
removal.set_path("/");
let removed = jar.remove(removal);
@@ -216,8 +324,12 @@ pub struct UserView {
/// GET /api/v1/auth/me
pub async fn me(State(state): State<AppState>, auth: AuthUser) -> Result<Json<MeResponse>> {
let user_roles = roles::list_user_roles(&state.pool, &auth.user.id).await?;
let user_perms = permissions::list_user_permissions(&state.pool, &auth.user.id).await?;
let user_roles = state.provider.roles().list_for_user(&auth.user.id).await?;
let user_perms = state
.provider
.permissions()
.list_for_user(&auth.user.id)
.await?;
Ok(Json(MeResponse {
user: UserView {