diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/admin.py | 39 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/relay.py | 23 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/revocation.py | 13 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 73 |
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) |