cista 1.3.0__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. {cista-1.3.0 → cista-1.4.0}/PKG-INFO +5 -1
  2. {cista-1.3.0 → cista-1.4.0}/cista/__main__.py +7 -5
  3. {cista-1.3.0 → cista-1.4.0}/cista/_version.py +1 -1
  4. {cista-1.3.0 → cista-1.4.0}/cista/api.py +37 -8
  5. {cista-1.3.0 → cista-1.4.0}/cista/app.py +26 -4
  6. cista-1.4.0/cista/auth.py +498 -0
  7. {cista-1.3.0 → cista-1.4.0}/cista/config.py +9 -1
  8. cista-1.4.0/cista/frontend-build/assets/icons-DMD182WZ.js +1 -0
  9. cista-1.4.0/cista/frontend-build/assets/index-D4WrmH4f.js +32 -0
  10. cista-1.4.0/cista/frontend-build/assets/index-DQ2iYrs-.css +1 -0
  11. {cista-1.3.0 → cista-1.4.0}/cista/frontend-build/index.html +4 -4
  12. {cista-1.3.0 → cista-1.4.0}/cista/preview.py +8 -1
  13. cista-1.4.0/cista/sso.py +324 -0
  14. {cista-1.3.0 → cista-1.4.0}/cista/util/apphelpers.py +5 -2
  15. {cista-1.3.0 → cista-1.4.0}/pyproject.toml +4 -0
  16. cista-1.3.0/cista/auth.py +0 -294
  17. cista-1.3.0/cista/frontend-build/assets/add-file-38ca9b7e.js +0 -1
  18. cista-1.3.0/cista/frontend-build/assets/add-folder-f3d443e0.js +0 -1
  19. cista-1.3.0/cista/frontend-build/assets/arrow-1760afa9.js +0 -1
  20. cista-1.3.0/cista/frontend-build/assets/arrows-h-31428902.js +0 -1
  21. cista-1.3.0/cista/frontend-build/assets/arrows-v-781a1376.js +0 -1
  22. cista-1.3.0/cista/frontend-build/assets/check-02b34fcd.js +0 -1
  23. cista-1.3.0/cista/frontend-build/assets/code-e2348499.js +0 -1
  24. cista-1.3.0/cista/frontend-build/assets/copy-6bb3930b.js +0 -1
  25. cista-1.3.0/cista/frontend-build/assets/create-file-9e37b1d6.js +0 -1
  26. cista-1.3.0/cista/frontend-build/assets/create-folder-f68cfe1f.js +0 -1
  27. cista-1.3.0/cista/frontend-build/assets/cross-d99708ee.js +0 -1
  28. cista-1.3.0/cista/frontend-build/assets/disk-68b87505.js +0 -1
  29. cista-1.3.0/cista/frontend-build/assets/download-60cf047e.js +0 -1
  30. cista-1.3.0/cista/frontend-build/assets/exclamation-ed20d895.js +0 -1
  31. cista-1.3.0/cista/frontend-build/assets/eye-4bdd3f22.js +0 -1
  32. cista-1.3.0/cista/frontend-build/assets/find-8c04a16e.js +0 -1
  33. cista-1.3.0/cista/frontend-build/assets/fullscreen-d5a124b1.js +0 -1
  34. cista-1.3.0/cista/frontend-build/assets/github-ac1f3711.js +0 -1
  35. cista-1.3.0/cista/frontend-build/assets/index-0e34deb4.css +0 -1
  36. cista-1.3.0/cista/frontend-build/assets/index-72c7a093.js +0 -25
  37. cista-1.3.0/cista/frontend-build/assets/info-7d74e0af.js +0 -1
  38. cista-1.3.0/cista/frontend-build/assets/link-86f2038d.js +0 -1
  39. cista-1.3.0/cista/frontend-build/assets/logo-304a4977.js +0 -1
  40. cista-1.3.0/cista/frontend-build/assets/loop-094fa59f.js +0 -1
  41. cista-1.3.0/cista/frontend-build/assets/menu-5aa43550.js +0 -1
  42. cista-1.3.0/cista/frontend-build/assets/next-73c63b6c.js +0 -1
  43. cista-1.3.0/cista/frontend-build/assets/open-83f85394.js +0 -1
  44. cista-1.3.0/cista/frontend-build/assets/paste-32e42b20.js +0 -1
  45. cista-1.3.0/cista/frontend-build/assets/pause-530d4eda.js +0 -1
  46. cista-1.3.0/cista/frontend-build/assets/pencil-3347872a.js +0 -1
  47. cista-1.3.0/cista/frontend-build/assets/plus-57ce8823.js +0 -1
  48. cista-1.3.0/cista/frontend-build/assets/previous-0b94b7ca.js +0 -1
  49. cista-1.3.0/cista/frontend-build/assets/reload-b01bccf2.js +0 -1
  50. cista-1.3.0/cista/frontend-build/assets/rename-aac51c4a.js +0 -1
  51. cista-1.3.0/cista/frontend-build/assets/scissors-4ab3f69f.js +0 -1
  52. cista-1.3.0/cista/frontend-build/assets/shuffle-241453fb.js +0 -1
  53. cista-1.3.0/cista/frontend-build/assets/signin-fb2b835d.js +0 -1
  54. cista-1.3.0/cista/frontend-build/assets/signout-ebb6ff80.js +0 -1
  55. cista-1.3.0/cista/frontend-build/assets/skip-92b79bc0.js +0 -1
  56. cista-1.3.0/cista/frontend-build/assets/spinner-8a1bf68f.js +0 -1
  57. cista-1.3.0/cista/frontend-build/assets/stop-2cfb5705.js +0 -1
  58. cista-1.3.0/cista/frontend-build/assets/trash-7d469999.js +0 -1
  59. cista-1.3.0/cista/frontend-build/assets/triangle-40858029.js +0 -1
  60. cista-1.3.0/cista/frontend-build/assets/unfullscreen-4710e51b.js +0 -1
  61. cista-1.3.0/cista/frontend-build/assets/up-arrow-f20ea5cf.js +0 -1
  62. cista-1.3.0/cista/frontend-build/assets/upload-cloud-80e5a96d.js +0 -1
  63. cista-1.3.0/cista/frontend-build/assets/user-70db25a5.js +0 -1
  64. cista-1.3.0/cista/frontend-build/assets/user-cog-b8ed5882.js +0 -1
  65. cista-1.3.0/cista/frontend-build/assets/volume-high-ea949028.js +0 -1
  66. cista-1.3.0/cista/frontend-build/assets/volume-low-f29113a6.js +0 -1
  67. cista-1.3.0/cista/frontend-build/assets/volume-medium-ce4f8214.js +0 -1
  68. cista-1.3.0/cista/frontend-build/assets/volume-mute-3899fd69.js +0 -1
  69. cista-1.3.0/cista/frontend-build/assets/window-b176eb67.js +0 -1
  70. cista-1.3.0/cista/frontend-build/assets/window-cross-786863fe.js +0 -1
  71. cista-1.3.0/cista/frontend-build/assets/wordwrap-04a75e12.js +0 -1
  72. cista-1.3.0/cista/frontend-build/assets/zoomin-4567de3c.js +0 -1
  73. cista-1.3.0/cista/frontend-build/assets/zoomout-55239db5.js +0 -1
  74. {cista-1.3.0 → cista-1.4.0}/.gitignore +0 -0
  75. {cista-1.3.0 → cista-1.4.0}/README.md +0 -0
  76. {cista-1.3.0 → cista-1.4.0}/cista/__init__.py +0 -0
  77. {cista-1.3.0 → cista-1.4.0}/cista/droppy.py +0 -0
  78. {cista-1.3.0 → cista-1.4.0}/cista/fileio.py +0 -0
  79. /cista-1.3.0/cista/frontend-build/assets/logo-97d1d7eb.svg → /cista-1.4.0/cista/frontend-build/assets/logo-ctv8tVwU.svg +0 -0
  80. {cista-1.3.0 → cista-1.4.0}/cista/frontend-build/robots.txt +0 -0
  81. {cista-1.3.0 → cista-1.4.0}/cista/protocol.py +0 -0
  82. {cista-1.3.0 → cista-1.4.0}/cista/serve.py +0 -0
  83. {cista-1.3.0 → cista-1.4.0}/cista/server80.py +0 -0
  84. {cista-1.3.0 → cista-1.4.0}/cista/session.py +0 -0
  85. {cista-1.3.0 → cista-1.4.0}/cista/util/__init__.py +0 -0
  86. {cista-1.3.0 → cista-1.4.0}/cista/util/asynclink.py +0 -0
  87. {cista-1.3.0 → cista-1.4.0}/cista/util/filename.py +0 -0
  88. {cista-1.3.0 → cista-1.4.0}/cista/util/lrucache.py +0 -0
  89. {cista-1.3.0 → cista-1.4.0}/cista/util/pwgen.py +0 -0
  90. {cista-1.3.0 → cista-1.4.0}/cista/watching.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cista
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: Dropbox-like file server with modern web interface
5
5
  Project-URL: Homepage, https://git.zi.fi/Vasanko/cista-storage
6
6
  Author: Vasanko
@@ -17,6 +17,10 @@ Requires-Dist: argon2-cffi>=25.1.0
17
17
  Requires-Dist: av>=15.0.0
18
18
  Requires-Dist: blake3>=1.0.5
19
19
  Requires-Dist: docopt>=0.6.2
20
+ Requires-Dist: fastapi-vue>=0.5.1
21
+ Requires-Dist: fastapi[standard]>=0.128.0
22
+ Requires-Dist: html5tagger>=1.3.0
23
+ Requires-Dist: httpx>=0.28.0
20
24
  Requires-Dist: inotify>=0.2.12
21
25
  Requires-Dist: msgspec>=0.19.0
22
26
  Requires-Dist: natsort>=8.4.0
@@ -42,13 +42,17 @@ Options:
42
42
  --import-droppy Import Droppy config from ~/.droppy/config
43
43
  --dev Developer mode (reloads, friendlier crashes, more logs)
44
44
 
45
- Listen address, path and imported options are preserved in config, and only
46
- custom config dir and dev mode need to be specified on subsequent runs.
45
+ Listen address and path are preserved in config,
46
+ and only config dir and dev mode need to be specified on subsequent runs.
47
47
 
48
48
  User management:
49
49
  --user NAME Create or modify user
50
50
  --privileged Give the user full admin rights
51
51
  --password Reset password
52
+
53
+ Environment:
54
+ PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
55
+ https://git.zi.fi/leovasanko/paskia
52
56
  """
53
57
 
54
58
  first_time_help = """\
@@ -107,6 +111,7 @@ def _main():
107
111
  f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
108
112
  )
109
113
  settings = droppy.readconf()
114
+ # Droppy's public flag is kept as-is (same name in our config)
110
115
  if path:
111
116
  settings["path"] = path
112
117
  elif not exists:
@@ -115,9 +120,6 @@ def _main():
115
120
  settings["listen"] = listen
116
121
  elif not exists:
117
122
  settings["listen"] = ":8000"
118
- if not exists and not import_droppy:
119
- # We have no users, so make it public
120
- settings["public"] = True
121
123
  operation = config.update_config(settings)
122
124
  sys.stderr.write(f"Config {operation}: {config.conffile}\n")
123
125
  # Prepare to serve
@@ -1,2 +1,2 @@
1
1
  # This file is automatically generated by hatch build.
2
- __version__ = '1.3.0'
2
+ __version__ = '1.4.0'
@@ -3,9 +3,10 @@ import typing
3
3
  from secrets import token_bytes
4
4
 
5
5
  import msgspec
6
- from sanic import Blueprint
6
+ from sanic import Blueprint, json
7
+ from sanic.exceptions import BadRequest
7
8
 
8
- from cista import __version__, config, watching
9
+ from cista import __version__, auth, config, sso, watching
9
10
  from cista.fileio import FileServer
10
11
  from cista.protocol import ControlTypes, FileRange, StatusMsg
11
12
  from cista.util.apphelpers import asend, websocket_wrapper
@@ -92,6 +93,23 @@ async def control(req, ws):
92
93
  @bp.websocket("watch")
93
94
  @websocket_wrapper
94
95
  async def watch(req, ws):
96
+ # Build user info from either built-in auth or SSO
97
+ user_info = None
98
+ if sso_user := getattr(req.ctx, "sso_user", None):
99
+ # SSO auth (paskia mode): extract from validation response
100
+ ctx = sso_user.get("ctx", {})
101
+ perms = ctx.get("permissions", [])
102
+ user_info = {
103
+ "username": ctx.get("user", {}).get("display_name", ""),
104
+ "privileged": "cista:admin" in perms,
105
+ }
106
+ elif req.ctx.user:
107
+ # Built-in auth: use local user database
108
+ user_info = {
109
+ "username": req.ctx.username,
110
+ "privileged": req.ctx.user.privileged,
111
+ }
112
+
95
113
  await ws.send(
96
114
  msgspec.json.encode(
97
115
  {
@@ -99,13 +117,9 @@ async def watch(req, ws):
99
117
  "name": config.config.name or config.config.path.name,
100
118
  "version": __version__,
101
119
  "public": config.config.public,
120
+ "paskia": sso.paskia_enabled(),
102
121
  },
103
- "user": {
104
- "username": req.ctx.username,
105
- "privileged": req.ctx.user.privileged,
106
- }
107
- if req.ctx.user
108
- else None,
122
+ "user": user_info,
109
123
  }
110
124
  ).decode()
111
125
  )
@@ -136,3 +150,18 @@ def subscribe(uuid, ws):
136
150
  watching.format_space(watching.state.space),
137
151
  watching.format_root(watching.state.root),
138
152
  )
153
+
154
+
155
+ @bp.put("config/public")
156
+ async def update_public(request):
157
+ await auth.verify(request, privileged=True)
158
+ try:
159
+ public = request.json["public"]
160
+ if not isinstance(public, bool):
161
+ raise ValueError("public must be a boolean")
162
+ except KeyError:
163
+ raise BadRequest("Missing public field") from None
164
+ except ValueError as e:
165
+ raise BadRequest(str(e)) from None
166
+ config.update_config({"public": public})
167
+ return json({"message": "Public access setting updated", "public": public})
@@ -18,7 +18,7 @@ from setproctitle import setproctitle
18
18
  from stream_zip import ZIP_AUTO, stream_zip
19
19
  from zstandard import ZstdCompressor
20
20
 
21
- from cista import auth, config, preview, session, watching
21
+ from cista import auth, config, preview, session, sso, watching
22
22
  from cista.api import bp
23
23
  from cista.util.apphelpers import handle_sanic_exception
24
24
 
@@ -26,7 +26,11 @@ from cista.util.apphelpers import handle_sanic_exception
26
26
  sanic.helpers._ENTITY_HEADERS = frozenset()
27
27
 
28
28
  app = Sanic("cista", strict_slashes=True)
29
- app.blueprint(auth.bp)
29
+ # Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
30
+ if sso.paskia_enabled():
31
+ app.blueprint(sso.bp) # SSO proxy for /auth/* routes
32
+ else:
33
+ app.blueprint(auth.bp) # Built-in auth routes
30
34
  app.blueprint(preview.bp)
31
35
  app.blueprint(bp)
32
36
  app.exception(Exception)(handle_sanic_exception)
@@ -52,6 +56,7 @@ async def main_stop(app):
52
56
  quit.set()
53
57
  watching.stop(app)
54
58
  app.ctx.threadexec.shutdown()
59
+ await sso.close_client()
55
60
  logger.debug("Cista worker threads all finished")
56
61
 
57
62
 
@@ -74,10 +79,23 @@ async def use_session(req):
74
79
  raise Forbidden("Invalid origin: Cross-Site requests not permitted")
75
80
 
76
81
 
82
+ @app.on_response
83
+ async def forward_sso_cookies(req, res):
84
+ """Forward Set-Cookie headers from SSO validation to client."""
85
+ if cookies := getattr(req.ctx, "sso_cookies", None):
86
+ for cookie in cookies:
87
+ res.headers.add("set-cookie", cookie)
88
+
89
+
77
90
  @app.before_server_start
78
91
  def http_fileserver(app):
79
92
  bp = Blueprint("fileserver")
80
- bp.on_request(auth.verify)
93
+
94
+ @bp.on_request
95
+ async def verify_fileserver(request):
96
+ """Verify access to file server routes."""
97
+ await auth.verify(request)
98
+
81
99
  bp.static(
82
100
  "/files/",
83
101
  config.config.path,
@@ -211,7 +229,7 @@ async def wwwroot(req, path=""):
211
229
  @app.route("/favicon.ico", methods=["GET", "HEAD"])
212
230
  async def favicon(req):
213
231
  # Browsers keep asking for it when viewing files (not HTML with icon link)
214
- return redirect("/assets/logo-97d1d7eb.svg", status=308)
232
+ return redirect("/assets/logo-ctv8tVwU.svg", status=308)
215
233
 
216
234
 
217
235
  def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
@@ -239,6 +257,10 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
239
257
  @app.get("/zip/<keys>/<zipfile:ext=zip>")
240
258
  async def zip_download(req, keys, zipfile, ext):
241
259
  """Download a zip archive of the given keys"""
260
+ if config.config.authentication == "paskia":
261
+ await auth.verify_sso(req)
262
+ else:
263
+ auth.verify(req)
242
264
 
243
265
  wanted = set(keys.split("+"))
244
266
  files = get_files(wanted)
@@ -0,0 +1,498 @@
1
+ import hmac
2
+ import re
3
+ from time import time
4
+ from unicodedata import normalize
5
+
6
+ import argon2
7
+ import msgspec
8
+ from html5tagger import Document
9
+ from sanic import Blueprint, html, json, redirect
10
+ from sanic.exceptions import BadRequest, Forbidden, Unauthorized
11
+
12
+ from cista import config, session
13
+ from cista.util import pwgen
14
+
15
+ _LOGIN_PAGE_CSS = """\
16
+ /* ===========================================
17
+ LOGIN PAGE STYLES
18
+ Must match ModalDialog.vue global styles.
19
+ =========================================== */
20
+ * { box-sizing: border-box; }
21
+ body {
22
+ font-family: 'Roboto', system-ui, -apple-system, sans-serif;
23
+ font-size: 1rem;
24
+ margin: 0;
25
+ min-height: 100vh;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ background: transparent;
30
+ }
31
+ .login-card {
32
+ background: #ddd;
33
+ color: #000;
34
+ border-radius: 0.5rem;
35
+ box-shadow: 0 0 1rem #0008;
36
+ width: 100%;
37
+ max-width: 320px;
38
+ }
39
+ h1 {
40
+ background: #146;
41
+ color: #fff;
42
+ margin: 0;
43
+ padding: 0.5rem 1rem;
44
+ font-size: 1.2rem;
45
+ font-weight: normal;
46
+ border-radius: 0.5rem 0.5rem 0 0;
47
+ }
48
+ .content {
49
+ padding: 1rem;
50
+ }
51
+ .message {
52
+ color: #444;
53
+ margin: 0 0 0.5rem 0;
54
+ font-size: 0.875rem;
55
+ }
56
+ form {
57
+ display: grid;
58
+ grid-template-columns: auto 1fr;
59
+ gap: 0.5rem 1rem;
60
+ align-items: center;
61
+ }
62
+ label {
63
+ font-size: 1rem;
64
+ }
65
+ input[type="text"],
66
+ input[type="password"] {
67
+ font: inherit;
68
+ font-size: 1rem;
69
+ padding: 0.5rem;
70
+ border: 2px solid #888;
71
+ border-radius: 0.25rem;
72
+ background: #fff;
73
+ color: #000;
74
+ min-width: 0;
75
+ }
76
+ input:focus {
77
+ outline: none;
78
+ border-color: #f80;
79
+ }
80
+ .button-row {
81
+ grid-column: 1 / -1;
82
+ display: flex;
83
+ justify-content: flex-end;
84
+ margin-top: 0.5rem;
85
+ }
86
+ button {
87
+ font: inherit;
88
+ font-size: 1rem;
89
+ padding: 0.5rem 1rem;
90
+ background: #146;
91
+ color: #fff;
92
+ border: none;
93
+ border-radius: 0.25rem;
94
+ cursor: pointer;
95
+ }
96
+ button:hover { background: #f80; }
97
+ button:disabled {
98
+ background: #888;
99
+ cursor: not-allowed;
100
+ }
101
+ .error {
102
+ grid-column: 1 / -1;
103
+ color: #c00;
104
+ font-size: 0.875rem;
105
+ min-height: 1.2em;
106
+ margin: 0;
107
+ }
108
+ """
109
+
110
+ _LOGIN_PAGE_JS = """\
111
+ const form = document.getElementById('loginForm');
112
+ const error = document.getElementById('error');
113
+ const submitBtn = document.getElementById('submitBtn');
114
+ const usernameField = document.getElementById('username');
115
+ const passwordField = document.getElementById('password');
116
+ const isInIframe = window.parent !== window;
117
+
118
+ // Focus username field on load
119
+ usernameField.focus();
120
+
121
+ const showError = (msg) => {
122
+ error.textContent = msg;
123
+ submitBtn.disabled = false;
124
+ submitBtn.textContent = 'Log in';
125
+ // Focus and select the relevant field
126
+ if (msg.toLowerCase().includes('password')) {
127
+ passwordField.focus();
128
+ passwordField.select();
129
+ } else {
130
+ usernameField.focus();
131
+ usernameField.select();
132
+ }
133
+ };
134
+
135
+ form.onsubmit = async (e) => {
136
+ e.preventDefault();
137
+ error.textContent = '';
138
+ submitBtn.disabled = true;
139
+ submitBtn.textContent = 'Logging in...';
140
+
141
+ try {
142
+ const res = await fetch('/auth/login', {
143
+ method: 'POST',
144
+ headers: {
145
+ 'Content-Type': 'application/json',
146
+ 'Accept': 'application/json'
147
+ },
148
+ body: JSON.stringify({
149
+ username: usernameField.value,
150
+ password: passwordField.value
151
+ })
152
+ });
153
+
154
+ if (res.ok) {
155
+ if (isInIframe) {
156
+ window.parent.postMessage({type: 'auth-success'}, '*');
157
+ } else {
158
+ window.location.href = '/';
159
+ }
160
+ } else {
161
+ const data = await res.json();
162
+ showError(data.message || data.detail || 'Login failed');
163
+ }
164
+ } catch (err) {
165
+ showError('Connection error. Please try again.');
166
+ }
167
+ };
168
+ """
169
+
170
+ # Import for SSO validation (lazily loaded to avoid circular imports)
171
+ _sso_module = None
172
+
173
+
174
+ def _get_sso():
175
+ global _sso_module
176
+ if _sso_module is None:
177
+ from cista import sso
178
+
179
+ _sso_module = sso
180
+ return _sso_module
181
+
182
+
183
+ _argon = argon2.PasswordHasher()
184
+ _droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
185
+
186
+
187
+ def _pwnorm(password):
188
+ return normalize("NFC", password).strip().encode()
189
+
190
+
191
+ def login(username: str, password: str):
192
+ un = _pwnorm(username)
193
+ pw = _pwnorm(password)
194
+ try:
195
+ u = config.config.users[un.decode()]
196
+ except KeyError:
197
+ raise ValueError("Invalid username") from None
198
+ # Verify password
199
+ need_rehash = False
200
+ if not u.hash:
201
+ raise ValueError("Account disabled")
202
+ if (m := _droppyhash.match(u.hash)) is not None:
203
+ h, s = m.groups()
204
+ h2 = hmac.digest(pw + s.encode() + un, b"", "sha256").hex()
205
+ if not hmac.compare_digest(h, h2):
206
+ raise ValueError("Invalid password")
207
+ # Droppy hashes are weak, do a hash update
208
+ need_rehash = True
209
+ else:
210
+ try:
211
+ _argon.verify(u.hash, pw)
212
+ except Exception:
213
+ raise ValueError("Invalid password") from None
214
+ if _argon.check_needs_rehash(u.hash):
215
+ need_rehash = True
216
+ # Login successful
217
+ if need_rehash:
218
+ set_password(u, password)
219
+ now = int(time())
220
+ u.lastSeen = now
221
+ return u
222
+
223
+
224
+ def set_password(user: config.User, password: str):
225
+ user.hash = _argon.hash(_pwnorm(password))
226
+
227
+
228
+ class LoginResponse(msgspec.Struct):
229
+ user: str = ""
230
+ privileged: bool = False
231
+ error: str = ""
232
+
233
+
234
+ async def verify(request, *, privileged=False):
235
+ """Verify that the request is authorized.
236
+
237
+ For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
238
+ For built-in mode, checks session-based authentication.
239
+ For public mode (config.public=True), allows all requests.
240
+
241
+ All 401/403 responses include auth.iframe URL for consistent frontend handling
242
+ via the paskia library's showAuthIframe().
243
+
244
+ Args:
245
+ request: The Sanic request object
246
+ privileged: If True, requires admin privileges
247
+
248
+ Raises:
249
+ Unauthorized: If authentication is required
250
+ Forbidden: If access is denied
251
+ """
252
+ sso = _get_sso()
253
+ if sso.paskia_enabled():
254
+ # SSO validation against auth backend
255
+ # Always check cista:login; privileged flag comes from response perm list
256
+ perm = "cista:admin" if privileged else "cista:login"
257
+ await sso.validate_sso_request(request, perm=perm)
258
+ return
259
+
260
+ user = getattr(request.ctx, "user", None)
261
+ if privileged:
262
+ if user:
263
+ if user.privileged:
264
+ return
265
+ raise Forbidden(
266
+ "Access Forbidden: Only for privileged users",
267
+ quiet=True,
268
+ )
269
+ elif config.config.public or user:
270
+ return
271
+ # Return iframe URL for paskia library to show login dialog
272
+ raise Unauthorized(
273
+ f"Login required for {request.path}",
274
+ "cookie",
275
+ context={"auth": {"iframe": "/auth/restricted"}},
276
+ quiet=True,
277
+ )
278
+
279
+
280
+ # Blueprint for built-in auth (only registered when paskia is NOT enabled)
281
+ bp = Blueprint("auth", url_prefix="/auth")
282
+
283
+
284
+ @bp.get("/restricted")
285
+ async def login_page(request):
286
+ """Login page that works both standalone and in paskia iframe."""
287
+ s = session.get(request)
288
+
289
+ # Check if already logged in
290
+ if s:
291
+ # Already authenticated - signal success if in iframe
292
+ return html(_login_success_page(s["username"]))
293
+
294
+ doc = Document("Cista - Login")
295
+ # Add paskia-compatible styling and scripts
296
+ doc.style(_LOGIN_PAGE_CSS)
297
+ with doc.div(class_="login-card"):
298
+ doc.h1("Authentication Required")
299
+ with doc.div(class_="content"):
300
+ with doc.form(method="POST", id="loginForm", autocomplete="on"):
301
+ doc.label("Username:", for_="username")
302
+ doc.input(
303
+ type="text",
304
+ id="username",
305
+ name="username",
306
+ autocomplete="username webauthn",
307
+ required=True,
308
+ )
309
+ doc.label("Password:", for_="password")
310
+ doc.input(
311
+ type="password",
312
+ id="password",
313
+ name="password",
314
+ autocomplete="current-password webauthn",
315
+ required=True,
316
+ )
317
+ with doc.div(class_="button-row"):
318
+ doc.button("Log in", type="submit", id="submitBtn")
319
+ doc.p("", class_="error", id="error")
320
+
321
+ # JavaScript for AJAX login and postMessage communication
322
+ doc.script_(_LOGIN_PAGE_JS)
323
+
324
+ res = html(doc)
325
+ if s is False:
326
+ session.delete(res)
327
+ return res
328
+
329
+
330
+ def _login_success_page(username: str) -> str:
331
+ """Minimal page that signals auth-success to parent iframe."""
332
+ return str(
333
+ Document().script_("window.parent.postMessage({type:'auth-success'},'*')")
334
+ )
335
+
336
+
337
+ @bp.post("/login")
338
+ async def login_post(request):
339
+ try:
340
+ if request.headers.content_type == "application/json":
341
+ username = request.json["username"]
342
+ password = request.json["password"]
343
+ else:
344
+ username = request.form["username"][0]
345
+ password = request.form["password"][0]
346
+ if not username or not password:
347
+ raise KeyError
348
+ except KeyError:
349
+ raise BadRequest(
350
+ "Missing username or password",
351
+ context={"redirect": "/login"},
352
+ ) from None
353
+ try:
354
+ user = login(username, password)
355
+ except ValueError as e:
356
+ raise Forbidden(str(e), context={"redirect": "/login"}) from e
357
+
358
+ if "text/html" in request.headers.accept:
359
+ res = redirect("/")
360
+ session.flash(res, "Logged in")
361
+ else:
362
+ res = json({"data": {"username": username, "privileged": user.privileged}})
363
+ session.create(res, username)
364
+ return res
365
+
366
+
367
+ @bp.post("/api/logout")
368
+ async def logout_post(request):
369
+ s = request.ctx.session
370
+ msg = "Logged out" if s else "Not logged in"
371
+ if "text/html" in request.headers.accept:
372
+ res = redirect("/login")
373
+ res.cookies.add_cookie("flash", msg, max_age=5)
374
+ else:
375
+ res = json({"message": msg})
376
+ session.delete(res)
377
+ return res
378
+
379
+
380
+ @bp.post("/password-change")
381
+ async def change_password(request):
382
+ try:
383
+ if request.headers.content_type == "application/json":
384
+ username = request.json["username"]
385
+ pwchange = request.json["passwordChange"]
386
+ password = request.json["password"]
387
+ else:
388
+ username = request.form["username"][0]
389
+ pwchange = request.form["passwordChange"][0]
390
+ password = request.form["password"][0]
391
+ if not username or not password:
392
+ raise KeyError
393
+ except KeyError:
394
+ raise BadRequest(
395
+ "Missing username, passwordChange or password",
396
+ ) from None
397
+ try:
398
+ user = login(username, password)
399
+ set_password(user, pwchange)
400
+ except ValueError as e:
401
+ raise Forbidden(str(e), context={"redirect": "/login"}) from e
402
+
403
+ if "text/html" in request.headers.accept:
404
+ res = redirect("/")
405
+ session.flash(res, "Password updated")
406
+ else:
407
+ res = json({"message": "Password updated"})
408
+ session.create(res, username)
409
+ return res
410
+
411
+
412
+ @bp.get("/users")
413
+ async def list_users(request):
414
+ await verify(request, privileged=True)
415
+ users = []
416
+ for name, user in config.config.users.items():
417
+ users.append(
418
+ {
419
+ "username": name,
420
+ "privileged": user.privileged,
421
+ "lastSeen": user.lastSeen,
422
+ }
423
+ )
424
+ return json({"users": users})
425
+
426
+
427
+ @bp.post("/users")
428
+ async def create_user(request):
429
+ await verify(request, privileged=True)
430
+ try:
431
+ if request.headers.content_type == "application/json":
432
+ username = request.json["username"]
433
+ password = request.json.get("password")
434
+ privileged = request.json.get("privileged", False)
435
+ else:
436
+ username = request.form["username"][0]
437
+ password = request.form.get("password", [None])[0]
438
+ privileged = request.form.get("privileged", ["false"])[0].lower() == "true"
439
+ if not username or not username.isidentifier():
440
+ raise ValueError("Invalid username")
441
+ except (KeyError, ValueError) as e:
442
+ raise BadRequest(str(e)) from e
443
+ if username in config.config.users:
444
+ raise BadRequest("User already exists")
445
+ if not password:
446
+ password = pwgen.generate()
447
+ changes = {"privileged": privileged}
448
+ changes["hash"] = _argon.hash(_pwnorm(password))
449
+ try:
450
+ config.update_user(username, changes)
451
+ except Exception as e:
452
+ raise BadRequest(str(e)) from e
453
+ return json({"message": f"User {username} created", "password": password})
454
+
455
+
456
+ @bp.put("/users/<username>")
457
+ async def update_user(request, username):
458
+ await verify(request, privileged=True)
459
+ try:
460
+ if request.headers.content_type == "application/json":
461
+ changes = request.json
462
+ else:
463
+ changes = {}
464
+ if "password" in request.form:
465
+ changes["password"] = request.form["password"][0]
466
+ if "privileged" in request.form:
467
+ changes["privileged"] = request.form["privileged"][0].lower() == "true"
468
+ except KeyError as e:
469
+ raise BadRequest("Missing fields") from e
470
+ password_response = None
471
+ if "password" in changes:
472
+ if changes["password"] == "":
473
+ changes["password"] = pwgen.generate()
474
+ password_response = changes["password"]
475
+ changes["hash"] = _argon.hash(_pwnorm(changes["password"]))
476
+ del changes["password"]
477
+ if not changes:
478
+ return json({"message": "No changes"})
479
+ try:
480
+ config.update_user(username, changes)
481
+ except Exception as e:
482
+ raise BadRequest(str(e)) from e
483
+ response = {"message": f"User {username} updated"}
484
+ if password_response:
485
+ response["password"] = password_response
486
+ return json(response)
487
+
488
+
489
+ @bp.delete("/users/<username>")
490
+ async def delete_user(request, username):
491
+ await verify(request, privileged=True)
492
+ if username not in config.config.users:
493
+ raise BadRequest("User does not exist")
494
+ try:
495
+ config.del_user(username)
496
+ except Exception as e:
497
+ raise BadRequest(str(e)) from e
498
+ return json({"message": f"User {username} deleted"})