Consistent dialog styling widgets and using Paskia's shared backdrop. Internal password auth mimics Paskia. API paths changed (/auth goes to internal or paskia depending on config). All API calls and previews get access checks.

This commit is contained in:
2026-01-30 18:28:05 +00:00
parent bb38328c24
commit 7be02e951d
23 changed files with 1109 additions and 386 deletions
+21 -6
View File
@@ -29,7 +29,7 @@ banner = create_banner()
doc = """\
Usage:
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] [-l <host>] [--auth <mode>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] --user <name> [--privileged] [--password]
Options:
@@ -39,11 +39,15 @@ 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 and imported options are preserved in config, and only
custom config dir and dev mode need to be specified on subsequent runs.
Listen address, path, auth mode and imported options 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
@@ -107,6 +111,11 @@ 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"
)
if path:
settings["path"] = path
elif not exists:
@@ -115,9 +124,15 @@ def _main():
settings["listen"] = listen
elif not exists:
settings["listen"] = ":8000"
if not exists and not import_droppy:
# 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["public"] = True
settings["authentication"] = "none"
operation = config.update_config(settings)
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
# Prepare to serve
@@ -159,7 +174,7 @@ def _user(args):
{
"listen": ":8000",
"path": Path.home() / "Downloads",
"public": False,
"authentication": "password",
}
)
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
+20 -2
View File
@@ -5,7 +5,10 @@ from secrets import token_bytes
import msgspec
from sanic import Blueprint
from cista import __version__, config, watching
from sanic import json
from sanic.exceptions import BadRequest
from cista import __version__, auth, config, watching
from cista.fileio import FileServer
from cista.protocol import ControlTypes, FileRange, StatusMsg
from cista.util.apphelpers import asend, websocket_wrapper
@@ -98,7 +101,7 @@ async def watch(req, ws):
"server": {
"name": config.config.name or config.config.path.name,
"version": __version__,
"public": config.config.public,
"authentication": config.config.authentication,
},
"user": {
"username": req.ctx.username,
@@ -136,3 +139,18 @@ def subscribe(uuid, ws):
watching.format_space(watching.state.space),
watching.format_root(watching.state.root),
)
@bp.put("config/authentication")
async def update_authentication(request):
await auth.verify(request, privileged=True)
try:
mode = request.json["authentication"]
if mode not in ("none", "paskia", "password"):
raise ValueError("Invalid authentication mode")
except KeyError:
raise BadRequest("Missing authentication 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})
+16 -2
View File
@@ -18,7 +18,7 @@ from setproctitle import setproctitle
from stream_zip import ZIP_AUTO, stream_zip
from zstandard import ZstdCompressor
from cista import auth, config, preview, session, watching
from cista import auth, config, preview, session, sso, watching
from cista.api import bp
from cista.util.apphelpers import handle_sanic_exception
@@ -27,6 +27,7 @@ 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)
app.blueprint(preview.bp)
app.blueprint(bp)
app.exception(Exception)(handle_sanic_exception)
@@ -52,6 +53,7 @@ async def main_stop(app):
quit.set()
watching.stop(app)
app.ctx.threadexec.shutdown()
await sso.close_client()
logger.debug("Cista worker threads all finished")
@@ -77,7 +79,15 @@ async def use_session(req):
@app.before_server_start
def http_fileserver(app):
bp = Blueprint("fileserver")
bp.on_request(auth.verify)
@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)
bp.static(
"/files/",
config.config.path,
@@ -239,6 +249,10 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
@app.get("/zip/<keys>/<zipfile:ext=zip>")
async def zip_download(req, keys, zipfile, ext):
"""Download a zip archive of the given keys"""
if config.config.authentication == "paskia":
await auth.verify_sso(req)
else:
auth.verify(req)
wanted = set(keys.split("+"))
files = get_files(wanted)
+287 -59
View File
@@ -12,6 +12,174 @@ from sanic.exceptions import BadRequest, Forbidden, Unauthorized
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('/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
_argon = argon2.PasswordHasher()
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
@@ -63,62 +231,133 @@ class LoginResponse(msgspec.Struct):
error: str = ""
def verify(request, *, privileged=False):
"""Raise Unauthorized or Forbidden if the request is not authorized"""
if privileged:
if request.ctx.user:
if request.ctx.user.privileged:
return
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
elif config.config.public or request.ctx.user:
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.
All 401/403 responses include auth.iframe URL for consistent frontend handling
via the paskia library's showAuthIframe().
Args:
request: The Sanic request object
privileged: If True, requires admin privileges
Raises:
Unauthorized: If authentication is required
Forbidden: If access is denied
"""
if config.config.authentication == "paskia":
# SSO validation against auth backend
sso = _get_sso()
perm = "cista:login cista:admin" if privileged else "cista:login"
await sso.validate_sso_request(request, perm=perm)
return
raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True)
user = getattr(request.ctx, "user", None)
if privileged:
if user:
if user.privileged:
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:
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"}},
quiet=True,
)
bp = Blueprint("auth")
bp = Blueprint("auth", url_prefix="/auth")
@bp.get("/login")
@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")
async def login_page(request):
doc = Document("Cista Login")
with doc.div(id="login"):
with doc.form(method="POST", autocomplete="on"):
doc.h1("Login")
doc.input(
name="username",
placeholder="Username",
autocomplete="username",
required=True,
).br
doc.input(
type="password",
name="password",
placeholder="Password",
autocomplete="current-password",
required=True,
).br
doc.input(type="submit", value="Login")
s = session.get(request)
if s:
name = s["username"]
with doc.form(method="POST", action="/logout"):
doc.input(type="submit", value=f"Logout {name}")
flash = request.cookies.message
if flash:
doc.dialog(
flash,
id="flash",
open=True,
style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8",
)
"""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")
s = session.get(request)
# Check if already logged in
if s and mode == "login":
# 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}")
# Add paskia-compatible styling and scripts
doc.style(_LOGIN_PAGE_CSS)
with doc.div(class_="login-card"):
doc.h1(title)
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(
type="text",
id="username",
name="username",
autocomplete="username webauthn",
required=True,
)
doc.label("Password:", for_="password")
doc.input(
type="password",
id="password",
name="password",
autocomplete="current-password webauthn",
required=True,
)
with doc.div(class_="button-row"):
doc.button("Log in", type="submit", id="submitBtn")
doc.p("", class_="error", id="error")
# JavaScript for AJAX login and postMessage communication
doc.script_(_LOGIN_PAGE_JS)
res = html(doc)
if flash:
res.cookies.delete_cookie("flash")
if s is False:
session.delete(res)
return res
def _login_success_page(username: str) -> 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:
@@ -196,7 +435,7 @@ async def change_password(request):
@bp.get("/users")
async def list_users(request):
verify(request, privileged=True)
await verify(request, privileged=True)
users = []
for name, user in config.config.users.items():
users.append(
@@ -211,7 +450,7 @@ async def list_users(request):
@bp.post("/users")
async def create_user(request):
verify(request, privileged=True)
await verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
username = request.json["username"]
@@ -240,7 +479,7 @@ async def create_user(request):
@bp.put("/users/<username>")
async def update_user(request, username):
verify(request, privileged=True)
await verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
changes = request.json
@@ -273,7 +512,7 @@ async def update_user(request, username):
@bp.delete("/users/<username>")
async def delete_user(request, username):
verify(request, privileged=True)
await verify(request, privileged=True)
if username not in config.config.users:
raise BadRequest("User does not exist")
try:
@@ -281,14 +520,3 @@ async def delete_user(request, username):
except Exception as e:
raise BadRequest(str(e)) from e
return json({"message": f"User {username} deleted"})
@bp.put("/config/public")
async def update_public(request):
verify(request, privileged=True)
try:
public = request.json["public"]
except KeyError:
raise BadRequest("Missing public field") from None
config.update_config({"public": public})
return json({"message": "Public setting updated"})
+13 -2
View File
@@ -13,12 +13,15 @@ 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)
public: bool = False
authentication: AuthMode = "password"
name: str = ""
users: dict[str, User] = {}
links: dict[str, Link] = {}
@@ -152,7 +155,15 @@ def modifies_config(
def load_config():
global config
init_confdir()
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
raw = conffile.read_bytes()
config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook)
# Migrate from old public flag 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)
update_config({}) # Save the migrated config
@modifies_config
+9 -2
View File
@@ -17,13 +17,20 @@ from sanic import Blueprint, empty, raw, redirect
from sanic.exceptions import NotFound
from sanic.log import logger
from cista import config
from cista import auth, config
from cista.util.filename import sanitize
pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview")
@bp.on_request
async def verify_preview(request):
"""Verify access to preview routes."""
await auth.verify(request)
# Map EXIF Orientation value to a corresponding PIL transpose
EXIF_ORI = {
2: Image.Transpose.FLIP_LEFT_RIGHT,
@@ -53,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.public else ", private"),
+ ("" if config.config.authentication == "none" else ", private"),
"content-type": "image/avif",
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
}
+225
View File
@@ -0,0 +1,225 @@
"""SSO (paskia) authentication proxy and validation module.
When paskia authentication mode is enabled:
- 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)
"""
import os
import httpx
from sanic import Blueprint
from sanic.exceptions import Forbidden, Unauthorized
from sanic.log import logger
from cista import config
# 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
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)
return _client
async def close_client():
"""Close the shared httpx client."""
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None:
"""Validate an SSO request against the auth backend.
Args:
request: The Sanic request object
perm: Permission to validate (default: cista:login, privileged also cista:admin)
Returns:
User info dict if valid, None if validation fails with auth required response
Raises:
Forbidden: If access is denied (403)
Unauthorized: If authentication is required (401)
"""
if config.config.authentication != "paskia":
return None
client = await get_client()
# Forward relevant headers (especially cookies for session validation)
headers = {}
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"]
try:
response = await client.post(
f"{PASKIA_BACKEND_URL}/auth/api/validate",
params={"perm": perm},
headers=headers,
)
if response.status_code == 200:
# Validation successful
try:
return response.json()
except Exception:
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:
raise Unauthorized(
error_data.get("detail", "Authentication required"),
"cookie",
context=error_data,
quiet=True,
)
elif response.status_code == 403:
raise Forbidden(
error_data.get("detail", "Access denied"),
context=error_data,
quiet=True,
)
else:
logger.warning(
f"SSO validation returned unexpected status: {response.status_code}"
)
raise Forbidden(
error_data.get("detail", "Authentication error"),
context=error_data,
quiet=True,
)
except httpx.RequestError as e:
logger.error(f"SSO validation request failed: {e}")
raise Forbidden(
"Authentication service unavailable",
quiet=True,
)
async def proxy_auth_request(request):
"""Proxy a request to the auth backend.
All requests under /auth/ are proxied when paskia mode is enabled.
"""
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",
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"proxy-authorization",
"proxy-authenticate",
]:
headers.pop(hop_header, None)
# Add forwarded headers
headers["x-forwarded-for"] = request.ip
headers["x-forwarded-host"] = request.host
headers["x-forwarded-proto"] = request.scheme
try:
response = await client.request(
method=request.method,
url=url,
headers=headers,
content=request.body if request.body else None,
)
# 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)
from sanic import raw as raw_response
return raw_response(
response.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}")
from sanic import json
return json(
{"detail": "Authentication service unavailable", "error": str(e)},
status=503,
)
# Blueprint for auth proxy routes
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."""
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."""
return await proxy_auth_request(request)
+5 -2
View File
@@ -33,8 +33,11 @@ async def handle_sanic_exception(request, e):
logger.exception(e)
# Non-browsers get JSON errors
if "text/html" not in request.headers.accept:
# Include auth context if present (for SSO auth required responses)
# Auth must be at top level for paskia library to detect it
response_data = {"code": code, "message": message, "detail": message, **context}
return jres(
ErrorMsg({"code": code, "message": message, **context}),
response_data,
status=code,
)
# Redirections flash the error message via cookies
@@ -52,7 +55,7 @@ def websocket_wrapper(handler):
@wraps(handler)
async def wrapper(request, ws, *args, **kwargs):
try:
auth.verify(request)
await auth.verify(request)
await handler(request, ws, *args, **kwargs)
except Exception as e:
context, code, message = {}, 500, str(e) or "Internal Server Error"