mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-12 09:08:11 +03:00
Harden os password (terminal windows and headless linux) anti brute force (#14985)
* fix(windows): terminal, preauth bruteforce Signed-off-by: fufesou <linlong1266@gmail.com> * fix(linux): headless, preauth bruteforce Signed-off-by: fufesou <linlong1266@gmail.com> * fix(linux): headless, OS login, minimal fix Signed-off-by: fufesou <linlong1266@gmail.com> * Terminal session, click-only Signed-off-by: fufesou <linlong1266@gmail.com> * Simple refactor, logs Signed-off-by: fufesou <linlong1266@gmail.com> * harden os password, better scoped failure set Signed-off-by: fufesou <linlong1266@gmail.com> * harden os password, ip failure count Signed-off-by: fufesou <linlong1266@gmail.com> * Check prelogin before starting cm Signed-off-by: fufesou <linlong1266@gmail.com> * Isolate terminal OS login failure tracking Terminal OS login no longer reads or updates the default RustDesk per-IP failure bucket. It now uses only the OS credential policy, while RustDesk password attempts keep using the existing LOGIN_FAILURES[0] bucket. Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
@@ -2,7 +2,7 @@ use super::{linux::*, ResultType};
|
|||||||
use crate::client::{
|
use crate::client::{
|
||||||
LOGIN_MSG_DESKTOP_NO_DESKTOP, LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER,
|
LOGIN_MSG_DESKTOP_NO_DESKTOP, LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER,
|
||||||
LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LOGIN_MSG_DESKTOP_XORG_NOT_FOUND,
|
LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LOGIN_MSG_DESKTOP_XORG_NOT_FOUND,
|
||||||
LOGIN_MSG_DESKTOP_XSESSION_FAILED,
|
LOGIN_MSG_DESKTOP_XSESSION_FAILED, LOGIN_MSG_PASSWORD_WRONG,
|
||||||
};
|
};
|
||||||
use hbb_common::{
|
use hbb_common::{
|
||||||
allow_err, bail, log,
|
allow_err, bail, log,
|
||||||
@@ -94,6 +94,49 @@ fn detect_headless() -> Option<&'static str> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||||
|
enum XSessionStartErrorKind {
|
||||||
|
Auth,
|
||||||
|
Env,
|
||||||
|
}
|
||||||
|
|
||||||
|
const XSESSION_AUTH_FAILURE_DETAIL: &str = "authentication failed";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct XSessionStartError {
|
||||||
|
kind: XSessionStartErrorKind,
|
||||||
|
detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XSessionStartError {
|
||||||
|
fn auth(detail: String) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: XSessionStartErrorKind::Auth,
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env(detail: String) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: XSessionStartErrorKind::Env,
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for XSessionStartError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_xsession_start_error_to_login_msg(kind: XSessionStartErrorKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
XSessionStartErrorKind::Auth => LOGIN_MSG_PASSWORD_WRONG,
|
||||||
|
XSessionStartErrorKind::Env => LOGIN_MSG_DESKTOP_XSESSION_FAILED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn try_start_desktop(_username: &str, _passsword: &str) -> String {
|
pub fn try_start_desktop(_username: &str, _passsword: &str) -> String {
|
||||||
debug_assert!(crate::is_server());
|
debug_assert!(crate::is_server());
|
||||||
if _username.is_empty() {
|
if _username.is_empty() {
|
||||||
@@ -136,14 +179,21 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to start xsession {}", e);
|
match e.kind {
|
||||||
LOGIN_MSG_DESKTOP_XSESSION_FAILED.to_owned()
|
XSessionStartErrorKind::Auth => {
|
||||||
|
log::warn!("Failed to authenticate xsession user {}", e);
|
||||||
|
}
|
||||||
|
XSessionStartErrorKind::Env => {
|
||||||
|
log::error!("Failed to start xsession {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map_xsession_start_error_to_login_msg(e.kind).to_owned()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_start_x_session(username: &str, password: &str) -> ResultType<(String, bool)> {
|
fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), XSessionStartError> {
|
||||||
let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap();
|
let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap();
|
||||||
if let Some(desktop_manager) = &mut (*desktop_manager) {
|
if let Some(desktop_manager) = &mut (*desktop_manager) {
|
||||||
if let Some(seat0_username) = desktop_manager.get_supported_display_seat0_username() {
|
if let Some(seat0_username) = desktop_manager.get_supported_display_seat0_username() {
|
||||||
@@ -161,7 +211,9 @@ fn try_start_x_session(username: &str, password: &str) -> ResultType<(String, bo
|
|||||||
desktop_manager.is_running(),
|
desktop_manager.is_running(),
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
bail!(crate::client::LOGIN_MSG_DESKTOP_NOT_INITED);
|
Err(XSessionStartError::env(
|
||||||
|
crate::client::LOGIN_MSG_DESKTOP_NOT_INITED.to_owned(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,10 +299,15 @@ impl DesktopManager {
|
|||||||
self.is_child_running.load(Ordering::SeqCst)
|
self.is_child_running.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_start_x_session(&mut self, username: &str, password: &str) -> ResultType<()> {
|
fn try_start_x_session(
|
||||||
|
&mut self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<(), XSessionStartError> {
|
||||||
match get_user_by_name(username) {
|
match get_user_by_name(username) {
|
||||||
Some(userinfo) => {
|
Some(userinfo) => {
|
||||||
let mut client = pam::Client::with_password(&pam_get_service_name())?;
|
let mut client = pam::Client::with_password(&pam_get_service_name())
|
||||||
|
.map_err(|e| XSessionStartError::env(format!("failed to init pam client, {}", e)))?;
|
||||||
client
|
client
|
||||||
.conversation_mut()
|
.conversation_mut()
|
||||||
.set_credentials(username, password);
|
.set_credentials(username, password);
|
||||||
@@ -267,17 +324,24 @@ impl DesktopManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
bail!("failed to start x session, {}", e);
|
Err(XSessionStartError::env(format!(
|
||||||
|
"failed to start x session, {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(_e) => {
|
||||||
bail!("failed to check user pass for {}, {}", username, e);
|
Err(XSessionStartError::auth(
|
||||||
|
XSESSION_AUTH_FAILURE_DETAIL.to_owned(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
bail!("failed to get userinfo of {}", username);
|
Err(XSessionStartError::auth(
|
||||||
|
XSESSION_AUTH_FAILURE_DETAIL.to_owned(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ pub mod input_service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mod connection;
|
mod connection;
|
||||||
|
mod login_failure_check;
|
||||||
pub mod display_service;
|
pub mod display_service;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub mod portable_service;
|
pub mod portable_service;
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use super::login_failure_check::try_acquire_os_credential_login_gate;
|
||||||
|
use super::login_failure_check::{
|
||||||
|
evaluate_os_credential_policy, record_os_credential_failure, FailureScope,
|
||||||
|
};
|
||||||
use super::{input_service::*, *};
|
use super::{input_service::*, *};
|
||||||
#[cfg(feature = "unix-file-copy-paste")]
|
#[cfg(feature = "unix-file-copy-paste")]
|
||||||
use crate::clipboard::try_empty_clipboard_files;
|
use crate::clipboard::try_empty_clipboard_files;
|
||||||
@@ -82,6 +87,9 @@ lazy_static::lazy_static! {
|
|||||||
static ref PENDING_SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
|
static ref PENDING_SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const TERMINAL_OS_LOGIN_FAILED_MSG: &str = "Incorrect username or password.";
|
||||||
|
|
||||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||||
if a.len() != b.len() {
|
if a.len() != b.len() {
|
||||||
return false;
|
return false;
|
||||||
@@ -94,6 +102,32 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
|||||||
x == 0
|
x == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn should_check_linux_headless_os_auth_before_desktop_start(
|
||||||
|
is_headless_allowed: bool,
|
||||||
|
username: &str,
|
||||||
|
) -> bool {
|
||||||
|
is_headless_allowed
|
||||||
|
&& !username.trim().is_empty()
|
||||||
|
&& linux_desktop_manager::get_username().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn should_record_linux_headless_os_auth_failure(
|
||||||
|
is_headless_allowed: bool,
|
||||||
|
username: &str,
|
||||||
|
err_msg: &str,
|
||||||
|
) -> bool {
|
||||||
|
is_headless_allowed
|
||||||
|
&& !username.trim().is_empty()
|
||||||
|
&& err_msg == crate::client::LOGIN_MSG_PASSWORD_WRONG
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
fn should_use_terminal_os_login_scope(is_terminal: bool, os_login_username: &str) -> bool {
|
||||||
|
cfg!(target_os = "windows") && is_terminal && !os_login_username.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref WALLPAPER_REMOVER: Arc<Mutex<Option<WallPaperRemover>>> = Default::default();
|
static ref WALLPAPER_REMOVER: Arc<Mutex<Option<WallPaperRemover>>> = Default::default();
|
||||||
@@ -1497,6 +1531,9 @@ impl Connection {
|
|||||||
// Keep the connection alive so the client can continue with 2FA.
|
// Keep the connection alive so the client can continue with 2FA.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await {
|
||||||
|
return keep_alive;
|
||||||
|
}
|
||||||
if !self.connect_port_forward_if_needed().await {
|
if !self.connect_port_forward_if_needed().await {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -2376,33 +2413,6 @@ impl Connection {
|
|||||||
o.terminal_persistent.enum_value() == Ok(BoolOption::Yes);
|
o.terminal_persistent.enum_value() == Ok(BoolOption::Yes);
|
||||||
}
|
}
|
||||||
self.terminal_service_id = terminal.service_id;
|
self.terminal_service_id = terminal.service_id;
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
if let Some(msg) =
|
|
||||||
self.fill_terminal_user_token(&lr.os_login.username, &lr.os_login.password)
|
|
||||||
{
|
|
||||||
self.send_login_error(msg).await;
|
|
||||||
sleep(1.).await;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
if let Some(is_user) =
|
|
||||||
terminal_service::is_service_specified_user(&self.terminal_service_id)
|
|
||||||
{
|
|
||||||
if let Some(user_token) = &self.terminal_user_token {
|
|
||||||
let has_service_token =
|
|
||||||
user_token.to_terminal_service_token().is_some();
|
|
||||||
if is_user != has_service_token {
|
|
||||||
// This occurs when the service id (in the configuration) is manually changed by the user, causing a mismatch in validation.
|
|
||||||
log::error!("Terminal service user mismatch detected. The service ID may have been manually changed in the configuration, causing validation to fail.");
|
|
||||||
// No need to translate the following message, because it is in an abnormal case.
|
|
||||||
self.send_login_error("Terminal service user mismatch detected.")
|
|
||||||
.await;
|
|
||||||
sleep(1.).await;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some(login_request::Union::PortForward(mut pf)) => {
|
Some(login_request::Union::PortForward(mut pf)) => {
|
||||||
if !Self::permission(keys::OPTION_ENABLE_TUNNEL, &self.control_permissions) {
|
if !Self::permission(keys::OPTION_ENABLE_TUNNEL, &self.control_permissions) {
|
||||||
@@ -2420,8 +2430,43 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !hbb_common::is_ip_str(&lr.username)
|
||||||
|
&& !hbb_common::is_domain_port_str(&lr.username)
|
||||||
|
&& lr.username != Config::get_id()
|
||||||
|
{
|
||||||
|
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)
|
||||||
|
.await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if self.terminal
|
||||||
|
&& lr.os_login.username.trim().is_empty()
|
||||||
|
&& crate::platform::is_prelogin()
|
||||||
|
{
|
||||||
|
self.send_login_error(
|
||||||
|
"No active console user logged on, please connect and logon first.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
sleep(1.).await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
self.try_start_cm_ipc();
|
if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
|
||||||
|
self.try_start_cm_ipc();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if should_check_linux_headless_os_auth_before_desktop_start(
|
||||||
|
self.linux_headless_handle.is_headless_allowed,
|
||||||
|
&lr.os_login.username,
|
||||||
|
) {
|
||||||
|
let (_failure, res) = self.check_failure(0).await;
|
||||||
|
if !res {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
let err_msg = "".to_owned();
|
let err_msg = "".to_owned();
|
||||||
@@ -2433,6 +2478,18 @@ impl Connection {
|
|||||||
// If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password.
|
// If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password.
|
||||||
if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY
|
if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY
|
||||||
{
|
{
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if should_record_linux_headless_os_auth_failure(
|
||||||
|
self.linux_headless_handle.is_headless_allowed,
|
||||||
|
&lr.os_login.username,
|
||||||
|
&err_msg,
|
||||||
|
) {
|
||||||
|
let (failure, res) = self.check_failure(0).await;
|
||||||
|
if !res {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.update_failure(failure, false, 0);
|
||||||
|
}
|
||||||
self.send_login_error(err_msg).await;
|
self.send_login_error(err_msg).await;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2461,17 +2518,16 @@ impl Connection {
|
|||||||
crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y"
|
crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y"
|
||||||
&& is_logon();
|
&& is_logon();
|
||||||
|
|
||||||
if !hbb_common::is_ip_str(&lr.username)
|
if (password::approve_mode() == ApproveMode::Click && !allow_logon_screen_password)
|
||||||
&& !hbb_common::is_domain_port_str(&lr.username)
|
|
||||||
&& lr.username != Config::get_id()
|
|
||||||
{
|
|
||||||
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)
|
|
||||||
.await;
|
|
||||||
return false;
|
|
||||||
} else if (password::approve_mode() == ApproveMode::Click
|
|
||||||
&& !allow_logon_screen_password)
|
|
||||||
|| password::approve_mode() == ApproveMode::Both && !password::has_valid_password()
|
|| password::approve_mode() == ApproveMode::Both && !password::has_valid_password()
|
||||||
{
|
{
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
|
||||||
|
if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await
|
||||||
|
{
|
||||||
|
return keep_alive;
|
||||||
|
}
|
||||||
|
}
|
||||||
self.try_start_cm(lr.my_id, lr.my_name, false);
|
self.try_start_cm(lr.my_id, lr.my_name, false);
|
||||||
if hbb_common::get_version_number(&lr.version)
|
if hbb_common::get_version_number(&lr.version)
|
||||||
>= hbb_common::get_version_number("1.2.0")
|
>= hbb_common::get_version_number("1.2.0")
|
||||||
@@ -2493,6 +2549,14 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
} else if lr.password.is_empty() {
|
} else if lr.password.is_empty() {
|
||||||
if err_msg.is_empty() {
|
if err_msg.is_empty() {
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
|
||||||
|
if let Some(keep_alive) =
|
||||||
|
self.prepare_terminal_login_for_authorization().await
|
||||||
|
{
|
||||||
|
return keep_alive;
|
||||||
|
}
|
||||||
|
}
|
||||||
self.try_start_cm(lr.my_id, lr.my_name, false);
|
self.try_start_cm(lr.my_id, lr.my_name, false);
|
||||||
} else {
|
} else {
|
||||||
self.send_login_error(
|
self.send_login_error(
|
||||||
@@ -2506,7 +2570,7 @@ impl Connection {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if !self.validate_password(allow_logon_screen_password) {
|
if !self.validate_password(allow_logon_screen_password) {
|
||||||
self.update_failure(failure, false, 0);
|
self.update_failure_with_scope(failure, false, 0, FailureScope::Default);
|
||||||
self.check_update_temporary_password(false);
|
self.check_update_temporary_password(false);
|
||||||
if err_msg.is_empty() {
|
if err_msg.is_empty() {
|
||||||
self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG)
|
self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG)
|
||||||
@@ -2519,7 +2583,7 @@ impl Connection {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.update_failure(failure, true, 0);
|
self.update_failure_with_scope(failure, true, 0, FailureScope::Default);
|
||||||
if err_msg.is_empty() {
|
if err_msg.is_empty() {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
self.linux_headless_handle.wait_desktop_cm_ready().await;
|
self.linux_headless_handle.wait_desktop_cm_ready().await;
|
||||||
@@ -3484,16 +3548,16 @@ impl Connection {
|
|||||||
self.terminal_user_token = Some(TerminalUserToken::SelfUser);
|
self.terminal_user_token = Some(TerminalUserToken::SelfUser);
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some("The user is not an administrator.")
|
Some(TERMINAL_OS_LOGIN_FAILED_MSG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
log::error!("Failed to check if the user is an administrator: {}", e);
|
log::error!("Failed to check if the user is an administrator: {}", e);
|
||||||
Some("Failed to check if the user is an administrator.")
|
Some(TERMINAL_OS_LOGIN_FAILED_MSG)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to get logon user token: {}", e);
|
log::error!("Failed to get logon user token: {}", e);
|
||||||
Some("Incorrect username or password.")
|
Some(TERMINAL_OS_LOGIN_FAILED_MSG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3529,6 +3593,146 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
async fn prepare_terminal_login_for_authorization(&mut self) -> Option<bool> {
|
||||||
|
if !self.terminal || self.terminal_user_token.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
enum TerminalAuthorizationMode {
|
||||||
|
OsLogin {
|
||||||
|
failure: ((i32, i32, i32), i32),
|
||||||
|
scope: FailureScope,
|
||||||
|
},
|
||||||
|
SessionUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized_username = self.lr.os_login.username.trim().to_owned();
|
||||||
|
let auth_mode = if should_use_terminal_os_login_scope(self.terminal, &normalized_username) {
|
||||||
|
// Check failure state
|
||||||
|
let failure_scope = FailureScope::TerminalOsLogin;
|
||||||
|
let (failure, res) = self.check_failure_with_scope(0, failure_scope).await;
|
||||||
|
if !res {
|
||||||
|
log::warn!(
|
||||||
|
"OS credential login blocked by failure policy: ip={} conn_id={} scope={:?}",
|
||||||
|
self.ip,
|
||||||
|
self.inner.id(),
|
||||||
|
failure_scope
|
||||||
|
);
|
||||||
|
// Terminal OS login is sensitive. Close this connection instead of keeping it
|
||||||
|
// alive for retries on the same socket after a rate-limit block.
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
TerminalAuthorizationMode::OsLogin {
|
||||||
|
failure,
|
||||||
|
scope: failure_scope,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TerminalAuthorizationMode::SessionUser
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_terminal_os_login = matches!(auth_mode, TerminalAuthorizationMode::OsLogin { .. });
|
||||||
|
let failure_scope = match auth_mode {
|
||||||
|
TerminalAuthorizationMode::OsLogin { scope, .. } => scope,
|
||||||
|
TerminalAuthorizationMode::SessionUser => FailureScope::Default,
|
||||||
|
};
|
||||||
|
|
||||||
|
let username = normalized_username;
|
||||||
|
let password = self.lr.os_login.password.clone();
|
||||||
|
let terminal_login_error = {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// Concurrency gate for terminal OS login with credentials, to prevent brute-force attacks.
|
||||||
|
let _os_login_concurrency_guard = if is_terminal_os_login {
|
||||||
|
let guard = try_acquire_os_credential_login_gate();
|
||||||
|
if guard.is_err() {
|
||||||
|
log::warn!(
|
||||||
|
"OS credential login blocked by concurrency gate: ip={} conn_id={} scope={:?}",
|
||||||
|
self.ip,
|
||||||
|
self.inner.id(),
|
||||||
|
failure_scope
|
||||||
|
);
|
||||||
|
self.send_login_error("Please try 1 minute later").await;
|
||||||
|
sleep(1.).await;
|
||||||
|
Self::post_alarm_audit(
|
||||||
|
AlarmAuditType::TerminalOsLoginConcurrency,
|
||||||
|
json!({
|
||||||
|
"ip": self.ip,
|
||||||
|
"id": self.lr.my_id.clone(),
|
||||||
|
"name": self.lr.my_name.clone(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
guard.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
self.fill_terminal_user_token(&username, &password)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
self.fill_terminal_user_token(&username, &password)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(msg) = terminal_login_error {
|
||||||
|
if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode {
|
||||||
|
self.update_failure_with_scope(failure, false, 0, scope);
|
||||||
|
}
|
||||||
|
let auth_context = if is_terminal_os_login {
|
||||||
|
"OS credential login verification"
|
||||||
|
} else {
|
||||||
|
"Terminal session-user authorization"
|
||||||
|
};
|
||||||
|
log::warn!(
|
||||||
|
"{} failed: ip={} conn_id={} scope={:?} msg='{}'",
|
||||||
|
auth_context,
|
||||||
|
self.ip,
|
||||||
|
self.inner.id(),
|
||||||
|
failure_scope,
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
self.send_login_error(msg).await;
|
||||||
|
sleep(1.).await;
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode {
|
||||||
|
self.update_failure_with_scope(failure, true, 0, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(is_user) =
|
||||||
|
terminal_service::is_service_specified_user(&self.terminal_service_id)
|
||||||
|
{
|
||||||
|
if let Some(user_token) = &self.terminal_user_token {
|
||||||
|
let has_service_token = user_token.to_terminal_service_token().is_some();
|
||||||
|
if is_user != has_service_token {
|
||||||
|
log::error!(
|
||||||
|
"Terminal service user mismatch: ip={} conn_id={} service_is_user={} has_service_token={}. The service ID may have been manually changed in the configuration, causing validation to fail.",
|
||||||
|
self.ip,
|
||||||
|
self.inner.id(),
|
||||||
|
is_user,
|
||||||
|
has_service_token
|
||||||
|
);
|
||||||
|
// No need to translate the following message, because it is in an abnormal case.
|
||||||
|
self.send_login_error("Terminal service user mismatch detected.")
|
||||||
|
.await;
|
||||||
|
sleep(1.).await;
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_terminal_os_login {
|
||||||
|
self.try_start_cm_ipc();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||||
|
async fn prepare_terminal_login_for_authorization(&mut self) -> Option<bool> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
// Try to parse connection IP as IPv6 address, returning /64, /56, and /48 prefixes.
|
// Try to parse connection IP as IPv6 address, returning /64, /56, and /48 prefixes.
|
||||||
// Parsing an IPv4 address just returns None.
|
// Parsing an IPv4 address just returns None.
|
||||||
// note: we specifically don't use hbb_common::is_ipv6_str to avoid divergence issues
|
// note: we specifically don't use hbb_common::is_ipv6_str to avoid divergence issues
|
||||||
@@ -3555,18 +3759,37 @@ impl Connection {
|
|||||||
Some((p64, p56, p48))
|
Some((p64, p56, p48))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_failure(&self, (failure, time): ((i32, i32, i32), i32), remove: bool, i: usize) {
|
fn bump_failure_entry(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) {
|
||||||
fn bump(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) {
|
if cur.0 == time {
|
||||||
if cur.0 == time {
|
cur.1 += 1;
|
||||||
cur.1 += 1;
|
cur.2 += 1;
|
||||||
cur.2 += 1;
|
} else {
|
||||||
} else {
|
cur.0 = time;
|
||||||
cur.0 = time;
|
cur.1 = 1;
|
||||||
cur.1 = 1;
|
cur.2 += 1;
|
||||||
cur.2 += 1;
|
|
||||||
}
|
|
||||||
cur
|
|
||||||
}
|
}
|
||||||
|
cur
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_failure(&self, failure: ((i32, i32, i32), i32), remove: bool, i: usize) {
|
||||||
|
self.update_failure_with_scope(failure, remove, i, FailureScope::Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_failure_with_scope(
|
||||||
|
&self,
|
||||||
|
(failure, time): ((i32, i32, i32), i32),
|
||||||
|
remove: bool,
|
||||||
|
i: usize,
|
||||||
|
scope: FailureScope,
|
||||||
|
) {
|
||||||
|
let os_credential_scope = matches!(scope, FailureScope::TerminalOsLogin);
|
||||||
|
if os_credential_scope {
|
||||||
|
if !remove {
|
||||||
|
record_os_credential_failure(scope);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let map_mutex = &LOGIN_FAILURES[i];
|
let map_mutex = &LOGIN_FAILURES[i];
|
||||||
if remove {
|
if remove {
|
||||||
if failure.0 != 0 {
|
if failure.0 != 0 {
|
||||||
@@ -3587,14 +3810,15 @@ impl Connection {
|
|||||||
let mut m = map_mutex.lock().unwrap();
|
let mut m = map_mutex.lock().unwrap();
|
||||||
for key in [p64, p56, p48] {
|
for key in [p64, p56, p48] {
|
||||||
let cur = m.get(&key).copied().unwrap_or((0, 0, 0));
|
let cur = m.get(&key).copied().unwrap_or((0, 0, 0));
|
||||||
m.insert(key, bump(cur, time));
|
m.insert(key, Self::bump_failure_entry(cur, time));
|
||||||
}
|
}
|
||||||
// Update full IP: bump from the *original* passed-in failure
|
let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0));
|
||||||
m.insert(self.ip.clone(), bump(failure, time));
|
m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time));
|
||||||
} else {
|
} else {
|
||||||
// Update full IP: bump from the *original* passed-in failure
|
// Re-read the full IP bucket in case another failed attempt updated it.
|
||||||
let mut m = map_mutex.lock().unwrap();
|
let mut m = map_mutex.lock().unwrap();
|
||||||
m.insert(self.ip.clone(), bump(failure, time));
|
let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0));
|
||||||
|
m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3634,8 +3858,50 @@ impl Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn check_failure(&mut self, i: usize) -> (((i32, i32, i32), i32), bool) {
|
async fn check_failure(&mut self, i: usize) -> (((i32, i32, i32), i32), bool) {
|
||||||
|
self.check_failure_with_scope(i, FailureScope::Default)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_failure_with_scope(
|
||||||
|
&mut self,
|
||||||
|
i: usize,
|
||||||
|
scope: FailureScope,
|
||||||
|
) -> (((i32, i32, i32), i32), bool) {
|
||||||
let time = (get_time() / 60_000) as i32;
|
let time = (get_time() / 60_000) as i32;
|
||||||
|
|
||||||
|
if matches!(scope, FailureScope::TerminalOsLogin) {
|
||||||
|
let decision = evaluate_os_credential_policy(scope, get_time());
|
||||||
|
let res = if decision.allowed {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"OS credential login blocked by policy: ip={} conn_id={} i={} msg='{}'",
|
||||||
|
self.ip,
|
||||||
|
self.inner.id(),
|
||||||
|
i,
|
||||||
|
decision.login_error.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
if let Some(login_error) = decision.login_error {
|
||||||
|
// Rare branch and currently temporary response copy; translation can be added later if needed.
|
||||||
|
self.send_login_error(login_error).await;
|
||||||
|
}
|
||||||
|
if let Some(audit) = decision.audit {
|
||||||
|
// For OS blocked/backoff events, we currently emit one alarm report per blocked attempt.
|
||||||
|
// TODO: Add unified cumulative/aggregation fields across alarm producers.
|
||||||
|
Self::post_alarm_audit(
|
||||||
|
audit,
|
||||||
|
json!({
|
||||||
|
"ip": self.ip,
|
||||||
|
"id": self.lr.my_id.clone(),
|
||||||
|
"name": self.lr.my_name.clone(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
};
|
||||||
|
return (((0, 0, 0), time), res);
|
||||||
|
}
|
||||||
|
|
||||||
// IPv6 addresses are cheap to make so we check prefix/netblock as well
|
// IPv6 addresses are cheap to make so we check prefix/netblock as well
|
||||||
if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() {
|
if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() {
|
||||||
if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p64, 64, 60).await {
|
if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p64, 64, 60).await {
|
||||||
@@ -5219,6 +5485,8 @@ pub enum AlarmAuditType {
|
|||||||
// MultipleLoginsAttemptsWithinOneMinute = 4,
|
// MultipleLoginsAttemptsWithinOneMinute = 4,
|
||||||
// MultipleLoginsAttemptsWithinOneHour = 5,
|
// MultipleLoginsAttemptsWithinOneHour = 5,
|
||||||
ExceedIPv6PrefixAttempts = 6,
|
ExceedIPv6PrefixAttempts = 6,
|
||||||
|
TerminalOsLoginBackoff = 7,
|
||||||
|
TerminalOsLoginConcurrency = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum FileAuditType {
|
pub enum FileAuditType {
|
||||||
|
|||||||
231
src/server/login_failure_check.rs
Normal file
231
src/server/login_failure_check.rs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
use crate::AlarmAuditType;
|
||||||
|
use hbb_common::get_time;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use hbb_common::tokio::sync::{Mutex as TokioMutex, OwnedMutexGuard};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS: i64 = 120 * 60 * 1_000;
|
||||||
|
const OS_CREDENTIAL_LOGIN_BACKOFF_BASE_SECONDS: i64 = 15;
|
||||||
|
const OS_CREDENTIAL_LOGIN_BACKOFF_MAX_SECONDS: i64 = 30 * 60;
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum FailureScope {
|
||||||
|
Default,
|
||||||
|
TerminalOsLogin,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct OsCredentialPolicyDecision {
|
||||||
|
pub allowed: bool,
|
||||||
|
pub login_error: Option<String>,
|
||||||
|
pub audit: Option<AlarmAuditType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Default)]
|
||||||
|
struct OsCredentialFailureState {
|
||||||
|
total_failures: i32,
|
||||||
|
backoff_until_ms: Option<i64>,
|
||||||
|
last_failure_ms: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
static ref OS_CREDENTIAL_LOGIN_FAILURE_STATE: Mutex<OsCredentialFailureState> =
|
||||||
|
Mutex::new(OsCredentialFailureState::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
static ref OS_CREDENTIAL_LOGIN_MUTEX: Arc<TokioMutex<()>> = Arc::new(TokioMutex::new(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_os_credential_scope(scope: FailureScope) -> bool {
|
||||||
|
matches!(scope, FailureScope::TerminalOsLogin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_for_os_credential_scope(
|
||||||
|
scope: FailureScope,
|
||||||
|
) -> Option<&'static Mutex<OsCredentialFailureState>> {
|
||||||
|
if is_os_credential_scope(scope) {
|
||||||
|
Some(&OS_CREDENTIAL_LOGIN_FAILURE_STATE)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backoff_audit_type_for_scope(scope: FailureScope) -> Option<AlarmAuditType> {
|
||||||
|
match scope {
|
||||||
|
FailureScope::TerminalOsLogin => Some(AlarmAuditType::TerminalOsLoginBackoff),
|
||||||
|
FailureScope::Default => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn os_credential_login_backoff_seconds(total_failures: i32) -> i64 {
|
||||||
|
if total_failures <= 2 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let exp = (total_failures - 3).min(7);
|
||||||
|
let seconds = OS_CREDENTIAL_LOGIN_BACKOFF_BASE_SECONDS * (1_i64 << exp);
|
||||||
|
seconds.min(OS_CREDENTIAL_LOGIN_BACKOFF_MAX_SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_backoff(state: &mut OsCredentialFailureState, now_ms: i64) {
|
||||||
|
if let Some(until_ms) = state.backoff_until_ms {
|
||||||
|
if until_ms <= now_ms {
|
||||||
|
state.backoff_until_ms = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_totals_on_idle(state: &mut OsCredentialFailureState, now_ms: i64) {
|
||||||
|
if let Some(last_ms) = state.last_failure_ms {
|
||||||
|
if now_ms.saturating_sub(last_ms) >= OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS {
|
||||||
|
state.total_failures = 0;
|
||||||
|
state.backoff_until_ms = None;
|
||||||
|
state.last_failure_ms = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allow_decision() -> OsCredentialPolicyDecision {
|
||||||
|
OsCredentialPolicyDecision {
|
||||||
|
allowed: true,
|
||||||
|
login_error: None,
|
||||||
|
audit: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_decision(
|
||||||
|
login_error: String,
|
||||||
|
alarm_type: Option<AlarmAuditType>,
|
||||||
|
) -> OsCredentialPolicyDecision {
|
||||||
|
OsCredentialPolicyDecision {
|
||||||
|
allowed: false,
|
||||||
|
login_error: Some(login_error),
|
||||||
|
audit: alarm_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn evaluate_os_credential_policy(
|
||||||
|
scope: FailureScope,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> OsCredentialPolicyDecision {
|
||||||
|
if !is_os_credential_scope(scope) {
|
||||||
|
return allow_decision();
|
||||||
|
}
|
||||||
|
let Some(state_mutex) = state_for_os_credential_scope(scope) else {
|
||||||
|
return allow_decision();
|
||||||
|
};
|
||||||
|
let mut state = state_mutex.lock().unwrap();
|
||||||
|
reset_totals_on_idle(&mut state, now_ms);
|
||||||
|
normalize_backoff(&mut state, now_ms);
|
||||||
|
|
||||||
|
if let Some(until_ms) = state.backoff_until_ms {
|
||||||
|
let remaining_ms = (until_ms - now_ms).max(0);
|
||||||
|
let remaining_seconds = ((remaining_ms + 999) / 1_000).max(1);
|
||||||
|
let seconds_label = if remaining_seconds == 1 {
|
||||||
|
"second"
|
||||||
|
} else {
|
||||||
|
"seconds"
|
||||||
|
};
|
||||||
|
block_decision(
|
||||||
|
format!(
|
||||||
|
"Please try again in {} {}.",
|
||||||
|
remaining_seconds, seconds_label
|
||||||
|
),
|
||||||
|
backoff_audit_type_for_scope(scope),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
allow_decision()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_os_credential_failure(scope: FailureScope) {
|
||||||
|
if !is_os_credential_scope(scope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(state_mutex) = state_for_os_credential_scope(scope) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut state = state_mutex.lock().unwrap();
|
||||||
|
let now_ms = get_time();
|
||||||
|
reset_totals_on_idle(&mut state, now_ms);
|
||||||
|
normalize_backoff(&mut state, now_ms);
|
||||||
|
state.total_failures = state.total_failures.saturating_add(1);
|
||||||
|
state.last_failure_ms = Some(now_ms);
|
||||||
|
let backoff_seconds = os_credential_login_backoff_seconds(state.total_failures);
|
||||||
|
if backoff_seconds > 0 {
|
||||||
|
state.backoff_until_ms = Some(now_ms + backoff_seconds * 1_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(crate) fn try_acquire_os_credential_login_gate() -> Result<OwnedMutexGuard<()>, ()> {
|
||||||
|
OS_CREDENTIAL_LOGIN_MUTEX
|
||||||
|
.clone()
|
||||||
|
.try_lock_owned()
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
static TEST_MUTEX: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
fn clear_os_credential_failure_state(scope: FailureScope) {
|
||||||
|
if let Some(state_mutex) = state_for_os_credential_scope(scope) {
|
||||||
|
*state_mutex.lock().unwrap() = OsCredentialFailureState::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn os_credential_policy_prioritizes_backoff() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap();
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
let now_ms = get_time();
|
||||||
|
for _ in 0..3 {
|
||||||
|
record_os_credential_failure(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
let decision = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms);
|
||||||
|
assert!(!decision.allowed);
|
||||||
|
assert!(decision.login_error.is_some());
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn os_credential_policy_idle_window_resets_total_counter() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap();
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
for _ in 0..13 {
|
||||||
|
record_os_credential_failure(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
let blocked = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, get_time());
|
||||||
|
assert!(!blocked.allowed);
|
||||||
|
|
||||||
|
let after_failures_ms = get_time();
|
||||||
|
let after_idle_ms = after_failures_ms + OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS + 1_000;
|
||||||
|
let allowed = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, after_idle_ms);
|
||||||
|
assert!(allowed.allowed);
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn os_credential_policy_audits_every_backoff_block() {
|
||||||
|
let _guard = TEST_MUTEX.lock().unwrap();
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
record_os_credential_failure(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
let now_ms = get_time();
|
||||||
|
let first = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms);
|
||||||
|
let second = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms + 1_000);
|
||||||
|
assert!(!first.allowed);
|
||||||
|
assert!(!second.allowed);
|
||||||
|
assert!(first.audit.is_some());
|
||||||
|
assert!(second.audit.is_some());
|
||||||
|
|
||||||
|
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user