feat: Google OAuth sign-in + per-user Gemini API key
Users with Google accounts can now sign in without a password. Auth flow: - GET /auth/google → Google consent page (CSRF state cookie) - GET /auth/google/callback → exchange code, lookup user, set JWT - auth.json gains google_sub + google_email fields - set_password() no longer overwrites unrelated auth.json fields Admin setup: python manage_passwords.py google-add <username> <email> # add GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET to .env Per-user Gemini key: - get_user_gemini_key() reads gemini_api_key from auth.json - orchestrator_engine.run() accepts gemini_api_key param - orchestrator router passes user's key, falls back to server key login.html: "Sign in with Google" button above the password form. manage_passwords.py list: now shows auth method columns (pw / google). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,33 +29,92 @@ ALGORITHM = "HS256"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password helpers
|
||||
# auth.json helpers — read/write without clobbering unrelated fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _auth_path(username: str) -> Path:
|
||||
return settings.home_root() / username / "auth.json"
|
||||
|
||||
|
||||
def _read_auth(username: str) -> dict:
|
||||
path = _auth_path(username)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _write_auth(username: str, data: dict) -> None:
|
||||
path = _auth_path(username)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2) + "\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def set_password(username: str, password: str) -> None:
|
||||
"""Hash and store a password for a user. Creates auth.json if needed."""
|
||||
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
_auth_path(username).write_text(json.dumps({"password_hash": hashed}) + "\n")
|
||||
"""Hash and store a password. Preserves any existing fields in auth.json."""
|
||||
data = _read_auth(username)
|
||||
data["password_hash"] = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
_write_auth(username, data)
|
||||
logger.info("password set for user: %s", username)
|
||||
|
||||
|
||||
def check_credentials(username: str, password: str) -> bool:
|
||||
"""Return True if username+password are valid, False otherwise."""
|
||||
path = _auth_path(username)
|
||||
if not path.exists():
|
||||
return False
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
stored = data.get("password_hash", "").encode()
|
||||
stored = _read_auth(username).get("password_hash", "").encode()
|
||||
if not stored:
|
||||
return False
|
||||
return bcrypt.checkpw(password.encode(), stored)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Google OAuth helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def find_user_by_google(sub: str, email: str) -> str | None:
|
||||
"""
|
||||
Scan all users for one whose auth.json matches the given Google sub or email.
|
||||
Sub match takes priority (stable); email match is a fallback for first sign-in.
|
||||
Returns the username, or None if no match.
|
||||
"""
|
||||
root = settings.home_root()
|
||||
if not root.exists():
|
||||
return None
|
||||
for user_dir in sorted(root.iterdir()):
|
||||
if not user_dir.is_dir():
|
||||
continue
|
||||
data = _read_auth(user_dir.name)
|
||||
if not data:
|
||||
continue
|
||||
if sub and data.get("google_sub") == sub:
|
||||
return user_dir.name
|
||||
if email and data.get("google_email", "").lower() == email.lower():
|
||||
return user_dir.name
|
||||
return None
|
||||
|
||||
|
||||
def link_google(username: str, sub: str, email: str) -> None:
|
||||
"""Store / update Google sub and email in a user's auth.json."""
|
||||
data = _read_auth(username)
|
||||
data["google_sub"] = sub
|
||||
data["google_email"] = email
|
||||
_write_auth(username, data)
|
||||
logger.info("Google account linked for user: %s (%s)", username, email)
|
||||
|
||||
|
||||
def get_user_gemini_key(username: str) -> str | None:
|
||||
"""Return the user's personal Gemini API key, or None to use the server key."""
|
||||
return _read_auth(username).get("gemini_api_key") or None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user