-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauth.py
More file actions
227 lines (182 loc) · 7.83 KB
/
Copy pathauth.py
File metadata and controls
227 lines (182 loc) · 7.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""Authentication / authorization for gemini2api.
The server exposes two distinct trust zones:
* **OpenAI-compatible API** (``/v1/*``) — protected by ``API_KEY`` and
the optional ``API_KEYS`` list via ``Authorization: Bearer <key>``
headers. Compatible with every OpenAI client by setting
``api_key``/``OPENAI_API_KEY`` to one of the accepted values.
* **Admin / WebUI** (``/api/*`` and the HTML pages) — protected by
``ADMIN_KEY`` and the optional ``ADMIN_KEYS`` list, accepted via
either the same ``Authorization: Bearer`` header *or* a signed
cookie (``admin_token``). The cookie path lets a human open the
dashboard in a browser once without re-pasting the key on every
request.
Setting either variable to the placeholder value (or to an empty string)
**disables** enforcement — handy for local dev, never recommended in prod.
"""
from __future__ import annotations
import hmac
import os
from dataclasses import dataclass, field
from typing import Iterable, Optional
from fastapi import Cookie, Header, HTTPException, Request, status
from logger import get_logger
log = get_logger("auth")
# Identifiers the codebase uses to mean "no real key configured".
_PLACEHOLDER = "sk-web2api-placeholder"
_DISABLE_SENTINELS = frozenset({"", _PLACEHOLDER, "disabled", "off"})
@dataclass(frozen=True)
class AuthConfig:
"""Effective auth settings after resolving env vars."""
api_keys: tuple[str, ...] = ()
admin_keys: tuple[str, ...] = ()
api_required: bool = False
admin_required: bool = False
@property
def api_status(self) -> str:
return "required" if self.api_required else "disabled"
@property
def admin_status(self) -> str:
return "required" if self.admin_required else "disabled"
@property
def api_key_count(self) -> int:
return len(self.api_keys)
@property
def admin_key_count(self) -> int:
return len(self.admin_keys)
def _is_disabled(raw: Optional[str]) -> bool:
return raw is None or raw.strip().lower() in _DISABLE_SENTINELS
def _parse_keys(*raws: Optional[str]) -> tuple[str, ...]:
"""Combine ``API_KEY`` + ``API_KEYS`` (and similar) into a deduped tuple.
Empty / placeholder entries are filtered out; whitespace-padded
values are trimmed. The first non-empty value wins on dedup so the
logs and ``CONFIG.api_key_count`` stay stable.
"""
seen: set[str] = set()
out: list[str] = []
for raw in raws:
if raw is None:
continue
# First treat the raw value as a single key.
candidates: list[str] = []
if not _is_disabled(raw):
candidates.append(raw.strip())
# Then, if the value contains commas, split it.
for part in raw.split(","):
part = part.strip()
if part and not _is_disabled(part) and part not in seen:
seen.add(part)
out.append(part)
if not raw.strip().count(",") and candidates:
# Single-key form (no commas) already handled above.
for c in candidates:
if c not in seen:
seen.add(c)
out.append(c)
return tuple(out)
def load_auth_config() -> AuthConfig:
"""Read ``API_KEY[S]`` / ``ADMIN_KEY[S]`` from env and resolve policy."""
api_single = os.getenv("API_KEY")
api_list = os.getenv("API_KEYS")
admin_single = os.getenv("ADMIN_KEY", api_single)
admin_list = os.getenv("ADMIN_KEYS")
api_keys = _parse_keys(api_single, api_list)
admin_keys = _parse_keys(admin_single, admin_list)
return AuthConfig(
api_keys=api_keys,
admin_keys=admin_keys,
api_required=bool(api_keys),
admin_required=bool(admin_keys),
)
# Module-level singleton, refreshed on server start.
CONFIG = load_auth_config()
def _safe_compare(provided: Optional[str], expected: Optional[str]) -> bool:
"""Constant-time comparison; tolerates ``None`` expected."""
if expected is None or not provided:
return False
return hmac.compare_digest(provided, expected)
def _safe_compare_any(provided: Optional[str], expected: Iterable[str]) -> bool:
"""Constant-time comparison against any candidate in ``expected``."""
if not provided:
return False
# We don't truly constant-time across candidates of different
# lengths, but every comparison uses hmac.compare_digest so timing
# leakage is bounded to length differences. This is good enough for
# the threat model here (untrusted LAN clients, not nation-states).
return any(_safe_compare(provided, k) for k in expected if k)
def _extract_bearer(authorization: Optional[str]) -> Optional[str]:
if not authorization:
return None
parts = authorization.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
return parts[1].strip() or None
# ── FastAPI dependencies ─────────────────────────────────────────────
def verify_api_key(
request: Request,
authorization: Optional[str] = Header(default=None),
x_api_key: Optional[str] = Header(default=None, alias="x-api-key"),
) -> None:
"""Dependency guarding ``/v1/*`` endpoints.
Accepts the key from ``Authorization: Bearer <key>`` or the legacy
``x-api-key`` header. Matches against ``API_KEY`` and any value in
``API_KEYS`` (comma-separated list). Returns 401 on mismatch.
"""
if not CONFIG.api_required:
return # Auth disabled — no-op.
provided = _extract_bearer(authorization) or x_api_key
if _safe_compare_any(provided, CONFIG.api_keys):
return
log.warning(
"api_key_invalid",
extra={"client": request.client.host if request.client else "?"},
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"type": "invalid_api_key", "message": "Invalid or missing API key"}},
headers={"WWW-Authenticate": 'Bearer realm="gemini2api"'},
)
def verify_admin_key(
request: Request,
authorization: Optional[str] = Header(default=None),
admin_token: Optional[str] = Cookie(default=None),
) -> None:
"""Dependency guarding the WebUI and admin REST API.
Accepts the key from ``Authorization: Bearer <key>`` or the
``admin_token`` cookie set by ``POST /api/auth/web_login``.
Matches against ``ADMIN_KEY`` and any value in ``ADMIN_KEYS``.
HTML page requests get a 302 to ``/webui/login`` on failure;
everything else gets JSON 401.
"""
if not CONFIG.admin_required:
return # Auth disabled — no-op.
provided = _extract_bearer(authorization) or admin_token
if _safe_compare_any(provided, CONFIG.admin_keys):
return
log.warning(
"admin_key_invalid",
extra={"path": request.url.path, "client": request.client.host if request.client else "?"},
)
path = request.url.path
if path.startswith("/webui") or path == "/" or path.endswith(".html"):
raise HTTPException(
status_code=status.HTTP_302_FOUND,
detail="redirect to login",
headers={"Location": "/webui/login"},
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"type": "invalid_admin_key", "message": "Invalid or missing admin key"}},
)
def check_admin_login(provided: str) -> bool:
"""Plain helper used by ``/api/auth/web_login`` to validate submitted keys."""
if not CONFIG.admin_required:
return True
return _safe_compare_any(provided, CONFIG.admin_keys)
def auth_summary() -> dict:
"""Return a non-sensitive snapshot of the auth state for the dashboard."""
return {
"api_key_status": CONFIG.api_status,
"admin_key_status": CONFIG.admin_status,
"api_key_count": CONFIG.api_key_count,
"admin_key_count": CONFIG.admin_key_count,
}