This commit is contained in:
Ludwig Lehnert
2026-10-03 13:58:22 +00:00
parent 3075d951ba
commit 98b08f6e57
431 changed files with 210848 additions and 60 deletions
+218 -19
View File
@@ -27,11 +27,11 @@ from typing import Dict, List, Optional, Tuple
try:
from app import reconcile_shares as directory
from app import trash, access_control
from app import trash, access_control, documents
from app.account_policy import is_excluded_user
except ImportError: # Container execution uses /app as the import root.
import reconcile_shares as directory
import trash, access_control
import trash, access_control, documents
from account_policy import is_excluded_user
try:
@@ -74,7 +74,7 @@ TLS_CERT_FILE = os.getenv("WEB_TLS_CERT_FILE", "/state/tls/web.crt")
TLS_KEY_FILE = os.getenv("WEB_TLS_KEY_FILE", "/state/tls/web.key")
JWT_COOKIE = "adfs_session"
JWT_ISSUER = "ad-file-server-web"
JWT_AUDIENCE = "domain-admins"
JWT_AUDIENCE = "ad-users"
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
SID_RE = re.compile(r"S-\d+(?:-\d+)+", re.IGNORECASE)
LOGIN_LIMIT: Dict[str, deque] = {}
@@ -262,7 +262,7 @@ class TokenManager:
self.secret = secret.encode("utf-8")
self.ttl_seconds = ttl_seconds
def issue(self, username: str) -> Tuple[str, int]:
def issue(self, username: str, sid: str, role: str = "user", uid: Optional[int] = None) -> Tuple[str, int]:
issued = int(time.time())
expires = issued + self.ttl_seconds
header = {"alg": "HS256", "typ": "JWT"}
@@ -270,7 +270,9 @@ class TokenManager:
"iss": JWT_ISSUER,
"aud": JWT_AUDIENCE,
"sub": username,
"role": "domain-admin",
"sid": sid,
"uid": uid,
"role": role,
"iat": issued,
"exp": expires,
"jti": secrets.token_urlsafe(16),
@@ -300,7 +302,7 @@ class TokenManager:
raise ValueError("header")
if payload.get("iss") != JWT_ISSUER or payload.get("aud") != JWT_AUDIENCE:
raise ValueError("issuer")
if payload.get("role") != "domain-admin":
if payload.get("role") not in {"domain-admin", "user"} or not SID_RE.fullmatch(str(payload.get("sid", ""))):
raise ValueError("role")
if int(payload.get("exp", 0)) <= int(time.time()):
raise ValueError("expired")
@@ -320,7 +322,7 @@ def normalize_username(username: str) -> str:
return username
def authenticate_domain_admin(username: str, password: str) -> Optional[str]:
def authenticate_user(username: str, password: str) -> Optional[Dict[str, str]]:
if not password or len(password) > 4096 or any(char in password for char in "\r\n\0"):
return None
try:
@@ -336,9 +338,10 @@ def authenticate_domain_admin(username: str, password: str) -> Optional[str]:
return None
else:
account, principal_realm = qualified.rsplit("@", 1)
if principal_realm.casefold() != realm.casefold():
domain_names = {realm.casefold(), workgroup.casefold(), os.getenv("DOMAIN", realm).casefold()}
if principal_realm.casefold() not in domain_names:
return None
if not account:
if not account or is_excluded_user(account):
return None
canonical_name = f"{workgroup}\\{account}"
principal = f"{account}@{realm}"
@@ -399,7 +402,29 @@ def authenticate_domain_admin(username: str, password: str) -> Optional[str]:
return None
admin_sid = os.environ["DOMAIN_ADMINS_SID"].casefold()
group_sids = {value.casefold() for value in SID_RE.findall(group_result.stdout)}
return canonical_name if admin_sid in group_sids else None
sid = user_sid_match.group(0)
try:
resolved = subprocess.run(["wbinfo", "--sid-to-name", sid], capture_output=True, text=True, timeout=15, check=False)
except (OSError, subprocess.TimeoutExpired):
return None
if resolved.returncode != 0 or not resolved.stdout.strip().endswith(" 1"):
return None
canonical_name = resolved.stdout.strip().rsplit(" ", 1)[0]
if is_excluded_user(canonical_name):
return None
try:
mapped = subprocess.run(["wbinfo", "--sid-to-uid", sid], capture_output=True, text=True, timeout=15, check=False)
uid = int(mapped.stdout.strip())
except (OSError, ValueError, subprocess.TimeoutExpired):
return None
if mapped.returncode or uid < 0:
return None
return {"sub": canonical_name, "sid": sid, "uid": uid, "role": "domain-admin" if admin_sid in group_sids else "user"}
def authenticate_domain_admin(username: str, password: str) -> Optional[str]:
identity = authenticate_user(username, password)
return identity["sub"] if identity and identity["role"] == "domain-admin" else None
def login_allowed(remote: str) -> bool:
@@ -798,13 +823,15 @@ class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
log(f"{self.client_address[0]} {fmt % args}")
def security_headers(self, cache_control: str = "no-store") -> None:
def security_headers(self, cache_control: str = "no-store", pdf_viewer: bool = False) -> None:
self.send_header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; connect-src 'self'; img-src 'self' data:; "
("default-src 'self'; connect-src 'self' blob: data:; img-src 'self' data: blob:; "
"worker-src 'self' blob:; font-src 'self' data: blob:; media-src blob:; "
if pdf_viewer else "default-src 'self'; connect-src 'self'; img-src 'self' data:; ") +
"style-src 'self'; script-src 'self' 'wasm-unsafe-eval'; "
"base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
"base-uri 'none'; frame-ancestors " + ("'self'" if pdf_viewer else "'none'") + "; form-action 'self'",
)
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
@@ -848,6 +875,13 @@ class Handler(BaseHTTPRequestHandler):
self.send_error_json(HTTPStatus.UNAUTHORIZED, "Anmeldung erforderlich")
return value
def require_admin(self) -> Optional[Dict[str, object]]:
value = self.require_user()
if value is not None and value.get("role") != "domain-admin":
self.send_error_json(HTTPStatus.FORBIDDEN, "Domänenadministrator erforderlich")
return None
return value
def read_json_body(self, maximum: int = 16384) -> Dict[str, object]:
try:
length = int(self.headers.get("Content-Length", "0"))
@@ -912,22 +946,49 @@ class Handler(BaseHTTPRequestHandler):
except ValueError as exc:
self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return
username = authenticate_domain_admin(str(body.get("username", "")), str(body.get("password", "")))
if username is None:
identity = authenticate_user(str(body.get("username", "")), str(body.get("password", "")))
if identity is None:
record_login_failure(remote)
time.sleep(0.4)
self.send_error_json(HTTPStatus.UNAUTHORIZED, "Ungültige Zugangsdaten oder keine Mitgliedschaft bei den Domänenadministratoren")
self.send_error_json(HTTPStatus.UNAUTHORIZED, "Ungültige Zugangsdaten")
return
clear_login_failures(remote)
token, expires = APP.tokens.issue(username)
token, expires = APP.tokens.issue(identity["sub"], identity["sid"], identity["role"], identity["uid"])
conn = directory.open_db()
try:
access_control.ensure_schema(conn)
sam = identity["sub"].split("\\")[-1]
conn.execute("INSERT INTO access_users(sid,sam,name) VALUES(?,?,?) ON CONFLICT(sid) DO UPDATE SET sam=excluded.sam",
(identity["sid"], sam, sam))
conn.commit()
finally:
conn.close()
max_age = max(0, expires - int(time.time()))
cookie = f"{JWT_COOKIE}={token}; Path=/; Max-Age={max_age}; HttpOnly; Secure; SameSite=Strict"
self.send_json({"user": username, "expiresAt": expires, "token": token, "tokenType": "Bearer"}, cookie=cookie)
self.send_json({"user": identity["sub"], "sid": identity["sid"], "role": identity["role"], "expiresAt": expires, "token": token, "tokenType": "Bearer"}, cookie=cookie)
return
if parsed.path == "/api/logout":
cookie = f"{JWT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict"
self.send_json({"ok": True}, cookie=cookie)
return
if not parsed.path.startswith("/admin/api/"):
self.send_error_json(HTTPStatus.NOT_FOUND, "Nicht gefunden")
return
administrator = self.require_admin()
if administrator is None:
return
parsed = parsed._replace(path=parsed.path[len("/admin"):])
if parsed.path == "/api/documents/control":
conn = documents.connect()
try:
documents.ensure_schema(conn)
body = self.read_json_body()
self.send_json(documents.control_worker(conn, body.get("action"), str(administrator["sub"])))
except (ValueError, OSError, RuntimeError, sqlite3.Error) as exc:
self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
finally:
conn.close()
return
if parsed.path == "/api/access":
user = self.require_user()
if user is None:
@@ -1032,6 +1093,21 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"status": "ok"})
return
if path.startswith("/api/"):
user = self.require_user()
if user is None:
return
params = urllib.parse.parse_qs(parsed.query)
if path == "/api/session":
self.send_json({"user": user["sub"], "sid": user["sid"], "role": user["role"], "expiresAt": user["exp"]})
elif path == "/api/documents" or path.startswith("/api/documents/"):
self.document_request(path, params, user)
else:
self.send_error_json(HTTPStatus.NOT_FOUND, "Nicht gefunden")
return
if path.startswith("/admin/api/"):
if self.require_admin() is None:
return
path = path[len("/admin"):]
user = self.require_user()
if user is None:
return
@@ -1039,6 +1115,13 @@ class Handler(BaseHTTPRequestHandler):
try:
if path == "/api/session":
self.send_json({"user": user["sub"], "expiresAt": user["exp"]})
elif path == "/api/documents/status":
conn = documents.connect()
try:
documents.ensure_schema(conn)
self.send_json(documents.worker_snapshot(conn))
finally:
conn.close()
elif path == "/api/overview":
self.send_json(APP.overview())
elif path == "/api/access":
@@ -1087,10 +1170,126 @@ class Handler(BaseHTTPRequestHandler):
log(f"Request {path} failed: {exc}")
self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return
legacy = {"/overview", "/access", "/reconciliation", "/storage/data", "/storage/users",
"/activity", "/activity/fslogix", "/trash", "/backup", "/report", "/system", "/shares"}
if path in legacy:
self.send_response(HTTPStatus.TEMPORARY_REDIRECT)
self.security_headers()
self.send_header("Location", "/admin/access" if path == "/shares" else "/admin" + path)
self.send_header("Content-Length", "0")
self.end_headers()
return
self.serve_static(path)
def document_request(self, path, params, identity):
conn = documents.connect()
try:
documents.ensure_schema(conn)
if path == "/api/documents":
self.send_json(documents.search(conn, identity, params))
return
parts = path.split("/")
if len(parts) not in {4, 5} or not re.fullmatch(r"[a-f0-9]{32}", parts[3]):
raise FileNotFoundError()
if len(parts) == 4:
self.send_json(documents.detail(conn, identity, parts[3]))
return
if parts[4] == "download":
with documents.download(conn, identity, parts[3]) as (handle, row):
self.send_document_file(handle, row["size"], "application/octet-stream", row["name"])
elif parts[4] == "content":
with documents.download(conn, identity, parts[3]) as (handle, row):
if row['extension'] != '.pdf':
raise FileNotFoundError()
self.send_document_file(handle, row['size'], 'application/pdf', row['name'], range_support=True, disposition='inline')
elif parts[4] == "preview":
with documents.preview(conn, identity, parts[3]) as (handle, size):
self.send_document_file(handle, size, "image/jpeg")
else:
raise FileNotFoundError()
except FileNotFoundError:
self.send_error_json(HTTPStatus.NOT_FOUND, "Datei nicht verfügbar")
except (ValueError, OSError, RuntimeError, sqlite3.Error) as exc:
log(f"Document request failed: {exc}")
self.send_error_json(HTTPStatus.BAD_REQUEST, "Dateien konnten nicht geladen werden")
finally:
conn.close()
def send_document_file(self, handle, size, mime, filename=None, *, range_support=False, disposition='attachment'):
start, end = 0, size - 1
ranged = range_support and self.headers.get('Range') is not None
if ranged:
value = self.headers.get('Range', '')
match = re.fullmatch(r'bytes=(\d*)-(\d*)', value) if len(value) <= 128 else None
valid = bool(match and size > 0 and (match[1] or match[2]))
if valid:
if match[1]:
start = int(match[1])
end = min(size - 1, int(match[2])) if match[2] else size - 1
else:
length = int(match[2])
start = max(0, size - length)
valid = length > 0
valid = valid and 0 <= start <= end < size
if not valid:
self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
self.security_headers()
self.send_header('Content-Range', f'bytes */{size}')
self.send_header('Content-Length', '0')
self.end_headers()
return
length = max(0, end - start + 1)
self.send_response(HTTPStatus.PARTIAL_CONTENT if ranged else HTTPStatus.OK)
self.security_headers()
self.send_header("Content-Type", mime)
self.send_header("Content-Length", str(length))
if range_support:
self.send_header('Accept-Ranges', 'bytes')
if ranged:
self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
if filename:
encoded = urllib.parse.quote(filename, safe="")
self.send_header("Content-Disposition", f"{disposition}; filename*=UTF-8{chr(39)*2}{encoded}")
self.end_headers()
try:
handle.seek(start)
while length:
chunk = handle.read(min(length, 1024 * 1024))
if not chunk:
break
self.wfile.write(chunk)
length -= len(chunk)
except (BrokenPipeError, ConnectionResetError):
pass
def serve_static(self, path: str) -> None:
pdf_prefix = '/assets/vendor/pdfjs/6.3.289-app1/'
if path.startswith(pdf_prefix):
relative = urllib.parse.unquote(path[len(pdf_prefix):])
if any(part in {'', '.', '..'} or '\\' in part or '\x00' in part for part in relative.split('/')):
self.send_error_json(HTTPStatus.NOT_FOUND, 'Statische Datei nicht gefunden')
return
mime = {'.html':'text/html; charset=utf-8', '.mjs':'text/javascript; charset=utf-8',
'.css':'text/css; charset=utf-8', '.svg':'image/svg+xml', '.png':'image/png',
'.json':'application/json', '.ftl':'text/plain; charset=utf-8', '.wasm':'application/wasm',
'.ttf':'font/ttf', '.woff':'font/woff', '.woff2':'font/woff2'}.get(os.path.splitext(relative)[1], 'application/octet-stream')
try:
with documents.open_file(os.path.join(STATIC_ROOT, 'vendor/pdfjs/6.3.289-app1'), relative) as (handle, _):
body = handle.read()
except (ValueError, OSError):
self.send_error_json(HTTPStatus.NOT_FOUND, 'Statische Datei nicht gefunden')
return
self.send_response(HTTPStatus.OK)
self.security_headers('public, max-age=31536000, immutable', pdf_viewer=relative == 'web/viewer.html')
self.send_header('Content-Type', mime)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
return
files = {
"/assets/portal.js": ("portal.js", "text/javascript; charset=utf-8"),
"/assets/pdf-viewer.js": ("pdf-viewer.js", "text/javascript; charset=utf-8"),
"/assets/pdf-viewer.css": ("pdf-viewer.css", "text/css; charset=utf-8"),
"/assets/app.js": ("app.js", "text/javascript; charset=utf-8"),
"/assets/report.mjs": ("report.mjs", "text/javascript; charset=utf-8"),
"/assets/styles.css": ("styles.css", "text/css; charset=utf-8"),
@@ -1102,7 +1301,7 @@ class Handler(BaseHTTPRequestHandler):
if path in files:
filename, content_type = files[path]
else:
filename, content_type = "index.html", "text/html; charset=utf-8"
filename, content_type = ("index.html" if path == "/admin" or path.startswith("/admin/") else "portal.html"), "text/html; charset=utf-8"
try:
with open(os.path.join(STATIC_ROOT, filename), "rb") as handle:
body = handle.read()