security: bind passkeys to upstream identity (Closes #1448)
This commit is contained in:
1 parent
ff469d96d1
commit
43b0216bda
6 files changed
+240
-39
No files matched your search
+45
-16
@@ -2211,7 +2211,9 @@ async def create_passkey_registration_options(
|
||||
)
|
||||
rp_id, _origin = _passkey_relying_party(request)
|
||||
store = _passkey_store()
|
||||
existing = await asyncio.to_thread(store.all)
|
||||
existing = await asyncio.to_thread(
|
||||
store.all, principal_id=request.state.dashboard_session.principal_id
|
||||
)
|
||||
options, challenge = passkeys.registration_options(
|
||||
rp_id=rp_id,
|
||||
excluded=[item.credential_id for item in existing],
|
||||
@@ -2284,6 +2286,7 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
|
||||
sign_count=verified.sign_count,
|
||||
device_label=current.device_label,
|
||||
management_id=current.management_id,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
try:
|
||||
@@ -2305,7 +2308,10 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
|
||||
@app.get("/api/v1/passkeys")
|
||||
async def list_enrolled_passkeys(request: Request):
|
||||
try:
|
||||
credentials = await asyncio.to_thread(_passkey_store().all)
|
||||
credentials = await asyncio.to_thread(
|
||||
_passkey_store().all,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
@@ -2352,7 +2358,11 @@ async def revoke_enrolled_passkey(
|
||||
)
|
||||
store = _passkey_store()
|
||||
try:
|
||||
credential = await asyncio.to_thread(store.get_management_id, management_id)
|
||||
credential = await asyncio.to_thread(
|
||||
store.get_management_id,
|
||||
management_id,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
@@ -2418,8 +2428,16 @@ async def revoke_enrolled_passkey(
|
||||
|
||||
@app.post("/api/v1/passkeys/authentication/options")
|
||||
async def create_passkey_authentication_options(request: Request):
|
||||
try:
|
||||
principal_id, _principal_login = await _upstream_identity()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"detail": "Gitea identity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "5"},
|
||||
)
|
||||
store = _passkey_store()
|
||||
credentials = await asyncio.to_thread(store.all)
|
||||
credentials = await asyncio.to_thread(store.all, principal_id=principal_id)
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=404, detail="No passkeys enrolled")
|
||||
peer_host = request.client.host if request.client is not None else "unknown"
|
||||
@@ -2472,7 +2490,9 @@ async def create_passkey_authorization_options(
|
||||
payload: PasskeyAuthorizationTarget, request: Request
|
||||
):
|
||||
store = _passkey_store()
|
||||
credentials = await asyncio.to_thread(store.all)
|
||||
credentials = await asyncio.to_thread(
|
||||
store.all, principal_id=request.state.dashboard_session.principal_id
|
||||
)
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=404, detail="No passkeys enrolled")
|
||||
rp_id, _origin = _passkey_relying_party(request)
|
||||
@@ -2509,7 +2529,11 @@ async def verify_passkey_authorization(
|
||||
action=payload.action,
|
||||
target=payload.target,
|
||||
)
|
||||
stored = await asyncio.to_thread(store.get, credential_id)
|
||||
stored = await asyncio.to_thread(
|
||||
store.get,
|
||||
credential_id,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
if not valid or stored is None:
|
||||
raise HTTPException(status_code=409, detail="Passkey challenge expired or already used")
|
||||
rp_id, origin = _passkey_relying_party(request)
|
||||
@@ -2594,6 +2618,14 @@ async def verify_passkey_authentication(
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Invalid passkey ceremony")
|
||||
store = _passkey_store()
|
||||
try:
|
||||
principal_id, principal_login = await _upstream_identity()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"detail": "Gitea identity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "5"},
|
||||
)
|
||||
valid = await asyncio.to_thread(
|
||||
store.consume_challenge,
|
||||
challenge,
|
||||
@@ -2602,7 +2634,9 @@ async def verify_passkey_authentication(
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
)
|
||||
stored = await asyncio.to_thread(store.get, credential_id)
|
||||
stored = await asyncio.to_thread(
|
||||
store.get, credential_id, principal_id=principal_id
|
||||
)
|
||||
if not valid or stored is None:
|
||||
try:
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
@@ -2638,14 +2672,6 @@ async def verify_passkey_authentication(
|
||||
target="sign_in:dashboard",
|
||||
)
|
||||
raise ValueError("stale passkey counter")
|
||||
try:
|
||||
principal_id, principal_login = await _upstream_identity()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"detail": "Gitea identity is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": "5"},
|
||||
)
|
||||
await dashboard_auth.revoke_managed_session(stored.management_id)
|
||||
signed, session = await asyncio.to_thread(
|
||||
dashboard_auth.issue_session,
|
||||
@@ -4049,7 +4075,10 @@ async def sign_out_all_devices(
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(_passkey_store().revoke_all_access)
|
||||
await asyncio.to_thread(
|
||||
_passkey_store().revoke_all_access,
|
||||
principal_id=request.state.dashboard_session.principal_id,
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
|
||||
+47
-19
@@ -18,6 +18,7 @@ class StoredPasskey:
|
||||
device_label: str
|
||||
management_id: str
|
||||
created_at: int
|
||||
principal_id: int
|
||||
|
||||
|
||||
class PasskeyStore:
|
||||
@@ -50,10 +51,19 @@ class PasskeyStore:
|
||||
sign_count INTEGER NOT NULL,
|
||||
device_label TEXT NOT NULL,
|
||||
management_id TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL
|
||||
created_at INTEGER NOT NULL,
|
||||
principal_id INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
credential_columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(passkey_credentials)")
|
||||
}
|
||||
if "principal_id" not in credential_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE passkey_credentials ADD COLUMN principal_id INTEGER"
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
||||
@@ -172,12 +182,14 @@ class PasskeyStore:
|
||||
sign_count: int,
|
||||
device_label: str,
|
||||
management_id: str,
|
||||
principal_id: int,
|
||||
) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_credentials(credential_id, public_key, sign_count, "
|
||||
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"device_label, management_id, created_at, principal_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
credential_id,
|
||||
public_key,
|
||||
@@ -185,41 +197,48 @@ class PasskeyStore:
|
||||
device_label,
|
||||
management_id,
|
||||
int(self.clock()),
|
||||
principal_id,
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def all(self) -> list[StoredPasskey]:
|
||||
def all(self, *, principal_id: int) -> list[StoredPasskey]:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials ORDER BY created_at DESC"
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
|
||||
"created_at, principal_id FROM passkey_credentials "
|
||||
"WHERE principal_id = ? ORDER BY created_at DESC",
|
||||
(principal_id,),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return [StoredPasskey(*row) for row in rows]
|
||||
|
||||
def get(self, credential_id: bytes) -> StoredPasskey | None:
|
||||
def get(self, credential_id: bytes, *, principal_id: int) -> StoredPasskey | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE credential_id = ?",
|
||||
(credential_id,),
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
|
||||
"created_at, principal_id FROM passkey_credentials "
|
||||
"WHERE credential_id = ? AND principal_id = ?",
|
||||
(credential_id, principal_id),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def get_management_id(self, management_id: str) -> StoredPasskey | None:
|
||||
def get_management_id(
|
||||
self, management_id: str, *, principal_id: int
|
||||
) -> StoredPasskey | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
|
||||
"created_at, principal_id FROM passkey_credentials "
|
||||
"WHERE management_id = ? AND principal_id = ?",
|
||||
(management_id, principal_id),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
@@ -326,13 +345,22 @@ class PasskeyStore:
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all_access(self) -> None:
|
||||
"""Atomically remove every passkey, challenge, grant, and active session."""
|
||||
def revoke_all_access(self, *, principal_id: int) -> None:
|
||||
"""Atomically remove one principal's passkeys, grants, and active sessions."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM passkey_credentials")
|
||||
connection.execute("DELETE FROM passkey_challenges")
|
||||
connection.execute("DELETE FROM step_up_grants")
|
||||
connection.execute("DELETE FROM active_sessions")
|
||||
connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE principal_id = ?",
|
||||
(principal_id,),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash IN ("
|
||||
"SELECT session_hash FROM active_sessions WHERE principal_id = ?)",
|
||||
(principal_id,),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM active_sessions WHERE principal_id = ?",
|
||||
(principal_id,),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
Reference in new issue
Block a user