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 sanic.exceptions import BadRequest
|
||||||
|
|
||||||
from cista import __version__, auth, config, sso, watching
|
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.fileio import FileServer
|
||||||
from cista.util.apphelpers import websocket_wrapper
|
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)
|
# Return the effective name (fallback to path.name if empty)
|
||||||
effective_name = name or config.config.path.name
|
effective_name = name or config.config.path.name
|
||||||
return json({"message": "Server name updated", "name": effective_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()
|
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
|
@app.on_request
|
||||||
async def use_session(req):
|
async def use_session(req):
|
||||||
req.ctx._log_start = time.perf_counter()
|
req.ctx._log_start = time.perf_counter()
|
||||||
req.ctx.session = session.get(req)
|
req.ctx._auth_flow = ["session: start"]
|
||||||
try:
|
auth.hydrate_request_auth_context(req, source="app.on_request")
|
||||||
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
|
|
||||||
# CSRF protection
|
# CSRF protection
|
||||||
if req.method == "GET" and req.headers.upgrade != "websocket":
|
if req.method == "GET" and req.headers.upgrade != "websocket":
|
||||||
return # Ordinary GET requests are fine
|
return # Ordinary GET requests are fine
|
||||||
@@ -129,6 +88,56 @@ async def forward_sso_cookies(req, res):
|
|||||||
res.headers.add("set-cookie", cookie)
|
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 = {}
|
www = {}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+911
-6
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ class Config(msgspec.Struct):
|
|||||||
name: str = ""
|
name: str = ""
|
||||||
users: dict[str, User] = {}
|
users: dict[str, User] = {}
|
||||||
links: dict[str, Link] = {}
|
links: dict[str, Link] = {}
|
||||||
|
tokens: dict[str, Token] = {}
|
||||||
|
|
||||||
|
|
||||||
# Typing: arguments for config-modifying functions
|
# Typing: arguments for config-modifying functions
|
||||||
@@ -43,6 +44,14 @@ class Link(msgspec.Struct, omit_defaults=True):
|
|||||||
expires: int = 0
|
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
|
# Global variables - initialized during application startup
|
||||||
config: Config
|
config: Config
|
||||||
conffile: Path
|
conffile: Path
|
||||||
@@ -204,3 +213,29 @@ def del_user(conf: Config, name: str) -> Config:
|
|||||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
settings["users"].pop(name)
|
settings["users"].pop(name)
|
||||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
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."""
|
"""Log WebSocket connection close with duration and status."""
|
||||||
id_str = _format_ws_id(ws_id)
|
id_str = _format_ws_id(ws_id)
|
||||||
timing = format_duration_ms(duration * 1000)
|
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)
|
method_str = _format_method_label("closed", color=_TIMING)
|
||||||
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
|
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
|
||||||
timing_str = f"{_TIMING}{timing}{_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:
|
def configure_access_logging() -> None:
|
||||||
|
|||||||
+4
-4
@@ -19,21 +19,21 @@ def get(request):
|
|||||||
return False if "s" in request.cookies else None
|
return False if "s" in request.cookies else None
|
||||||
|
|
||||||
|
|
||||||
def create(res, username, **kwargs):
|
def create(res, username, *, secure: bool = True, **kwargs):
|
||||||
data = {
|
data = {
|
||||||
"exp": int(time()) + max_age,
|
"exp": int(time()) + max_age,
|
||||||
"username": username,
|
"username": username,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
}
|
}
|
||||||
s = jwt.encode(data, session_secret())
|
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.update(kwargs)
|
||||||
s = jwt.encode(s, session_secret())
|
s = jwt.encode(s, session_secret())
|
||||||
max_age = max(1, s["exp"] - int(time())) # type: ignore
|
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):
|
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):
|
async def proxy_auth_request(request):
|
||||||
"""Proxy a request to the auth backend.
|
"""Proxy a request to the auth backend.
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ def jres(data, **kwargs):
|
|||||||
|
|
||||||
async def handle_sanic_exception(request, e):
|
async def handle_sanic_exception(request, e):
|
||||||
context, code = {}, 500
|
context, code = {}, 500
|
||||||
|
headers = None
|
||||||
message = str(e)
|
message = str(e)
|
||||||
if isinstance(e, SanicException):
|
if isinstance(e, SanicException):
|
||||||
context = e.context or {}
|
context = e.context or {}
|
||||||
code = e.status_code
|
code = e.status_code
|
||||||
|
headers = getattr(e, "headers", None)
|
||||||
if not message or not request.app.debug and code == 500:
|
if not message or not request.app.debug and code == 500:
|
||||||
message = "Internal Server Error"
|
message = "Internal Server Error"
|
||||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||||
@@ -41,6 +43,7 @@ async def handle_sanic_exception(request, e):
|
|||||||
return jres(
|
return jres(
|
||||||
response_data,
|
response_data,
|
||||||
status=code,
|
status=code,
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
# Redirections flash the error message via cookies
|
# Redirections flash the error message via cookies
|
||||||
if "redirect" in context:
|
if "redirect" in context:
|
||||||
@@ -60,6 +63,7 @@ def websocket_wrapper(handler):
|
|||||||
extra = username if username else None
|
extra = username if username else None
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
ws_id = log_ws_open(request, extra=extra)
|
ws_id = log_ws_open(request, extra=extra)
|
||||||
|
close_extra = None
|
||||||
try:
|
try:
|
||||||
await auth.verify(request)
|
await auth.verify(request)
|
||||||
await handler(request, ws, *args, **kwargs)
|
await handler(request, ws, *args, **kwargs)
|
||||||
@@ -72,6 +76,7 @@ def websocket_wrapper(handler):
|
|||||||
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
||||||
if not getattr(e, "quiet", False) or code == 500:
|
if not getattr(e, "quiet", False) or code == 500:
|
||||||
logger.exception(f"{code} {e!r}")
|
logger.exception(f"{code} {e!r}")
|
||||||
|
close_extra = f"{code} {message}"
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
duration = time.perf_counter() - start
|
duration = time.perf_counter() - start
|
||||||
@@ -86,6 +91,6 @@ def websocket_wrapper(handler):
|
|||||||
close_code = p.close_code
|
close_code = p.close_code
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
log_ws_close(ws_id, close_code, duration)
|
log_ws_close(ws_id, close_code, duration, extra=close_extra)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<UserManagementModal />
|
<UserManagementModal />
|
||||||
|
<UserTokensModal />
|
||||||
<AccessDeniedModal />
|
<AccessDeniedModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
||||||
@@ -35,6 +36,7 @@ import Router from '@/router/index'
|
|||||||
import type { SortOrder } from './utils/docsort'
|
import type { SortOrder } from './utils/docsort'
|
||||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
import UserManagementModal from './components/UserManagementModal.vue'
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
|
import UserTokensModal from './components/UserTokensModal.vue'
|
||||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ const settingsMenu = (e: Event) => {
|
|||||||
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
|
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (store.user.isLoggedIn) {
|
||||||
|
items.push({ label: '🔑 API Tokens', onClick: () => { store.dialog = 'tokens' }})
|
||||||
|
}
|
||||||
|
|
||||||
if (store.user.privileged) {
|
if (store.user.privileged) {
|
||||||
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
|
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,14 +188,14 @@ const deleteUserAction = async (username: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copySuccess = async (isButtonClick: boolean = false) => {
|
const copySuccess = async (isButtonClick: boolean = false) => {
|
||||||
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
|
const passwordMatch = success.value.match(/(?:Password|New password|Key): (.+)/)
|
||||||
if (passwordMatch) {
|
if (passwordMatch) {
|
||||||
await navigator.clipboard.writeText(passwordMatch[1]!)
|
await navigator.clipboard.writeText(passwordMatch[1]!)
|
||||||
if (isButtonClick) {
|
if (isButtonClick) {
|
||||||
// Show "Copied!" indication on button
|
// Show "Copied!" indication on button
|
||||||
copyButtonText.value = '✅ Copied!'
|
copyButtonText.value = '✅ Copied!'
|
||||||
// Hide password and button immediately after copying
|
// Hide password/key and button immediately after copying
|
||||||
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
|
const baseMessage = success.value.replace(/(?:Password|New password|Key): .+/, 'Copied to clipboard!')
|
||||||
success.value = baseMessage
|
success.value = baseMessage
|
||||||
// Hide the entire message after 3 seconds
|
// Hide the entire message after 3 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog name=tokens title="My API Tokens">
|
||||||
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
<div v-else>
|
||||||
|
<p class="hint">Create tokens to access Cista from scripts or other apps. Tokens are tied to your account.</p>
|
||||||
|
|
||||||
|
<!-- Creation form -->
|
||||||
|
<div v-if="mode === 'creating'" class="create-form">
|
||||||
|
<label for="token-name">Token name (optional)</label>
|
||||||
|
<input
|
||||||
|
id="token-name"
|
||||||
|
v-model="newTokenName"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. backup-script"
|
||||||
|
@keyup.enter="submitCreate"
|
||||||
|
ref="nameInput"
|
||||||
|
/>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="submitCreate" class="button primary" :disabled="creating">Create</button>
|
||||||
|
<button @click="cancelCreate" class="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Creation result -->
|
||||||
|
<div v-else-if="mode === 'created' && createdToken" class="created-result">
|
||||||
|
<p class="success-title">✅ Token created</p>
|
||||||
|
<p class="hint">Copy this URL — it will not be shown again.</p>
|
||||||
|
<div class="url-box">
|
||||||
|
<code class="token-url">{{ createdToken.url }}</code>
|
||||||
|
<button @click="copyUrl" class="button small">{{ copyButtonText }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Use it like: <code>curl {{ createdToken.url }}/...</code></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="finishCreate" class="button primary">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token list -->
|
||||||
|
<div v-else>
|
||||||
|
<button @click="startCreate" class="button" title="Add new token">➕ Add Token</button>
|
||||||
|
<table v-if="tokens.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="token in tokens" :key="token.id">
|
||||||
|
<td>{{ token.name || 'Unnamed' }}</td>
|
||||||
|
<td>{{ formatDate(token.created) }}</td>
|
||||||
|
<td>
|
||||||
|
<button @click="deleteTokenAction(token.id)" class="button small danger" title="Revoke token">🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else class="empty">You have no API tokens.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button @click="close" class="button">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import { listTokens, createToken, deleteToken } from '@/repositories/User'
|
||||||
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
sso_user_id: string
|
||||||
|
name: string
|
||||||
|
created: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatedToken extends Token {
|
||||||
|
key: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const tokens = ref<Token[]>([])
|
||||||
|
const mode = ref<'list' | 'creating' | 'created'>('list')
|
||||||
|
const newTokenName = ref('')
|
||||||
|
const creating = ref(false)
|
||||||
|
const createdToken = ref<CreatedToken | null>(null)
|
||||||
|
const copyButtonText = ref('📋')
|
||||||
|
const nameInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
store.dialog = ''
|
||||||
|
resetCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetCreate = () => {
|
||||||
|
mode.value = 'list'
|
||||||
|
newTokenName.value = ''
|
||||||
|
creating.value = false
|
||||||
|
createdToken.value = null
|
||||||
|
copyButtonText.value = '📋'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTokens = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const data = await listTokens()
|
||||||
|
tokens.value = data.tokens
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to load tokens')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCreate = () => {
|
||||||
|
mode.value = 'creating'
|
||||||
|
nextTick(() => nameInput.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelCreate = () => {
|
||||||
|
resetCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureFilesBaseUrl = (url: string) => {
|
||||||
|
const trimmed = url.replace(/\/+$/, '')
|
||||||
|
if (trimmed.endsWith('/files')) return trimmed
|
||||||
|
return `${trimmed}/files`
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (creating.value) return
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const result = await createToken(newTokenName.value)
|
||||||
|
await loadTokens()
|
||||||
|
if (result.url) {
|
||||||
|
createdToken.value = {
|
||||||
|
...(result as CreatedToken),
|
||||||
|
url: ensureFilesBaseUrl((result as CreatedToken).url),
|
||||||
|
}
|
||||||
|
mode.value = 'created'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to create token')
|
||||||
|
mode.value = 'list'
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishCreate = () => {
|
||||||
|
resetCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyUrl = async () => {
|
||||||
|
if (!createdToken.value) return
|
||||||
|
await navigator.clipboard.writeText(createdToken.value.url)
|
||||||
|
copyButtonText.value = '✅ Copied!'
|
||||||
|
setTimeout(() => {
|
||||||
|
copyButtonText.value = '📋'
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTokenAction = async (tokenId: string) => {
|
||||||
|
if (!confirm('Revoke this token? It will no longer work.')) return
|
||||||
|
try {
|
||||||
|
await deleteToken(tokenId)
|
||||||
|
await loadTokens()
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to revoke token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (ts: number) => {
|
||||||
|
if (!ts) return '—'
|
||||||
|
return new Date(ts * 1000).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tokens when dialog opens
|
||||||
|
watch(() => store.dialog, (newVal) => {
|
||||||
|
if (newVal === 'tokens') {
|
||||||
|
resetCreate()
|
||||||
|
loadTokens()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hint {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.create-form {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.create-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.create-form input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 2px solid #888;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.create-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f80;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.created-result {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.success-title {
|
||||||
|
color: #080;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
.url-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.token-url {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.dialog-buttons {
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -65,3 +65,20 @@ export async function getServerConfig() {
|
|||||||
const data = await Client.get('/api/config')
|
const data = await Client.get('/api/config')
|
||||||
return data as { name: string, public: boolean }
|
return data as { name: string, public: boolean }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const url_tokens = '/api/tokens'
|
||||||
|
|
||||||
|
export async function listTokens() {
|
||||||
|
const data = await Client.get(url_tokens)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createToken(name: string) {
|
||||||
|
const data = await Client.post(url_tokens, { name })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteToken(tokenId: string) {
|
||||||
|
const data = await Client.delete(`${url_tokens}/${tokenId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
authInProgress: false,
|
authInProgress: false,
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
|
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
|
||||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
|
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
|
||||||
uprogress: {} as any,
|
uprogress: {} as any,
|
||||||
dprogress: {} as any,
|
dprogress: {} as any,
|
||||||
prefs: {
|
prefs: {
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ def setup_sanic_backend(
|
|||||||
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
||||||
host = opts.get("host", "localhost") or "localhost"
|
host = opts.get("host", "localhost") or "localhost"
|
||||||
|
|
||||||
cmd = ["cista", "--dev", "-l", listen] + extra_args
|
# Use the current interpreter/module path so devserver always runs
|
||||||
|
# workspace source code instead of a potentially stale installed script.
|
||||||
|
cmd = [sys.executable, "-m", "cista", "--dev", "-l", listen] + extra_args
|
||||||
return f"http://{host}:{port}", cmd
|
return f"http://{host}:{port}", cmd
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
from time import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sanic import Sanic
|
||||||
|
|
||||||
|
from cista import auth, config, session, watching
|
||||||
|
from cista.app import use_session
|
||||||
|
from cista.fileserver import bp as fileserver_bp
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth(username: str, password: str) -> dict[str, str]:
|
||||||
|
creds = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||||
|
return {"Authorization": f"Basic {creds}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _ntlm_type1() -> dict[str, str]:
|
||||||
|
msg = b"NTLMSSP\x00" + struct.pack("<I", 1) + struct.pack("<I", 0x20080205)
|
||||||
|
return {"Authorization": f"NTLM {base64.b64encode(msg).decode()}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _ntlm_type3(username: str, password: str, domain: str, challenge: bytes) -> dict[str, str]:
|
||||||
|
"""Build an NTLMv2 Type 3 message for testing."""
|
||||||
|
from Crypto.Hash import MD4
|
||||||
|
|
||||||
|
# NT hash
|
||||||
|
nt_hash = MD4.new(password.encode("utf-16le")).digest()
|
||||||
|
# NTLMv2 hash
|
||||||
|
ntlmv2_hash = hmac.new(nt_hash, (username.upper() + domain).encode("utf-16le"), hashlib.md5).digest()
|
||||||
|
|
||||||
|
# Build a minimal blob
|
||||||
|
timestamp = struct.pack("<Q", 0)
|
||||||
|
client_nonce = b"\x01" * 8
|
||||||
|
blob = b"\x01\x01\x00\x00\x00\x00\x00\x00" + timestamp + client_nonce + b"\x00\x00\x00\x00"
|
||||||
|
|
||||||
|
# NT proof
|
||||||
|
nt_proof = hmac.new(ntlmv2_hash, challenge + blob, hashlib.md5).digest()
|
||||||
|
nt_response = nt_proof + blob
|
||||||
|
|
||||||
|
domain_enc = domain.encode("utf-16le")
|
||||||
|
username_enc = username.encode("utf-16le")
|
||||||
|
workstation_enc = b""
|
||||||
|
|
||||||
|
lm_response = b"" # Empty for NTLMv2
|
||||||
|
|
||||||
|
# Build Type 3 message
|
||||||
|
msg = bytearray()
|
||||||
|
msg.extend(b"NTLMSSP\x00")
|
||||||
|
msg.extend(struct.pack("<I", 3))
|
||||||
|
|
||||||
|
# Security buffers offsets will be calculated
|
||||||
|
payload_start = 64
|
||||||
|
payloads = []
|
||||||
|
|
||||||
|
def add_buf(data: bytes):
|
||||||
|
offset = payload_start + sum(len(p) for p in payloads)
|
||||||
|
payloads.append(data)
|
||||||
|
return struct.pack("<HHI", len(data), len(data), offset)
|
||||||
|
|
||||||
|
lm_buf = add_buf(lm_response)
|
||||||
|
nt_buf = add_buf(nt_response)
|
||||||
|
domain_buf = add_buf(domain_enc)
|
||||||
|
user_buf = add_buf(username_enc)
|
||||||
|
ws_buf = add_buf(workstation_enc)
|
||||||
|
session_buf = add_buf(b"")
|
||||||
|
|
||||||
|
msg.extend(lm_buf)
|
||||||
|
msg.extend(nt_buf)
|
||||||
|
msg.extend(domain_buf)
|
||||||
|
msg.extend(user_buf)
|
||||||
|
msg.extend(ws_buf)
|
||||||
|
msg.extend(session_buf)
|
||||||
|
msg.extend(struct.pack("<I", 0x20080205))
|
||||||
|
for p in payloads:
|
||||||
|
msg.extend(p)
|
||||||
|
|
||||||
|
return {"Authorization": f"NTLM {base64.b64encode(bytes(msg)).decode()}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _session_cookie_header(username: str) -> dict[str, str]:
|
||||||
|
token = jwt.encode(
|
||||||
|
{"exp": int(time()) + session.max_age, "username": username},
|
||||||
|
session.session_secret(),
|
||||||
|
algorithm="HS256",
|
||||||
|
)
|
||||||
|
return {"Cookie": f"s={token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def setup_storage(tmp_path: Path):
|
||||||
|
user = config.User()
|
||||||
|
auth.set_password(user, "secret")
|
||||||
|
token = config.Token(key="test_token_123", username="alice")
|
||||||
|
config.config = config.Config(
|
||||||
|
path=tmp_path,
|
||||||
|
listen=":0",
|
||||||
|
public=False,
|
||||||
|
users={"alice": user},
|
||||||
|
tokens={"test_token_123": token},
|
||||||
|
)
|
||||||
|
watching.state.root = []
|
||||||
|
watching.rootpath = tmp_path
|
||||||
|
(tmp_path / "hello.txt").write_text("hello", encoding="utf-8")
|
||||||
|
yield tmp_path
|
||||||
|
watching.state.root = []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def client(setup_storage: Path):
|
||||||
|
app = Sanic(f"files-auth-test-{uuid4().hex}", strict_slashes=True)
|
||||||
|
app.router.ALLOWED_METHODS = (
|
||||||
|
*app.router.ALLOWED_METHODS,
|
||||||
|
"MKCOL",
|
||||||
|
"MOVE",
|
||||||
|
"COPY",
|
||||||
|
"PROPFIND",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.on_request
|
||||||
|
async def load_auth_context(request):
|
||||||
|
await use_session(request)
|
||||||
|
|
||||||
|
app.blueprint(fileserver_bp)
|
||||||
|
yield app.asgi_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_basic_auth_allows_private_file_access(client):
|
||||||
|
_, res = await client.get("/files/hello.txt", headers=_basic_auth("alice", "secret"))
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.body == b"hello"
|
||||||
|
assert "set-cookie" not in res.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_basic_auth_with_invalid_creds_falls_back_to_session_cookie(client):
|
||||||
|
_, res = await client.get(
|
||||||
|
"/files/hello.txt",
|
||||||
|
headers={**_basic_auth("alice", "wrong"), **_session_cookie_header("alice")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_options_unauthenticated_allowed(client):
|
||||||
|
_, res = await client.options("/files/")
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unauthenticated_sends_no_auth_challenge(client):
|
||||||
|
_, res = await client.request("PROPFIND", "/files/")
|
||||||
|
|
||||||
|
assert res.status_code == 401
|
||||||
|
assert "www-authenticate" not in res.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_basic_auth_with_token(client):
|
||||||
|
_, res = await client.get("/files/hello.txt", headers=_basic_auth("token", "test_token_123"))
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.body == b"hello"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browser_unauthenticated_sends_cookie_challenge(client):
|
||||||
|
_, res = await client.get("/files/", headers={"Accept": "text/html,application/xhtml+xml"})
|
||||||
|
|
||||||
|
assert res.status_code == 401
|
||||||
|
assert res.headers.get("www-authenticate", "").lower().startswith("cookie")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ntlm_auth_with_token(client):
|
||||||
|
# Step 1: request without auth should NOT advertise NTLM
|
||||||
|
# (we prefer clients use BASIC; NTLM still works if client initiates it)
|
||||||
|
_, res1 = await client.get("/files/hello.txt")
|
||||||
|
assert res1.status_code == 401
|
||||||
|
assert "ntlm" not in res1.headers.get("www-authenticate", "").lower()
|
||||||
|
|
||||||
|
# Step 2: client proactively sends Type 1, gets Type 2 challenge
|
||||||
|
_, res2 = await client.get("/files/hello.txt", headers=_ntlm_type1())
|
||||||
|
assert res2.status_code == 401
|
||||||
|
auth_hdr = res2.headers.get("www-authenticate", "")
|
||||||
|
assert auth_hdr.lower().startswith("ntlm ")
|
||||||
|
type2_data = base64.b64decode(auth_hdr.split(" ", 1)[1])
|
||||||
|
challenge = type2_data[24:32]
|
||||||
|
|
||||||
|
# Step 3: send Type 3 with token as password
|
||||||
|
_, res3 = await client.get(
|
||||||
|
"/files/hello.txt",
|
||||||
|
headers=_ntlm_type3("anyuser", "test_token_123", "WORKGROUP", challenge),
|
||||||
|
)
|
||||||
|
assert res3.status_code == 200
|
||||||
|
assert res3.body == b"hello"
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from time import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sanic import Sanic
|
||||||
|
|
||||||
|
from cista import auth, config, watching
|
||||||
|
from cista.auth import bp as auth_bp
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_config():
|
||||||
|
import msgspec
|
||||||
|
from pathlib import PurePath
|
||||||
|
|
||||||
|
def enc_hook(obj):
|
||||||
|
if isinstance(obj, PurePath):
|
||||||
|
return obj.as_posix()
|
||||||
|
raise TypeError
|
||||||
|
|
||||||
|
raw = msgspec.to_builtins(config.config, enc_hook=enc_hook)
|
||||||
|
config.conffile.write_bytes(msgspec.toml.encode(raw))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def setup_storage(tmp_path: Path):
|
||||||
|
os.environ["CISTA_HOME"] = str(tmp_path)
|
||||||
|
config.init_confdir()
|
||||||
|
user = config.User()
|
||||||
|
auth.set_password(user, "secret")
|
||||||
|
admin = config.User(privileged=True)
|
||||||
|
auth.set_password(admin, "secret")
|
||||||
|
config.config = config.Config(
|
||||||
|
path=tmp_path,
|
||||||
|
listen=":0",
|
||||||
|
public=False,
|
||||||
|
users={"alice": user, "admin": admin},
|
||||||
|
)
|
||||||
|
_persist_config()
|
||||||
|
watching.state.root = []
|
||||||
|
watching.rootpath = tmp_path
|
||||||
|
yield tmp_path
|
||||||
|
watching.state.root = []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def client(setup_storage: Path):
|
||||||
|
app = Sanic(f"token-test-{uuid4().hex}", strict_slashes=True)
|
||||||
|
app.router.ALLOWED_METHODS = (
|
||||||
|
*app.router.ALLOWED_METHODS,
|
||||||
|
"MKCOL",
|
||||||
|
"MOVE",
|
||||||
|
"COPY",
|
||||||
|
"PROPFIND",
|
||||||
|
)
|
||||||
|
app.blueprint(auth_bp)
|
||||||
|
yield app.asgi_client
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth(username: str, password: str) -> str:
|
||||||
|
return f"Basic {__import__('base64').b64encode(f'{username}:{password}'.encode()).decode()}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_crud(client):
|
||||||
|
# Admin creates a token without specifying username (auto-assigned)
|
||||||
|
_, res = await client.post(
|
||||||
|
"/auth/tokens",
|
||||||
|
json={"name": "test"},
|
||||||
|
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json
|
||||||
|
assert "id" in data
|
||||||
|
assert "key" in data
|
||||||
|
assert data["username"] == "admin"
|
||||||
|
assert data["name"] == "test"
|
||||||
|
token_id = data["id"]
|
||||||
|
token_key = data["key"]
|
||||||
|
|
||||||
|
# List tokens - admin sees only their own
|
||||||
|
_, res = await client.get(
|
||||||
|
"/auth/tokens",
|
||||||
|
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
tokens = res.json["tokens"]
|
||||||
|
assert len(tokens) == 1
|
||||||
|
assert tokens[0]["id"] == token_id
|
||||||
|
assert tokens[0]["username"] == "admin"
|
||||||
|
|
||||||
|
# Use token via Basic auth (token:<secret>)
|
||||||
|
_, res = await client.get(
|
||||||
|
"/auth/tokens",
|
||||||
|
headers={"Authorization": _basic_auth("token", token_key)},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
# Delete token
|
||||||
|
_, res = await client.delete(
|
||||||
|
f"/auth/tokens/{token_id}",
|
||||||
|
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
# List should be empty
|
||||||
|
_, res = await client.get(
|
||||||
|
"/auth/tokens",
|
||||||
|
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert len(res.json["tokens"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_user_scoped(client):
|
||||||
|
# Alice creates a token for herself (no username specified)
|
||||||
|
_, res = await client.post(
|
||||||
|
"/auth/tokens",
|
||||||
|
json={"name": "alice-token"},
|
||||||
|
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
alice_token_id = res.json["id"]
|
||||||
|
alice_token_key = res.json["key"]
|
||||||
|
|
||||||
|
# Admin creates a token for themselves
|
||||||
|
_, res = await client.post(
|
||||||
|
"/auth/tokens",
|
||||||
|
json={"name": "admin-token"},
|
||||||
|
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
admin_token_id = res.json["id"]
|
||||||
|
|
||||||
|
# Alice lists tokens - sees only her own
|
||||||
|
_, res = await client.get(
|
||||||
|
"/auth/tokens",
|
||||||
|
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
tokens = res.json["tokens"]
|
||||||
|
assert len(tokens) == 1
|
||||||
|
assert tokens[0]["id"] == alice_token_id
|
||||||
|
assert tokens[0]["username"] == "alice"
|
||||||
|
|
||||||
|
# Admin lists tokens - sees only their own
|
||||||
|
_, res = await client.get(
|
||||||
|
"/auth/tokens",
|
||||||
|
headers={"Authorization": _basic_auth("admin", "secret")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
tokens = res.json["tokens"]
|
||||||
|
assert len(tokens) == 1
|
||||||
|
assert tokens[0]["id"] == admin_token_id
|
||||||
|
assert tokens[0]["username"] == "admin"
|
||||||
|
|
||||||
|
# Alice cannot create a token for admin
|
||||||
|
_, res = await client.post(
|
||||||
|
"/auth/tokens",
|
||||||
|
json={"username": "admin", "name": "impersonation"},
|
||||||
|
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
# Alice cannot delete admin's token
|
||||||
|
_, res = await client.delete(
|
||||||
|
f"/auth/tokens/{admin_token_id}",
|
||||||
|
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
# Alice can delete her own token
|
||||||
|
_, res = await client.delete(
|
||||||
|
f"/auth/tokens/{alice_token_id}",
|
||||||
|
headers={"Authorization": _basic_auth("alice", "secret")},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
# Alice's token auth still works until deletion is processed
|
||||||
|
# Verify token auth worked during the test
|
||||||
|
_, res = await client.get(
|
||||||
|
"/auth/tokens",
|
||||||
|
headers={"Authorization": _basic_auth("token", alice_token_key)},
|
||||||
|
)
|
||||||
|
# Token was deleted above, so this should now be unauthenticated
|
||||||
|
# Actually the token key lookup will fail, and since there's no session fallback...
|
||||||
|
# With auth header present but invalid, it should return 401
|
||||||
|
assert res.status_code == 401
|
||||||
Reference in New Issue
Block a user