From de78b41be4ab5ea0b3c44819a5cc68d6fc002feb Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 26 Apr 2026 04:58:46 +0000 Subject: [PATCH] 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: 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 --- cista/api.py | 21 + cista/app.py | 95 +- cista/auth.py | 915 +++++++++++++++++- cista/config.py | 35 + cista/sanic_logging.py | 5 +- cista/session.py | 8 +- cista/sso.py | 55 ++ cista/util/apphelpers.py | 7 +- frontend/src/App.vue | 2 + frontend/src/components/HeaderMain.vue | 4 + .../src/components/UserManagementModal.vue | 6 +- frontend/src/components/UserTokensModal.vue | 264 +++++ frontend/src/repositories/User.ts | 17 + frontend/src/stores/main.ts | 2 +- scripts/devserver.py | 4 +- tests/test_files_auth.py | 207 ++++ tests/test_tokens.py | 192 ++++ 17 files changed, 1779 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/UserTokensModal.vue create mode 100644 tests/test_files_auth.py create mode 100644 tests/test_tokens.py diff --git a/cista/api.py b/cista/api.py index ec74d95..82f8148 100644 --- a/cista/api.py +++ b/cista/api.py @@ -7,6 +7,11 @@ from sanic import Blueprint, json from sanic.exceptions import BadRequest from cista import __version__, auth, config, sso, watching +from cista.auth import ( + create_token_handler, + delete_token_handler, + list_tokens_handler, +) from cista.fileio import FileServer from cista.util.apphelpers import websocket_wrapper @@ -132,3 +137,19 @@ async def update_name(request): # Return the effective name (fallback to path.name if empty) effective_name = name or config.config.path.name return json({"message": "Server name updated", "name": effective_name}) + + +# Token management endpoints (available in all modes; primary path in SSO mode) +@bp.get("tokens") +async def list_api_tokens(request): + return await list_tokens_handler(request) + + +@bp.post("tokens") +async def create_api_token(request): + return await create_token_handler(request) + + +@bp.delete("tokens/") +async def delete_api_token(request, token_id): + return await delete_token_handler(request, token_id) diff --git a/cista/app.py b/cista/app.py index 01cc33f..18b17ba 100644 --- a/cista/app.py +++ b/cista/app.py @@ -40,54 +40,13 @@ app.router.ALLOWED_METHODS = ( ) configure_main_logging() -# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL -if sso.paskia_enabled(): - app.blueprint(sso.bp) # SSO proxy for /auth/* routes -else: - app.blueprint(auth.bp) # Built-in auth routes -app.blueprint(preview.bp) -app.blueprint(bp) -app.blueprint(fileserver.bp) -app.exception(Exception)(handle_sanic_exception) - - -setproctitle("cista-main") - - -@app.before_server_start -async def main_start(app): - config.load_config() - setproctitle(f"cista {config.config.path.name}") - app.ctx.threadexec = ThreadPoolExecutor( - max_workers=4, thread_name_prefix="cista-worker" - ) - # Larger pool for long-running but low-memory zip operations - app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip") - await start_preview_workers() - watching.start(app) - - -# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers) -@app.before_server_stop -async def main_stop(app): - watching.stop(app) - await shutdown_preview_workers() - app.ctx.threadexec.shutdown() - app.ctx.zipexec.shutdown(cancel_futures=True) - await sso.close_client() - logger.debug("Cista worker threads all finished") @app.on_request async def use_session(req): req.ctx._log_start = time.perf_counter() - req.ctx.session = session.get(req) - try: - req.ctx.username = req.ctx.session["username"] # type: ignore - req.ctx.user = config.config.users[req.ctx.username] - except (AttributeError, KeyError, TypeError): - req.ctx.username = None - req.ctx.user = None + req.ctx._auth_flow = ["session: start"] + auth.hydrate_request_auth_context(req, source="app.on_request") # CSRF protection if req.method == "GET" and req.headers.upgrade != "websocket": return # Ordinary GET requests are fine @@ -129,6 +88,56 @@ async def forward_sso_cookies(req, res): res.headers.add("set-cookie", cookie) +@app.on_response +async def persist_auth_session(req, res): + """Persist a session cookie after successful Authorization-based auth.""" + username = getattr(req.ctx, "_create_session_username", None) + if not username or res.status >= 400: + return + existing = getattr(req.ctx, "session", None) + if isinstance(existing, dict) and existing.get("username") == username: + return + session.create(res, username, secure=req.scheme == "https") + + +# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL +if sso.paskia_enabled(): + app.blueprint(sso.bp) # SSO proxy for /auth/* routes +else: + app.blueprint(auth.bp) # Built-in auth routes +app.blueprint(preview.bp) +app.blueprint(bp) +app.blueprint(fileserver.bp) +app.exception(Exception)(handle_sanic_exception) + + +setproctitle("cista-main") + + +@app.before_server_start +async def main_start(app): + config.load_config() + setproctitle(f"cista {config.config.path.name}") + app.ctx.threadexec = ThreadPoolExecutor( + max_workers=4, thread_name_prefix="cista-worker" + ) + # Larger pool for long-running but low-memory zip operations + app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip") + await start_preview_workers() + watching.start(app) + + +# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers) +@app.before_server_stop +async def main_stop(app): + watching.stop(app) + await shutdown_preview_workers() + app.ctx.threadexec.shutdown() + app.ctx.zipexec.shutdown(cancel_futures=True) + await sso.close_client() + logger.debug("Cista worker threads all finished") + + www = {} diff --git a/cista/auth.py b/cista/auth.py index f558cd0..9074513 100644 --- a/cista/auth.py +++ b/cista/auth.py @@ -1,5 +1,10 @@ +import base64 +import binascii import hmac +import hashlib import re +import secrets +import struct from time import time from unicodedata import normalize @@ -8,6 +13,7 @@ import msgspec from html5tagger import Document from sanic import Blueprint, html, json, redirect from sanic.exceptions import BadRequest, Forbidden, Unauthorized +from sanic.log import logger from cista import config, session from cista.util import pwgen @@ -180,15 +186,344 @@ def _get_sso(): return _sso_module +def _set_auth_failure_log(request, auth_flow: list[str]) -> None: + parts = list(auth_flow) + # Only add request headers that are present and useful for debugging + for header, label in ( + ("accept", "accept"), + ("origin", "origin"), + ("referer", "referer"), + ("sec-fetch-site", "site"), + ("sec-fetch-mode", "mode"), + ("sec-fetch-dest", "dest"), + ): + value = request.headers.get(header) + if value: + parts.append(f"{label}={value}") + request.ctx._log_extra = " | ".join(parts) + + +def hydrate_request_auth_context(request, *, source: str) -> None: + auth_flow = getattr(request.ctx, "_auth_flow", None) + if auth_flow is None: + auth_flow = request.ctx._auth_flow = [] + + if hasattr(request.ctx, "session"): + # Already hydrated by an earlier caller (e.g., use_session middleware) + return + + request.ctx.session = session.get(request) + if request.ctx.session is None: + request.ctx.username = None + request.ctx.user = None + auth_flow.append(f"session:{source}(none)") + elif request.ctx.session is False: + request.ctx.username = None + request.ctx.user = None + auth_flow.append(f"session:{source}(invalid)") + else: + try: + request.ctx.username = request.ctx.session["username"] # type: ignore[index] + request.ctx.user = config.config.users[request.ctx.username] + auth_flow.append(f"session:{source}({request.ctx.username})") + except (AttributeError, KeyError, TypeError): + request.ctx.username = None + request.ctx.user = None + auth_flow.append(f"session:{source}(bad-jwt)") + + _argon = argon2.PasswordHasher() _droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$") +_AUTH_REALM = "cista" +_AUTH_CACHE_TTL = 10 +_auth_cache: dict[str, tuple[float, config.User]] = {} +_WINDOWS_UA_HINTS = ( + "windows", + "microsoft-webdav-miniredir", + "davclnt", +) +_WEBDAV_METHODS = { + "OPTIONS", + "PROPFIND", + "MKCOL", + "COPY", + "MOVE", + "LOCK", + "UNLOCK", +} +_seen_webdav_uas: set[str] = set() + +# NTLM challenge storage: global rolling window of random challenges. +# Challenges are always generated with secrets.token_bytes; no client-IP or +# request-order keying is used so parallel requests do not overwrite state. +_ntlm_challenges: list[tuple[float, bytes]] = [] +_NTLM_CHALLENGE_TTL = 30 +_NTLM_CHALLENGE_MAX = 64 + + +def _is_windows_auth_client(user_agent: str) -> bool: + ua = user_agent.casefold() + return any(marker in ua for marker in _WINDOWS_UA_HINTS) + + +def _log_webdav_user_agent_once(request, user_agent: str): + if request.method not in _WEBDAV_METHODS: + return + key = (user_agent or "").strip() or "" + if key in _seen_webdav_uas: + return + _seen_webdav_uas.add(key) + # Temporary stdout print so operators can quickly capture real client UAs. + print(f"WebDAV User-Agent observed: {key} path={request.path}") + + +def _build_ua_auth_headers(request, *, include_hint=False) -> dict[str, str]: + return {} + + +def _cleanup_ntlm_challenges(): + now = time() + _ntlm_challenges[:] = [ + (ts, challenge) + for ts, challenge in _ntlm_challenges + if now - ts <= _NTLM_CHALLENGE_TTL + ] + + +def _set_ntlm_challenge(challenge: bytes): + _cleanup_ntlm_challenges() + _ntlm_challenges.append((time(), challenge)) + if len(_ntlm_challenges) > _NTLM_CHALLENGE_MAX: + del _ntlm_challenges[:-_NTLM_CHALLENGE_MAX] + + +def _get_ntlm_challenges() -> list[bytes]: + _cleanup_ntlm_challenges() + # Try newest challenge first; older ones are fallback for request races. + return [challenge for _, challenge in reversed(_ntlm_challenges)] + + +def _ntlm_parse_type1(data: bytes) -> dict: + if len(data) < 16 or data[:7] != b"NTLMSSP" or data[7] != 0: + return {} + msg_type = struct.unpack(" bytes: + target = target_name.encode("utf-16le") + + # AV pairs for TargetInfo: NetBIOS + DNS names, terminated by EOL. + av_pairs = bytearray() + av_pairs.extend(struct.pack(" bytes: + if n < 0x80: + return bytes([n]) + b = n.to_bytes((n.bit_length() + 7) // 8, "big") + return bytes([0x80 | len(b)]) + b + + +def _der_tlv(tag: int, value: bytes) -> bytes: + return bytes([tag]) + _der_len(len(value)) + value + + +def _spnego_wrap_ntlm_challenge(ntlm_type2: bytes) -> bytes: + """Wrap an NTLM Type 2 token in SPNEGO NegTokenResp. + + Some Windows clients send SPNEGO-wrapped Negotiate tokens and require + a SPNEGO-wrapped response token rather than raw NTLMSSP. + """ + # OID 1.3.6.1.4.1.311.2.2.10 (NTLMSSP) + ntlm_oid = bytes.fromhex("060a2b06010401823702020a") + neg_state_accept_incomplete = _der_tlv(0xA0, _der_tlv(0x0A, b"\x01")) + supported_mech = _der_tlv(0xA1, ntlm_oid) + response_token = _der_tlv(0xA2, _der_tlv(0x04, ntlm_type2)) + neg_token_resp = _der_tlv( + 0xA1, + _der_tlv( + 0x30, + neg_state_accept_incomplete + supported_mech + response_token, + ), + ) + return neg_token_resp + + +def _ntlm_parse_type3(data: bytes) -> dict | None: + if len(data) < 64 or data[:7] != b"NTLMSSP" or data[7] != 0: + return None + msg_type = struct.unpack(" bytes: + length, max_len, buf_offset = struct.unpack(" len(data): + return b"" + return data[buf_offset : buf_offset + length] + + lm_response = read_buf(12) + nt_response = read_buf(20) + domain = read_buf(28) + username = read_buf(36) + workstation = read_buf(44) + + return { + "lm_response": lm_response, + "nt_response": nt_response, + "domain": domain.decode("utf-16le", errors="ignore"), + "username": username.decode("utf-16le", errors="ignore"), + "workstation": workstation.decode("utf-16le", errors="ignore"), + } + + +def _ntlmv2_verify( + token_secret: str, + username: str, + domain: str, + challenge: bytes, + nt_response: bytes, +) -> bool: + """Verify an NTLMv2 response using the plaintext token secret as the password.""" + try: + from Crypto.Hash import MD4 + except ImportError: + logger.error("pycryptodome MD4 not available, cannot verify NTLM") + return False + + if len(nt_response) < 16: + return False + + client_proof = nt_response[:16] + blob = nt_response[16:] + + # NT hash = MD4(UTF-16LE(password)) + nt_hash = MD4.new(token_secret.encode("utf-16le")).digest() + + raw_username = username or "" + raw_domain = domain or "" + + # Windows clients vary in how they populate Username/Domain fields. + user_candidates: list[str] = [] + domain_candidates: list[str] = [] + + def _add_user(value: str): + if value and value not in user_candidates: + user_candidates.append(value) + + def _add_domain(value: str): + if value not in domain_candidates: + domain_candidates.append(value) + + _add_user(raw_username) + _add_user(raw_username.upper()) + _add_domain(raw_domain) + _add_domain(raw_domain.upper()) + _add_domain("") + + if "\\" in raw_username: + dom_part, user_part = raw_username.split("\\", 1) + _add_user(user_part) + _add_user(user_part.upper()) + _add_domain(dom_part) + _add_domain(dom_part.upper()) + + if "@" in raw_username: + user_part, dom_part = raw_username.split("@", 1) + _add_user(user_part) + _add_user(user_part.upper()) + _add_domain(dom_part) + _add_domain(dom_part.upper()) + + for user_candidate in user_candidates: + for domain_candidate in domain_candidates: + # NTLMv2 hash = HMAC_MD5(NT_hash, UTF-16LE(username.upper() + domain)) + ntlmv2_hash = hmac.new( + nt_hash, + (user_candidate.upper() + domain_candidate).encode("utf-16le"), + hashlib.md5, + ).digest() + + # Expected proof = HMAC_MD5(NTLMv2_hash, challenge + blob) + expected_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest() + if hmac.compare_digest(client_proof, expected_proof): + return True + + return False + def _pwnorm(password): return normalize("NFC", password).strip().encode() +def _cache_key(username: str, password: str) -> str: + return hashlib.sha256(f"{username}\x00{password}".encode()).hexdigest() + + def login(username: str, password: str): + cache_key = _cache_key(username, password) + cached = _auth_cache.get(cache_key) + if cached: + ts, user = cached + if time() - ts < _AUTH_CACHE_TTL: + return user + del _auth_cache[cache_key] + un = _pwnorm(username) pw = _pwnorm(password) try: @@ -218,11 +553,13 @@ def login(username: str, password: str): set_password(u, password) now = int(time()) u.lastSeen = now + _auth_cache[cache_key] = (now, u) return u def set_password(user: config.User, password: str): user.hash = _argon.hash(_pwnorm(password)) + _auth_cache.clear() class LoginResponse(msgspec.Struct): @@ -231,6 +568,306 @@ class LoginResponse(msgspec.Struct): error: str = "" +def _basic_auth_login(request): + """Authenticate built-in users from an Authorization: Basic header. + + Supports two credential formats: + - Basic : (normal password login) + - Basic token: (token-based login) + """ + auth_header = request.headers.get("authorization") + if not auth_header: + return None + + scheme, _, encoded = auth_header.partition(" ") + if scheme.lower() != "basic": + return None # e.g. Negotiate/NTLM — ignore for this auth path + if not encoded: + raise Unauthorized("Invalid Authorization header", quiet=True) + + try: + raw = base64.b64decode(encoded, validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError) as e: + raise Unauthorized("Invalid Authorization header", quiet=True) from e + + username, sep, password = raw.partition(":") + if not sep: + raise Unauthorized("Invalid Authorization header", quiet=True) + + # Token auth: Basic token: + if username == "token": + token = config.config.tokens.get(password) + if token: + user = config.config.users.get(token.username) + if user: + request.ctx.session = None + request.ctx.username = token.username + request.ctx.user = user + user.lastSeen = int(time()) + return user + raise Unauthorized("Invalid token", quiet=True) + + # Password auth + try: + user = login(username, password) + except ValueError as e: + raise Unauthorized(str(e), quiet=True) from e + + request.ctx.session = None + request.ctx.username = username + request.ctx.user = user + return user + + + + + +async def _token_auth_login(request, privileged=False): + """Authenticate via Basic token: in SSO mode. + + Returns True if authenticated, False if no token matched. + Raises Unauthorized/Forbidden on invalid token or insufficient permissions. + """ + auth_header = request.headers.get("authorization", "") + if not auth_header: + return False + + scheme, _, value = auth_header.partition(" ") + if scheme.lower() != "basic": + return False + + try: + raw = base64.b64decode(value, validate=True).decode("utf-8") + username, _, password = raw.partition(":") + except Exception: + return False + + if username != "token" or not password: + return False + + token = config.config.tokens.get(password) + if not token: + return False + + sso = _get_sso() + if sso.paskia_enabled() and token.sso_user_id: + perm = "cista:admin" if privileged else "cista:login" + try: + data = await sso.check_permissions(token.sso_user_id, perm) + request.ctx.sso_user = data + ctx = data.get("ctx", {}) if isinstance(data, dict) else {} + user_info = ctx.get("user", {}) if isinstance(ctx, dict) else {} + request.ctx.username = user_info.get("display_name", "") + return True + except Forbidden: + raise + except Exception: + return False + + if token.username: + user = config.config.users.get(token.username) + if not user: + return False + if privileged and not user.privileged: + return False + request.ctx.session = None + request.ctx.username = token.username + request.ctx.user = user + user.lastSeen = int(time()) + return True + + return False + + +async def _ntlm_auth_login(request, privileged=False): + """Handle NTLM authentication for token-based login. + + Supports NTLMv2 responses where the token secret is used as the password. + State is kept in-memory keyed by client IP. + """ + auth_header = request.headers.get("authorization", "") + if not auth_header: + return None + + scheme, _, encoded = auth_header.partition(" ") + if scheme.lower() not in ("ntlm", "negotiate"): + return None + + www_auth_scheme = "Negotiate" if scheme.lower() == "negotiate" else "NTLM" + client_key = request.client_ip or "unknown" + spnego_wrapped = False + + try: + data = base64.b64decode(encoded) + except Exception: + logger.warning("NTLM decode failed: client=%s", client_key) + raise Unauthorized("Invalid NTLM message", www_auth_scheme, quiet=True) + + # Windows commonly sends SPNEGO-wrapped Negotiate tokens that embed NTLMSSP. + # Extract the NTLMSSP blob when present so downstream parsing sees raw Type 1/3. + marker = b"NTLMSSP\x00" + marker_pos = data.find(marker) + if marker_pos == 0: + pass + elif marker_pos > 0: + spnego_wrapped = True + data = data[marker_pos:] + else: + logger.warning("NTLM token missing NTLMSSP marker: client=%s", client_key) + + if len(data) < 12: + logger.warning("NTLM message too short: client=%s bytes=%d", client_key, len(data)) + raise Unauthorized("Invalid NTLM message", www_auth_scheme, quiet=True) + + msg_type = struct.unpack("") +async def delete_token(request, token_id): + return await delete_token_handler(request, token_id) diff --git a/cista/config.py b/cista/config.py index 9ae56c7..500f231 100644 --- a/cista/config.py +++ b/cista/config.py @@ -22,6 +22,7 @@ class Config(msgspec.Struct): name: str = "" users: dict[str, User] = {} links: dict[str, Link] = {} + tokens: dict[str, Token] = {} # Typing: arguments for config-modifying functions @@ -43,6 +44,14 @@ class Link(msgspec.Struct, omit_defaults=True): expires: int = 0 +class Token(msgspec.Struct, omit_defaults=True): + key: str = "" # plain text secret (shown once on creation) + username: str = "" # set in built-in mode + sso_user_id: str = "" # set in SSO mode + name: str = "" + created: int = 0 # noqa: N815 + + # Global variables - initialized during application startup config: Config conffile: Path @@ -204,3 +213,29 @@ def del_user(conf: Config, name: str) -> Config: settings = msgspec.to_builtins(conf, enc_hook=enc_hook) settings["users"].pop(name) return msgspec.convert(settings, Config, dec_hook=dec_hook) + + +@modifies_config +def update_token(conf: Config, token_id: str, changes: dict) -> Config: + """Create or update a token.""" + try: + t = msgspec.convert( + msgspec.to_builtins(conf.tokens[token_id], enc_hook=enc_hook), + Token, + dec_hook=dec_hook, + ) + except KeyError: + t = Token() + tdict = msgspec.to_builtins(t, enc_hook=enc_hook) + tdict.update(changes) + settings = msgspec.to_builtins(conf, enc_hook=enc_hook) + settings["tokens"][token_id] = msgspec.convert(tdict, Token, dec_hook=dec_hook) + return msgspec.convert(settings, Config, dec_hook=dec_hook) + + +@modifies_config +def del_token(conf: Config, token_id: str) -> Config: + """Delete a token by its stable id.""" + settings = msgspec.to_builtins(conf, enc_hook=enc_hook) + settings["tokens"].pop(token_id, None) + return msgspec.convert(settings, Config, dec_hook=dec_hook) diff --git a/cista/sanic_logging.py b/cista/sanic_logging.py index 7283922..4d91709 100644 --- a/cista/sanic_logging.py +++ b/cista/sanic_logging.py @@ -201,7 +201,7 @@ WS_CLOSE_CODES = { } -def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None: +def log_ws_close(ws_id: int, close_code: int | None, duration: float, extra: str | None = None) -> None: """Log WebSocket connection close with duration and status.""" id_str = _format_ws_id(ws_id) timing = format_duration_ms(duration * 1000) @@ -216,8 +216,9 @@ def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None: method_str = _format_method_label("closed", color=_TIMING) status_str = f"{_WS_STATUS}{code} {status}{_RESET}" timing_str = f"{_TIMING}{timing}{_RESET}" + extra_str = f" {_TIMING}{extra}{_RESET}" if extra else "" - logger.info("%s %s %s %s %s", " " * 19, id_str, method_str, status_str, timing_str) + logger.info("%s %s %s %s %s%s", " " * 19, id_str, method_str, status_str, timing_str, extra_str) def configure_access_logging() -> None: diff --git a/cista/session.py b/cista/session.py index 6075bcb..d5f3bb0 100644 --- a/cista/session.py +++ b/cista/session.py @@ -19,21 +19,21 @@ def get(request): return False if "s" in request.cookies else None -def create(res, username, **kwargs): +def create(res, username, *, secure: bool = True, **kwargs): data = { "exp": int(time()) + max_age, "username": username, **kwargs, } s = jwt.encode(data, session_secret()) - res.cookies.add_cookie("s", s, httponly=True, max_age=max_age) + res.cookies.add_cookie("s", s, httponly=True, max_age=max_age, secure=secure) -def update(res, s, **kwargs): +def update(res, s, *, secure: bool = True, **kwargs): s.update(kwargs) s = jwt.encode(s, session_secret()) max_age = max(1, s["exp"] - int(time())) # type: ignore - res.cookies.add_cookie("s", s, httponly=True, max_age=max_age) + res.cookies.add_cookie("s", s, httponly=True, max_age=max_age, secure=secure) def delete(res): diff --git a/cista/sso.py b/cista/sso.py index 97c0588..3af4491 100644 --- a/cista/sso.py +++ b/cista/sso.py @@ -152,6 +152,61 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | ) +async def check_permissions(user_id: str, perm: str) -> dict: + """Check if a Paskia user has the given permission. + + Args: + user_id: The Paskia user UUID + perm: Permission to check (e.g. cista:login or cista:admin) + + Returns: + User info dict if permission is granted + + Raises: + Forbidden: If permission is denied or check fails + SanicException: If the auth service is unreachable + """ + if not paskia_enabled(): + raise ValueError("Paskia not enabled") + + client = await get_client() + url = f"{PASKIA_BACKEND_URL}/auth/api/check-permissions" + + try: + response = await client.post( + url, + json={"user_id": user_id, "perm": perm}, + headers={"accept": "application/json"}, + ) + + if response.status_code == 200: + return response.json() + + try: + error_data = response.json() + except Exception: + error_data = {"detail": response.text or "Permission check failed"} + + if response.status_code == 403: + raise Forbidden( + error_data.get("detail", "Access denied"), + quiet=True, + ) + else: + raise Forbidden( + error_data.get("detail", "Permission check failed"), + quiet=True, + ) + + except httpx.RequestError as e: + logger.error(f"Permission check {url} network error: {e}") + raise SanicException( + "Authentication service unavailable", + status_code=502, + quiet=True, + ) + + async def proxy_auth_request(request): """Proxy a request to the auth backend. diff --git a/cista/util/apphelpers.py b/cista/util/apphelpers.py index 497c681..d1174cd 100644 --- a/cista/util/apphelpers.py +++ b/cista/util/apphelpers.py @@ -24,10 +24,12 @@ def jres(data, **kwargs): async def handle_sanic_exception(request, e): context, code = {}, 500 + headers = None message = str(e) if isinstance(e, SanicException): context = e.context or {} code = e.status_code + headers = getattr(e, "headers", None) if not message or not request.app.debug and code == 500: message = "Internal Server Error" message = f"⚠️ {message}" if code < 500 else f"🛑 {message}" @@ -41,6 +43,7 @@ async def handle_sanic_exception(request, e): return jres( response_data, status=code, + headers=headers, ) # Redirections flash the error message via cookies if "redirect" in context: @@ -60,6 +63,7 @@ def websocket_wrapper(handler): extra = username if username else None start = time.perf_counter() ws_id = log_ws_open(request, extra=extra) + close_extra = None try: await auth.verify(request) await handler(request, ws, *args, **kwargs) @@ -72,6 +76,7 @@ def websocket_wrapper(handler): await asend(ws, ErrorMsg({"code": code, "message": message, **context})) if not getattr(e, "quiet", False) or code == 500: logger.exception(f"{code} {e!r}") + close_extra = f"{code} {message}" raise finally: duration = time.perf_counter() - start @@ -86,6 +91,6 @@ def websocket_wrapper(handler): close_code = p.close_code except AttributeError: pass - log_ws_close(ws_id, close_code, duration) + log_ws_close(ws_id, close_code, duration, extra=close_extra) return wrapper diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 54ecd38..6171f11 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,6 +7,7 @@ +
@@ -35,6 +36,7 @@ import Router from '@/router/index' import type { SortOrder } from './utils/docsort' import type SettingsModalVue from './components/SettingsModal.vue' import UserManagementModal from './components/UserManagementModal.vue' +import UserTokensModal from './components/UserTokensModal.vue' import AccessDeniedModal from './components/AccessDeniedModal.vue' import SelectionToolbar from './components/SelectionToolbar.vue' diff --git a/frontend/src/components/HeaderMain.vue b/frontend/src/components/HeaderMain.vue index 42495d7..233a37c 100644 --- a/frontend/src/components/HeaderMain.vue +++ b/frontend/src/components/HeaderMain.vue @@ -105,6 +105,10 @@ const settingsMenu = (e: Event) => { items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }}) } + if (store.user.isLoggedIn) { + items.push({ label: '🔑 API Tokens', onClick: () => { store.dialog = 'tokens' }}) + } + if (store.user.privileged) { items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }}) } diff --git a/frontend/src/components/UserManagementModal.vue b/frontend/src/components/UserManagementModal.vue index 64344c1..f7dccbc 100644 --- a/frontend/src/components/UserManagementModal.vue +++ b/frontend/src/components/UserManagementModal.vue @@ -188,14 +188,14 @@ const deleteUserAction = async (username: string) => { } const copySuccess = async (isButtonClick: boolean = false) => { - const passwordMatch = success.value.match(/(?:Password|New password): (.+)/) + const passwordMatch = success.value.match(/(?:Password|New password|Key): (.+)/) if (passwordMatch) { await navigator.clipboard.writeText(passwordMatch[1]!) if (isButtonClick) { // Show "Copied!" indication on button copyButtonText.value = '✅ Copied!' - // Hide password and button immediately after copying - const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!') + // Hide password/key and button immediately after copying + const baseMessage = success.value.replace(/(?:Password|New password|Key): .+/, 'Copied to clipboard!') success.value = baseMessage // Hide the entire message after 3 seconds setTimeout(() => { diff --git a/frontend/src/components/UserTokensModal.vue b/frontend/src/components/UserTokensModal.vue new file mode 100644 index 0000000..e761eb1 --- /dev/null +++ b/frontend/src/components/UserTokensModal.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/frontend/src/repositories/User.ts b/frontend/src/repositories/User.ts index b2f6cc5..7fc6aad 100644 --- a/frontend/src/repositories/User.ts +++ b/frontend/src/repositories/User.ts @@ -65,3 +65,20 @@ export async function getServerConfig() { const data = await Client.get('/api/config') return data as { name: string, public: boolean } } + +export const url_tokens = '/api/tokens' + +export async function listTokens() { + const data = await Client.get(url_tokens) + return data +} + +export async function createToken(name: string) { + const data = await Client.post(url_tokens, { name }) + return data +} + +export async function deleteToken(tokenId: string) { + const data = await Client.delete(`${url_tokens}/${tokenId}`) + return data +} diff --git a/frontend/src/stores/main.ts b/frontend/src/stores/main.ts index 060aeb2..7438be9 100644 --- a/frontend/src/stores/main.ts +++ b/frontend/src/stores/main.ts @@ -80,7 +80,7 @@ export const useMainStore = defineStore('main', { authInProgress: false, cursor: '' as string, server: {} as Record & { public?: boolean, paskia?: boolean }, - dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied', + dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens', uprogress: {} as any, dprogress: {} as any, prefs: { diff --git a/scripts/devserver.py b/scripts/devserver.py index 43ee766..22c79e5 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -44,7 +44,9 @@ def setup_sanic_backend( port = opts.get("port", DEFAULT_BACKEND_PORT) host = opts.get("host", "localhost") or "localhost" - cmd = ["cista", "--dev", "-l", listen] + extra_args + # Use the current interpreter/module path so devserver always runs + # workspace source code instead of a potentially stale installed script. + cmd = [sys.executable, "-m", "cista", "--dev", "-l", listen] + extra_args return f"http://{host}:{port}", cmd diff --git a/tests/test_files_auth.py b/tests/test_files_auth.py new file mode 100644 index 0000000..76533eb --- /dev/null +++ b/tests/test_files_auth.py @@ -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(" 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(" 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" diff --git a/tests/test_tokens.py b/tests/test_tokens.py new file mode 100644 index 0000000..738cb40 --- /dev/null +++ b/tests/test_tokens.py @@ -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:) + _, 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