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:
+21
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user