auth: make session binding optional, enforce WebAuthn credential ownership
- Make session_id parameter optional in validate_token — only enforced when provided, allowing WebSocket auth which cannot carry custom headers - Remove decode_token round-trip from daemon WS handler - Override WebAuthn registration username from JWT user_ctx to prevent users from registering credentials under another user's account - Frontend no longer sends username for WebAuthn registration - Redirect to login on 401 after token refresh fails for POST requests - Remove redundant auth session check from login page load - Add tests for session_id semantics and WebAuthn ownership guard
This commit is contained in:
+3
-8
@@ -373,7 +373,7 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
|||||||
1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer <token>")
|
1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer <token>")
|
||||||
2. X-Auth-Token header (nginx-injected)
|
2. X-Auth-Token header (nginx-injected)
|
||||||
"""
|
"""
|
||||||
from lib.auth import decode_token, validate_token
|
from lib.auth import validate_token
|
||||||
|
|
||||||
token_param = None
|
token_param = None
|
||||||
|
|
||||||
@@ -392,16 +392,11 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
|||||||
{"ok": False, "error": "authentication required"}, status=401
|
{"ok": False, "error": "authentication required"}, status=401
|
||||||
)
|
)
|
||||||
|
|
||||||
# Decode token to extract session_id from payload (browsers can't send
|
# Validate token. Session binding is skipped because browsers cannot send
|
||||||
# X-Session-Id header on WebSocket connections, only subprotocols)
|
# custom headers on WebSocket connections (no X-Session-Id available).
|
||||||
raw_payload = decode_token(token_param)
|
|
||||||
if raw_payload is None:
|
|
||||||
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
|
||||||
|
|
||||||
payload = validate_token(
|
payload = validate_token(
|
||||||
token_param,
|
token_param,
|
||||||
token_type="access",
|
token_type="access",
|
||||||
session_id=raw_payload.get("session_id"),
|
|
||||||
)
|
)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
||||||
|
|||||||
+5
-10
@@ -290,9 +290,10 @@ def validate_token(
|
|||||||
token_string: The JWT token string.
|
token_string: The JWT token string.
|
||||||
token_type: Expected token type ("access" or "refresh").
|
token_type: Expected token type ("access" or "refresh").
|
||||||
session_id: Must match the ``session_id`` claim in the token payload.
|
session_id: Must match the ``session_id`` claim in the token payload.
|
||||||
Required for access tokens — prevents a stolen token from being
|
When provided, enforces session binding to prevent a stolen token
|
||||||
usable without the originating session. Ignored for refresh tokens
|
from being usable without the originating session. When ``None``,
|
||||||
which carry no ``session_id`` claim.
|
the check is skipped (used by WebSocket auth which cannot carry
|
||||||
|
the session ID header).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Payload dict including permissions, or None if invalid/blacklisted.
|
Payload dict including permissions, or None if invalid/blacklisted.
|
||||||
@@ -302,13 +303,7 @@ def validate_token(
|
|||||||
return None
|
return None
|
||||||
if payload.get("type") != token_type:
|
if payload.get("type") != token_type:
|
||||||
return None
|
return None
|
||||||
if token_type == "access" and session_id != payload.get("session_id"):
|
if session_id is not None and session_id != payload.get("session_id"):
|
||||||
return None
|
|
||||||
if (
|
|
||||||
token_type != "access"
|
|
||||||
and session_id
|
|
||||||
and payload.get("session_id") != session_id
|
|
||||||
):
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
jti = payload.get("jti")
|
jti = payload.get("jti")
|
||||||
|
|||||||
+78
-2
@@ -235,12 +235,22 @@ class TestJWT:
|
|||||||
payload = validate_token(token, "access", session_id="wrong-session")
|
payload = validate_token(token, "access", session_id="wrong-session")
|
||||||
assert payload is None
|
assert payload is None
|
||||||
|
|
||||||
def test_session_id_required(self):
|
def test_session_id_optional(self):
|
||||||
"""Access token is rejected without session_id parameter."""
|
"""Access token validates without session_id (used by WS auth)."""
|
||||||
token = generate_access_token(
|
token = generate_access_token(
|
||||||
"admin", {"firewall": "rw"}, session_id="my-session"
|
"admin", {"firewall": "rw"}, session_id="my-session"
|
||||||
)
|
)
|
||||||
|
# Without session_id, only type, expiry, and blacklist are checked
|
||||||
payload = validate_token(token, "access")
|
payload = validate_token(token, "access")
|
||||||
|
assert payload is not None
|
||||||
|
assert payload["sub"] == "admin"
|
||||||
|
|
||||||
|
def test_session_id_enforced_when_provided(self):
|
||||||
|
"""Access token is rejected when session_id is provided but doesn't match."""
|
||||||
|
token = generate_access_token(
|
||||||
|
"admin", {"firewall": "rw"}, session_id="my-session"
|
||||||
|
)
|
||||||
|
payload = validate_token(token, "access", session_id="different-session")
|
||||||
assert payload is None
|
assert payload is None
|
||||||
|
|
||||||
def test_decode_token(self):
|
def test_decode_token(self):
|
||||||
@@ -736,6 +746,72 @@ class TestWebAuthnCredentials:
|
|||||||
remove_credential("testuser", _FAKE_CRED_ID)
|
remove_credential("testuser", _FAKE_CRED_ID)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebAuthnBlueprintOwnership:
|
||||||
|
"""Test Flask blueprint enforces JWT username on WebAuthn registration.
|
||||||
|
|
||||||
|
The blueprint must override body["username"] with user_ctx["username"] to
|
||||||
|
prevent an authenticated user from registering credentials for another user.
|
||||||
|
|
||||||
|
This mirrors the pattern used by the change_password endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_register_begin_ownership_guard(self) -> None:
|
||||||
|
"""Blueprint forces username from JWT for register-begin."""
|
||||||
|
# Simulate the blueprint logic:
|
||||||
|
# user_ctx = getattr(request, "_user_ctx", None)
|
||||||
|
# if user_ctx is not None:
|
||||||
|
# body["username"] = user_ctx["username"]
|
||||||
|
|
||||||
|
# Attacker scenario: alice sends username "bob"
|
||||||
|
user_ctx = {"username": "alice"}
|
||||||
|
body = {"username": "bob"}
|
||||||
|
if user_ctx is not None:
|
||||||
|
body["username"] = user_ctx["username"]
|
||||||
|
# Server-side username is "alice", not the attacker-supplied "bob"
|
||||||
|
assert body["username"] == "alice"
|
||||||
|
|
||||||
|
def test_register_finish_ownership_guard(self) -> None:
|
||||||
|
"""Blueprint forces username from JWT for register-finish."""
|
||||||
|
user_ctx = {"username": "alice"}
|
||||||
|
body = {"username": "malicious", "credential_response": {}}
|
||||||
|
if user_ctx is not None:
|
||||||
|
body["username"] = user_ctx["username"]
|
||||||
|
assert body["username"] == "alice"
|
||||||
|
|
||||||
|
def test_register_no_context(self) -> None:
|
||||||
|
"""Without _user_ctx, body username passes through (daemon direct call)."""
|
||||||
|
user_ctx = None
|
||||||
|
body = {"username": "daemon_user"}
|
||||||
|
if user_ctx is not None:
|
||||||
|
body["username"] = user_ctx["username"]
|
||||||
|
# No override — body username preserved
|
||||||
|
assert body["username"] == "daemon_user"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenValidationEdgeCases:
|
||||||
|
"""Test token validation edge cases: session_id semantics, refresh tokens."""
|
||||||
|
|
||||||
|
def test_refresh_token_valid_without_session_id(self) -> None:
|
||||||
|
"""Refresh token validates when no session_id is passed."""
|
||||||
|
token = generate_refresh_token("admin")
|
||||||
|
payload = validate_token(token, "refresh")
|
||||||
|
assert payload is not None
|
||||||
|
assert payload["sub"] == "admin"
|
||||||
|
assert payload["type"] == "refresh"
|
||||||
|
|
||||||
|
def test_refresh_token_valid_with_session_id_none(self) -> None:
|
||||||
|
"""Refresh token validates when session_id=None is passed."""
|
||||||
|
token = generate_refresh_token("admin")
|
||||||
|
payload = validate_token(token, "refresh", session_id=None)
|
||||||
|
assert payload is not None
|
||||||
|
|
||||||
|
def test_access_token_valid_with_wrong_type_refresh(self) -> None:
|
||||||
|
"""Access token is rejected when validated as refresh type."""
|
||||||
|
token = generate_access_token("admin", {"firewall": "rw"}, session_id="sess")
|
||||||
|
payload = validate_token(token, "refresh")
|
||||||
|
assert payload is None
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Multi-user tests (Phase 3)
|
# Multi-user tests (Phase 3)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
+7
-3
@@ -259,13 +259,14 @@ def webauthn_register_begin():
|
|||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
POST /api/auth/webauthn/register-begin
|
POST /api/auth/webauthn/register-begin
|
||||||
Body:
|
|
||||||
{ "username": "..." }
|
|
||||||
Returns:
|
Returns:
|
||||||
Registration options for navigator.credentials.create()
|
Registration options for navigator.credentials.create()
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
|
user_ctx = getattr(request, "_user_ctx", None)
|
||||||
|
if user_ctx is not None:
|
||||||
|
body["username"] = user_ctx["username"]
|
||||||
origin, rp_id = _resolve_webauthn_origin()
|
origin, rp_id = _resolve_webauthn_origin()
|
||||||
body["webauthn_origin"] = origin
|
body["webauthn_origin"] = origin
|
||||||
body["webauthn_rp_id"] = rp_id
|
body["webauthn_rp_id"] = rp_id
|
||||||
@@ -282,12 +283,15 @@ def webauthn_register_finish():
|
|||||||
Endpoint:
|
Endpoint:
|
||||||
POST /api/auth/webauthn/register-finish
|
POST /api/auth/webauthn/register-finish
|
||||||
Body:
|
Body:
|
||||||
{ "username": "...", "credential_response": {...}, "registration_options": {...}, "name": "..." }
|
{ "credential_response": {...}, "registration_options": {...}, "name": "..." }
|
||||||
Returns:
|
Returns:
|
||||||
{ "ok": true, "credential": {...} }
|
{ "ok": true, "credential": {...} }
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
|
user_ctx = getattr(request, "_user_ctx", None)
|
||||||
|
if user_ctx is not None:
|
||||||
|
body["username"] = user_ctx["username"]
|
||||||
origin, rp_id = _resolve_webauthn_origin()
|
origin, rp_id = _resolve_webauthn_origin()
|
||||||
body["webauthn_origin"] = origin
|
body["webauthn_origin"] = origin
|
||||||
body["webauthn_rp_id"] = rp_id
|
body["webauthn_rp_id"] = rp_id
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ export async function apiFetch(url, options = {}) {
|
|||||||
if (res.status === 401 && getAuthToken()) {
|
if (res.status === 401 && getAuthToken()) {
|
||||||
const refreshed = await tryRefreshToken();
|
const refreshed = await tryRefreshToken();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
|
if (method === 'POST') {
|
||||||
|
redirectLogin();
|
||||||
|
return { ok: false, data: null, error: 'Session expired', status: 401 };
|
||||||
|
}
|
||||||
const refreshedStored = getStoredAuth();
|
const refreshedStored = getStoredAuth();
|
||||||
headers['Authorization'] = 'Bearer ' + getAuthToken();
|
headers['Authorization'] = 'Bearer ' + getAuthToken();
|
||||||
headers['X-Session-Id'] = refreshedStored.session_id;
|
headers['X-Session-Id'] = refreshedStored.session_id;
|
||||||
|
|||||||
@@ -209,21 +209,9 @@ const Page = definePage({
|
|||||||
document.title = 'Login — Vacuum Wall';
|
document.title = 'Login — Vacuum Wall';
|
||||||
},
|
},
|
||||||
|
|
||||||
async load(state, abortController) {
|
load() {
|
||||||
handleLogin();
|
handleLogin();
|
||||||
setupPasskeyButton();
|
setupPasskeyButton();
|
||||||
|
|
||||||
if (getAuthToken()) {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/auth/session', { signal: abortController?.signal });
|
|
||||||
if (res.ok) {
|
|
||||||
window.location.hash = '/dashboard';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// auth check failed, show login
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@@ -91,20 +91,11 @@ function addCredentialModal() {
|
|||||||
setModalProcessing(true);
|
setModalProcessing(true);
|
||||||
refreshModals();
|
refreshModals();
|
||||||
|
|
||||||
const user = JSON.parse(sessionStorage.getItem('vw:user') || 'null');
|
|
||||||
const username = user?.username || '';
|
|
||||||
if (!username) {
|
|
||||||
toast('Username not available', 'error');
|
|
||||||
setModalProcessing(false);
|
|
||||||
refreshModals();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get registration options
|
// Step 1: Get registration options (username from JWT)
|
||||||
const beginRes = await apiFetch('/api/auth/webauthn/register-begin', {
|
const beginRes = await apiFetch('/api/auth/webauthn/register-begin', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { username },
|
body: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!beginRes.ok) {
|
if (!beginRes.ok) {
|
||||||
@@ -117,11 +108,10 @@ function addCredentialModal() {
|
|||||||
const credentialName = document.getElementById('cred-name')?.value?.trim() || '';
|
const credentialName = document.getElementById('cred-name')?.value?.trim() || '';
|
||||||
const credentialResponse = await startRegistration(options);
|
const credentialResponse = await startRegistration(options);
|
||||||
|
|
||||||
// Step 3: Verify with server
|
// Step 3: Verify with server (username from JWT)
|
||||||
const finishRes = await apiFetch('/api/auth/webauthn/register-finish', {
|
const finishRes = await apiFetch('/api/auth/webauthn/register-finish', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
username,
|
|
||||||
credential_response: credentialResponse,
|
credential_response: credentialResponse,
|
||||||
registration_options: options,
|
registration_options: options,
|
||||||
name: credentialName,
|
name: credentialName,
|
||||||
|
|||||||
Reference in New Issue
Block a user