Add token-based auth for WebDAV/NTLM and API access
- Add Token model with CRUD endpoints (/api/tokens, /auth/tokens) - Support Basic auth with token:<secret> for built-in users - Implement full NTLMv2 handshake for Windows WebDAV clients - Add SSO token auth via check_permissions() proxy - Hydrate request auth context from session or Authorization header - Persist session cookie after successful Authorization-based login - Add secure flag to session cookies based on request scheme - Add frontend UserTokensModal for creating/revoking tokens - Fix devserver to run workspace source via python -m cista - Add tests for token CRUD and file auth (Basic, NTLM, session) - Remove proactive WWW-Authenticate advertisement
This commit is contained in:
@@ -7,6 +7,11 @@ from sanic import Blueprint, json
|
||||
from sanic.exceptions import BadRequest
|
||||
|
||||
from cista import __version__, auth, config, sso, watching
|
||||
from cista.auth import (
|
||||
create_token_handler,
|
||||
delete_token_handler,
|
||||
list_tokens_handler,
|
||||
)
|
||||
from cista.fileio import FileServer
|
||||
from cista.util.apphelpers import websocket_wrapper
|
||||
|
||||
@@ -132,3 +137,19 @@ async def update_name(request):
|
||||
# Return the effective name (fallback to path.name if empty)
|
||||
effective_name = name or config.config.path.name
|
||||
return json({"message": "Server name updated", "name": effective_name})
|
||||
|
||||
|
||||
# Token management endpoints (available in all modes; primary path in SSO mode)
|
||||
@bp.get("tokens")
|
||||
async def list_api_tokens(request):
|
||||
return await list_tokens_handler(request)
|
||||
|
||||
|
||||
@bp.post("tokens")
|
||||
async def create_api_token(request):
|
||||
return await create_token_handler(request)
|
||||
|
||||
|
||||
@bp.delete("tokens/<token_id>")
|
||||
async def delete_api_token(request, token_id):
|
||||
return await delete_token_handler(request, token_id)
|
||||
|
||||
+52
-43
@@ -40,54 +40,13 @@ app.router.ALLOWED_METHODS = (
|
||||
)
|
||||
|
||||
configure_main_logging()
|
||||
# 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.blueprint(fileserver.bp)
|
||||
app.exception(Exception)(handle_sanic_exception)
|
||||
|
||||
|
||||
setproctitle("cista-main")
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
async def main_start(app):
|
||||
config.load_config()
|
||||
setproctitle(f"cista {config.config.path.name}")
|
||||
app.ctx.threadexec = ThreadPoolExecutor(
|
||||
max_workers=4, thread_name_prefix="cista-worker"
|
||||
)
|
||||
# Larger pool for long-running but low-memory zip operations
|
||||
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
|
||||
await start_preview_workers()
|
||||
watching.start(app)
|
||||
|
||||
|
||||
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
||||
@app.before_server_stop
|
||||
async def main_stop(app):
|
||||
watching.stop(app)
|
||||
await shutdown_preview_workers()
|
||||
app.ctx.threadexec.shutdown()
|
||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||
await sso.close_client()
|
||||
logger.debug("Cista worker threads all finished")
|
||||
|
||||
|
||||
@app.on_request
|
||||
async def use_session(req):
|
||||
req.ctx._log_start = time.perf_counter()
|
||||
req.ctx.session = session.get(req)
|
||||
try:
|
||||
req.ctx.username = req.ctx.session["username"] # type: ignore
|
||||
req.ctx.user = config.config.users[req.ctx.username]
|
||||
except (AttributeError, KeyError, TypeError):
|
||||
req.ctx.username = None
|
||||
req.ctx.user = None
|
||||
req.ctx._auth_flow = ["session: start"]
|
||||
auth.hydrate_request_auth_context(req, source="app.on_request")
|
||||
# CSRF protection
|
||||
if req.method == "GET" and req.headers.upgrade != "websocket":
|
||||
return # Ordinary GET requests are fine
|
||||
@@ -129,6 +88,56 @@ async def forward_sso_cookies(req, res):
|
||||
res.headers.add("set-cookie", cookie)
|
||||
|
||||
|
||||
@app.on_response
|
||||
async def persist_auth_session(req, res):
|
||||
"""Persist a session cookie after successful Authorization-based auth."""
|
||||
username = getattr(req.ctx, "_create_session_username", None)
|
||||
if not username or res.status >= 400:
|
||||
return
|
||||
existing = getattr(req.ctx, "session", None)
|
||||
if isinstance(existing, dict) and existing.get("username") == username:
|
||||
return
|
||||
session.create(res, username, secure=req.scheme == "https")
|
||||
|
||||
|
||||
# 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.blueprint(fileserver.bp)
|
||||
app.exception(Exception)(handle_sanic_exception)
|
||||
|
||||
|
||||
setproctitle("cista-main")
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
async def main_start(app):
|
||||
config.load_config()
|
||||
setproctitle(f"cista {config.config.path.name}")
|
||||
app.ctx.threadexec = ThreadPoolExecutor(
|
||||
max_workers=4, thread_name_prefix="cista-worker"
|
||||
)
|
||||
# Larger pool for long-running but low-memory zip operations
|
||||
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
|
||||
await start_preview_workers()
|
||||
watching.start(app)
|
||||
|
||||
|
||||
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
||||
@app.before_server_stop
|
||||
async def main_stop(app):
|
||||
watching.stop(app)
|
||||
await shutdown_preview_workers()
|
||||
app.ctx.threadexec.shutdown()
|
||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||
await sso.close_client()
|
||||
logger.debug("Cista worker threads all finished")
|
||||
|
||||
|
||||
www = {}
|
||||
|
||||
|
||||
|
||||
+910
-5
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ class Config(msgspec.Struct):
|
||||
name: str = ""
|
||||
users: dict[str, User] = {}
|
||||
links: dict[str, Link] = {}
|
||||
tokens: dict[str, Token] = {}
|
||||
|
||||
|
||||
# Typing: arguments for config-modifying functions
|
||||
@@ -43,6 +44,14 @@ class Link(msgspec.Struct, omit_defaults=True):
|
||||
expires: int = 0
|
||||
|
||||
|
||||
class Token(msgspec.Struct, omit_defaults=True):
|
||||
key: str = "" # plain text secret (shown once on creation)
|
||||
username: str = "" # set in built-in mode
|
||||
sso_user_id: str = "" # set in SSO mode
|
||||
name: str = ""
|
||||
created: int = 0 # noqa: N815
|
||||
|
||||
|
||||
# Global variables - initialized during application startup
|
||||
config: Config
|
||||
conffile: Path
|
||||
@@ -204,3 +213,29 @@ def del_user(conf: Config, name: str) -> Config:
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings["users"].pop(name)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
|
||||
@modifies_config
|
||||
def update_token(conf: Config, token_id: str, changes: dict) -> Config:
|
||||
"""Create or update a token."""
|
||||
try:
|
||||
t = msgspec.convert(
|
||||
msgspec.to_builtins(conf.tokens[token_id], enc_hook=enc_hook),
|
||||
Token,
|
||||
dec_hook=dec_hook,
|
||||
)
|
||||
except KeyError:
|
||||
t = Token()
|
||||
tdict = msgspec.to_builtins(t, enc_hook=enc_hook)
|
||||
tdict.update(changes)
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings["tokens"][token_id] = msgspec.convert(tdict, Token, dec_hook=dec_hook)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
|
||||
@modifies_config
|
||||
def del_token(conf: Config, token_id: str) -> Config:
|
||||
"""Delete a token by its stable id."""
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings["tokens"].pop(token_id, None)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
@@ -201,7 +201,7 @@ WS_CLOSE_CODES = {
|
||||
}
|
||||
|
||||
|
||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float, extra: str | None = None) -> None:
|
||||
"""Log WebSocket connection close with duration and status."""
|
||||
id_str = _format_ws_id(ws_id)
|
||||
timing = format_duration_ms(duration * 1000)
|
||||
@@ -216,8 +216,9 @@ def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||
method_str = _format_method_label("closed", color=_TIMING)
|
||||
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
|
||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||
|
||||
logger.info("%s %s %s %s %s", " " * 19, id_str, method_str, status_str, timing_str)
|
||||
logger.info("%s %s %s %s %s%s", " " * 19, id_str, method_str, status_str, timing_str, extra_str)
|
||||
|
||||
|
||||
def configure_access_logging() -> None:
|
||||
|
||||
+4
-4
@@ -19,21 +19,21 @@ def get(request):
|
||||
return False if "s" in request.cookies else None
|
||||
|
||||
|
||||
def create(res, username, **kwargs):
|
||||
def create(res, username, *, secure: bool = True, **kwargs):
|
||||
data = {
|
||||
"exp": int(time()) + max_age,
|
||||
"username": username,
|
||||
**kwargs,
|
||||
}
|
||||
s = jwt.encode(data, session_secret())
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age, secure=secure)
|
||||
|
||||
|
||||
def update(res, s, **kwargs):
|
||||
def update(res, s, *, secure: bool = True, **kwargs):
|
||||
s.update(kwargs)
|
||||
s = jwt.encode(s, session_secret())
|
||||
max_age = max(1, s["exp"] - int(time())) # type: ignore
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age, secure=secure)
|
||||
|
||||
|
||||
def delete(res):
|
||||
|
||||
@@ -152,6 +152,61 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
|
||||
)
|
||||
|
||||
|
||||
async def check_permissions(user_id: str, perm: str) -> dict:
|
||||
"""Check if a Paskia user has the given permission.
|
||||
|
||||
Args:
|
||||
user_id: The Paskia user UUID
|
||||
perm: Permission to check (e.g. cista:login or cista:admin)
|
||||
|
||||
Returns:
|
||||
User info dict if permission is granted
|
||||
|
||||
Raises:
|
||||
Forbidden: If permission is denied or check fails
|
||||
SanicException: If the auth service is unreachable
|
||||
"""
|
||||
if not paskia_enabled():
|
||||
raise ValueError("Paskia not enabled")
|
||||
|
||||
client = await get_client()
|
||||
url = f"{PASKIA_BACKEND_URL}/auth/api/check-permissions"
|
||||
|
||||
try:
|
||||
response = await client.post(
|
||||
url,
|
||||
json={"user_id": user_id, "perm": perm},
|
||||
headers={"accept": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
error_data = response.json()
|
||||
except Exception:
|
||||
error_data = {"detail": response.text or "Permission check failed"}
|
||||
|
||||
if response.status_code == 403:
|
||||
raise Forbidden(
|
||||
error_data.get("detail", "Access denied"),
|
||||
quiet=True,
|
||||
)
|
||||
else:
|
||||
raise Forbidden(
|
||||
error_data.get("detail", "Permission check failed"),
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Permission check {url} network error: {e}")
|
||||
raise SanicException(
|
||||
"Authentication service unavailable",
|
||||
status_code=502,
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
|
||||
async def proxy_auth_request(request):
|
||||
"""Proxy a request to the auth backend.
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@ def jres(data, **kwargs):
|
||||
|
||||
async def handle_sanic_exception(request, e):
|
||||
context, code = {}, 500
|
||||
headers = None
|
||||
message = str(e)
|
||||
if isinstance(e, SanicException):
|
||||
context = e.context or {}
|
||||
code = e.status_code
|
||||
headers = getattr(e, "headers", None)
|
||||
if not message or not request.app.debug and code == 500:
|
||||
message = "Internal Server Error"
|
||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||
@@ -41,6 +43,7 @@ async def handle_sanic_exception(request, e):
|
||||
return jres(
|
||||
response_data,
|
||||
status=code,
|
||||
headers=headers,
|
||||
)
|
||||
# Redirections flash the error message via cookies
|
||||
if "redirect" in context:
|
||||
@@ -60,6 +63,7 @@ def websocket_wrapper(handler):
|
||||
extra = username if username else None
|
||||
start = time.perf_counter()
|
||||
ws_id = log_ws_open(request, extra=extra)
|
||||
close_extra = None
|
||||
try:
|
||||
await auth.verify(request)
|
||||
await handler(request, ws, *args, **kwargs)
|
||||
@@ -72,6 +76,7 @@ def websocket_wrapper(handler):
|
||||
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
||||
if not getattr(e, "quiet", False) or code == 500:
|
||||
logger.exception(f"{code} {e!r}")
|
||||
close_extra = f"{code} {message}"
|
||||
raise
|
||||
finally:
|
||||
duration = time.perf_counter() - start
|
||||
@@ -86,6 +91,6 @@ def websocket_wrapper(handler):
|
||||
close_code = p.close_code
|
||||
except AttributeError:
|
||||
pass
|
||||
log_ws_close(ws_id, close_code, duration)
|
||||
log_ws_close(ws_id, close_code, duration, extra=close_extra)
|
||||
|
||||
return wrapper
|
||||
|
||||
Reference in New Issue
Block a user