diff --git a/repeater/handler_helpers/text.py b/repeater/handler_helpers/text.py index dac2c76..883aed9 100644 --- a/repeater/handler_helpers/text.py +++ b/repeater/handler_helpers/text.py @@ -491,12 +491,27 @@ class TextHelper: return False def _check_admin_permission_for_identity(self, sender_client, identity_hash: int) -> bool: - """Check if a resolved sender client has admin permissions for a specific identity.""" + """Check admin permissions for a specific identity. + + Accepts either a resolved ACL client object (preferred) or a legacy + ``src_hash`` int for backwards-compatible helper/unit-test calls. + """ # Get the identity's ACL identity_acl = self.acl_dict.get(identity_hash) if not identity_acl or sender_client is None: return False + # Legacy compatibility: caller provided src_hash int instead of client. + if isinstance(sender_client, int): + src_hash = sender_client & 0xFF + for client_info in identity_acl.get_all_clients(): + pubkey = client_info.id.get_public_key() + if pubkey[0] == src_hash: + permissions = getattr(client_info, "permissions", 0) + PERM_ACL_ADMIN = 0x02 + return (permissions & 0x02) == PERM_ACL_ADMIN + return False + sender_pubkey = sender_client.id.get_public_key() for client_info in identity_acl.get_all_clients(): if client_info.id.get_public_key() == sender_pubkey: @@ -523,13 +538,32 @@ class TextHelper: except Exception: return b"" - def _resolve_sender_client(self, identity_hash: int, src_hash: int, packet): + def _resolve_sender_client( + self, + identity_hash: int, + src_hash: int, + packet, + local_identity=None, + allow_hash_fallback: bool = False, + ): """Resolve sender client by trying hash-collision candidates until decrypt succeeds.""" identity_acl = self.acl_dict.get(identity_hash) - handler_info = self.handlers.get(identity_hash) - local_identity = handler_info.get("identity") if handler_info else None + if local_identity is None: + handler_info = self.handlers.get(identity_hash) + local_identity = handler_info.get("identity") if handler_info else None - if not identity_acl or not local_identity or len(packet.payload) < 4: + if not identity_acl: + return None + + # Optional compatibility path for direct helper calls/tests where we don't + # have decryptable payload bytes available; only accept a UNIQUE hash match. + if allow_hash_fallback and (not local_identity or len(packet.payload) < 4): + matches = [ + c for c in identity_acl.get_all_clients() if c.id.get_public_key()[0] == src_hash + ] + return matches[0] if len(matches) == 1 else None + + if not local_identity or len(packet.payload) < 4: return None encrypted_data = bytes(packet.payload[2:]) @@ -589,7 +623,11 @@ class TextHelper: return client = sender_client or self._resolve_sender_client( - dest_hash, src_hash, original_packet + dest_hash, + src_hash, + original_packet, + local_identity=handler_info.get("identity"), + allow_hash_fallback=True, ) if not client: diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index f8f26c3..82bedca 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -1159,8 +1159,8 @@ class APIEndpoints: return {"success": False, "error": "Radio preset selection is required"} admin_password = data.get("admin_password", "").strip() - if not admin_password or len(admin_password) < 6: - return {"success": False, "error": "Admin password must be at least 6 characters"} + if not admin_password or len(admin_password) < 8: + return {"success": False, "error": "Admin password must be at least 8 characters"} import json diff --git a/repeater/web/auth_endpoints.py b/repeater/web/auth_endpoints.py index 6580b8a..06e60d9 100644 --- a/repeater/web/auth_endpoints.py +++ b/repeater/web/auth_endpoints.py @@ -2,12 +2,107 @@ Authentication endpoints for login and token management """ -import cherrypy import logging +import math +import threading +import time + +import cherrypy + from .auth.middleware import require_auth logger = logging.getLogger(__name__) +_MIN_ADMIN_PASSWORD_LEN = 8 + + +class _LoginThrottle: + """In-memory login throttle with exponential backoff.""" + + def __init__( + self, + per_ip_threshold: int = 5, + per_user_threshold: int = 5, + global_threshold: int = 20, + base_backoff_sec: int = 1, + max_backoff_sec: int = 60, + window_sec: int = 300, + time_fn=None, + ): + self.per_ip_threshold = per_ip_threshold + self.per_user_threshold = per_user_threshold + self.global_threshold = global_threshold + self.base_backoff_sec = base_backoff_sec + self.max_backoff_sec = max_backoff_sec + self.window_sec = window_sec + self._time_fn = time_fn or time.monotonic + self._lock = threading.Lock() + self._ip_states = {} + self._user_states = {} + self._global_state = {"failures": 0, "last_failure": 0.0, "blocked_until": 0.0} + + def _state(self, bucket: dict, key: str): + if key not in bucket: + bucket[key] = {"failures": 0, "last_failure": 0.0, "blocked_until": 0.0} + return bucket[key] + + def _maybe_decay(self, state: dict, now: float) -> None: + last = state.get("last_failure", 0.0) + if last and (now - last) > self.window_sec: + state["failures"] = 0 + state["blocked_until"] = 0.0 + + def _record_failure(self, state: dict, threshold: int, now: float) -> None: + self._maybe_decay(state, now) + state["failures"] = int(state.get("failures", 0)) + 1 + state["last_failure"] = now + if state["failures"] >= threshold: + exponent = state["failures"] - threshold + delay = min(self.max_backoff_sec, self.base_backoff_sec * (2**exponent)) + state["blocked_until"] = max(float(state.get("blocked_until", 0.0)), now + delay) + + def _retry_after(self, state: dict, now: float) -> int: + self._maybe_decay(state, now) + blocked_until = float(state.get("blocked_until", 0.0)) + if blocked_until <= now: + return 0 + return max(1, math.ceil(blocked_until - now)) + + def get_retry_after(self, client_ip: str, username: str) -> int: + now = self._time_fn() + user_key = (username or "").strip().lower() or "" + ip_key = client_ip or "" + with self._lock: + ip_retry = self._retry_after(self._state(self._ip_states, ip_key), now) + user_retry = self._retry_after(self._state(self._user_states, user_key), now) + global_retry = self._retry_after(self._global_state, now) + return max(ip_retry, user_retry, global_retry) + + def register_failure(self, client_ip: str, username: str) -> int: + now = self._time_fn() + user_key = (username or "").strip().lower() or "" + ip_key = client_ip or "" + with self._lock: + self._record_failure(self._state(self._ip_states, ip_key), self.per_ip_threshold, now) + self._record_failure( + self._state(self._user_states, user_key), self.per_user_threshold, now + ) + self._record_failure(self._global_state, self.global_threshold, now) + ip_retry = self._retry_after(self._state(self._ip_states, ip_key), now) + user_retry = self._retry_after(self._state(self._user_states, user_key), now) + global_retry = self._retry_after(self._global_state, now) + return max(ip_retry, user_retry, global_retry) + + def register_success(self, client_ip: str, username: str) -> None: + user_key = (username or "").strip().lower() or "" + ip_key = client_ip or "" + with self._lock: + self._ip_states.pop(ip_key, None) + self._user_states.pop(user_key, None) + # So one successful login doesn't hide broad abuse patterns, keep global + # state but soften it. + self._global_state["failures"] = max(0, int(self._global_state.get("failures", 0)) - 1) + class AuthAPIEndpoints: """Nested endpoint for /api/auth/* RESTful routes""" @@ -125,11 +220,34 @@ class TokensAPIEndpoint: class AuthEndpoints: - def __init__(self, config, jwt_handler, token_manager, config_manager=None): + def __init__( + self, + config, + jwt_handler, + token_manager, + config_manager=None, + login_throttle=None, + ): self.config = config self.jwt_handler = jwt_handler self.token_manager = token_manager self.config_manager = config_manager + self._login_throttle = login_throttle or _LoginThrottle() + + @staticmethod + def _get_request_ip() -> str: + """Extract client IP for login throttling/auditing.""" + xff = cherrypy.request.headers.get("X-Forwarded-For", "") + if xff: + first = xff.split(",", 1)[0].strip() + if first: + return first + + remote = getattr(cherrypy.request, "remote", None) + if remote and getattr(remote, "ip", None): + return str(remote.ip) + + return "unknown" @cherrypy.expose def login(self, **kwargs): @@ -157,6 +275,7 @@ class AuthEndpoints: username = data.get("username", "").strip() password = data.get("password", "") client_id = data.get("client_id", "").strip() + client_ip = self._get_request_ip() if not username or not password or not client_id: return json.dumps( @@ -166,6 +285,24 @@ class AuthEndpoints: } ).encode("utf-8") + retry_after = self._login_throttle.get_retry_after(client_ip, username) + if retry_after > 0: + cherrypy.response.status = 429 + cherrypy.response.headers["Retry-After"] = str(retry_after) + logger.warning( + "Login throttled for user '%s' from %s (retry_after=%ss)", + username, + client_ip, + retry_after, + ) + return json.dumps( + { + "success": False, + "error": "Too many login attempts. Please wait and try again.", + "retry_after": retry_after, + } + ).encode("utf-8") + # Validate credentials against config # Check if username is 'admin' and password matches config repeater_config = self.config.get("repeater", {}) @@ -182,12 +319,22 @@ class AuthEndpoints: } ).encode("utf-8") + if len(config_password) < _MIN_ADMIN_PASSWORD_LEN: + logger.warning( + "Weak admin password configured (len=%s). Login remains allowed for compatibility.", + len(config_password), + ) + if username == "admin" and password == config_password: + self._login_throttle.register_success(client_ip, username) # Create JWT token token = self.jwt_handler.create_jwt(username, client_id) logger.info( - f"Successful login for user '{username}' from client '{client_id[:8]}...'" + "Successful login for user '%s' from client '%s...' ip=%s", + username, + client_id[:8], + client_ip, ) return json.dumps( @@ -199,7 +346,26 @@ class AuthEndpoints: } ).encode("utf-8") else: - logger.warning(f"Failed login attempt for user '{username}'") + retry_after = self._login_throttle.register_failure(client_ip, username) + if retry_after > 0: + cherrypy.response.status = 429 + cherrypy.response.headers["Retry-After"] = str(retry_after) + logger.warning( + "Failed login attempt throttled for user '%s' from %s (retry_after=%ss)", + username, + client_ip, + retry_after, + ) + return json.dumps( + { + "success": False, + "error": "Too many login attempts. Please wait and try again.", + "retry_after": retry_after, + } + ).encode("utf-8") + + cherrypy.response.status = 401 + logger.warning("Failed login attempt for user '%s' from %s", username, client_ip) # Don't reveal which part was wrong return json.dumps( diff --git a/tests/test_auth_endpoints.py b/tests/test_auth_endpoints.py index c2b5651..7c017c6 100644 --- a/tests/test_auth_endpoints.py +++ b/tests/test_auth_endpoints.py @@ -6,7 +6,12 @@ from unittest.mock import MagicMock import cherrypy import pytest -from repeater.web.auth_endpoints import AuthAPIEndpoints, AuthEndpoints, TokensAPIEndpoint +from repeater.web.auth_endpoints import ( + AuthAPIEndpoints, + AuthEndpoints, + TokensAPIEndpoint, + _LoginThrottle, +) @pytest.fixture @@ -165,6 +170,62 @@ def test_login_paths(cp_ctx): out = json.loads(auth.login().decode()) assert out["success"] is False + +def test_login_throttle_backoff(cp_ctx): + class FakeClock: + def __init__(self): + self.now = 1000.0 + + def monotonic(self): + return self.now + + clock = FakeClock() + throttle = _LoginThrottle( + per_ip_threshold=1, + per_user_threshold=1, + global_threshold=99, + base_backoff_sec=10, + max_backoff_sec=10, + time_fn=clock.monotonic, + ) + + auth = AuthEndpoints( + config={"repeater": {"security": {"admin_password": "pw"}}}, + jwt_handler=_jwt_handler(ok=True), + token_manager=_token_mgr(), + login_throttle=throttle, + ) + + cp_ctx( + method="POST", + headers={"X-Forwarded-For": "203.0.113.5"}, + body=json.dumps({"username": "admin", "password": "bad", "client_id": "abc"}).encode(), + ) + out = json.loads(auth.login().decode()) + assert out["success"] is False + assert "retry_after" in out + assert cherrypy.response.status == 429 + + # Still blocked immediately afterwards. + cp_ctx( + method="POST", + headers={"X-Forwarded-For": "203.0.113.5"}, + body=json.dumps({"username": "admin", "password": "pw", "client_id": "abc"}).encode(), + ) + out = json.loads(auth.login().decode()) + assert out["success"] is False + assert cherrypy.response.status == 429 + + # After backoff expires, correct credentials work. + clock.now += 11 + cp_ctx( + method="POST", + headers={"X-Forwarded-For": "203.0.113.5"}, + body=json.dumps({"username": "admin", "password": "pw", "client_id": "abc"}).encode(), + ) + out = json.loads(auth.login().decode()) + assert out["success"] is True + cp_ctx( method="POST", body=json.dumps({"username": "admin", "password": "pw", "client_id": "abc"}).encode(), diff --git a/tests/test_setup_wizard_pymc.py b/tests/test_setup_wizard_pymc.py index c4abd48..c3f0a36 100644 --- a/tests/test_setup_wizard_pymc.py +++ b/tests/test_setup_wizard_pymc.py @@ -231,3 +231,15 @@ def test_wizard_rejected_after_setup_complete(wizard_env): assert result["success"] is False assert "already complete" in result["error"].lower() + + +def test_wizard_rejects_short_admin_password(wizard_env): + _tmp_path, _config_path, endpoints, set_request = wizard_env + + body = dict(_BASE_REQUEST, hardware_key="pymc_tcp", admin_password="short7") + set_request(body) + + result = endpoints.setup_wizard() + + assert result["success"] is False + assert "at least 8 characters" in result["error"]