Fix login being permanently impossible without a pre-existing saved key

api_key_hash is a one-way hash, so /api/login could never hand back a
previously-issued key — the frontend's fallback of reusing whatever key
happened to already be in localStorage meant login only worked on the
exact browser that originally signed up, and was completely broken on
any new device, incognito window, or after clearing storage, even with
the correct password.

/api/login now issues a fresh 'Dashboard Login' named key on every
successful login, replacing only that one key each time (no duplicate
accumulation) and never touching the user's other named keys (e.g. an
already-connected Claude Desktop/Codex client) or the legacy key.

Also fixes a bug this exposed: list_api_keys' legacy-key backfill only
ran when a user had zero rows in api_keys at all, so if login created a
'Dashboard Login' row first, the original signup key would never get
backfilled and would silently vanish from the Tokens page (while still
remaining fully valid for authentication). Backfill now checks
specifically whether the legacy key's hash is already represented,
independent of what other named keys exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
MOHAN 2026-07-05 03:02:57 +05:30
parent 7384a6c05a
commit 9df12ac8c5
2 changed files with 57 additions and 16 deletions

View File

@ -5,7 +5,7 @@ from pydantic import BaseModel, EmailStr
from typing import Optional from typing import Optional
from ..config import settings from ..config import settings
from ..auth.service import ( from ..auth.service import (
AuthError, signup, login, get_user_by_api_key, AuthError, signup, login_and_issue_key, get_user_by_api_key,
upsert_odoo_credential, list_odoo_credentials, upsert_odoo_credential, list_odoo_credentials,
delete_odoo_credential, regenerate_api_key, delete_odoo_credential, regenerate_api_key,
create_api_key, list_api_keys, revoke_api_key, create_api_key, list_api_keys, revoke_api_key,
@ -115,17 +115,24 @@ def api_signup(body: SignupRequest):
@router.post("/login") @router.post("/login")
def api_login(body: LoginRequest): def api_login(body: LoginRequest):
"""Verify email + password. Returns account info (not the API key — use /api/api-key to regenerate).""" """Verify email + password. Issues a fresh 'Dashboard Login' API key
for this session api_key_hash is a one-way hash, so a previously
issued key can never be recovered here. Does not affect any other
named keys (e.g. an already-connected Claude Desktop/Codex client)."""
try: try:
user = login(body.email, body.password) user, full_key = login_and_issue_key(body.email, body.password)
except AuthError as e: except AuthError as e:
raise HTTPException(status_code=401, detail=str(e)) raise HTTPException(status_code=401, detail=str(e))
return { return {
"user_id": user.id, "user_id": user.id,
"email": user.email, "email": user.email,
"api_key_prefix": user.api_key_prefix + "...", "api_key": full_key,
"created_at": user.created_at, "warning": "Save this key — it will not be shown again.",
"message": "Login successful. Use your API key to connect MCP clients.", "message": "Login successful.",
"mcp_connection": {
"url": f"{settings.public_url}/mcp/sse",
"header": f"Authorization: Bearer {full_key}",
},
} }

View File

@ -71,6 +71,33 @@ def login(email: str, password: str) -> User:
return _row_to_user(row) return _row_to_user(row)
DASHBOARD_LOGIN_KEY_NAME = "Dashboard Login"
def login_and_issue_key(email: str, password: str) -> tuple[User, str]:
"""Verify email + password, then issue a usable API key for the
session. Necessary because api_key_hash/key_hash are one-way hashes
the backend can never hand back a previously-issued key, so without
this, logging in on any browser/device that doesn't already have a key
saved in localStorage would be permanently impossible.
Replaces any existing 'Dashboard Login' key each call (so repeated
logins don't accumulate duplicates) but never touches the user's other
named keys (e.g. 'Claude Desktop', 'Codex') or the legacy key an
already-connected MCP client is never affected by a dashboard login
elsewhere. Returns (user, full_key)."""
user = login(email, password)
old = db_fetchone(
"SELECT id FROM api_keys WHERE user_id = ? AND name = ?",
(user.id, DASHBOARD_LOGIN_KEY_NAME),
)
if old:
db_execute("DELETE FROM api_keys WHERE id = ?", (old["id"],))
db_commit()
_, full_key = create_api_key(user.id, DASHBOARD_LOGIN_KEY_NAME)
return user, full_key
def get_user_by_api_key(api_key: str) -> Optional[User]: def get_user_by_api_key(api_key: str) -> Optional[User]:
"""Look up a user by their API key. Returns None if not found/invalid. """Look up a user by their API key. Returns None if not found/invalid.
@ -178,21 +205,28 @@ def create_api_key(user_id: int, name: str, instance_name: Optional[str] = None)
def list_api_keys(user_id: int) -> list[ApiKeyInfo]: def list_api_keys(user_id: int) -> list[ApiKeyInfo]:
"""List all named API keys for a user. Lazily backfills a 'Default (legacy)' """List all named API keys for a user. Lazily backfills a 'Default (legacy)'
row mirroring users.api_key_hash the first time this is called, so the row mirroring users.api_key_hash the first time this is called, so the
legacy signup key shows up in the UI like any other key.""" legacy signup key shows up in the UI like any other key.
rows = db_fetchall(
"SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at", (user_id,) Checks specifically whether the legacy key's hash is already represented
) as a row not just "does this user have zero rows at all" since
if not rows: login_and_issue_key() creates a 'Dashboard Login' row on first login,
user_row = db_fetchone("SELECT api_key_hash, api_key_prefix FROM users WHERE id = ?", (user_id,)) which would otherwise permanently prevent the legacy key from ever being
if user_row and user_row["api_key_hash"]: backfilled (its row count would never be zero again)."""
user_row = db_fetchone("SELECT api_key_hash, api_key_prefix FROM users WHERE id = ?", (user_id,))
if user_row and user_row["api_key_hash"]:
legacy_exists = db_fetchone(
"SELECT 1 FROM api_keys WHERE user_id = ? AND key_hash = ?",
(user_id, user_row["api_key_hash"]),
)
if not legacy_exists:
db_execute( db_execute(
"INSERT INTO api_keys (user_id, name, key_hash, key_prefix) VALUES (?, ?, ?, ?)", "INSERT INTO api_keys (user_id, name, key_hash, key_prefix) VALUES (?, ?, ?, ?)",
(user_id, "Default (legacy)", user_row["api_key_hash"], user_row["api_key_prefix"]), (user_id, "Default (legacy)", user_row["api_key_hash"], user_row["api_key_prefix"]),
) )
db_commit() db_commit()
rows = db_fetchall( rows = db_fetchall(
"SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at", (user_id,) "SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at", (user_id,)
) )
return [_row_to_api_key(r) for r in rows] return [_row_to_api_key(r) for r in rows]