diff --git a/cista/__main__.py b/cista/__main__.py index b2ed167..d52104c 100644 --- a/cista/__main__.py +++ b/cista/__main__.py @@ -29,7 +29,7 @@ banner = create_banner() doc = """\ Usage: - cista [-c ] [-l ] [--auth ] [--import-droppy] [--dev] [] + cista [-c ] [-l ] [--import-droppy] [--dev] [] cista [-c ] --user [--privileged] [--password] Options: @@ -39,20 +39,20 @@ Options: :3000 (bind another address, port) /path/to/unix.sock (unix socket) example.com (run on 80 and 443 with LetsEncrypt) - --auth MODE Authentication mode: none, password, paskia - none - public access, no login required - password - built-in user accounts (default) - paskia - external SSO via PASKIA_BACKEND_URL --import-droppy Import Droppy config from ~/.droppy/config --dev Developer mode (reloads, friendlier crashes, more logs) -Listen address, path, auth mode and imported options are preserved in config, +Listen address and path are preserved in config, and only config dir and dev mode need to be specified on subsequent runs. User management: --user NAME Create or modify user --privileged Give the user full admin rights --password Reset password + +Environment: + PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401) + https://git.zi.fi/leovasanko/paskia """ first_time_help = """\ @@ -111,11 +111,7 @@ def _main(): f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}", ) settings = droppy.readconf() - # Convert Droppy's public flag to authentication mode - if "public" in settings: - settings["authentication"] = ( - "none" if settings.pop("public") else "password" - ) + # Droppy's public flag is kept as-is (same name in our config) if path: settings["path"] = path elif not exists: @@ -124,17 +120,6 @@ def _main(): settings["listen"] = listen elif not exists: settings["listen"] = ":8000" - # Authentication mode - auth_mode = args["--auth"] - if auth_mode: - if auth_mode not in ("none", "password", "paskia"): - raise ValueError( - f"Invalid auth mode: {auth_mode}. Use: none, password, paskia" - ) - settings["authentication"] = auth_mode - elif not exists and not import_droppy: - # We have no users, so make it public - settings["authentication"] = "none" operation = config.update_config(settings) sys.stderr.write(f"Config {operation}: {config.conffile}\n") # Prepare to serve @@ -176,7 +161,7 @@ def _user(args): { "listen": ":8000", "path": Path.home() / "Downloads", - "authentication": "password", + "public": False, } ) sys.stderr.write(f"Config {operation}: {config.conffile}\n\n") diff --git a/cista/api.py b/cista/api.py index ec93561..7a2799a 100644 --- a/cista/api.py +++ b/cista/api.py @@ -6,7 +6,7 @@ import msgspec from sanic import Blueprint, json from sanic.exceptions import BadRequest -from cista import __version__, auth, config, watching +from cista import __version__, auth, config, sso, watching from cista.fileio import FileServer from cista.protocol import ControlTypes, FileRange, StatusMsg from cista.util.apphelpers import asend, websocket_wrapper @@ -93,20 +93,33 @@ async def control(req, ws): @bp.websocket("watch") @websocket_wrapper async def watch(req, ws): + # Build user info from either built-in auth or SSO + user_info = None + if sso_user := getattr(req.ctx, "sso_user", None): + # SSO auth (paskia mode): extract from validation response + ctx = sso_user.get("ctx", {}) + perms = ctx.get("permissions", []) + user_info = { + "username": ctx.get("user", {}).get("display_name", ""), + "privileged": "cista:admin" in perms, + } + elif req.ctx.user: + # Built-in auth: use local user database + user_info = { + "username": req.ctx.username, + "privileged": req.ctx.user.privileged, + } + await ws.send( msgspec.json.encode( { "server": { "name": config.config.name or config.config.path.name, "version": __version__, - "authentication": config.config.authentication, + "public": config.config.public, + "paskia": sso.paskia_enabled(), }, - "user": { - "username": req.ctx.username, - "privileged": req.ctx.user.privileged, - } - if req.ctx.user - else None, + "user": user_info, } ).decode() ) @@ -139,16 +152,16 @@ def subscribe(uuid, ws): ) -@bp.put("config/authentication") -async def update_authentication(request): +@bp.put("config/public") +async def update_public(request): await auth.verify(request, privileged=True) try: - mode = request.json["authentication"] - if mode not in ("none", "paskia", "password"): - raise ValueError("Invalid authentication mode") + public = request.json["public"] + if not isinstance(public, bool): + raise ValueError("public must be a boolean") except KeyError: - raise BadRequest("Missing authentication field") from None + raise BadRequest("Missing public field") from None except ValueError as e: raise BadRequest(str(e)) from None - config.update_config({"authentication": mode}) - return json({"message": "Authentication setting updated", "authentication": mode}) + config.update_config({"public": public}) + return json({"message": "Public access setting updated", "public": public}) diff --git a/cista/app.py b/cista/app.py index 32e2f14..f65de02 100644 --- a/cista/app.py +++ b/cista/app.py @@ -26,8 +26,11 @@ from cista.util.apphelpers import handle_sanic_exception sanic.helpers._ENTITY_HEADERS = frozenset() app = Sanic("cista", strict_slashes=True) -app.blueprint(auth.bp) -app.blueprint(sso.bp) # SSO proxy for /auth/* routes (when paskia mode enabled) +# 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.exception(Exception)(handle_sanic_exception) @@ -76,6 +79,14 @@ async def use_session(req): raise Forbidden("Invalid origin: Cross-Site requests not permitted") +@app.on_response +async def forward_sso_cookies(req, res): + """Forward Set-Cookie headers from SSO validation to client.""" + if cookies := getattr(req.ctx, "sso_cookies", None): + for cookie in cookies: + res.headers.add("set-cookie", cookie) + + @app.before_server_start def http_fileserver(app): bp = Blueprint("fileserver") @@ -83,10 +94,7 @@ def http_fileserver(app): @bp.on_request async def verify_fileserver(request): """Verify access to file server routes.""" - if config.config.authentication == "paskia": - await auth.verify_sso(request) - else: - await auth.verify(request) + await auth.verify(request) bp.static( "/files/", diff --git a/cista/auth.py b/cista/auth.py index fcb72ea..ff9bc1b 100644 --- a/cista/auth.py +++ b/cista/auth.py @@ -139,7 +139,7 @@ form.onsubmit = async (e) => { submitBtn.textContent = 'Logging in...'; try { - const res = await fetch('/login', { + const res = await fetch('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -234,9 +234,9 @@ class LoginResponse(msgspec.Struct): async def verify(request, *, privileged=False): """Verify that the request is authorized. - For paskia mode, validates against the SSO backend. - For password mode, checks session-based authentication. - For none mode, allows all requests. + For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend. + For built-in mode, checks session-based authentication. + For public mode (config.public=True), allows all requests. All 401/403 responses include auth.iframe URL for consistent frontend handling via the paskia library's showAuthIframe(). @@ -249,10 +249,11 @@ async def verify(request, *, privileged=False): Unauthorized: If authentication is required Forbidden: If access is denied """ - if config.config.authentication == "paskia": + sso = _get_sso() + if sso.paskia_enabled(): # SSO validation against auth backend - sso = _get_sso() - perm = "cista:login cista:admin" if privileged else "cista:login" + # Always check cista:login; privileged flag comes from response perm list + perm = "cista:admin" if privileged else "cista:login" await sso.validate_sso_request(request, perm=perm) return @@ -263,64 +264,39 @@ async def verify(request, *, privileged=False): return raise Forbidden( "Access Forbidden: Only for privileged users", - context={"auth": {"iframe": "/auth/api/restricted?mode=forbidden"}}, quiet=True, ) - elif config.config.authentication == "none" or user: + elif config.config.public or user: return # Return iframe URL for paskia library to show login dialog raise Unauthorized( f"Login required for {request.path}", "cookie", - context={"auth": {"iframe": "/auth/api/restricted?mode=login"}}, + context={"auth": {"iframe": "/auth/restricted"}}, quiet=True, ) +# Blueprint for built-in auth (only registered when paskia is NOT enabled) bp = Blueprint("auth", url_prefix="/auth") -@bp.on_request -async def check_external_auth(request): - """Disable built-in auth routes when external auth is enabled""" - if config.config.authentication == "paskia": - from sanic.exceptions import NotFound - - raise NotFound("Not available in external auth mode") - - -@bp.get("/api/restricted") +@bp.get("/restricted") async def login_page(request): - """Login page that works both standalone and in paskia iframe. - - Query params: - - mode: 'login' (default), 'reauth', or 'forbidden' - affects messaging - """ - mode = request.args.get("mode", "login") + """Login page that works both standalone and in paskia iframe.""" s = session.get(request) # Check if already logged in - if s and mode == "login": + if s: # Already authenticated - signal success if in iframe return html(_login_success_page(s["username"])) - title = { - "forbidden": "Access Denied", - "reauth": "Re-authenticate", - }.get(mode, "Login Required") - - message = { - "forbidden": "You don't have permission. Try logging in with a different account.", - "reauth": "Your session has expired. Please log in again.", - }.get(mode, "Please log in to continue.") - - doc = Document(f"Cista - {title}") + doc = Document("Cista - Login") # Add paskia-compatible styling and scripts doc.style(_LOGIN_PAGE_CSS) with doc.div(class_="login-card"): - doc.h1(title) + doc.h1("Authentication Required") with doc.div(class_="content"): - doc.p(message, class_="message") with doc.form(method="POST", id="loginForm", autocomplete="on"): doc.label("Username:", for_="username") doc.input( @@ -388,7 +364,7 @@ async def login_post(request): return res -@bp.post("/logout") +@bp.post("/api/logout") async def logout_post(request): s = request.ctx.session msg = "Logged out" if s else "Not logged in" diff --git a/cista/config.py b/cista/config.py index ad843a5..9ae56c7 100644 --- a/cista/config.py +++ b/cista/config.py @@ -13,15 +13,12 @@ from typing import Callable, Concatenate, Literal, ParamSpec import msgspec import msgspec.toml -# Authentication modes -AuthMode = Literal["none", "paskia", "password"] - class Config(msgspec.Struct): path: Path listen: str secret: str = secrets.token_hex(12) - authentication: AuthMode = "password" + public: bool = False name: str = "" users: dict[str, User] = {} links: dict[str, Link] = {} @@ -157,12 +154,12 @@ def load_config(): init_confdir() raw = conffile.read_bytes() config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook) - # Migrate from old public flag if present + # Migrate from old authentication field if present raw_dict = msgspec.toml.decode(raw) - if "public" in raw_dict and "authentication" not in raw_dict: - # Old config: migrate public flag to authentication mode - new_auth = "none" if raw_dict["public"] else "password" - config = msgspec.structs.replace(config, authentication=new_auth) + if "authentication" in raw_dict and "public" not in raw_dict: + # Old config with authentication mode: migrate to public bool + new_public = raw_dict["authentication"] == "none" + config = msgspec.structs.replace(config, public=new_public) update_config({}) # Save the migrated config diff --git a/cista/preview.py b/cista/preview.py index 87bd6e0..4466395 100644 --- a/cista/preview.py +++ b/cista/preview.py @@ -60,7 +60,7 @@ async def preview(req, path): "etag": etag, "last-modified": format_date_time(stat.st_mtime), "cache-control": "max-age=604800, immutable" - + ("" if config.config.authentication == "none" else ", private"), + + ("" if config.config.public else ", private"), "content-type": "image/avif", "content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}", } diff --git a/cista/sso.py b/cista/sso.py index ed51855..573bbef 100644 --- a/cista/sso.py +++ b/cista/sso.py @@ -1,26 +1,43 @@ """SSO (paskia) authentication proxy and validation module. -When paskia authentication mode is enabled: +When paskia mode is enabled (PASKIA_BACKEND_URL is set): - Backend validates requests against PASKIA_BACKEND_URL/auth/api/validate?perm=cista:login - All /auth/* requests are proxied to the paskia backend Environment variables: - PASKIA_BACKEND_URL - URL of the paskia auth server (default: http://localhost:4401) + PASKIA_BACKEND_URL - URL of the paskia auth server (e.g., http://localhost:4401) + Must include scheme (http/https), no trailing slash """ +import asyncio import os +import re import httpx +import websockets from sanic import Blueprint -from sanic.exceptions import Forbidden, Unauthorized +from sanic.exceptions import Forbidden, SanicException, Unauthorized from sanic.log import logger -from cista import config +# Auth backend URL for SSO validation (from env, no trailing slash) +_raw_url = os.environ.get("PASKIA_BACKEND_URL", "").rstrip("/") + +# Validate and set PASKIA_BACKEND_URL +if _raw_url: + if not re.match(r"^https?://[^\s/]+$", _raw_url): + raise ValueError( + f"Invalid PASKIA_BACKEND_URL: {_raw_url!r} - " + "must be http(s)://host[:port] with no path or trailing slash" + ) + PASKIA_BACKEND_URL = _raw_url +else: + PASKIA_BACKEND_URL = "" + + +def paskia_enabled() -> bool: + """Check if paskia SSO mode is enabled (PASKIA_BACKEND_URL is set).""" + return bool(PASKIA_BACKEND_URL) -# Auth backend URL for SSO validation (from env with default, no trailing slash) -PASKIA_BACKEND_URL = os.environ.get( - "PASKIA_BACKEND_URL", "http://localhost:4401" -).rstrip("/") # Shared httpx client for SSO requests (reused for connection pooling) _client: httpx.AsyncClient | None = None @@ -30,7 +47,7 @@ async def get_client() -> httpx.AsyncClient: """Get or create the shared httpx client.""" global _client if _client is None or _client.is_closed: - _client = httpx.AsyncClient(timeout=10.0) + _client = httpx.AsyncClient(timeout=1.0) return _client @@ -56,43 +73,50 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | Forbidden: If access is denied (403) Unauthorized: If authentication is required (401) """ - if config.config.authentication != "paskia": + if not paskia_enabled(): return None client = await get_client() - # Forward relevant headers (especially cookies for session validation) headers = {} + if "host" in request.headers: + headers["host"] = request.headers["host"] if "cookie" in request.headers: headers["cookie"] = request.headers["cookie"] if "authorization" in request.headers: headers["authorization"] = request.headers["authorization"] headers["accept"] = "application/json" - headers["x-forwarded-for"] = request.ip - if "x-forwarded-for" in request.headers: - headers["x-forwarded-for"] = request.headers["x-forwarded-for"] + headers["x-forwarded-for"] = request.client_ip + headers["x-forwarded-host"] = request.host + headers["x-forwarded-proto"] = request.scheme + + url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}" try: response = await client.post( - f"{PASKIA_BACKEND_URL}/auth/api/validate", - params={"perm": perm}, + url, headers=headers, ) if response.status_code == 200: - # Validation successful try: - return response.json() + data = response.json() + request.ctx.sso_user = data + if "set-cookie" in response.headers: + request.ctx.sso_cookies = response.headers.get_list("set-cookie") + return data except Exception: + request.ctx.sso_user = {} return {} - # Handle auth errors - return the JSON response for frontend handling try: error_data = response.json() except Exception: error_data = {"detail": response.text or "Authentication error"} if response.status_code == 401: + if "auth" in error_data and "iframe" in error_data["auth"]: + error_data["auth"]["iframe"] += "&theme=light" raise Unauthorized( error_data.get("detail", "Authentication required"), "cookie", @@ -106,19 +130,21 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | quiet=True, ) else: + detail = error_data.get("detail", "") logger.warning( - f"SSO validation returned unexpected status: {response.status_code}" + f"SSO validation {url} returned {response.status_code}: {detail}" ) raise Forbidden( - error_data.get("detail", "Authentication error"), + detail or "Authentication error", context=error_data, quiet=True, ) except httpx.RequestError as e: - logger.error(f"SSO validation request failed: {e}") - raise Forbidden( + logger.error(f"SSO validation {url} network error: {e}") + raise SanicException( "Authentication service unavailable", + status_code=502, quiet=True, ) @@ -130,18 +156,13 @@ async def proxy_auth_request(request): """ client = await get_client() - # Build the target URL - strip any prefix and forward to auth backend path = request.path query_string = request.query_string url = f"{PASKIA_BACKEND_URL}{path}" if query_string: url = f"{url}?{query_string}" - # Forward headers - headers = dict(request.headers) - # Remove hop-by-hop headers - for hop_header in [ - "host", + skip_headers = { "connection", "keep-alive", "transfer-encoding", @@ -150,45 +171,53 @@ async def proxy_auth_request(request): "upgrade", "proxy-authorization", "proxy-authenticate", - ]: - headers.pop(hop_header, None) + "forwarded", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + } - # Add forwarded headers - headers["x-forwarded-for"] = request.ip - headers["x-forwarded-host"] = request.host - headers["x-forwarded-proto"] = request.scheme + headers = [ + (key, value) + for key, value in request.headers.items() + if key.lower() not in skip_headers + ] + headers.append(("x-forwarded-for", request.client_ip)) + headers.append(("x-forwarded-host", request.host)) + headers.append(("x-forwarded-proto", request.scheme)) try: - response = await client.request( + async with client.stream( method=request.method, url=url, headers=headers, content=request.body if request.body else None, - ) + ) as response: + raw_content = b"".join([chunk async for chunk in response.aiter_raw()]) - # Build response headers - resp_headers = dict(response.headers) - # Remove hop-by-hop headers from response - for hop_header in [ - "connection", - "keep-alive", - "transfer-encoding", - "te", - "trailer", - "upgrade", - "content-encoding", - "content-length", - ]: - resp_headers.pop(hop_header, None) + resp_hop_by_hop = { + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + } - from sanic import raw as raw_response + resp_headers = [ + (key, value) + for key, value in response.headers.multi_items() + if key.lower() not in resp_hop_by_hop + ] - return raw_response( - response.content, - status=response.status_code, - headers=resp_headers, - content_type=response.headers.get("content-type", "application/json"), - ) + from sanic import raw as raw_response + + return raw_response( + raw_content, + status=response.status_code, + headers=resp_headers, + content_type=response.headers.get("content-type", "application/json"), + ) except httpx.RequestError as e: logger.error(f"Auth proxy request failed: {e}") @@ -200,28 +229,96 @@ async def proxy_auth_request(request): ) -# Blueprint for auth proxy routes +async def proxy_auth_websocket(request, ws): + """Proxy a WebSocket connection to the auth backend.""" + path = request.path + query_string = request.query_string + ws_backend = PASKIA_BACKEND_URL.replace("http://", "ws://").replace( + "https://", "wss://" + ) + url = f"{ws_backend}{path}" + if query_string: + url = f"{url}?{query_string}" + + additional_headers = {} + if "cookie" in request.headers: + additional_headers["cookie"] = request.headers["cookie"] + if "authorization" in request.headers: + additional_headers["authorization"] = request.headers["authorization"] + if "origin" in request.headers: + additional_headers["origin"] = request.headers["origin"] + if "user-agent" in request.headers: + additional_headers["user-agent"] = request.headers["user-agent"] + additional_headers["x-forwarded-for"] = request.ip + additional_headers["x-forwarded-host"] = request.host + additional_headers["x-forwarded-proto"] = request.scheme + + try: + async with websockets.connect( + url, additional_headers=additional_headers + ) as backend_ws: + + async def forward_to_backend(): + try: + async for message in ws: + await backend_ws.send(message) + except Exception: + pass + + async def forward_to_client(): + try: + async for message in backend_ws: + await ws.send(message) + except Exception: + pass + + await asyncio.gather( + forward_to_backend(), + forward_to_client(), + return_exceptions=True, + ) + except Exception as e: + logger.error(f"WebSocket proxy to {url} failed: {e}") + + +def _is_websocket_request(request) -> bool: + """Check if the request is a WebSocket upgrade request.""" + connection = request.headers.get("connection", "").lower() + upgrade = request.headers.get("upgrade", "").lower() + connection_tokens = [t.strip() for t in connection.split(",")] + return "upgrade" in connection_tokens and upgrade == "websocket" + + +async def _handle_websocket_upgrade(request): + """Handle WebSocket upgrade and proxy the connection.""" + protocol = request.transport.get_protocol() + ws = await protocol.websocket_handshake(request, subprotocols=None) + await proxy_auth_websocket(request, ws) + + +# Blueprint for auth proxy routes (only registered when paskia_enabled()) bp = Blueprint("sso", url_prefix="/auth") -@bp.on_request -async def check_sso_enabled(request): - """Only handle requests if paskia mode is enabled.""" - if config.config.authentication != "paskia": - from sanic.exceptions import NotFound - - raise NotFound("SSO authentication not enabled") - - @bp.route( "/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] ) async def auth_proxy(request, path=""): """Proxy all auth requests to the auth backend.""" + if _is_websocket_request(request): + await _handle_websocket_upgrade(request) + from sanic import empty + + return empty() return await proxy_auth_request(request) @bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]) async def auth_proxy_root(request): """Proxy root auth requests to the auth backend.""" + if _is_websocket_request(request): + await _handle_websocket_upgrade(request) + from sanic import empty + + return empty() return await proxy_auth_request(request) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4c8fa2b..1eb861e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,10 @@