import base64 import binascii import hmac import hashlib import re import secrets import struct from time import time from unicodedata import normalize import argon2 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 _LOGIN_PAGE_CSS = """\ /* =========================================== LOGIN PAGE STYLES Must match ModalDialog.vue global styles. =========================================== */ * { box-sizing: border-box; } body { font-family: 'Roboto', system-ui, -apple-system, sans-serif; font-size: 1rem; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: transparent; } .login-card { background: #ddd; color: #000; border-radius: 0.5rem; box-shadow: 0 0 1rem #0008; width: 100%; max-width: 320px; } h1 { background: #146; color: #fff; margin: 0; padding: 0.5rem 1rem; font-size: 1.2rem; font-weight: normal; border-radius: 0.5rem 0.5rem 0 0; } .content { padding: 1rem; } .message { color: #444; margin: 0 0 0.5rem 0; font-size: 0.875rem; } form { display: grid; grid-template-columns: auto 1fr; gap: 0.5rem 1rem; align-items: center; } label { font-size: 1rem; } input[type="text"], input[type="password"] { font: inherit; font-size: 1rem; padding: 0.5rem; border: 2px solid #888; border-radius: 0.25rem; background: #fff; color: #000; min-width: 0; } input:focus { outline: none; border-color: #f80; } .button-row { grid-column: 1 / -1; display: flex; justify-content: flex-end; margin-top: 0.5rem; } button { font: inherit; font-size: 1rem; padding: 0.5rem 1rem; background: #146; color: #fff; border: none; border-radius: 0.25rem; cursor: pointer; } button:hover { background: #f80; } button:disabled { background: #888; cursor: not-allowed; } .error { grid-column: 1 / -1; color: #c00; font-size: 0.875rem; min-height: 1.2em; margin: 0; } """ _LOGIN_PAGE_JS = """\ const form = document.getElementById('loginForm'); const error = document.getElementById('error'); const submitBtn = document.getElementById('submitBtn'); const usernameField = document.getElementById('username'); const passwordField = document.getElementById('password'); const isInIframe = window.parent !== window; // Focus username field on load usernameField.focus(); const showError = (msg) => { error.textContent = msg; submitBtn.disabled = false; submitBtn.textContent = 'Log in'; // Focus and select the relevant field if (msg.toLowerCase().includes('password')) { passwordField.focus(); passwordField.select(); } else { usernameField.focus(); usernameField.select(); } }; form.onsubmit = async (e) => { e.preventDefault(); error.textContent = ''; submitBtn.disabled = true; submitBtn.textContent = 'Logging in...'; try { const res = await fetch('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ username: usernameField.value, password: passwordField.value }) }); if (res.ok) { if (isInIframe) { window.parent.postMessage({type: 'auth-success'}, '*'); } else { window.location.href = '/'; } } else { const data = await res.json(); showError(data.message || data.detail || 'Login failed'); } } catch (err) { showError('Connection error. Please try again.'); } }; """ # Import for SSO validation (lazily loaded to avoid circular imports) _sso_module = None def _get_sso(): global _sso_module if _sso_module is None: from cista import sso _sso_module = 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: u = config.config.users[un.decode()] except KeyError: raise ValueError("Invalid username") from None # Verify password need_rehash = False if not u.hash: raise ValueError("Account disabled") if (m := _droppyhash.match(u.hash)) is not None: h, s = m.groups() h2 = hmac.digest(pw + s.encode() + un, b"", "sha256").hex() if not hmac.compare_digest(h, h2): raise ValueError("Invalid password") # Droppy hashes are weak, do a hash update need_rehash = True else: try: _argon.verify(u.hash, pw) except Exception: raise ValueError("Invalid password") from None if _argon.check_needs_rehash(u.hash): need_rehash = True # Login successful if need_rehash: 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): user: str = "" privileged: bool = False 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(" str: """Minimal page that signals auth-success to parent iframe.""" return str( Document().script_("window.parent.postMessage({type:'auth-success'},'*')") ) @bp.post("/login") async def login_post(request): try: if request.headers.content_type == "application/json": username = request.json["username"] password = request.json["password"] else: username = request.form["username"][0] password = request.form["password"][0] if not username or not password: raise KeyError except KeyError: raise BadRequest( "Missing username or password", context={"redirect": "/login"}, ) from None try: user = login(username, password) except ValueError as e: raise Forbidden(str(e), context={"redirect": "/login"}) from e if "text/html" in request.headers.accept: res = redirect("/") session.flash(res, "Logged in") else: res = json({"data": {"username": username, "privileged": user.privileged}}) session.create(res, username, secure=request.scheme == "https") return res @bp.post("/api/logout") async def logout_post(request): s = request.ctx.session msg = "Logged out" if s else "Not logged in" if "text/html" in request.headers.accept: res = redirect("/login") res.cookies.add_cookie("flash", msg, max_age=5) else: res = json({"message": msg}) session.delete(res) return res @bp.post("/password-change") async def change_password(request): try: if request.headers.content_type == "application/json": username = request.json["username"] pwchange = request.json["passwordChange"] password = request.json["password"] else: username = request.form["username"][0] pwchange = request.form["passwordChange"][0] password = request.form["password"][0] if not username or not password: raise KeyError except KeyError: raise BadRequest( "Missing username, passwordChange or password", ) from None try: user = login(username, password) set_password(user, pwchange) except ValueError as e: raise Forbidden(str(e), context={"redirect": "/login"}) from e if "text/html" in request.headers.accept: res = redirect("/") session.flash(res, "Password updated") else: res = json({"message": "Password updated"}) session.create(res, username, secure=request.scheme == "https") return res @bp.get("/users") async def list_users(request): await verify(request, privileged=True) users = [] for name, user in config.config.users.items(): users.append( { "username": name, "privileged": user.privileged, "lastSeen": user.lastSeen, } ) return json({"users": users}) @bp.post("/users") async def create_user(request): await verify(request, privileged=True) try: if request.headers.content_type == "application/json": username = request.json["username"] password = request.json.get("password") privileged = request.json.get("privileged", False) else: username = request.form["username"][0] password = request.form.get("password", [None])[0] privileged = request.form.get("privileged", ["false"])[0].lower() == "true" if not username or not username.isidentifier(): raise ValueError("Invalid username") except (KeyError, ValueError) as e: raise BadRequest(str(e)) from e if username in config.config.users: raise BadRequest("User already exists") if not password: password = pwgen.generate() changes = {"privileged": privileged} changes["hash"] = _argon.hash(_pwnorm(password)) try: config.update_user(username, changes) except Exception as e: raise BadRequest(str(e)) from e return json({"message": f"User {username} created", "password": password}) @bp.put("/users/") async def update_user(request, username): await verify(request, privileged=True) try: if request.headers.content_type == "application/json": changes = request.json else: changes = {} if "password" in request.form: changes["password"] = request.form["password"][0] if "privileged" in request.form: changes["privileged"] = request.form["privileged"][0].lower() == "true" except KeyError as e: raise BadRequest("Missing fields") from e password_response = None if "password" in changes: if changes["password"] == "": changes["password"] = pwgen.generate() password_response = changes["password"] changes["hash"] = _argon.hash(_pwnorm(changes["password"])) del changes["password"] if not changes: return json({"message": "No changes"}) try: config.update_user(username, changes) except Exception as e: raise BadRequest(str(e)) from e response = {"message": f"User {username} updated"} if password_response: response["password"] = password_response return json(response) @bp.delete("/users/") async def delete_user(request, username): await verify(request, privileged=True) if username not in config.config.users: raise BadRequest("User does not exist") try: config.del_user(username) except Exception as e: raise BadRequest(str(e)) from e return json({"message": f"User {username} deleted"}) def _current_user_id(request): """Return (username, sso_user_id) for the currently authenticated user.""" user = getattr(request.ctx, "user", None) if user is not None: return (getattr(request.ctx, "username", None), None) sso_user = getattr(request.ctx, "sso_user", None) if isinstance(sso_user, dict): ctx = sso_user.get("ctx", {}) if isinstance(sso_user, dict) else {} user_info = ctx.get("user", {}) if isinstance(ctx, dict) else {} sso_user_id = ( user_info.get("id") or user_info.get("uuid") or user_info.get("sub") ) return (None, sso_user_id) return (None, None) def _token_belongs_to_user(token, username, sso_user_id): """Check if a token belongs to the given user.""" if username is not None and token.username == username: return True if sso_user_id is not None and token.sso_user_id == sso_user_id: return True return False # Token management handlers (shared between /auth and /api blueprints) async def list_tokens_handler(request): await verify(request) username, sso_user_id = _current_user_id(request) tokens = [] for tid, t in config.config.tokens.items(): if _token_belongs_to_user(t, username, sso_user_id): tokens.append( { "id": tid, "username": t.username, "sso_user_id": t.sso_user_id, "name": t.name, "created": t.created, } ) return json({"tokens": tokens}) async def create_token_handler(request): await verify(request) current_username, current_sso_user_id = _current_user_id(request) try: if request.headers.content_type == "application/json": username = request.json.get("username") sso_user_id = request.json.get("sso_user_id") name = request.json.get("name", "") else: username = request.form.get("username", [None])[0] sso_user_id = request.form.get("sso_user_id", [None])[0] name = request.form.get("name", [""])[0] except (KeyError, IndexError): raise BadRequest("Missing fields") from None sso = _get_sso() if sso.paskia_enabled(): if sso_user_id: # Non-admin cannot create tokens for other users if sso_user_id != current_sso_user_id: raise Forbidden( "Cannot create tokens for other users", quiet=True ) else: sso_user_id = current_sso_user_id if not sso_user_id: raise BadRequest("Could not determine SSO user") else: if username: if username != current_username: raise Forbidden( "Cannot create tokens for other users", quiet=True ) else: username = current_username if not username: raise BadRequest("Could not determine user") if username not in config.config.users: raise BadRequest("User does not exist") token = secrets.token_urlsafe(8) changes = { "key": token, "username": username or "", "sso_user_id": sso_user_id or "", "name": name, "created": int(time()), } config.update_token(token, changes) scheme = request.scheme host = request.host or "localhost" token_url = f"{scheme}://token:{token}@{host}/" return json( { "id": token, "key": token, "url": token_url, "username": username or "", "sso_user_id": sso_user_id or "", "name": name, } ) async def delete_token_handler(request, token_id): await verify(request) if token_id not in config.config.tokens: raise BadRequest("Token does not exist") token = config.config.tokens[token_id] username, sso_user_id = _current_user_id(request) if not _token_belongs_to_user(token, username, sso_user_id): raise Forbidden("Cannot delete tokens belonging to other users", quiet=True) config.del_token(token_id) return json({"message": f"Token {token_id} deleted"}) # Register on auth blueprint (built-in mode) @bp.get("/tokens") async def list_tokens(request): return await list_tokens_handler(request) @bp.post("/tokens") async def create_token(request): return await create_token_handler(request) @bp.delete("/tokens/") async def delete_token(request, token_id): return await delete_token_handler(request, token_id)