Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/memory/feature-flows/email-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Passwordless email-based authentication with verification codes. Users enter the
## Revision History
| Date | Changes |
|------|---------|
| 2026-09-29 | **Binding a sign-in email needs mailbox proof (trinity-enterprise#720)**: new `POST /api/users/me/email/code` sends a bind code; `PUT /api/users/me/email` now requires it (console-provider admin bypass, audited unverified), caps wrong guesses at 5 per 10 min under `otp_attempts:bind:{user_id}:{email}`, and `users.email` is unique (case-insensitive). See [Binding a Sign-In Email](#binding-a-sign-in-email-trinity-enterprise720). |
| 2026-07-04 | **Enumeration hardening (#186)**: `request_email_login_code()` now returns a **byte-identical** body + status for whitelisted, non-whitelisted, and rate-limited emails — the generic `{"success": true, "message": "If your email is registered, you'll receive a code shortly"}` (dropped the distinct `"Verification code sent…"` message and `expires_in_seconds`). Over-limit returns the same generic 200 (WARN-logged `"email-code suppressed: rate limit"`, **no 429**). The verification email is dispatched **fire-and-forget** (`asyncio.create_task`, strong-ref set) so the whitelisted path's latency matches the immediate-return paths — closing the body/status/timing membership oracle (pentest 3.3.3). Frontend safe: `stores/auth.js` falls back to `|| 600` when `expires_in_seconds` is absent. |
| 2026-05-18 | **Contextual email subjects (#890)**: `send_verification_code()` gains optional `agent_name` and `context_label` params. When `agent_name` is set, subject becomes `Your Trinity access code for "{agent_name}"`; when `context_label` is set, subject becomes `Your {context_label} verification code`. A new private `_get_verification_email_html()` generates an HTML body with inline CSS, 36px monospace code block, and contextual intro text. `auth.py` now passes `context_label="Trinity login"`; `public.py` now passes `agent_name=link["agent_name"]`. |
| 2026-03-26 | **OTP rate limiting added**: `verify_email_login_code()` now enforces both IP-based rate limiting (existing) and new per-email OTP rate limiting (5 failed attempts → 429 for 10 minutes, Redis key `otp_attempts:{email}`). `confirm_verification_code()` in public.py also gains IP-based rate limiting. New helpers: `check_otp_rate_limit(email)`, `record_otp_attempt(email, success)`. |
Expand All @@ -31,6 +32,7 @@ As an admin, I want to control who can access the platform via an email whitelis
- **UI**: `src/frontend/src/views/Settings.vue:291-390` - Email Whitelist management section
- **API**: `POST /api/auth/email/request` - Request verification code (lines 140-198 in auth.py)
- **API**: `POST /api/auth/email/verify` - Verify code and login (lines 201-268 in auth.py)
- **API**: `POST /api/users/me/email/code` + `PUT /api/users/me/email` - Bind a sign-in email to the signed-in account (routers/users.py, trinity-enterprise#720)
- **API**: `GET /api/settings/email-whitelist` - List whitelisted emails (lines 429-443 in settings.py)
- **API**: `POST /api/settings/email-whitelist` - Add email to whitelist (lines 446-478 in settings.py)
- **API**: `DELETE /api/settings/email-whitelist/{email}` - Remove from whitelist (lines 481-503 in settings.py)
Expand Down Expand Up @@ -680,6 +682,68 @@ error for the grant endpoint, an absent field for the JSON one. See
OSS-only builds register no provider, `gate_login` returns `None`, and this
section does not apply.

### Binding a Sign-In Email (trinity-enterprise#720)

Every sign-in path resolves the account by email alone, so the address on a
`users` row *is* that identity. Binding one therefore needs proof of the
mailbox, and an address can sit on only one account.

**Caller**: `stores/auth.js::bindOwnEmail(email, code)` — tries the bind once
without a code; on `400 code_required` it requests a code and returns
`{ needsCode: true }`, then the caller retries with the code.
`resendEmailBindCode(email)` re-sends.

Both routes (`src/backend/routers/users.py`) take `require_interactive`: a
JWT session only — agent, MCP and system keys get **403**. Both first
normalise the address (trim + lower-case) and refuse one another account holds.

#### POST /api/users/me/email/code

1. Validate shape → `400 invalid_email`; held by another account → `409 email_in_use`.
2. Provider is `console` (the code would only reach the server log) and the
caller is not an admin → `409 email_verification_unavailable`.
3. More than 3 codes for that address in 10 minutes → `429 too_many_codes`.
4. `db.create_login_code(email, expiry_minutes=10, purpose="email_bind:<user id>")`,
then mail it (send failure → `502 email_send_failed`). Audited as
`email_bind_code_sent`. Returns `{sent, expires_in_seconds}`.

**Code purpose**: `email_login_codes.purpose` is `NULL` for a sign-in code and
`email_bind:<user id>` for a bind code. `verify_login_code` matches on purpose,
so a bind code never signs anyone in, a sign-in code never binds, and a code
minted by one account cannot complete another account's bind.

#### PUT /api/users/me/email

| Case | Result |
|------|--------|
| `code` present, attempt counter already at 5 | `429 too_many_attempts` — even the right code is refused |
| `code` present and wrong/expired | `400 invalid_code` (counter +1) |
| `code` present and right | bind; counter cleared; audited `email_bound`, `verified: true` |
| no `code`, provider can deliver | `400 code_required` |
| no `code`, provider `console`, caller is admin | bind without proof; audited `email_bind_unverified`, `verified: false` (the #82 transition) |
| no `code`, provider `console`, caller not admin | `409 email_verification_unavailable` |
| address held by another account (incl. a lost race on the index) | `409 email_in_use` |

**Attempt cap**: reuses the sign-in OTP limiter (`routers/auth.py`
`check_otp_rate_limit` / `record_otp_attempt`, `OTP_MAX_ATTEMPTS = 5`,
`OTP_RATE_WINDOW = 600s`) under the bind-scoped key
`otp_attempts:bind:{user_id}:{email}`. It is deliberately **not** the bare
`otp_attempts:{email}` key: wrong bind guesses must not lock the address's
owner out of email sign-in (the same reason the portal uses `portal:`).
Like sign-in, it fails open when Redis is unavailable.

**Uniqueness**: `idx_users_email_unique ON users(lower(email)) WHERE email IS
NOT NULL`, and every `users.email` write goes through one checked writer that
raises `EmailInUseError`. The `ent720_email_identity` migration (SQLite) /
Alembic `0085_ent720_email_identity` (PostgreSQL) resolves pre-existing
duplicates first: per address the **earliest-created** account keeps it, every
other account's email is set to `NULL`, and the migration log names those
accounts by username (never the address). Their owners lose email sign-in to
that address until they bind another.

**Tests**: `tests/unit/test_ent720_email_binding.py`;
`src/frontend/tests/unit/emailBindProof.spec.js`.

### Whitelist Management Endpoints (`src/backend/routers/settings.py`)

All whitelist endpoints are **admin-only** (require `role="admin"`).
Expand Down
35 changes: 35 additions & 0 deletions docs/memory/requirements/lifecycle-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,41 @@ password) is **Phase 2**, gated on a configured email provider and the existing
(own-account scoped; 409 if the email belongs to another account), surfaced as
an **Admin sign-in email** card in Settings → General. No verification email is
sent; existing `admin`+password login keeps working until/unless an email is set.
- **FR-4 — A sign-in email is proven, unique, and written in one place
(trinity-enterprise#720, residual of #711).** Every sign-in path resolves the
account by email alone, so whoever holds an email on a `users` row holds that
identity — what is shared with the address, its Workspace threads, and the
real person's next sign-in.
- **Mailbox proof to bind.** `PUT /api/users/me/email` requires `{email, code}`,
where the code was sent to the NEW address by
`POST /api/users/me/email/code` (same rate limits as sign-in codes). Bind
codes live in `email_login_codes` with `purpose='email_bind'` and are tied to
the requesting account; a bind code never signs anyone in and a sign-in code
never binds. Missing/wrong/expired code → 400 `invalid_code`.
- **The one no-proof bind** is the #82 transition (FR-3) on an install that
cannot deliver mail (provider `console`): an **interactive admin** session may
bind without a code, audited as `email_bind_unverified`. Anyone else on such an
install gets 409 `email_verification_unavailable`. First-run setup (FR-2) is
unchanged — it binds before any provider can exist.
- **Unique.** `users.email` is unique on its lower-cased value (NULL allowed):
`idx_users_email_unique ON users(lower(email)) WHERE email IS NOT NULL`, on both
migration tracks. Pre-existing duplicates are resolved first: per address the
EARLIEST-created account keeps it; the others are set to NULL and logged by
username only.
- **One writer.** Every write of `users.email` (setup, the bind route,
`create_user`, `update_user`, the password upsert, Auth0, email sign-in
creation) goes through `db/users.py`'s checked write, which refuses an address
another account holds (`EmailInUseError` → 409 `email_in_use`) and maps a lost
race on the unique index to the same refusal. A test enumerates the writers.
- **A reclaimed username is not a 500.** Email sign-in creates the account with
`username = email`; when that username is already taken (an account that has
since re-bound away from the address), the new account gets a unique suffixed
username instead of an unhandled IntegrityError.
- **Redeemers honour suspension.** The Telegram, WhatsApp and MCP-inline code
redeemers and `email_has_agent_access` refuse an address whose account is
suspended (`users.suspended_at`), the same account-state rule `/verify` and
`get_current_user` apply. (Second-factor on those channels is out of scope:
they cannot present a challenge; tracked as a follow-up.)

---

Expand Down
8 changes: 8 additions & 0 deletions src/backend/adapters/message_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,14 @@ async def _enforce_access_policy(
if verified_email:
verified_email = verified_email.strip().lower() or None

# ent#720: a verified email whose account is suspended reaches
# nothing — not even an open_access agent. An email verified BEFORE
# the suspension is still recorded on the channel link, so the check
# has to live here, per message, not only at code redemption.
if verified_email and db.is_email_account_suspended(verified_email):
logger.info(f"[ROUTER:{channel}] Access denied: account suspended (agent={agent_name})")
return False, None

require_email = policy.get("require_email", False)
open_access = policy.get("open_access", False)

Expand Down
6 changes: 6 additions & 0 deletions src/backend/adapters/telegram_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,12 @@ async def _handle_login_command(
result = db.verify_login_code(pending_email, arg)
if not result:
return "❌ Invalid or expired code. Try again or request a new one."
# ent#720: a suspended account is refused here as on the web — the
# verified email is NOT recorded, so the open-access shortcut below
# and the per-message gate never see it.
if db.is_email_account_suspended(pending_email):
_clear_pending_login(binding["id"], message.sender_id)
return "❌ This account is suspended. Contact the agent's owner."
db.set_telegram_verified_email(binding["id"], message.sender_id, pending_email)
_clear_pending_login(binding["id"], message.sender_id)

Expand Down
6 changes: 6 additions & 0 deletions src/backend/adapters/whatsapp_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,12 @@ async def _handle_login_command(
result = db.verify_login_code(pending_email, arg)
if not result:
return "❌ Invalid or expired code. Try again or request a new one."
# ent#720: a suspended account is refused here as on the web — the
# verified email is NOT recorded, so the open-access shortcut below
# and the per-message gate never see it.
if db.is_email_account_suspended(pending_email):
_clear_pending_login(binding["id"], message.sender_id)
return "❌ This account is suspended. Contact the agent's owner."
db.set_whatsapp_verified_email(binding["id"], message.sender_id, pending_email)
_clear_pending_login(binding["id"], message.sender_id)

Expand Down
14 changes: 10 additions & 4 deletions src/backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -2644,15 +2644,21 @@ def remove_from_whitelist(self, email: str):
def list_whitelist(self, limit: int = 100):
return self._email_auth_ops.list_whitelist(limit)

def create_login_code(self, email: str, expiry_minutes: int = 10):
return self._email_auth_ops.create_login_code(email, expiry_minutes)
def create_login_code(self, email: str, expiry_minutes: int = 10, purpose=None):
return self._email_auth_ops.create_login_code(email, expiry_minutes, purpose=purpose)

def verify_login_code(self, email: str, code: str):
return self._email_auth_ops.verify_login_code(email, code)
def is_email_account_suspended(self, email: str) -> bool:
return self._user_ops.is_email_account_suspended(email)

def verify_login_code(self, email: str, code: str, purpose=None):
return self._email_auth_ops.verify_login_code(email, code, purpose=purpose)

def count_recent_code_requests(self, email: str, minutes: int = 10):
return self._email_auth_ops.count_recent_code_requests(email, minutes)

def count_recent_codes_for_purpose(self, purpose: str, minutes: int = 10):
return self._email_auth_ops.count_recent_codes_for_purpose(purpose, minutes)

def cleanup_old_codes(self, days: int = 1):
return self._email_auth_ops.cleanup_old_codes(days)

Expand Down
6 changes: 6 additions & 0 deletions src/backend/db/agent_settings/sharing.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,12 @@ def email_has_agent_access(self, agent_name: str, email: str) -> bool:
if not normalized:
return False
user = self._user_ops.get_user_by_email(normalized)
# ent#720: a suspended account reaches nothing by channel either. The
# web path refuses it in `get_current_user`; the channel gates (Telegram,
# WhatsApp, the MCP inline redeemer) come here instead, and used to skip
# the check — a share to a suspended person's address kept working.
if user and user.get("suspended_at"):
return False
if user:
if user.get("role") == "admin":
return True
Expand Down
64 changes: 43 additions & 21 deletions src/backend/db/email_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from .engine import get_engine
from .tables import email_whitelist, email_login_codes, users
from utils.helpers import utc_now_iso
from .users import EmailInUseError

# Valid role values for new users. Kept local to avoid a circular import with
# dependencies.py (which imports from database, which imports from this module).
Expand Down Expand Up @@ -152,10 +153,15 @@ def list_whitelist(self, limit: int = 100) -> List[dict]:
# Login Code Operations
# =========================================================================

def create_login_code(self, email: str, expiry_minutes: int = 10) -> dict:
def create_login_code(self, email: str, expiry_minutes: int = 10,
purpose: Optional[str] = None) -> dict:
"""
Generate a 6-digit login code for an email.

purpose (ent#720): NULL is a sign-in code. An email-bind code carries
`email_bind:<user id>` so it can only complete THAT account's bind, and
neither kind of code is accepted where the other is expected.

Returns:
dict with code_id, code, expires_at
"""
Expand All @@ -172,6 +178,7 @@ def create_login_code(self, email: str, expiry_minutes: int = 10) -> dict:
created_at=created_at.isoformat(),
expires_at=expires_at.isoformat(),
verified=0,
purpose=purpose,
)
with get_engine().begin() as conn:
conn.execute(stmt)
Expand All @@ -184,9 +191,11 @@ def create_login_code(self, email: str, expiry_minutes: int = 10) -> dict:
"expires_in_seconds": expiry_minutes * 60
}

def verify_login_code(self, email: str, code: str) -> Optional[dict]:
def verify_login_code(self, email: str, code: str,
purpose: Optional[str] = None) -> Optional[dict]:
"""
Verify a login code for an email.
Verify a login code for an email. `purpose` must match exactly — the
default (None) accepts only sign-in codes (ent#720).

Returns:
dict with code verification result, or None if invalid
Expand All @@ -205,6 +214,8 @@ def verify_login_code(self, email: str, code: str) -> Optional[dict]:
func.lower(email_login_codes.c.email) == email.lower(),
email_login_codes.c.code == code,
email_login_codes.c.verified == 0,
(email_login_codes.c.purpose.is_(None) if purpose is None
else email_login_codes.c.purpose == purpose),
)
)
.order_by(email_login_codes.c.created_at.desc())
Expand Down Expand Up @@ -236,11 +247,31 @@ def verify_login_code(self, email: str, code: str) -> Optional[dict]:
}

def count_recent_code_requests(self, email: str, minutes: int = 10) -> int:
"""Count how many code requests were made for this email recently."""
"""Count recent SIGN-IN code requests for this email (`purpose IS NULL`).

ent#720: bind codes are excluded. Counting them would let any signed-in
user request bind codes for someone else's address and keep its owner's
sign-in codes suppressed; the bind route limits its caller instead
(`count_recent_codes_for_purpose`)."""
cutoff = (datetime.utcnow() - timedelta(minutes=minutes)).isoformat()
stmt = select(func.count().label("count")).where(
and_(
func.lower(email_login_codes.c.email) == email.lower(),
email_login_codes.c.purpose.is_(None),
email_login_codes.c.created_at > cutoff,
)
)
with get_engine().connect() as conn:
result = conn.execute(stmt).mappings().first()
return result["count"] if result else 0

def count_recent_codes_for_purpose(self, purpose: str, minutes: int = 10) -> int:
"""Count recent codes minted for one purpose, across every address (ent#720:
the per-account bind allowance, `email_bind:<user id>`)."""
cutoff = (datetime.utcnow() - timedelta(minutes=minutes)).isoformat()
stmt = select(func.count().label("count")).where(
and_(
email_login_codes.c.purpose == purpose,
email_login_codes.c.created_at > cutoff,
)
)
Expand Down Expand Up @@ -283,20 +314,11 @@ def get_or_create_email_user(self, email: str) -> dict:
if role not in _VALID_DEFAULT_ROLES:
role = "user"

# Create new user
now = utc_now_iso()
# Username = email (lowercase)
username = email.lower()

stmt = insert(users).values(
username=username,
email=email.lower(),
role=role,
created_at=now,
updated_at=now,
)
with get_engine().begin() as conn:
conn.execute(stmt)

# Return the created user
return self._user_ops.get_user_by_email(email)
# Create new user — through the one checked writer (ent#720), which
# also keeps a reclaimed `username = email` from becoming a 500.
try:
return self._user_ops.insert_email_user(email, role)
except EmailInUseError:
# Lost a race with a concurrent first sign-in for the same address:
# that account is this person's account.
return self._user_ops.get_user_by_email(email)
Loading
Loading