summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/users.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py73
1 files changed, 59 insertions, 14 deletions
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)