summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-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
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/hub_settings.py31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/login_throttle.py143
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js51
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py6
24 files changed, 527 insertions, 36 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)
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 82ae110..afc1cde 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -142,6 +142,14 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
version=__version__,
description="MeshBay identity authority and group registry",
lifespan=lifespan,
+ # No interactive docs and no schema. The full description of the
+ # identity authority's API is a map for whoever probes it, and nothing
+ # in the tree reads it. meshbay.org hid these in its Caddyfile (S18),
+ # which protects exactly one deployment: a hub installed from the
+ # package, behind any other proxy, published all three.
+ docs_url=None,
+ redoc_url=None,
+ openapi_url=None,
)
# Rate limiting
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py
new file mode 100644
index 0000000..2fead6c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py
@@ -0,0 +1,33 @@
+"""add login_throttle
+
+Wrong passphrases per username, for the per-account sign-in lockout. Keyed by a
+hash of the name as typed, so unknown names are counted like real ones.
+
+Revision ID: a9b8c7d6e5f4
+Revises: e5f6a7b8c9d0
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "a9b8c7d6e5f4"
+down_revision: Union[str, Sequence[str], None] = "e5f6a7b8c9d0"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "login_throttle",
+ sa.Column("key", sa.String(64), primary_key=True),
+ sa.Column("failures", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("last_failure_at", sa.DateTime(timezone=True), nullable=False),
+ )
+
+
+def downgrade() -> None:
+ # Dropping this forgets every count in progress, which unlocks everyone —
+ # the limits themselves live in `hub_settings`.
+ op.drop_table("login_throttle")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index b052e00..f1fff41 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -330,6 +330,22 @@ class MailQuota(Base):
last_sent: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+class LoginThrottle(Base):
+ """Wrong passphrases per username, for the sign-in lockout (`login_throttle.py`).
+
+ Keyed by a hash of the name as typed rather than by account, so an unknown
+ name is counted — and locked — exactly like a real one (M1), and so a
+ passphrase typed into the username field is never stored. Rows age out with
+ the lockout window and are purged by the cleanup task.
+ """
+
+ __tablename__ = "login_throttle"
+
+ key: Mapped[str] = mapped_column(String(64), primary_key=True)
+ failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ last_failure_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+
+
class HubSetting(Base):
"""
Instance-wide settings an admin changes at runtime from the panel.
diff --git a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
index 1c2d2c4..1dcff84 100644
--- a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
+++ b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
@@ -85,6 +85,37 @@ async def mail_limits(db: AsyncSession) -> dict[str, int]:
return {k: await get_int(db, f"mail.{k}", mail_default(k)) for k in MAIL_KEYS}
+# ── Sign-in lockout ──────────────────────────────────────────────────────────
+#
+# After `max_failures` wrong passphrases for one username, sign-in with a
+# passphrase is refused for `lockout_minutes` (`login_throttle.py`). Zero
+# failures turns the lockout off; a zero-minute lockout would be the same thing
+# said less clearly, so the duration starts at one.
+
+LOGIN_KEYS = ("max_failures", "lockout_minutes")
+
+LOGIN_DEFAULTS: dict[str, int] = {
+ "max_failures": 4,
+ "lockout_minutes": 60,
+}
+
+LOGIN_BOUNDS: dict[str, tuple[int, int]] = {
+ "max_failures": (0, 100),
+ "lockout_minutes": (1, 10_080), # a week
+}
+
+
+def clamp_login_value(key: str, value: int) -> int:
+ low, high = LOGIN_BOUNDS[key]
+ return max(low, min(high, int(value)))
+
+
+async def login_limits(db: AsyncSession) -> dict[str, int]:
+ """Both lockout numbers, stored value or built-in default."""
+ return {k: clamp_login_value(k, await get_int(db, f"login.{k}", LOGIN_DEFAULTS[k]))
+ for k in LOGIN_KEYS}
+
+
async def get_raw(db: AsyncSession, key: str) -> str | None:
row = await db.get(HubSetting, key)
return row.value if row else None
diff --git a/packages/meshbay-hub/src/meshbay_hub/login_throttle.py b/packages/meshbay-hub/src/meshbay_hub/login_throttle.py
new file mode 100644
index 0000000..3281088
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/login_throttle.py
@@ -0,0 +1,143 @@
+"""Per-account sign-in lockout: after N wrong passphrases, refuse for a while.
+
+The per-IP rate limit on `login` bounds one address, and IPv6 hands every
+subscriber a /64 of them. What an online guess actually targets is an account,
+so that is what this counts.
+
+Three properties, each for a reason:
+
+- **Keyed by the username as typed, whether or not the account exists.** An
+ unknown name locks exactly like a real one, so a 429 says nothing a 401 did
+ not — `login` stays uniform (M1). The key is a hash: people type passphrases
+ into the username field, and this table must not keep them.
+- **The attempt is counted before the passphrase is checked, in one statement.**
+ Read-then-write would let a burst of concurrent requests all read "three
+ failures" and all be checked; an `INSERT … ON CONFLICT DO UPDATE … WHERE`
+ either takes one attempt or reports that none is left, atomically on SQLite
+ and PostgreSQL alike.
+- **A locked account is refused without verifying anything**, so a guess made
+ during the lockout learns nothing — not even whether it was right.
+
+What a lockout does not touch: sessions already open, token renewal, and device
+sign-in, none of which take a passphrase. That is what keeps a stranger who
+locks somebody else's name from signing them out (§13.5b, AV26).
+"""
+
+import hashlib
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import case, delete, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub import hub_settings
+from meshbay_hub.db.models import LoginThrottle
+
+
+def _key(username: str) -> str:
+ return hashlib.sha256(f"meshbay:login:{username}".encode()).hexdigest()
+
+
+def _aware(dt: datetime) -> datetime:
+ # SQLite hands back naive datetimes for a timezone-aware column.
+ return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
+
+
+def _insert_for(db: AsyncSession):
+ dialect = db.bind.dialect.name
+ if dialect == "postgresql":
+ from sqlalchemy.dialects.postgresql import insert
+ elif dialect == "sqlite":
+ from sqlalchemy.dialects.sqlite import insert
+ else:
+ raise RuntimeError(f"login throttle has no upsert for dialect {dialect!r}")
+ return insert
+
+
+async def reserve(db: AsyncSession, username: str) -> tuple[bool, int]:
+ """Take one attempt for `username`, and commit it before anything is checked.
+
+ Returns `(allowed, retry_after_seconds)`. When allowed, the second value is
+ 0; when not, it is how long the lockout has left.
+ """
+ limits = await hub_settings.login_limits(db)
+ max_failures = limits["max_failures"]
+ if max_failures == 0:
+ return True, 0
+
+ now = datetime.now(timezone.utc)
+ window = timedelta(minutes=limits["lockout_minutes"])
+ window_start = now - window
+ key = _key(username)
+
+ table = LoginThrottle.__table__
+ stale = table.c.last_failure_at < window_start
+ insert = _insert_for(db)
+ stmt = (
+ insert(table)
+ .values(key=key, failures=1, last_failure_at=now)
+ .on_conflict_do_update(
+ index_elements=[table.c.key],
+ # Failures older than the window have aged out: start again at one
+ # rather than carrying three typos from last week into today.
+ set_={"failures": case((stale, 1), else_=table.c.failures + 1),
+ "last_failure_at": now},
+ where=(table.c.failures < max_failures) | stale,
+ )
+ .returning(table.c.failures)
+ )
+ taken = (await db.execute(stmt)).first()
+ await db.commit()
+ if taken is not None:
+ return True, 0
+ return False, max(1, await locked_for(db, username))
+
+
+async def locked_for(db: AsyncSession, username: str) -> int:
+ """Seconds left on a lockout, 0 if none — without spending an attempt."""
+ limits = await hub_settings.login_limits(db)
+ if limits["max_failures"] == 0:
+ return 0
+ row = await db.get(LoginThrottle, _key(username))
+ if row is None or row.failures < limits["max_failures"]:
+ return 0
+ remaining = (_aware(row.last_failure_at)
+ + timedelta(minutes=limits["lockout_minutes"])
+ - datetime.now(timezone.utc)).total_seconds()
+ return max(0, int(remaining + 0.999))
+
+
+async def is_now_locked(db: AsyncSession, username: str) -> bool:
+ """After a failure: did that one spend the last attempt?"""
+ limits = await hub_settings.login_limits(db)
+ if limits["max_failures"] == 0:
+ return False
+ failures = await db.scalar(
+ select(LoginThrottle.failures).where(LoginThrottle.key == _key(username)))
+ return (failures or 0) >= limits["max_failures"]
+
+
+async def release(db: AsyncSession, username: str) -> None:
+ """Give back an attempt that checked no passphrase. The caller owns the commit.
+
+ Only ever undoes the caller's own reservation, so it cannot be used to earn
+ attempts: the net effect of reserve-then-release is nothing.
+ """
+ await db.execute(
+ update(LoginThrottle)
+ .where(LoginThrottle.key == _key(username), LoginThrottle.failures > 0)
+ .values(failures=LoginThrottle.failures - 1))
+
+
+async def clear(db: AsyncSession, username: str) -> None:
+ """The right passphrase, or a reset proved by e-mail. The caller owns the commit."""
+ await db.execute(delete(LoginThrottle).where(LoginThrottle.key == _key(username)))
+
+
+async def purge_expired(db: AsyncSession) -> int:
+ """Rows whose failures have aged out. Every unknown name typed creates one."""
+ limits = await hub_settings.login_limits(db)
+ cutoff = datetime.now(timezone.utc) - timedelta(minutes=limits["lockout_minutes"])
+ result = await db.execute(
+ delete(LoginThrottle).where(LoginThrottle.last_failure_at < cutoff))
+ await db.commit()
+ return result.rowcount
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
index 8800615..c40d240 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
@@ -16,6 +16,7 @@ export function AdminPage({ token, role }) {
// Edited values live here until Save, so a half-typed number is never sent
// and a rejected one never looks applied.
const [mailDraft, setMailDraft] = useState(null);
+ const [loginDraft, setLoginDraft] = useState(null);
const [users, setUsers] = useState([]);
const [usersTotal, setUsersTotal] = useState(0);
const [userSearch, setUserSearch] = useState('');
@@ -43,6 +44,7 @@ export function AdminPage({ token, role }) {
const data = await hubFetch('/v1/admin/settings', { token });
setSettings(data);
setMailDraft({ ...data.mail });
+ setLoginDraft({ ...data.login });
} catch (e) { setError(e.message); }
try {
setMailStatus(await hubFetch('/v1/admin/mail', { token }));
@@ -61,6 +63,7 @@ export function AdminPage({ token, role }) {
// The hub clamps what it was given, so the draft is reset from the
// answer rather than left showing a number that was not stored.
setMailDraft({ ...data.mail });
+ setLoginDraft({ ...data.login });
if (patch.mail) {
try {
setMailStatus(await hubFetch('/v1/admin/mail', { token }));
@@ -193,6 +196,14 @@ const MAIL_FIELDS = [
'email_change_cooldown',
];
+const LOGIN_FIELDS = ['max_failures', 'lockout_minutes'];
+
+// Only what changed, and only what is a number: an empty field is someone
+// mid-edit, not a request to set zero.
+const changedNumbers = (fields, draft, stored) => Object.fromEntries(fields
+ .filter(k => draft[k] !== '' && draft[k] !== null && Number(draft[k]) !== stored[k])
+ .map(k => [k, Number(draft[k])]));
+
const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist'];
const canEditSettings = role === 'admin';
@@ -247,12 +258,7 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist
<div class="settings-row">
<button class="btn" disabled=${settingsSaving}
onClick=${() => saveSettings({
- mail: Object.fromEntries(MAIL_FIELDS
- // Only what changed, and only what is a number: an empty
- // field is someone mid-edit, not a request to set zero.
- .filter(k => mailDraft[k] !== '' && mailDraft[k] !== null
- && Number(mailDraft[k]) !== settings.mail[k])
- .map(k => [k, Number(mailDraft[k])])),
+ mail: changedNumbers(MAIL_FIELDS, mailDraft, settings.mail),
})}>${t('admin.mail_save')}</button>
<button class="btn btn-secondary" disabled=${settingsSaving}
onClick=${() => setMailDraft({ ...settings.mail_defaults })}
@@ -260,6 +266,37 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist
</div>
`}
</div>
+
+ ${settings.login && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('admin.login_heading')}</h3>
+ <p class="settings-hint">${t('admin.login_hint')}</p>
+
+ ${loginDraft && LOGIN_FIELDS.map(key => html`
+ <div class="settings-row" key=${key}>
+ <span class="settings-label">${t('admin.login_' + key)}</span>
+ <input type="number" class="settings-number"
+ min=${(settings.login_bounds?.[key] || [0])[0]}
+ max=${(settings.login_bounds?.[key] || [0, 0])[1]}
+ value=${loginDraft[key]}
+ disabled=${!canEditSettings || settingsSaving}
+ onInput=${e => setLoginDraft(d => ({ ...d, [key]: e.target.value }))} />
+ </div>
+ `)}
+
+ ${canEditSettings && loginDraft && html`
+ <div class="settings-row">
+ <button class="btn" disabled=${settingsSaving}
+ onClick=${() => saveSettings({
+ login: changedNumbers(LOGIN_FIELDS, loginDraft, settings.login),
+ })}>${t('admin.login_save')}</button>
+ <button class="btn btn-secondary" disabled=${settingsSaving}
+ onClick=${() => setLoginDraft({ ...settings.login_defaults })}
+ >${t('admin.mail_reset_defaults')}</button>
+ </div>
+ `}
+ </div>
+ `}
`}
${tab === 'stats' && stats && html`
@@ -436,7 +473,7 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist
loadLogs(e.target.value, 0);
}}>
<option value="">${t('admin.filter_all')}</option>
- ${['login', 'login_fail', 'account_create', 'token_refresh', 'group_create',
+ ${['login', 'login_fail', 'login_locked', 'account_create', 'token_refresh', 'group_create',
'group_join', 'group_leave', 'node_announce', 'revoke_user', 'revoke_group',
'admin_user_update', 'admin_group_update'].map(ev => html`
<option key=${ev} value=${ev}>${ev}</option>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index 9ded87b..d4dc5a4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -157,6 +157,12 @@ export function LoginPage({ onLogin }) {
} catch (err) {
if (err.message === 'email_verification_required') {
setPendingVerif(true);
+ } else if (err.message === 'account_locked') {
+ setError(t('login.locked', {
+ minutes: Math.max(1, Math.ceil((err.retryAfter || 60) / 60)),
+ }));
+ } else if (err.message === 'Invalid credentials') {
+ setError(t('login.invalid'));
} else {
setError(err.message);
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index a540a94..918ade3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -368,7 +368,23 @@ async function loginAndRecover(username, password) {
body: JSON.stringify({ username, auth_key: authKey }),
});
- if (!resp.ok) throw new Error(`Login failed: ${await resp.text()}`);
+ if (!resp.ok) {
+ // The hub's `detail`, not the raw body: the sign-in page matches on it
+ // (`email_verification_required`, `account_locked`), and a message wrapped
+ // as "Login failed: {json}" matched nothing, so neither was ever shown.
+ const body = await resp.text();
+ let detail = body;
+ // `error` is the per-IP rate limiter's field (slowapi), `detail` everyone else's.
+ try {
+ const j = JSON.parse(body);
+ detail = j.detail || j.error || body;
+ } catch { /* not JSON */ }
+ const err = new Error(String(detail));
+ err.status = resp.status;
+ err.retryAfter = Number(resp.headers && resp.headers.get
+ ? resp.headers.get('Retry-After') : 0) || 0;
+ throw err;
+ }
const data = await resp.json();
const result = {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index f460255..9d6fc61 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -87,6 +87,8 @@ export default {
'register.resend_sent': 'Ein neuer Code wurde gesendet.',
'login.pending_verification': 'Ihre E-Mail-Adresse ist noch nicht bestätigt. Bitte prüfen Sie Ihr Postfach auf den Bestätigungscode.',
'login.verify_link': 'E-Mail bestätigen',
+ 'login.invalid': "Benutzername oder Passphrase falsch.",
+ 'login.locked': "Zu viele falsche Passphrasen für dieses Konto. Versuchen Sie es in {minutes} Min. erneut oder setzen Sie Ihre Passphrase zurück.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Das Internet, wie es gedacht war.',
'welcome.lead': 'MeshBay ist Open-Source-Software, mit der Sie aus der Ferne auf Ihre persönlichen Dateien zugreifen und Anwendungen direkt auf dem Speicher Ihres eigenen Computers betreiben:',
@@ -499,6 +501,11 @@ export default {
'admin.mail_email_change_cooldown': "Sekunden, bevor ein Konto eine andere Adresse vorschlagen darf",
'admin.mail_save': "Mail-Grenzen speichern",
'admin.mail_reset_defaults': "Standardwerte wiederherstellen",
+ 'admin.login_heading': "Anmeldung",
+ 'admin.login_hint': "Nach so vielen falschen Passphrasen für einen Benutzernamen wird die Anmeldung mit Passphrase für die angegebene Dauer verweigert. Bereits offene Sitzungen und registrierte Geräte funktionieren weiter, und ein Zurücksetzen der Passphrase hebt die Sperre auf. 0 schaltet sie ab.",
+ 'admin.login_max_failures': "Falsche Passphrasen bis zur Sperre",
+ 'admin.login_lockout_minutes': "Dauer der Sperre (Minuten)",
+ 'admin.login_save': "Anmeldegrenzen speichern",
'admin.mail_state_is_in_stats': "Der Verbrauch der aktuellen Stunde steht unter Statistik.",
'admin.mail_state_hint': "Rücksetzungen und Einladungen dürfen das ganze Budget nutzen; Registrierungen und Adressänderungen nicht den dafür reservierten Anteil. Administratoren werden einmal pro Stunde benachrichtigt, wenn eine der beiden Grenzen erreicht ist.",
'admin.mail_left_signups': "Rest für Registrierungen",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index e7ffa64..a5dd5d4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -90,6 +90,8 @@ export default {
'register.resend_sent': 'A new code has been sent.',
'login.pending_verification': 'Your email is not yet verified. Please check your inbox for the verification code.',
'login.verify_link': 'Verify email',
+ 'login.invalid': "Wrong username or passphrase.",
+ 'login.locked': "Too many wrong passphrases for this account. Try again in {minutes} min, or reset your passphrase.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'The Internet as it was meant to be.',
'welcome.lead': 'MeshBay is open-source software that gives you remote access to your personal files, and runs applications on top of the storage on your own computer:',
@@ -489,6 +491,11 @@ export default {
'admin.mail_email_change_cooldown': "Seconds before an account may propose another address",
'admin.mail_save': "Save mail limits",
'admin.mail_reset_defaults': "Restore defaults",
+ 'admin.login_heading': "Sign-in",
+ 'admin.login_hint': "After this many wrong passphrases for one username, signing in with a passphrase is refused for the set duration. Sessions already open and registered devices keep working, and a passphrase reset ends the lockout. 0 turns it off.",
+ 'admin.login_max_failures': "Wrong passphrases before a lockout",
+ 'admin.login_lockout_minutes': "Lockout duration (minutes)",
+ 'admin.login_save': "Save sign-in limits",
'admin.mail_state_is_in_stats': "The current hour's usage is shown under Statistics.",
'admin.mail_state_hint': "Resets and invitations may spend the whole budget; sign-ups and address changes may not spend the share reserved for them. Administrators are notified once per hour when either ceiling is reached.",
'admin.mail_left_signups': "Left for sign-ups",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 2641708..6e8e191 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -86,6 +86,8 @@ export default {
'register.resend_sent': 'Se ha enviado un nuevo código.',
'login.pending_verification': 'Su correo electrónico aún no ha sido verificado. Revise su bandeja de entrada para obtener el código de verificación.',
'login.verify_link': 'Verificar correo',
+ 'login.invalid': "Nombre de usuario o frase de contraseña incorrectos.",
+ 'login.locked': "Demasiadas frases de contraseña incorrectas para esta cuenta. Vuelva a intentarlo en {minutes} min o restablezca su frase de contraseña.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Internet como debió ser.',
'welcome.lead': 'MeshBay es un software de código abierto que le da acceso remoto a sus archivos personales y ejecuta aplicaciones sobre el almacenamiento de su propio ordenador:',
@@ -495,6 +497,11 @@ export default {
'admin.mail_email_change_cooldown': "Segundos antes de que una cuenta pueda proponer otra dirección",
'admin.mail_save': "Guardar límites de correo",
'admin.mail_reset_defaults': "Restaurar valores por defecto",
+ 'admin.login_heading': "Inicio de sesión",
+ 'admin.login_hint': "Tras este número de frases de contraseña incorrectas para un mismo nombre de usuario, se rechaza el inicio de sesión con frase de contraseña durante el tiempo indicado. Las sesiones ya abiertas y los dispositivos registrados siguen funcionando, y restablecer la frase de contraseña levanta el bloqueo. 0 lo desactiva.",
+ 'admin.login_max_failures': "Frases incorrectas antes del bloqueo",
+ 'admin.login_lockout_minutes': "Duración del bloqueo (minutos)",
+ 'admin.login_save': "Guardar límites de inicio de sesión",
'admin.mail_state_is_in_stats': "El uso de la hora actual se muestra en Estadísticas.",
'admin.mail_state_hint': "Los restablecimientos y las invitaciones pueden gastar todo el presupuesto; los registros y cambios de dirección no pueden tocar la parte reservada. Se avisa a los administradores una vez por hora cuando se alcanza cualquiera de los dos límites.",
'admin.mail_left_signups': "Restante para registros",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 0c639b7..47de45f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -86,6 +86,8 @@ export default {
'register.resend_sent': 'Un nouveau code a été envoyé.',
'login.pending_verification': 'Votre e-mail n\'est pas encore vérifié. Consultez votre boîte de réception pour le code de vérification.',
'login.verify_link': 'Vérifier l\'e-mail',
+ 'login.invalid': "Nom d'utilisateur ou phrase secrète incorrect.",
+ 'login.locked': "Trop de phrases secrètes erronées pour ce compte. Réessayez dans {minutes} min, ou réinitialisez votre phrase secrète.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'L’Internet tel qu’il aurait dû être.',
'welcome.lead': 'MeshBay est un logiciel open source qui vous donne accès à distance à vos fichiers personnels et fait tourner des applications sur le stockage de votre ordinateur :',
@@ -498,6 +500,11 @@ export default {
'admin.mail_email_change_cooldown': "Secondes avant qu'un compte puisse proposer une autre adresse",
'admin.mail_save': "Enregistrer les limites",
'admin.mail_reset_defaults': "Rétablir les valeurs par défaut",
+ 'admin.login_heading': "Connexion",
+ 'admin.login_hint': "Après ce nombre de phrases secrètes erronées pour un même nom d'utilisateur, la connexion par phrase secrète est refusée pendant la durée indiquée. Les sessions déjà ouvertes et les appareils enregistrés continuent de fonctionner, et une réinitialisation de la phrase secrète lève le blocage. 0 le désactive.",
+ 'admin.login_max_failures': "Phrases secrètes erronées avant blocage",
+ 'admin.login_lockout_minutes': "Durée du blocage (minutes)",
+ 'admin.login_save': "Enregistrer les limites de connexion",
'admin.mail_state_is_in_stats': "La consommation de l'heure en cours est affichée dans Statistiques.",
'admin.mail_state_hint': "Les réinitialisations et invitations peuvent dépenser tout le budget ; les inscriptions et changements d'adresse ne peuvent pas entamer la part qui leur est réservée. Les administrateurs sont prévenus une fois par heure lorsqu'un des deux plafonds est atteint.",
'admin.mail_left_signups': "Restant pour les inscriptions",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index efda136..660298e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -87,6 +87,8 @@ export default {
'register.resend_sent': 'Un nuovo codice è stato inviato.',
'login.pending_verification': 'Il suo indirizzo e-mail non è ancora verificato. Controlli la sua casella di posta per il codice di verifica.',
'login.verify_link': 'Verifica e-mail',
+ 'login.invalid': "Nome utente o passphrase errati.",
+ 'login.locked': "Troppe passphrase errate per questo account. Riprovi tra {minutes} min oppure reimposti la passphrase.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Internet come doveva essere.',
'welcome.lead': 'MeshBay è un software open source che le permette di accedere da remoto ai suoi file personali e di usare applicazioni basate sullo spazio di archiviazione del suo computer:',
@@ -498,6 +500,11 @@ export default {
'admin.mail_email_change_cooldown': "Secondi prima che un account possa proporre un altro indirizzo",
'admin.mail_save': "Salva i limiti di posta",
'admin.mail_reset_defaults': "Ripristina i valori predefiniti",
+ 'admin.login_heading': "Accesso",
+ 'admin.login_hint': "Dopo questo numero di passphrase errate per uno stesso nome utente, l'accesso con passphrase viene rifiutato per la durata indicata. Le sessioni già aperte e i dispositivi registrati continuano a funzionare, e la reimpostazione della passphrase rimuove il blocco. 0 lo disattiva.",
+ 'admin.login_max_failures': "Passphrase errate prima del blocco",
+ 'admin.login_lockout_minutes': "Durata del blocco (minuti)",
+ 'admin.login_save': "Salva i limiti di accesso",
'admin.mail_state_is_in_stats': "Il consumo dell'ora corrente è mostrato in Statistiche.",
'admin.mail_state_hint': "Reimpostazioni e inviti possono spendere l'intero budget; registrazioni e cambi di indirizzo non possono intaccare la quota riservata. Gli amministratori vengono avvisati una volta all'ora quando uno dei due limiti viene raggiunto.",
'admin.mail_left_signups': "Rimanente per le registrazioni",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 22f5d6b..ff5b693 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -87,6 +87,8 @@ export default {
'register.resend_sent': '新しいコードを送信しました。',
'login.pending_verification': 'メールアドレスがまだ確認されていません。受信トレイで確認コードをご確認ください。',
'login.verify_link': 'メールを確認',
+ 'login.invalid': "ユーザー名またはパスフレーズが正しくありません。",
+ 'login.locked': "このアカウントでパスフレーズの誤りが多すぎます。{minutes} 分後にもう一度お試しいただくか、パスフレーズをリセットしてください。",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': '本来あるべき姿のインターネット。',
'welcome.lead': 'MeshBay は、個人のファイルにどこからでもアクセスでき、自分のパソコンのストレージ上でアプリを動かせるオープンソースソフトウェアです。',
@@ -491,6 +493,11 @@ export default {
'admin.mail_email_change_cooldown': "別のアドレスを申請できるようになるまでの秒数",
'admin.mail_save': "メール制限を保存",
'admin.mail_reset_defaults': "既定値に戻す",
+ 'admin.login_heading': "サインイン",
+ 'admin.login_hint': "1つのユーザー名に対してこの回数パスフレーズを誤ると、設定した時間のあいだパスフレーズによるサインインが拒否されます。すでに開いているセッションと登録済みの端末は引き続き使え、パスフレーズをリセットするとロックは解除されます。0 で無効になります。",
+ 'admin.login_max_failures': "ロックまでの誤りの回数",
+ 'admin.login_lockout_minutes': "ロック時間(分)",
+ 'admin.login_save': "サインインの制限を保存",
'admin.mail_state_is_in_stats': "現在の 1 時間の使用状況は「統計」に表示されます。",
'admin.mail_state_hint': "再設定と招待は上限全体を使えます。登録とアドレス変更は、確保された分には手を付けられません。いずれかの上限に達すると、管理者に 1 時間に 1 回通知されます。",
'admin.mail_left_signups': "登録に残っている数",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 88aebfa..3c07b3e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -87,6 +87,8 @@ export default {
'register.resend_sent': 'Er is een nieuwe code verzonden.',
'login.pending_verification': 'Uw e-mailadres is nog niet geverifieerd. Controleer uw inbox voor de verificatiecode.',
'login.verify_link': 'E-mail verifiëren',
+ 'login.invalid': "Onjuiste gebruikersnaam of wachtwoordzin.",
+ 'login.locked': "Te veel onjuiste wachtwoordzinnen voor dit account. Probeer het over {minutes} min opnieuw of stel uw wachtwoordzin opnieuw in.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Het internet zoals het bedoeld was.',
'welcome.lead': 'MeshBay is opensourcesoftware waarmee u op afstand bij uw persoonlijke bestanden kunt, en die toepassingen draait bovenop de opslag van uw eigen computer:',
@@ -499,6 +501,11 @@ export default {
'admin.mail_email_change_cooldown': "Seconden voordat een account een ander adres mag voorstellen",
'admin.mail_save': "E-maillimieten opslaan",
'admin.mail_reset_defaults': "Standaardwaarden herstellen",
+ 'admin.login_heading': "Aanmelden",
+ 'admin.login_hint': "Na dit aantal onjuiste wachtwoordzinnen voor één gebruikersnaam wordt aanmelden met een wachtwoordzin geweigerd gedurende de ingestelde tijd. Al geopende sessies en geregistreerde apparaten blijven werken, en het opnieuw instellen van de wachtwoordzin heft de blokkade op. 0 schakelt dit uit.",
+ 'admin.login_max_failures': "Onjuiste wachtwoordzinnen vóór blokkade",
+ 'admin.login_lockout_minutes': "Duur van de blokkade (minuten)",
+ 'admin.login_save': "Aanmeldlimieten opslaan",
'admin.mail_state_is_in_stats': "Het verbruik van dit uur staat onder Statistieken.",
'admin.mail_state_hint': "Herstel en uitnodigingen mogen het hele budget gebruiken; registraties en adreswijzigingen niet het gereserveerde deel. Beheerders krijgen één keer per uur bericht wanneer een van beide grenzen is bereikt.",
'admin.mail_left_signups': "Resterend voor registraties",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index bed39d2..2ff9b84 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -90,6 +90,8 @@ export default {
'register.resend_sent': 'Nowy kod został wysłany.',
'login.pending_verification': 'Twój adres e-mail nie został jeszcze zweryfikowany. Sprawdź skrzynkę odbiorczą.',
'login.verify_link': 'Zweryfikuj e-mail',
+ 'login.invalid': "Nieprawidłowa nazwa użytkownika lub hasło-fraza.",
+ 'login.locked': "Zbyt wiele błędnych haseł-fraz dla tego konta. Spróbuj ponownie za {minutes} min lub zresetuj hasło-frazę.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Internet taki, jaki miał być.',
'welcome.lead': 'MeshBay to oprogramowanie open source, które daje zdalny dostęp do Twoich plików osobistych i uruchamia aplikacje korzystające z pamięci Twojego własnego komputera:',
@@ -511,6 +513,11 @@ export default {
'admin.mail_email_change_cooldown': "Sekundy, zanim konto może zaproponować inny adres",
'admin.mail_save': "Zapisz limity poczty",
'admin.mail_reset_defaults': "Przywróć domyślne",
+ 'admin.login_heading': "Logowanie",
+ 'admin.login_hint': "Po tylu błędnych hasłach-frazach dla jednej nazwy użytkownika logowanie hasłem-frazą jest odrzucane przez ustawiony czas. Otwarte już sesje i zarejestrowane urządzenia działają dalej, a zresetowanie hasła-frazy znosi blokadę. 0 ją wyłącza.",
+ 'admin.login_max_failures': "Błędne hasła-frazy przed blokadą",
+ 'admin.login_lockout_minutes': "Czas blokady (minuty)",
+ 'admin.login_save': "Zapisz limity logowania",
'admin.mail_state_is_in_stats': "Zużycie w bieżącej godzinie pokazano w Statystykach.",
'admin.mail_state_hint': "Resety i zaproszenia mogą wykorzystać cały budżet; rejestracje i zmiany adresu nie mogą naruszyć zarezerwowanej części. Administratorzy są powiadamiani raz na godzinę, gdy któryś z limitów zostanie osiągnięty.",
'admin.mail_left_signups': "Pozostało na rejestracje",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index cd5da7d..33fa2be 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -88,6 +88,8 @@ export default {
'register.resend_sent': 'Um novo código foi enviado.',
'login.pending_verification': 'Seu e-mail ainda não foi verificado. Verifique sua caixa de entrada.',
'login.verify_link': 'Verificar e-mail',
+ 'login.invalid': "Nome de usuário ou frase secreta incorretos.",
+ 'login.locked': "Muitas frases secretas incorretas para esta conta. Tente novamente em {minutes} min ou redefina sua frase secreta.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'A internet como deveria ser.',
'welcome.lead': 'O MeshBay é um software de código aberto que dá acesso remoto aos seus arquivos pessoais e executa aplicativos sobre o armazenamento do seu próprio computador:',
@@ -497,6 +499,11 @@ export default {
'admin.mail_email_change_cooldown': "Segundos até uma conta poder propor outro endereço",
'admin.mail_save': "Salvar limites de correio",
'admin.mail_reset_defaults': "Restaurar padrões",
+ 'admin.login_heading': "Login",
+ 'admin.login_hint': "Após este número de frases secretas incorretas para um mesmo nome de usuário, o login com frase secreta é recusado pelo tempo definido. Sessões já abertas e dispositivos registrados continuam funcionando, e redefinir a frase secreta encerra o bloqueio. 0 desativa.",
+ 'admin.login_max_failures': "Frases incorretas antes do bloqueio",
+ 'admin.login_lockout_minutes': "Duração do bloqueio (minutos)",
+ 'admin.login_save': "Salvar limites de login",
'admin.mail_state_is_in_stats': "O consumo da hora atual aparece em Estatísticas.",
'admin.mail_state_hint': "Redefinições e convites podem gastar todo o orçamento; cadastros e trocas de endereço não podem usar a parte reservada. Os administradores são avisados uma vez por hora quando qualquer um dos limites é atingido.",
'admin.mail_left_signups': "Restante para cadastros",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 06aa011..b58ba64 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -87,6 +87,8 @@ export default {
'register.resend_sent': '新验证码已发送。',
'login.pending_verification': '您的邮箱尚未验证。请查看收件箱中的验证码。',
'login.verify_link': '验证邮箱',
+ 'login.invalid': "用户名或密码短语错误。",
+ 'login.locked': "此账户输错密码短语的次数过多。请在 {minutes} 分钟后重试,或重置密码短语。",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': '互联网本该有的样子。',
'welcome.lead': 'MeshBay 是一款开源软件,让您远程访问个人文件,并在您自己电脑的存储之上运行应用:',
@@ -483,6 +485,11 @@ export default {
'admin.mail_email_change_cooldown': "账号可再次申请其他地址前的秒数",
'admin.mail_save': "保存邮件限制",
'admin.mail_reset_defaults': "恢复默认值",
+ 'admin.login_heading': "登录",
+ 'admin.login_hint': "同一用户名输错密码短语达到此次数后,将在设定时长内拒绝使用密码短语登录。已打开的会话和已注册的设备不受影响,重置密码短语即可解除锁定。设为 0 则关闭。",
+ 'admin.login_max_failures': "锁定前允许的错误次数",
+ 'admin.login_lockout_minutes': "锁定时长(分钟)",
+ 'admin.login_save': "保存登录限制",
'admin.mail_state_is_in_stats': "本小时的用量显示在「统计」中。",
'admin.mail_state_hint': "重置与邀请可动用全部额度;注册与更换地址不得占用为前者保留的份额。任一上限达到时,每小时通知管理员一次。",
'admin.mail_left_signups': "注册剩余",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index 10bcee7..7e355e7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -8,6 +8,21 @@ import {
_storeBundleKey, _loadBundleKey, _storeRecoveryKey,
} from './hub-client.js';
+// A sign-in lockout also refuses the two actions here that re-check the
+// passphrase (the hub counts them on the same row).
+function lockedText(seconds) {
+ return t('login.locked', { minutes: Math.max(1, Math.ceil(seconds / 60)) });
+}
+
+async function lockedMessage(token) {
+ try {
+ const me = await hubFetch('/v1/users/me', { token });
+ return lockedText(me.passphrase_locked_for || 60);
+ } catch {
+ return lockedText(60);
+ }
+}
+
export function ProfilePage({ user, onLogout }) {
const [nodeKey, setNodeKey] = useState('');
const [currentNodeKey, setCurrentNodeKey] = useState(null);
@@ -39,7 +54,8 @@ export function ProfilePage({ user, onLogout }) {
});
onLogout();
} catch (err) {
- setDelError(err.message);
+ setDelError(err.message === 'account_locked'
+ ? await lockedMessage(user.token) : err.message);
} finally {
setDeleting(false);
}
@@ -76,6 +92,14 @@ export function ProfilePage({ user, onLogout }) {
if (cpNew !== cpNew2) { setCpError(t('settings.pw_mismatch')); return; }
if (cpNew === cpOld) { setCpError(t('settings.pw_same')); return; }
try {
+ // The next step re-wraps every node's bundle before the hub is asked to
+ // accept the new passphrase. Started during a lockout, the nodes would
+ // take the new one and the hub would refuse it — so ask first.
+ const me = await hubFetch('/v1/users/me', { token: user.token });
+ if (me.passphrase_locked_for > 0) {
+ setCpError(lockedText(me.passphrase_locked_for));
+ return;
+ }
const mine = await hubFetch('/v1/groups/mine', { token: user.token });
const groups = mine.groups || [];
setCpEstimate({
@@ -118,8 +142,10 @@ export function ProfilePage({ user, onLogout }) {
setCpResult(result);
setCpPhase('done');
} catch (err) {
- const msg = /403|does not match/i.test(err.message)
- ? t('settings.pw_wrong_current') : err.message;
+ const msg = err.message === 'account_locked'
+ ? await lockedMessage(user.token)
+ : /403|does not match/i.test(err.message)
+ ? t('settings.pw_wrong_current') : err.message;
setCpError(msg);
setCpPhase('confirm');
}
@@ -361,6 +387,7 @@ export function ProfilePage({ user, onLogout }) {
<input type="password" autocomplete="new-password"
placeholder=${t('settings.passphrase_new_repeat')}
value=${cpNew2} onInput=${e => setCpNew2(e.target.value)} required />
+ ${cpError && html`<p class="error-msg">${cpError}</p>`}
<div style="display:flex;gap:8px">
<button class="admin-btn" type="submit">${t('settings.continue')}</button>
<button class="btn-secondary" type="button" onClick=${cpReset}>
diff --git a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
index dfe7c78..5c52387 100644
--- a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
+++ b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
@@ -64,6 +64,12 @@ async def cleanup_loop(get_session):
quota = await mail.purge_expired_quota(db)
if quota:
log.info("Purged %d expired mail counters", quota)
+ # Every name anybody types at the sign-in form is a row,
+ # real or not; once its window has passed, nothing reads it.
+ from meshbay_hub import login_throttle
+ throttled = await login_throttle.purge_expired(db)
+ if throttled:
+ log.info("Purged %d expired sign-in counters", throttled)
except asyncio.CancelledError:
raise
except Exception as e: