Finalize Paskia integration and built-in authentication.

This commit is contained in:
2026-01-31 00:47:04 +00:00
parent be69164c8f
commit 232fd92b22
19 changed files with 668 additions and 449 deletions
+8 -23
View File
@@ -29,7 +29,7 @@ banner = create_banner()
doc = """\
Usage:
cista [-c <confdir>] [-l <host>] [--auth <mode>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] --user <name> [--privileged] [--password]
Options:
@@ -39,20 +39,20 @@ Options:
<addr>: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")
+29 -16
View File
@@ -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})
+14 -6
View File
@@ -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/",
+17 -41
View File
@@ -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"
+6 -9
View File
@@ -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
+1 -1
View File
@@ -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())}",
}
+165 -68
View File
@@ -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(
"/<path:path>", 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)