Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
579f415a9f | ||
|
|
9c410cc717 | ||
|
|
312c78dbbb | ||
|
|
b25a015898 | ||
|
|
f2f615b456 |
No files matched your search
Generated
+1
-1
@@ -1239,7 +1239,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nx9-auth"
|
name = "nx9-auth"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "nx9-auth"
|
name = "nx9-auth"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["NX9 Team","Sunil Thakare"]
|
authors = ["NX9 Team","Sunil Thakare"]
|
||||||
description = "Lightweight self-hosted IAM service for the NX9 ecosystem"
|
description = "Lightweight self-hosted IAM service for the NX9 ecosystem"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
*Self-Hosted • Privacy-First • Pure Rust • Single Binary • SQLite & PostgreSQL*
|
*Self-Hosted • Privacy-First • Pure Rust • Single Binary • SQLite & PostgreSQL*
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://www.rust-lang.org/)
|
[](https://www.rust-lang.org/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[]()
|
[]()
|
||||||
|
|||||||
+12
-12
@@ -68,10 +68,10 @@ pub async fn login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit check (per IP)
|
// Rate limit check (per IP)
|
||||||
if let Some(ip_str) = &ctx.ip_address {
|
if let Some(ip_str) = &ctx.ip_address
|
||||||
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
&& let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>()
|
||||||
state.rate_limiter.check(ip_addr)?;
|
{
|
||||||
}
|
state.rate_limiter.check(ip_addr)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up user — always run comparable work on failure paths (timing).
|
// Look up user — always run comparable work on failure paths (timing).
|
||||||
@@ -103,10 +103,10 @@ pub async fn login(
|
|||||||
|
|
||||||
if !is_authed {
|
if !is_authed {
|
||||||
record_login_failure(&state, body.username.trim(), 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 Some(ip_str) = &ctx.ip_address
|
||||||
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
&& let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>()
|
||||||
state.rate_limiter.record_failure(ip_addr);
|
{
|
||||||
}
|
state.rate_limiter.record_failure(ip_addr);
|
||||||
}
|
}
|
||||||
// Non-enumerating error for both unknown user and bad password.
|
// Non-enumerating error for both unknown user and bad password.
|
||||||
return Err(AppError::InvalidCredentials);
|
return Err(AppError::InvalidCredentials);
|
||||||
@@ -118,10 +118,10 @@ pub async fn login(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Clear rate limit on success
|
// Clear rate limit on success
|
||||||
if let Some(ip_str) = &ctx.ip_address {
|
if let Some(ip_str) = &ctx.ip_address
|
||||||
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
&& let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>()
|
||||||
state.rate_limiter.record_success(ip_addr);
|
{
|
||||||
}
|
state.rate_limiter.record_success(ip_addr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session fixation mitigation: revoke prior sessions + refresh tokens.
|
// Session fixation mitigation: revoke prior sessions + refresh tokens.
|
||||||
|
|||||||
+6
-6
@@ -103,12 +103,12 @@ pub async fn terminate_session(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Value>> {
|
) -> Result<Json<Value>> {
|
||||||
// If the user is trying to terminate the current session, disallow it
|
// If the user is trying to terminate the current session, disallow it
|
||||||
if let Some(current_id) = auth.session_id.as_deref() {
|
if let Some(current_id) = auth.session_id.as_deref()
|
||||||
if id == current_id {
|
&& id == current_id
|
||||||
return Err(AppError::InvalidInput(
|
{
|
||||||
"Cannot terminate current session".into(),
|
return Err(AppError::InvalidInput(
|
||||||
));
|
"Cannot terminate current session".into(),
|
||||||
}
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admins can terminate any session, users can only terminate their own
|
// Admins can terminate any session, users can only terminate their own
|
||||||
|
|||||||
+8
-8
@@ -53,10 +53,10 @@ pub async fn create_tenant(
|
|||||||
) -> Result<Json<Value>> {
|
) -> Result<Json<Value>> {
|
||||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||||
|
|
||||||
if let Some(ref s) = body.slug {
|
if let Some(ref s) = body.slug
|
||||||
if !s.trim().is_empty() {
|
&& !s.trim().is_empty()
|
||||||
crate::identity::slug::validate_slug(s)?;
|
{
|
||||||
}
|
crate::identity::slug::validate_slug(s)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = uuid::Uuid::new_v4().to_string();
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
@@ -124,10 +124,10 @@ pub async fn update_tenant(
|
|||||||
) -> Result<Json<Value>> {
|
) -> Result<Json<Value>> {
|
||||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||||
|
|
||||||
if let Some(ref s) = body.slug {
|
if let Some(ref s) = body.slug
|
||||||
if !s.trim().is_empty() {
|
&& !s.trim().is_empty()
|
||||||
crate::identity::slug::validate_slug(s)?;
|
{
|
||||||
}
|
crate::identity::slug::validate_slug(s)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
state
|
state
|
||||||
|
|||||||
+7
-7
@@ -26,13 +26,13 @@ pub fn ui_dist_dir() -> PathBuf {
|
|||||||
return c.clone();
|
return c.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe()
|
||||||
if let Some(dir) = exe.parent() {
|
&& let Some(dir) = exe.parent()
|
||||||
for rel in ["ui/dist", "../ui/dist", "../../ui/dist"] {
|
{
|
||||||
let candidate = dir.join(rel);
|
for rel in ["ui/dist", "../ui/dist", "../../ui/dist"] {
|
||||||
if candidate.exists() {
|
let candidate = dir.join(rel);
|
||||||
return candidate;
|
if candidate.exists() {
|
||||||
}
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-16
@@ -566,10 +566,10 @@ async fn cmd_init(
|
|||||||
let sqlite_path = config.database.sqlite_path();
|
let sqlite_path = config.database.sqlite_path();
|
||||||
let db_path = std::path::Path::new(&sqlite_path);
|
let db_path = std::path::Path::new(&sqlite_path);
|
||||||
println!("Creating database directory...");
|
println!("Creating database directory...");
|
||||||
if let Some(parent) = db_path.parent() {
|
if let Some(parent) = db_path.parent()
|
||||||
if !parent.as_os_str().is_empty() {
|
&& !parent.as_os_str().is_empty()
|
||||||
std::fs::create_dir_all(parent)?;
|
{
|
||||||
}
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create state directory
|
// Create state directory
|
||||||
@@ -664,10 +664,11 @@ async fn run_init_validation(config: &Config, admin_skipped: bool) -> anyhow::Re
|
|||||||
let sqlite_path = config.database.sqlite_path();
|
let sqlite_path = config.database.sqlite_path();
|
||||||
let db_path = std::path::Path::new(&sqlite_path);
|
let db_path = std::path::Path::new(&sqlite_path);
|
||||||
let mut dirs_ok = true;
|
let mut dirs_ok = true;
|
||||||
if let Some(parent) = db_path.parent() {
|
if let Some(parent) = db_path.parent()
|
||||||
if !parent.as_os_str().is_empty() && std::fs::create_dir_all(parent).is_err() {
|
&& !parent.as_os_str().is_empty()
|
||||||
dirs_ok = false;
|
&& std::fs::create_dir_all(parent).is_err()
|
||||||
}
|
{
|
||||||
|
dirs_ok = false;
|
||||||
}
|
}
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
let state_dir = std::path::Path::new(&home).join(".local/state/nx9-auth");
|
let state_dir = std::path::Path::new(&home).join(".local/state/nx9-auth");
|
||||||
@@ -892,10 +893,10 @@ async fn cmd_backup(config: &Config, path: &std::path::Path) -> anyhow::Result<(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent()
|
||||||
if !parent.as_os_str().is_empty() {
|
&& !parent.as_os_str().is_empty()
|
||||||
std::fs::create_dir_all(parent)?;
|
{
|
||||||
}
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
@@ -958,10 +959,10 @@ async fn cmd_restore(config: &Config, path: &std::path::Path) -> anyhow::Result<
|
|||||||
crate::config::DatabaseBackend::Sqlite => {
|
crate::config::DatabaseBackend::Sqlite => {
|
||||||
let sqlite_path = config.database.sqlite_path();
|
let sqlite_path = config.database.sqlite_path();
|
||||||
let target_path = std::path::Path::new(&sqlite_path);
|
let target_path = std::path::Path::new(&sqlite_path);
|
||||||
if let Some(parent) = target_path.parent() {
|
if let Some(parent) = target_path.parent()
|
||||||
if !parent.as_os_str().is_empty() {
|
&& !parent.as_os_str().is_empty()
|
||||||
std::fs::create_dir_all(parent)?;
|
{
|
||||||
}
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
std::fs::copy(path, target_path).with_context(|| {
|
std::fs::copy(path, target_path).with_context(|| {
|
||||||
format!("failed to restore backup to {}", target_path.display())
|
format!("failed to restore backup to {}", target_path.display())
|
||||||
|
|||||||
+16
-16
@@ -293,13 +293,13 @@ impl Default for ShutdownConfig {
|
|||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn resolve_home_path(path: &str) -> String {
|
fn resolve_home_path(path: &str) -> String {
|
||||||
if let Some(stripped) = path.strip_prefix("~/") {
|
if let Some(stripped) = path.strip_prefix("~/")
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
&& let Ok(home) = std::env::var("HOME")
|
||||||
return Path::new(&home)
|
{
|
||||||
.join(stripped)
|
return Path::new(&home)
|
||||||
.to_string_lossy()
|
.join(stripped)
|
||||||
.into_owned();
|
.to_string_lossy()
|
||||||
}
|
.into_owned();
|
||||||
}
|
}
|
||||||
path.to_string()
|
path.to_string()
|
||||||
}
|
}
|
||||||
@@ -312,11 +312,11 @@ impl Config {
|
|||||||
if let Some(ref mut path) = self.database.path {
|
if let Some(ref mut path) = self.database.path {
|
||||||
*path = resolve_home_path(path);
|
*path = resolve_home_path(path);
|
||||||
}
|
}
|
||||||
if let Some(ref mut url) = self.database.url {
|
if let Some(ref mut url) = self.database.url
|
||||||
if let Some(stripped) = url.strip_prefix("sqlite://") {
|
&& let Some(stripped) = url.strip_prefix("sqlite://")
|
||||||
let clean = resolve_home_path(stripped);
|
{
|
||||||
*url = format!("sqlite://{clean}");
|
let clean = resolve_home_path(stripped);
|
||||||
}
|
*url = format!("sqlite://{clean}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,10 +372,10 @@ impl Config {
|
|||||||
|
|
||||||
/// Default user configuration path (~/.config/nx9-auth/config.toml)
|
/// Default user configuration path (~/.config/nx9-auth/config.toml)
|
||||||
pub fn default_user_config_path() -> Option<PathBuf> {
|
pub fn default_user_config_path() -> Option<PathBuf> {
|
||||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
|
||||||
if !xdg.is_empty() {
|
&& !xdg.is_empty()
|
||||||
return Some(PathBuf::from(xdg).join("nx9-auth/config.toml"));
|
{
|
||||||
}
|
return Some(PathBuf::from(xdg).join("nx9-auth/config.toml"));
|
||||||
}
|
}
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
return Some(PathBuf::from(home).join(".config/nx9-auth/config.toml"));
|
return Some(PathBuf::from(home).join(".config/nx9-auth/config.toml"));
|
||||||
|
|||||||
+12
-12
@@ -57,12 +57,12 @@ pub async fn init_provider(
|
|||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
DatabaseBackend::Sqlite => {
|
DatabaseBackend::Sqlite => {
|
||||||
let path = config.database.sqlite_path();
|
let path = config.database.sqlite_path();
|
||||||
if let Some(parent) = std::path::Path::new(&path).parent() {
|
if let Some(parent) = std::path::Path::new(&path).parent()
|
||||||
if !parent.as_os_str().is_empty() {
|
&& !parent.as_os_str().is_empty()
|
||||||
std::fs::create_dir_all(parent).with_context(|| {
|
{
|
||||||
format!("failed to create database directory: {}", parent.display())
|
std::fs::create_dir_all(parent).with_context(|| {
|
||||||
})?;
|
format!("failed to create database directory: {}", parent.display())
|
||||||
}
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let max_conn = config.database.max_connections.unwrap_or(16);
|
let max_conn = config.database.max_connections.unwrap_or(16);
|
||||||
@@ -177,12 +177,12 @@ pub async fn init_provider(
|
|||||||
/// Helper function to create an SQLite pool for legacy CLI commands or tests.
|
/// Helper function to create an SQLite pool for legacy CLI commands or tests.
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
pub async fn create_pool(path: &str) -> Result<SqlitePool> {
|
pub async fn create_pool(path: &str) -> Result<SqlitePool> {
|
||||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
if let Some(parent) = std::path::Path::new(path).parent()
|
||||||
if !parent.as_os_str().is_empty() {
|
&& !parent.as_os_str().is_empty()
|
||||||
std::fs::create_dir_all(parent).with_context(|| {
|
{
|
||||||
format!("failed to create database directory: {}", parent.display())
|
std::fs::create_dir_all(parent).with_context(|| {
|
||||||
})?;
|
format!("failed to create database directory: {}", parent.display())
|
||||||
}
|
})?;
|
||||||
}
|
}
|
||||||
let url = if path.starts_with("sqlite://") {
|
let url = if path.starts_with("sqlite://") {
|
||||||
path.to_string()
|
path.to_string()
|
||||||
|
|||||||
@@ -221,40 +221,40 @@ pub async fn update(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(new_enabled) = enabled {
|
if let Some(new_enabled) = enabled
|
||||||
if new_enabled != existing.enabled {
|
&& new_enabled != existing.enabled
|
||||||
let action = if new_enabled {
|
{
|
||||||
"application.member_enabled"
|
let action = if new_enabled {
|
||||||
} else {
|
"application.member_enabled"
|
||||||
"application.member_disabled"
|
} else {
|
||||||
};
|
"application.member_disabled"
|
||||||
|
};
|
||||||
|
|
||||||
let metadata = serde_json::json!({
|
let metadata = serde_json::json!({
|
||||||
"application_id": application_id,
|
"application_id": application_id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"role": existing.role,
|
"role": existing.role,
|
||||||
"enabled": new_enabled,
|
"enabled": new_enabled,
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let audit_event = crate::audit::AuditEvent {
|
let audit_event = crate::audit::AuditEvent {
|
||||||
actor_id: audit_actor_id,
|
actor_id: audit_actor_id,
|
||||||
target_id: Some(user_id),
|
target_id: Some(user_id),
|
||||||
action,
|
action,
|
||||||
resource_type: "application",
|
resource_type: "application",
|
||||||
resource_id: Some(application_id),
|
resource_id: Some(application_id),
|
||||||
severity: crate::db::models::AuditSeverity::Info,
|
severity: crate::db::models::AuditSeverity::Info,
|
||||||
ip: audit_ip,
|
ip: audit_ip,
|
||||||
ua: audit_ua,
|
ua: audit_ua,
|
||||||
metadata: Some(&metadata),
|
metadata: Some(&metadata),
|
||||||
};
|
};
|
||||||
|
|
||||||
provider
|
provider
|
||||||
.application_members()
|
.application_members()
|
||||||
.set_enabled_with_audit(application_id, user_id, new_enabled, Some(audit_event))
|
.set_enabled_with_audit(application_id, user_id, new_enabled, Some(audit_event))
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
provider
|
provider
|
||||||
|
|||||||
@@ -324,10 +324,9 @@ pub async fn update(
|
|||||||
.find_by_slug(slug)
|
.find_by_slug(slug)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
|
&& (other.entity_id != id || other.entity_type != "application")
|
||||||
{
|
{
|
||||||
if other.entity_id != id || other.entity_type != "application" {
|
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
||||||
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let redirect_json = redirect_uris.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
let redirect_json = redirect_uris.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
||||||
|
|||||||
@@ -191,10 +191,9 @@ pub async fn update_role(
|
|||||||
.find_by_name(name)
|
.find_by_name(name)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
|
&& other.id != id
|
||||||
{
|
{
|
||||||
if other.id != id {
|
return Err(AppError::Conflict(format!("role '{name}' already exists")));
|
||||||
return Err(AppError::Conflict(format!("role '{name}' already exists")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
provider
|
provider
|
||||||
|
|||||||
+46
-50
@@ -72,59 +72,55 @@ where
|
|||||||
// 2. Try Authorization: Bearer — PAT first, then session token.
|
// 2. Try Authorization: Bearer — PAT first, then session token.
|
||||||
// Session tokens are returned from /auth/login for SPA clients that
|
// Session tokens are returned from /auth/login for SPA clients that
|
||||||
// cannot rely solely on the HttpOnly cookie.
|
// cannot rely solely on the HttpOnly cookie.
|
||||||
if let Some(auth_header) = parts.headers.get(axum::http::header::AUTHORIZATION) {
|
if let Some(auth_header) = parts.headers.get(axum::http::header::AUTHORIZATION)
|
||||||
if let Ok(value) = auth_header.to_str() {
|
&& let Ok(value) = auth_header.to_str()
|
||||||
if let Some(raw) = value.strip_prefix("Bearer ") {
|
&& let Some(raw) = value.strip_prefix("Bearer ")
|
||||||
let raw = raw.trim();
|
{
|
||||||
|
let raw = raw.trim();
|
||||||
|
|
||||||
// 2a. Personal access token
|
// 2a. Personal access token
|
||||||
if let Some(token) = tokens::validate_token(&app_state.provider, raw).await? {
|
if let Some(token) = tokens::validate_token(&app_state.provider, raw).await? {
|
||||||
let user = app_state
|
let user = app_state
|
||||||
.provider
|
.provider
|
||||||
.users()
|
.users()
|
||||||
.find_by_id(&token.user_id)
|
.find_by_id(&token.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
.ok_or(AppError::Unauthorized)?;
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
if !user.is_active() {
|
if !user.is_active() {
|
||||||
return Err(AppError::Unauthorized);
|
return Err(AppError::Unauthorized);
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(AuthUser {
|
|
||||||
user,
|
|
||||||
method: AuthMethod::Token,
|
|
||||||
session_id: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2b. Session token (same value as nx9_session cookie)
|
|
||||||
if let Some(session) = sessions::validate_session(
|
|
||||||
&app_state.provider,
|
|
||||||
raw,
|
|
||||||
&app_state.config.security,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
let user = app_state
|
|
||||||
.provider
|
|
||||||
.users()
|
|
||||||
.find_by_id(&session.user_id)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?
|
|
||||||
.ok_or(AppError::Unauthorized)?;
|
|
||||||
|
|
||||||
if !user.is_active() {
|
|
||||||
return Err(AppError::Unauthorized);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(AuthUser {
|
|
||||||
user,
|
|
||||||
method: AuthMethod::Session,
|
|
||||||
session_id: Some(session.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Ok(AuthUser {
|
||||||
|
user,
|
||||||
|
method: AuthMethod::Token,
|
||||||
|
session_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Session token (same value as nx9_session cookie)
|
||||||
|
if let Some(session) =
|
||||||
|
sessions::validate_session(&app_state.provider, raw, &app_state.config.security)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
let user = app_state
|
||||||
|
.provider
|
||||||
|
.users()
|
||||||
|
.find_by_id(&session.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
if !user.is_active() {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(AuthUser {
|
||||||
|
user,
|
||||||
|
method: AuthMethod::Session,
|
||||||
|
session_id: Some(session.id),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,12 +162,12 @@ impl Lifecycle for Application {
|
|||||||
self.pool_handle = Some(pool_handle);
|
self.pool_handle = Some(pool_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.router.is_none() {
|
if self.router.is_none()
|
||||||
if let Some(provider) = &self.provider {
|
&& let Some(provider) = &self.provider
|
||||||
let app_state = crate::state::AppState::new(provider.clone(), config);
|
{
|
||||||
let router = crate::api::router::build(app_state);
|
let app_state = crate::state::AppState::new(provider.clone(), config);
|
||||||
self.router = Some(router);
|
let router = crate::api::router::build(app_state);
|
||||||
}
|
self.router = Some(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+31
-14
@@ -17,10 +17,10 @@ pub fn hash_password(password: &str, cfg: &SecurityConfig) -> Result<String, App
|
|||||||
cfg.argon2_parallelism,
|
cfg.argon2_parallelism,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!(error = %e, "invalid argon2 params");
|
tracing::error!(error = %e, "invalid argon2 params");
|
||||||
AppError::Internal
|
AppError::Internal
|
||||||
})?,
|
})?,
|
||||||
);
|
);
|
||||||
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
@@ -55,7 +55,10 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
|
|||||||
pub fn verify_dummy(cfg: &SecurityConfig) -> Result<(), AppError> {
|
pub fn verify_dummy(cfg: &SecurityConfig) -> Result<(), AppError> {
|
||||||
// We hash a constant string to ensure the time taken is consistent
|
// We hash a constant string to ensure the time taken is consistent
|
||||||
// and aligns with the cost parameters defined in the config.
|
// and aligns with the cost parameters defined in the config.
|
||||||
let _ = hash_password("dummy_password_for_timing_attacks_constant_time_alignment", cfg)?;
|
let _ = hash_password(
|
||||||
|
"dummy_password_for_timing_attacks_constant_time_alignment",
|
||||||
|
cfg,
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +73,8 @@ pub fn validate_password_strength(password: &str, is_admin: bool) -> Result<(),
|
|||||||
|
|
||||||
let normalized = password.to_lowercase();
|
let normalized = password.to_lowercase();
|
||||||
|
|
||||||
// Use a HashSet for O(1) exact matching against compromised/weak passwords
|
// 1. Exact matches for highly common passwords
|
||||||
let weak_passwords: HashSet<&str> = [
|
let exact_weak: HashSet<&str> = [
|
||||||
"password",
|
"password",
|
||||||
"admin123",
|
"admin123",
|
||||||
"qwerty",
|
"qwerty",
|
||||||
@@ -84,23 +87,37 @@ pub fn validate_password_strength(password: &str, is_admin: bool) -> Result<(),
|
|||||||
"admin",
|
"admin",
|
||||||
"letmein",
|
"letmein",
|
||||||
"welcome",
|
"welcome",
|
||||||
|
"password12345",
|
||||||
]
|
]
|
||||||
.iter()
|
.into_iter()
|
||||||
.copied()
|
.collect();
|
||||||
.collect();
|
|
||||||
|
|
||||||
if weak_passwords.contains(normalized.as_str()) {
|
if exact_weak.contains(normalized.as_str()) {
|
||||||
return Err(AppError::InvalidInput(
|
return Err(AppError::InvalidInput(
|
||||||
"password is too common or weak".to_string(),
|
"password is too common or weak".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional check: reject if it's a simple sequence or pattern
|
// 2. Substring matches for highly restricted roots
|
||||||
if password.chars().all(|c| c == password.chars().next().unwrap()) {
|
// We ban these substrings because "password12345" or "qwerty2024" are trivially guessable.
|
||||||
|
let banned_substrings = ["password", "qwerty", "123456"];
|
||||||
|
for banned in &banned_substrings {
|
||||||
|
if normalized.contains(banned) {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"password contains a restricted sequence".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reject single repeated characters
|
||||||
|
if password
|
||||||
|
.chars()
|
||||||
|
.all(|c| c == password.chars().next().unwrap())
|
||||||
|
{
|
||||||
return Err(AppError::InvalidInput(
|
return Err(AppError::InvalidInput(
|
||||||
"password cannot be a single repeated character".to_string(),
|
"password cannot be a single repeated character".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
+11
-12
@@ -31,7 +31,7 @@ impl IpState {
|
|||||||
|
|
||||||
/// Returns true if this state is no longer active and can be removed.
|
/// Returns true if this state is no longer active and can be removed.
|
||||||
fn is_stale(&self, now: Instant) -> bool {
|
fn is_stale(&self, now: Instant) -> bool {
|
||||||
self.window.is_empty() && self.locked_until.map_or(true, |until| now >= until)
|
self.window.is_empty() && self.locked_until.is_none_or(|until| now >= until)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +71,11 @@ impl RateLimiter {
|
|||||||
|
|
||||||
/// Check if the given IP is currently allowed to attempt a login.
|
/// Check if the given IP is currently allowed to attempt a login.
|
||||||
pub fn check(&self, ip: IpAddr) -> Result<(), AppError> {
|
pub fn check(&self, ip: IpAddr) -> Result<(), AppError> {
|
||||||
if let Some(s) = self.state.get(&ip) {
|
if let Some(s) = self.state.get(&ip)
|
||||||
if let Some(until) = s.locked_until {
|
&& let Some(until) = s.locked_until
|
||||||
if Instant::now() < until {
|
&& Instant::now() < until
|
||||||
return Err(AppError::RateLimited);
|
{
|
||||||
}
|
return Err(AppError::RateLimited);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -87,10 +86,10 @@ impl RateLimiter {
|
|||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
// Clear the lockout if it has expired
|
// Clear the lockout if it has expired
|
||||||
if let Some(until) = s.locked_until {
|
if let Some(until) = s.locked_until
|
||||||
if now >= until {
|
&& now >= until
|
||||||
s.locked_until = None;
|
{
|
||||||
}
|
s.locked_until = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune old failures outside the window
|
// Prune old failures outside the window
|
||||||
@@ -142,4 +141,4 @@ impl Default for RateLimiter {
|
|||||||
max_failures: 5,
|
max_failures: 5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,15 +77,15 @@ pub async fn validate_session(
|
|||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
// Check absolute expiry
|
// Check absolute expiry
|
||||||
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
|
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at)
|
||||||
if now > expires {
|
&& now > expires
|
||||||
provider
|
{
|
||||||
.sessions()
|
provider
|
||||||
.revoke(&session.id)
|
.sessions()
|
||||||
.await
|
.revoke(&session.id)
|
||||||
.map_err(AppError::Database)?;
|
.await
|
||||||
return Ok(None);
|
.map_err(AppError::Database)?;
|
||||||
}
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check idle timeout
|
// Check idle timeout
|
||||||
|
|||||||
@@ -130,12 +130,11 @@ pub async fn validate_token(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check expiry if set
|
// Check expiry if set
|
||||||
if let Some(ref exp) = token.expires_at {
|
if let Some(ref exp) = token.expires_at
|
||||||
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(exp) {
|
&& let Ok(expires) = chrono::DateTime::parse_from_rfc3339(exp)
|
||||||
if chrono::Utc::now() > expires {
|
&& chrono::Utc::now() > expires
|
||||||
return Ok(None);
|
{
|
||||||
}
|
return Ok(None);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Touch last_used_at (fire-and-forget)
|
// Touch last_used_at (fire-and-forget)
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ fn test_config(db_path: String) -> Config {
|
|||||||
async fn test_security_no_plaintext_passwords_in_db() {
|
async fn test_security_no_plaintext_passwords_in_db() {
|
||||||
let (provider, pool, db_path) = setup_test_db().await;
|
let (provider, pool, db_path) = setup_test_db().await;
|
||||||
let sec_cfg = test_security_config();
|
let sec_cfg = test_security_config();
|
||||||
let password = "super_secret_special_pass_123456";
|
|
||||||
|
//let password = "super_secret_special_pass_123456";
|
||||||
|
// Use a strong, random string to bypass the restricted sequence validator
|
||||||
|
let password = "Xy7#bN9@mK2$pQ5!vL8&zW4";
|
||||||
|
|
||||||
let user = identity_users::create_user(
|
let user = identity_users::create_user(
|
||||||
&provider,
|
&provider,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "nx9-auth-ui"
|
name = "nx9-auth-ui"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["NX9 Team", "Sunil Thakare"]
|
authors = ["NX9 Team", "Sunil Thakare"]
|
||||||
description = "Dioxus web UI for nx9-auth IAM"
|
description = "Dioxus web UI for nx9-auth IAM"
|
||||||
|
|||||||
Reference in new issue
Block a user