Add token-based auth for WebDAV/NTLM and API access

- Add Token model with CRUD endpoints (/api/tokens, /auth/tokens)
- Support Basic auth with token:<secret> for built-in users
- Implement full NTLMv2 handshake for Windows WebDAV clients
- Add SSO token auth via check_permissions() proxy
- Hydrate request auth context from session or Authorization header
- Persist session cookie after successful Authorization-based login
- Add secure flag to session cookies based on request scheme
- Add frontend UserTokensModal for creating/revoking tokens
- Fix devserver to run workspace source via python -m cista
- Add tests for token CRUD and file auth (Basic, NTLM, session)
- Remove proactive WWW-Authenticate advertisement
This commit is contained in:
Leo Vasanko
2026-04-26 04:58:46 +00:00
parent d1faedc011
commit de78b41be4
17 changed files with 1779 additions and 60 deletions
+207
View File
@@ -0,0 +1,207 @@
import base64
import hashlib
import hmac
import re
import struct
from pathlib import Path
from time import time
from uuid import uuid4
import jwt
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import auth, config, session, watching
from cista.app import use_session
from cista.fileserver import bp as fileserver_bp
def _basic_auth(username: str, password: str) -> dict[str, str]:
creds = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {creds}"}
def _ntlm_type1() -> dict[str, str]:
msg = b"NTLMSSP\x00" + struct.pack("<I", 1) + struct.pack("<I", 0x20080205)
return {"Authorization": f"NTLM {base64.b64encode(msg).decode()}"}
def _ntlm_type3(username: str, password: str, domain: str, challenge: bytes) -> dict[str, str]:
"""Build an NTLMv2 Type 3 message for testing."""
from Crypto.Hash import MD4
# NT hash
nt_hash = MD4.new(password.encode("utf-16le")).digest()
# NTLMv2 hash
ntlmv2_hash = hmac.new(nt_hash, (username.upper() + domain).encode("utf-16le"), hashlib.md5).digest()
# Build a minimal blob
timestamp = struct.pack("<Q", 0)
client_nonce = b"\x01" * 8
blob = b"\x01\x01\x00\x00\x00\x00\x00\x00" + timestamp + client_nonce + b"\x00\x00\x00\x00"
# NT proof
nt_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest()
nt_response = nt_proof + blob
domain_enc = domain.encode("utf-16le")
username_enc = username.encode("utf-16le")
workstation_enc = b""
lm_response = b"" # Empty for NTLMv2
# Build Type 3 message
msg = bytearray()
msg.extend(b"NTLMSSP\x00")
msg.extend(struct.pack("<I", 3))
# Security buffers offsets will be calculated
payload_start = 64
payloads = []
def add_buf(data: bytes):
offset = payload_start + sum(len(p) for p in payloads)
payloads.append(data)
return struct.pack("<HHI", len(data), len(data), offset)
lm_buf = add_buf(lm_response)
nt_buf = add_buf(nt_response)
domain_buf = add_buf(domain_enc)
user_buf = add_buf(username_enc)
ws_buf = add_buf(workstation_enc)
session_buf = add_buf(b"")
msg.extend(lm_buf)
msg.extend(nt_buf)
msg.extend(domain_buf)
msg.extend(user_buf)
msg.extend(ws_buf)
msg.extend(session_buf)
msg.extend(struct.pack("<I", 0x20080205))
for p in payloads:
msg.extend(p)
return {"Authorization": f"NTLM {base64.b64encode(bytes(msg)).decode()}"}
def _session_cookie_header(username: str) -> dict[str, str]:
token = jwt.encode(
{"exp": int(time()) + session.max_age, "username": username},
session.session_secret(),
algorithm="HS256",
)
return {"Cookie": f"s={token}"}
@pytest.fixture()
def setup_storage(tmp_path: Path):
user = config.User()
auth.set_password(user, "secret")
token = config.Token(key="test_token_123", username="alice")
config.config = config.Config(
path=tmp_path,
listen=":0",
public=False,
users={"alice": user},
tokens={"test_token_123": token},
)
watching.state.root = []
watching.rootpath = tmp_path
(tmp_path / "hello.txt").write_text("hello", encoding="utf-8")
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"files-auth-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
@app.on_request
async def load_auth_context(request):
await use_session(request)
app.blueprint(fileserver_bp)
yield app.asgi_client
@pytest.mark.asyncio
async def test_basic_auth_allows_private_file_access(client):
_, res = await client.get("/files/hello.txt", headers=_basic_auth("alice", "secret"))
assert res.status_code == 200
assert res.body == b"hello"
assert "set-cookie" not in res.headers
@pytest.mark.asyncio
async def test_basic_auth_with_invalid_creds_falls_back_to_session_cookie(client):
_, res = await client.get(
"/files/hello.txt",
headers={**_basic_auth("alice", "wrong"), **_session_cookie_header("alice")},
)
assert res.status_code == 200
@pytest.mark.asyncio
async def test_options_unauthenticated_allowed(client):
_, res = await client.options("/files/")
assert res.status_code == 200
@pytest.mark.asyncio
async def test_unauthenticated_sends_no_auth_challenge(client):
_, res = await client.request("PROPFIND", "/files/")
assert res.status_code == 401
assert "www-authenticate" not in res.headers
@pytest.mark.asyncio
async def test_basic_auth_with_token(client):
_, res = await client.get("/files/hello.txt", headers=_basic_auth("token", "test_token_123"))
assert res.status_code == 200
assert res.body == b"hello"
@pytest.mark.asyncio
async def test_browser_unauthenticated_sends_cookie_challenge(client):
_, res = await client.get("/files/", headers={"Accept": "text/html,application/xhtml+xml"})
assert res.status_code == 401
assert res.headers.get("www-authenticate", "").lower().startswith("cookie")
@pytest.mark.asyncio
async def test_ntlm_auth_with_token(client):
# Step 1: request without auth should NOT advertise NTLM
# (we prefer clients use BASIC; NTLM still works if client initiates it)
_, res1 = await client.get("/files/hello.txt")
assert res1.status_code == 401
assert "ntlm" not in res1.headers.get("www-authenticate", "").lower()
# Step 2: client proactively sends Type 1, gets Type 2 challenge
_, res2 = await client.get("/files/hello.txt", headers=_ntlm_type1())
assert res2.status_code == 401
auth_hdr = res2.headers.get("www-authenticate", "")
assert auth_hdr.lower().startswith("ntlm ")
type2_data = base64.b64decode(auth_hdr.split(" ", 1)[1])
challenge = type2_data[24:32]
# Step 3: send Type 3 with token as password
_, res3 = await client.get(
"/files/hello.txt",
headers=_ntlm_type3("anyuser", "test_token_123", "WORKGROUP", challenge),
)
assert res3.status_code == 200
assert res3.body == b"hello"
+192
View File
@@ -0,0 +1,192 @@
from pathlib import Path
from time import time
from uuid import uuid4
import os
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import auth, config, watching
from cista.auth import bp as auth_bp
def _persist_config():
import msgspec
from pathlib import PurePath
def enc_hook(obj):
if isinstance(obj, PurePath):
return obj.as_posix()
raise TypeError
raw = msgspec.to_builtins(config.config, enc_hook=enc_hook)
config.conffile.write_bytes(msgspec.toml.encode(raw))
@pytest.fixture()
def setup_storage(tmp_path: Path):
os.environ["CISTA_HOME"] = str(tmp_path)
config.init_confdir()
user = config.User()
auth.set_password(user, "secret")
admin = config.User(privileged=True)
auth.set_password(admin, "secret")
config.config = config.Config(
path=tmp_path,
listen=":0",
public=False,
users={"alice": user, "admin": admin},
)
_persist_config()
watching.state.root = []
watching.rootpath = tmp_path
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"token-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(auth_bp)
yield app.asgi_client
def _basic_auth(username: str, password: str) -> str:
return f"Basic {__import__('base64').b64encode(f'{username}:{password}'.encode()).decode()}"
@pytest.mark.asyncio
async def test_token_crud(client):
# Admin creates a token without specifying username (auto-assigned)
_, res = await client.post(
"/auth/tokens",
json={"name": "test"},
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
data = res.json
assert "id" in data
assert "key" in data
assert data["username"] == "admin"
assert data["name"] == "test"
token_id = data["id"]
token_key = data["key"]
# List tokens - admin sees only their own
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
tokens = res.json["tokens"]
assert len(tokens) == 1
assert tokens[0]["id"] == token_id
assert tokens[0]["username"] == "admin"
# Use token via Basic auth (token:<secret>)
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("token", token_key)},
)
assert res.status_code == 200
# Delete token
_, res = await client.delete(
f"/auth/tokens/{token_id}",
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
# List should be empty
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
assert len(res.json["tokens"]) == 0
@pytest.mark.asyncio
async def test_token_user_scoped(client):
# Alice creates a token for herself (no username specified)
_, res = await client.post(
"/auth/tokens",
json={"name": "alice-token"},
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 200
alice_token_id = res.json["id"]
alice_token_key = res.json["key"]
# Admin creates a token for themselves
_, res = await client.post(
"/auth/tokens",
json={"name": "admin-token"},
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
admin_token_id = res.json["id"]
# Alice lists tokens - sees only her own
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 200
tokens = res.json["tokens"]
assert len(tokens) == 1
assert tokens[0]["id"] == alice_token_id
assert tokens[0]["username"] == "alice"
# Admin lists tokens - sees only their own
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("admin", "secret")},
)
assert res.status_code == 200
tokens = res.json["tokens"]
assert len(tokens) == 1
assert tokens[0]["id"] == admin_token_id
assert tokens[0]["username"] == "admin"
# Alice cannot create a token for admin
_, res = await client.post(
"/auth/tokens",
json={"username": "admin", "name": "impersonation"},
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 403
# Alice cannot delete admin's token
_, res = await client.delete(
f"/auth/tokens/{admin_token_id}",
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 403
# Alice can delete her own token
_, res = await client.delete(
f"/auth/tokens/{alice_token_id}",
headers={"Authorization": _basic_auth("alice", "secret")},
)
assert res.status_code == 200
# Alice's token auth still works until deletion is processed
# Verify token auth worked during the test
_, res = await client.get(
"/auth/tokens",
headers={"Authorization": _basic_auth("token", alice_token_key)},
)
# Token was deleted above, so this should now be unauthenticated
# Actually the token key lookup will fail, and since there's no session fallback...
# With auth header present but invalid, it should return 401
assert res.status_code == 401