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.py47
1 files changed, 32 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 53238de..5a0f9dc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -5,7 +5,7 @@ import uuid
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
-from pydantic import BaseModel, EmailStr, field_validator
+from pydantic import BaseModel, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,6 +22,7 @@ from meshbay_hub.auth import (
verify_password,
)
from meshbay_hub.api.middleware import limiter
+from meshbay_hub.api.netutil import client_ip
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import GroupMember, IPLog, RefreshToken, User
@@ -62,6 +63,24 @@ class RegisterRequest(BaseModel):
raise ValueError("username: only letters, digits, -, _, .")
return v
+ @field_validator("email")
+ @classmethod
+ def email_valid(cls, v: str) -> str:
+ """
+ Sanity-check the address (L6): the field was plain `str`, so any junk was
+ accepted and stored encrypted forever. Deliberately not RFC 5322 — full
+ validation would pull in the email-validator dependency for little gain,
+ and the address is only ever used for recovery and legal contact.
+ """
+ v = v.strip()
+ local, sep, domain = v.partition("@")
+ if (not sep or not local or not domain
+ or "." not in domain
+ or len(v) > 254
+ or any(c.isspace() or ord(c) < 32 for c in v)):
+ raise ValueError("invalid email address")
+ return v
+
class LoginRequest(BaseModel):
username: str
@@ -106,21 +125,24 @@ async def register(
hub_id=hub_id,
)
db.add(user)
+ # flush assigns user.id so the log row can be attributed directly.
+ #
+ # Finding M6: this used to insert the row with a NULL user_id and then run
+ # UPDATE ip_logs SET user_id = <new user> WHERE user_id IS NULL
+ # which claimed *every* unattributed row in the table — failed logins for other
+ # usernames, other registrations racing this one — and stamped them with the
+ # account just created. For logs retained a year to answer legal requests, that
+ # attributed other people's connections to the wrong person.
+ await db.flush()
db.add(IPLog(
+ user_id=user.id,
event="account_create",
- ip_address=_client_ip(request),
+ ip_address=client_ip(request),
detail=body.username,
))
await db.commit()
await db.refresh(user)
- # Set user_id in IPLog after commit
- await db.execute(
- IPLog.__table__.update()
- .where(IPLog.user_id == None) # noqa: E711
- .values(user_id=user.id))
- await db.commit()
-
return {"user_id": user.id}
@@ -135,7 +157,7 @@ async def login(
select(User).where(User.username == body.username))
user = result.scalar_one_or_none()
- ip = _client_ip(request)
+ ip = client_ip(request)
if not body.auth_key and not body.password:
raise HTTPException(status_code=401, detail="No credentials provided")
@@ -358,8 +380,3 @@ async def get_user_pubkeys(
return resp
-def _client_ip(request: Request) -> str:
- forwarded = request.headers.get("X-Forwarded-For")
- if forwarded:
- return forwarded.split(",")[0].strip()
- return request.client.host if request.client else "unknown"