Skip to content

Commit 7071de2

Browse files
committed
Harden single sign on identity and session, unblock the API review, fix recovery codes
Four fixes from a security review. Single sign on now identifies an account by the provider's immutable subject, not by the user name, which a provider can rename or reuse. The subject is recorded the first time an account signs in, and from then on a different subject that arrives carrying the same name is refused rather than inheriting the account and its role. Pinning a role still works exactly as before; it now attaches to the verified identity. A new column holds the subject and is added on boot. The sign on flow is bound to the browser that started it. A nonce is set in a cookie at the start and checked at the callback, so a callback opened in another browser, even with a valid state and code, is refused. The API review endpoint ran the scanner inline on the one web worker, which froze every other request for the length of a scan. It now runs off the event loop, which also bounds how many run at once, so sign in and the health check stay responsive. Account recovery codes never verified: generation hashed the code with its hyphen while the check stripped it first. Both now use one form, and codes issued before the fix still work.
1 parent 7ec096c commit 7071de2

5 files changed

Lines changed: 97 additions & 15 deletions

File tree

‎app/auth/oidc.py‎

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import base64
1414
import hashlib
15+
import hmac
1516
import json
1617
import logging
1718
import secrets
@@ -92,7 +93,7 @@ def _cleanup_pending() -> None:
9293
_pending.pop(state, None)
9394

9495

95-
async def begin_login(source_ip: str = "") -> str:
96+
async def begin_login(source_ip: str = "", browser: str = "") -> str:
9697
"""Start a login and give back the URL to send the browser to.
9798
9899
Two things get made here and both matter.
@@ -113,7 +114,7 @@ async def begin_login(source_ip: str = "") -> str:
113114

114115
_cleanup_pending()
115116
_pending[state] = {"verifier": verifier, "created": time.time(),
116-
"source_ip": source_ip}
117+
"source_ip": source_ip, "browser": browser}
117118

118119
params = {
119120
"response_type": "code",
@@ -127,14 +128,24 @@ async def begin_login(source_ip: str = "") -> str:
127128
return f"{doc['authorization_endpoint']}?{urlencode(params)}"
128129

129130

130-
async def complete_login(code: str, state: str, source_ip: str = "") -> dict:
131+
async def complete_login(code: str, state: str, source_ip: str = "",
132+
browser: str = "") -> dict:
131133
"""Finish the login. Swap the code for tokens and check the id_token."""
132134
pending = _pending.pop(state, None)
133135
if not pending:
134136
raise OIDCError("that sign in is not one we started, or it took too "
135137
"long. Try again.")
136138
if time.time() - pending["created"] > _PENDING_TTL:
137139
raise OIDCError("that sign in took too long, start again")
140+
# Bind the flow to the browser that started it. The state alone travels in
141+
# the URL, so a stolen or planted callback could sign a victim into the
142+
# attacker's account (login CSRF). The browser nonce was set as a cookie at
143+
# the start and is not in the URL, so a callback opened in another browser,
144+
# even with a valid state and code, is refused.
145+
if pending.get("browser") and not hmac.compare_digest(
146+
str(pending.get("browser")), str(browser or "")):
147+
raise OIDCError("that sign in was started in a different browser. "
148+
"Start again here.")
138149

139150
doc = await get_discovery()
140151

@@ -301,8 +312,22 @@ def sync_user(session, profile: dict) -> User:
301312
username = (profile.get("username") or "").strip().lower()
302313
if not username:
303314
raise OIDCError("the provider did not tell us who you are")
304-
305-
user = session.query(User).filter(User.username == username).one_or_none()
315+
subject = (profile.get("subject") or "").strip()
316+
317+
# Who this is comes from the provider's immutable subject, not the name,
318+
# which the provider can rename or reuse. Match on the subject first; the
319+
# account follows it even if the name changed. Fall back to the name only
320+
# to adopt an account that has no subject recorded yet (the first sign in
321+
# after this landed), or when no subject was sent at all.
322+
user = None
323+
if subject:
324+
user = (session.query(User)
325+
.filter(User.oidc_subject == subject,
326+
User.source == AuthSource.OIDC).one_or_none())
327+
by_name = session.query(User).filter(User.username == username).one_or_none()
328+
matched_by_name = user is None
329+
if user is None:
330+
user = by_name
306331

307332
# Turned off here stays off, whatever the provider says. Signing in used
308333
# to clear the flag, so disabling an SSO account lasted until that person
@@ -319,6 +344,15 @@ def sync_user(session, profile: dict) -> User:
319344
f"there is already a local account called {username}. "
320345
"An administrator needs to sort that out.")
321346

347+
# The name matched an SSO account already bound to a different subject: a
348+
# different identity wearing the same name. Refuse, so one person's name
349+
# can never inherit another's account, access or pinned role.
350+
if (subject and matched_by_name and user is not None
351+
and (user.oidc_subject or "") and user.oidc_subject != subject):
352+
raise OIDCError(
353+
"that name belongs to a different single sign on identity. "
354+
"An administrator needs to sort that out.")
355+
322356
# Whose account this is has to be known before the role is decided, because
323357
# a pinned account keeps the role it was given and the groups do not come
324358
# into it. That is the whole point of pinning one: it is the way back in
@@ -339,6 +373,11 @@ def sync_user(session, profile: dict) -> User:
339373
user = User(username=username, source=AuthSource.OIDC)
340374
session.add(user)
341375

376+
# Record the subject the first time we see it (trust on first use), so every
377+
# later sign in is matched on it and a colliding name is refused above.
378+
if subject and not (user.oidc_subject or ""):
379+
user.oidc_subject = subject
380+
342381
user.email = (profile.get("email") or "")[:255]
343382
user.display_name = (profile.get("full_name") or "")[:255]
344383
user.role = role

‎app/auth/service.py‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,19 @@ def make_recovery_codes() -> tuple:
162162
for _ in range(RECOVERY_CODE_COUNT):
163163
raw = secrets.token_hex(5)
164164
plain.append(f"{raw[:5]}-{raw[5:]}")
165-
hashes = [hashlib.sha256(c.encode()).hexdigest() for c in plain]
165+
# Hash the code the way check_totp normalises what is typed (spaces and the
166+
# hyphen removed), so a code entered with or without its hyphen verifies.
167+
# Hashing the displayed form here while check_totp strips the hyphen before
168+
# checking meant no recovery code ever matched.
169+
hashes = [hashlib.sha256(_canon_recovery(c).encode()).hexdigest() for c in plain]
166170
return plain, hashes
167171

168172

173+
def _canon_recovery(code: str) -> str:
174+
"""The one form a recovery code is hashed and checked in."""
175+
return (code or "").strip().replace(" ", "").replace("-", "")
176+
177+
169178
def _consume_recovery_code(user: User, code: str) -> bool:
170179
"""Burn a recovery code if it matches. Each one works exactly once."""
171180
try:
@@ -175,9 +184,15 @@ def _consume_recovery_code(user: User, code: str) -> bool:
175184
if not stored:
176185
return False
177186

178-
candidate = hashlib.sha256(code.encode()).hexdigest()
187+
# The code arrives already normalised (check_totp strips spaces and the
188+
# hyphen). Match the canonical hash, and also the older form that hashed
189+
# the hyphenated code, so codes issued before the fix still work.
190+
candidates = {hashlib.sha256(code.encode()).hexdigest()}
191+
if len(code) == 10 and code.isalnum():
192+
legacy = f"{code[:5]}-{code[5:]}"
193+
candidates.add(hashlib.sha256(legacy.encode()).hexdigest())
179194
for held in list(stored):
180-
if hmac.compare_digest(held, candidate):
195+
if any(hmac.compare_digest(held, c) for c in candidates):
181196
stored.remove(held)
182197
user.recovery_hashes = json.dumps(stored)
183198
return True

‎app/core/models.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ class User(Base):
145145
role = Column(Enum(UserRole), nullable=False, default=UserRole.READER)
146146
source = Column(Enum(AuthSource), nullable=False, default=AuthSource.LOCAL)
147147

148+
# The immutable identity of an SSO account: the provider's subject claim.
149+
# The account is matched on this, not on the user name, which the provider
150+
# can rename or reuse. Recorded the first time an account signs in, and
151+
# from then on a different subject carrying the same name is refused rather
152+
# than inheriting the account and its role. Empty on local accounts.
153+
oidc_subject = Column(String(255), default="", index=True)
154+
148155
# Hold the role against the provider.
149156
#
150157
# The provider owns the role, which is right while it sends group names.

‎app/web/routes_api_v1.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from __future__ import annotations
2323

24+
import functools
2425
import hashlib
2526
import json
2627
import logging
@@ -343,10 +344,16 @@ async def review(request: Request,
343344
target.parent.mkdir(parents=True, exist_ok=True)
344345
target.write_bytes(raw)
345346

346-
completed = subprocess.run(
347-
[str(GHSCAN), "review", workdir, "--only", only, "--fail-on", "none"],
348-
capture_output=True, text=True, timeout=REVIEW_TIMEOUT,
349-
env={**os.environ, "GHSCAN_LOG_LEVEL": "warn"})
347+
# Run the scanner off the event loop. It is a blocking subprocess, and
348+
# this is the one web worker: calling it inline would freeze every other
349+
# request, including sign in and the health check, for the whole scan.
350+
# run_in_threadpool also bounds how many run at once to the thread pool.
351+
completed = await run_in_threadpool(
352+
functools.partial(
353+
subprocess.run,
354+
[str(GHSCAN), "review", workdir, "--only", only, "--fail-on", "none"],
355+
capture_output=True, text=True, timeout=REVIEW_TIMEOUT,
356+
env={**os.environ, "GHSCAN_LOG_LEVEL": "warn"}))
350357

351358
if completed.returncode not in (0, 3) or not completed.stdout.strip():
352359
tail = (completed.stderr or "").strip().splitlines()[-3:]

‎app/web/routes_auth.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import json
1010
import logging
11+
import secrets
1112

1213
from fastapi import APIRouter, Form, Request
1314
from fastapi.responses import RedirectResponse
@@ -27,6 +28,9 @@
2728
log = logging.getLogger("gcr.auth.routes")
2829
router = APIRouter()
2930

31+
# The cookie that binds an OIDC flow to the browser that began it.
32+
OIDC_FLOW_COOKIE = "gcr_oidc_flow"
33+
3034

3135
def _set_session_cookie(response, token: str) -> None:
3236
response.set_cookie(
@@ -265,14 +269,22 @@ async def oidc_start(request: Request):
265269
return page(request, "login.html", stage="password", auth=live.auth_config(),
266270
error="Single sign on is not set up here.")
267271

272+
# A nonce that stays in a cookie on this browser and never travels in the
273+
# URL, so the callback can tell the browser that started the flow from one
274+
# a victim was handed (login CSRF).
275+
browser = secrets.token_urlsafe(24)
268276
try:
269-
url = await oidc.begin_login(client_ip(request))
277+
url = await oidc.begin_login(client_ip(request), browser)
270278
except oidc.OIDCError as exc:
271279
log.warning("could not start an oidc login: %s", exc)
272280
return page(request, "login.html", stage="password", auth=live.auth_config(),
273281
error=str(exc))
274282

275-
return RedirectResponse(url, status_code=303)
283+
response = RedirectResponse(url, status_code=303)
284+
response.set_cookie(OIDC_FLOW_COOKIE, browser, max_age=600, httponly=True,
285+
secure=live.force_https(), samesite="lax",
286+
path=settings.base_path or "/")
287+
return response
276288

277289

278290
@router.get("/auth/callback")
@@ -297,8 +309,9 @@ async def oidc_callback(request: Request):
297309
error="That callback was missing something. Try again.")
298310

299311
ip = client_ip(request)
312+
browser = request.cookies.get(OIDC_FLOW_COOKIE, "")
300313
try:
301-
profile = await oidc.complete_login(code, state, ip)
314+
profile = await oidc.complete_login(code, state, ip, browser)
302315
except oidc.OIDCError as exc:
303316
log.warning("oidc sign in failed from %s: %s", ip, exc)
304317
with session_scope() as db:
@@ -327,4 +340,5 @@ async def oidc_callback(request: Request):
327340
response = landing(request)
328341
_set_session_cookie(response, token)
329342
issue_csrf(response, new_csrf_token())
343+
response.delete_cookie(OIDC_FLOW_COOKIE, path=settings.base_path or "/")
330344
return response

0 commit comments

Comments
 (0)