aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py39
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/relay.py23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py73
4 files changed, 123 insertions, 25 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index 087c221..397b4d9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -40,20 +40,25 @@ class SettingsPatchRequest(BaseModel):
allow_public_groups: bool | None = None
# Every mail bound, each optional: the panel sends only what changed.
mail: dict[str, int] | None = None
+ # The sign-in lockout's two numbers, each optional, as for mail.
+ login: dict[str, int] | None = None
# ── Instance settings ────────────────────────────────────────────────────────
-def _settings_payload(allow_public_groups: bool, mail: dict) -> dict:
+async def _settings_payload(db: AsyncSession) -> dict:
return {
- "allow_public_groups": allow_public_groups,
- "mail": mail,
+ "allow_public_groups": await hub_settings.public_groups_allowed(db),
+ "mail": await hub_settings.mail_limits(db),
# So the panel can show what a field falls back to, and label the
# bounds it will refuse — rather than the operator finding out by
# having a value silently clamped.
"mail_defaults": {k: hub_settings.mail_default(k)
for k in hub_settings.MAIL_KEYS},
"mail_bounds": {k: list(v) for k, v in hub_settings.MAIL_BOUNDS.items()},
+ "login": await hub_settings.login_limits(db),
+ "login_defaults": dict(hub_settings.LOGIN_DEFAULTS),
+ "login_bounds": {k: list(v) for k, v in hub_settings.LOGIN_BOUNDS.items()},
}
@@ -63,9 +68,7 @@ async def admin_get_settings(
db: AsyncSession = Depends(get_db),
):
"""Instance-wide policy an admin controls from the panel. Moderators may read."""
- return _settings_payload(
- await hub_settings.public_groups_allowed(db),
- await hub_settings.mail_limits(db))
+ return await _settings_payload(db)
@router.patch("/settings")
@@ -115,9 +118,27 @@ async def admin_patch_settings(
))
await db.commit()
- return _settings_payload(
- await hub_settings.public_groups_allowed(db),
- await hub_settings.mail_limits(db))
+ if body.login:
+ unknown = sorted(set(body.login) - set(hub_settings.LOGIN_KEYS))
+ if unknown:
+ raise HTTPException(
+ status_code=422, detail=f"Unknown login setting(s): {unknown}")
+ changed = []
+ for key, value in body.login.items():
+ clamped = hub_settings.clamp_login_value(key, value)
+ await hub_settings.set_raw(db, f"login.{key}", str(clamped))
+ changed.append(f"{key}={clamped}")
+ log.info("Sign-in lockout changed by %s: %s",
+ current_user.username, ", ".join(changed))
+ db.add(IPLog(
+ user_id=current_user.id,
+ event="admin_login_lockout_update",
+ ip_address="admin",
+ detail=", ".join(changed)[:255],
+ ))
+ await db.commit()
+
+ return await _settings_payload(db)
@router.get("/mail")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/relay.py b/packages/meshbay-hub/src/meshbay_hub/api/relay.py
index c6ef26e..08d935b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/relay.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/relay.py
@@ -32,7 +32,28 @@ from meshbay_hub.db.models import User
log = logging.getLogger(__name__)
-router = APIRouter(prefix="/v1/relays", tags=["relay"])
+# **Closed, the same way and for a similar reason as federation.** Nothing in the
+# tree calls these routes — no node asks for a relay, no client offers one — and
+# §11.1 measured two ISPs with no TURN relay needed. Two of the three take no
+# account and answer anyone who can reach the hub, so a registry nothing uses
+# was an unauthenticated surface kept for its own sake. A constant, not a
+# setting: re-opening it means building the node side first, then flipping this.
+RELAYS_ENABLED = False
+
+
+def _relays_open() -> None:
+ """Refuse every route on this router while the registry is closed.
+
+ On the router rather than in each handler, so a route added later is closed
+ before anybody remembers to write the check (C6).
+ """
+ if not RELAYS_ENABLED:
+ raise HTTPException(status_code=503,
+ detail="The relay registry is not enabled on this hub")
+
+
+router = APIRouter(prefix="/v1/relays", tags=["relay"],
+ dependencies=[Depends(_relays_open)])
# In-memory relay registry (production: DB table)
_relays: dict[str, dict] = {} # relay_id → {endpoint, pk, last_seen, capacity}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index 60b0c88..1f1f5c5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -56,6 +56,11 @@ _connected_nodes: dict[str, WebSocket] = {} # node_id → websocket
_node_groups: dict[str, list[str]] = {} # node_id → [group_id, ...]
_punch_events: dict[str, asyncio.Event] = {} # node_id → signaling event
+# How long an unauthenticated socket may stay open before saying who it is. The
+# node sends its auth message the moment the connection opens; anything that
+# has not spoken by now is holding a socket and a task for nothing.
+NODE_WS_AUTH_TIMEOUT = 10.0
+
def is_node_connected(node_id: str) -> bool:
return node_id in _connected_nodes
@@ -336,7 +341,13 @@ async def node_websocket(ws: WebSocket):
try:
# Auth: expect {"type": "auth", "token": "<jwt>", "node_id": "..."}
- raw = await ws.receive_text()
+ # Bounded: the socket is accepted before anyone is authenticated, so an
+ # unbounded wait is a connection any stranger can hold open for ever.
+ try:
+ raw = await asyncio.wait_for(ws.receive_text(), NODE_WS_AUTH_TIMEOUT)
+ except TimeoutError:
+ await _reject(ws, "Authentication timed out", 4001)
+ return
msg = json.loads(raw)
if msg.get("type") != "auth" or "token" not in msg:
await _reject(ws, "Send auth first", 4001)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 7cebd91..2a6baf0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -14,7 +14,7 @@ from pydantic import BaseModel, field_validator
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub import hub_settings, mail
+from meshbay_hub import hub_settings, login_throttle, mail
from meshbay_hub.api.deps import get_current_user, require_user_scope
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
@@ -296,6 +296,31 @@ async def verify_email(
return {"status": "verified"}
+async def _take_login_attempt(db: AsyncSession, username: str) -> None:
+ """Spend one passphrase attempt for `username`, or refuse with 429.
+
+ Every path that checks a passphrase goes through here first — `login` and
+ `change_password` alike, because a lockout on one door is not a lockout.
+ """
+ allowed, retry_after = await login_throttle.reserve(db, username)
+ if not allowed:
+ raise HTTPException(status_code=429, detail="account_locked",
+ headers={"Retry-After": str(retry_after)})
+
+
+async def _login_failed(db: AsyncSession, username: str, ip: str,
+ user_id: str | None = None) -> None:
+ """Record a wrong passphrase and answer 401. Always raises."""
+ db.add(IPLog(user_id=user_id, event="login_fail", ip_address=ip, detail=username))
+ if await login_throttle.is_now_locked(db, username):
+ # Once, on the failure that spent the last attempt — so the logs tab
+ # shows when a name was locked, not every refusal after it.
+ db.add(IPLog(user_id=user_id, event="login_locked", ip_address=ip,
+ detail=username))
+ await db.commit()
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+
+
@router.post("/login")
@limiter.limit("10/minute")
async def login(
@@ -303,38 +328,39 @@ async def login(
request: Request,
db: AsyncSession = Depends(get_db),
):
- result = await db.execute(
- select(User).where(User.username == body.username))
- user = result.scalar_one_or_none()
-
ip = client_ip(request)
if not body.auth_key and not body.password:
raise HTTPException(status_code=401, detail="No credentials provided")
+ # Before the account is even looked up: an unknown name spends attempts and
+ # locks exactly like a real one, so neither answer tells them apart (M1).
+ await _take_login_attempt(db, body.username)
+
+ result = await db.execute(
+ select(User).where(User.username == body.username))
+ user = result.scalar_one_or_none()
+
if not user:
- db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username))
- await db.commit()
- raise HTTPException(status_code=401, detail="Invalid credentials")
+ await _login_failed(db, body.username, ip)
if user.pw_version >= 3:
# New scheme: verify auth_key
if not body.auth_key or not verify_password(
body.auth_key, user.pw_hash, user.pw_salt, version=user.pw_version
):
- db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username))
- await db.commit()
- raise HTTPException(status_code=401, detail="Invalid credentials")
+ await _login_failed(db, body.username, ip, user.id)
else:
# Legacy scheme: need raw password
if not body.password:
+ # Nothing was checked, so nothing was guessed.
+ await login_throttle.release(db, body.username)
+ await db.commit()
raise HTTPException(status_code=401, detail="auth_upgrade_required")
if not verify_password(
body.password, user.pw_hash, user.pw_salt, version=user.pw_version
):
- db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username))
- await db.commit()
- raise HTTPException(status_code=401, detail="Invalid credentials")
+ await _login_failed(db, body.username, ip, user.id)
# Migrate to new scheme if auth_key provided alongside password
if body.auth_key:
new_hash, new_salt = hash_password(body.auth_key)
@@ -348,6 +374,11 @@ async def login(
user.pw_salt = new_salt
user.pw_version = 2
+ # The passphrase was right, whatever the account's status turns out to be.
+ await login_throttle.clear(db, body.username)
+
+ if user.status != "active":
+ await db.commit()
if user.status == "pending":
raise HTTPException(status_code=403, detail="email_verification_required")
if user.status != "active":
@@ -624,6 +655,7 @@ async def token_refresh(
@router.get("/me")
async def get_current_user_info(
current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
):
email = ""
try:
@@ -636,6 +668,12 @@ async def get_current_user_info(
"email": email,
"role": current_user.role,
"status": current_user.status,
+ # Seconds left on a sign-in lockout, 0 when there is none. Told to the
+ # account's own session only, so it reveals nothing about anyone else.
+ # A passphrase change re-wraps every node's bundle *before* the hub
+ # accepts the new passphrase, and must not start while the hub would
+ # then refuse it.
+ "passphrase_locked_for": await login_throttle.locked_for(db, current_user.username),
}
@@ -846,10 +884,12 @@ async def change_password(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ await _take_login_attempt(db, current_user.username)
if not verify_password(body.old_auth_key, current_user.pw_hash,
current_user.pw_salt, current_user.pw_version):
raise HTTPException(status_code=403,
detail="Current passphrase does not match")
+ await login_throttle.clear(db, current_user.username)
if body.new_auth_key == body.old_auth_key:
raise HTTPException(status_code=400,
detail="New passphrase must differ from the current one")
@@ -1049,6 +1089,9 @@ async def password_reset(
update(RefreshToken).where(RefreshToken.user_id == user.id)
.values(revoked=True))
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
+ # A code sent to the address on file is a stronger proof than a passphrase,
+ # and it is the way out of a lockout somebody else caused.
+ await login_throttle.clear(db, user.username)
db.add(IPLog(user_id=user.id, event="password_reset",
ip_address=client_ip(request)))
await db.commit()
@@ -1286,9 +1329,11 @@ async def delete_own_account(
borrowed laptop or a session left open. Same value as at sign-in, so the hub
still never sees the passphrase itself.
"""
+ await _take_login_attempt(db, current_user.username)
if not verify_password(body.auth_key, current_user.pw_hash, current_user.pw_salt,
current_user.pw_version):
raise HTTPException(status_code=403, detail="Passphrase does not match")
+ await login_throttle.clear(db, current_user.username)
return await erase_account(db, current_user)