From 507f0d060da30a8f65bec8b2ac1f08837d536b4c Mon Sep 17 00:00:00 2001 From: cbdev Date: Mon, 4 Jul 2022 19:21:34 +0200 Subject: Implement authentication modules for admin panel --- README.md | 6 +++- backend/Admin.py | 9 ++++++ backend/ExternalAuth.py | 22 +++++++++++++++ backend/LocalBasicAuth.py | 35 +++++++++++++++++++++++ backend/NoneAuth.py | 15 ++++++++++ backend/RemoteCookieAuth.py | 68 +++++++++++++++++++++++++++++++++++++++++++++ backend/cargohold.sql | 1 + backend/config.py | 2 ++ backend/main.py | 17 ++++++++---- backend/utils.py | 12 ++++++++ 10 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 backend/Admin.py create mode 100644 backend/ExternalAuth.py create mode 100644 backend/LocalBasicAuth.py create mode 100644 backend/NoneAuth.py create mode 100644 backend/RemoteCookieAuth.py create mode 100644 backend/utils.py diff --git a/README.md b/README.md index bd6f60f..eb59c09 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Initial configuration * Create the cargohold database by running `sqlite3 cargohold.db3 < backend/cargohold.sql` * Update `backend/config.py` with the path to your database - +* If you want to use the web administration interface, select an authentication provider in `backend/config.py`. Some providers might need additional configuration. See the section on Authentication for more details. TBD: Extend this. ## Usage @@ -56,3 +56,7 @@ The storage limit for each alias (applied when uploading files) is calculated as cargohold integrates somewhat tightly with nginx using the X-Accel-Redirect mechanism. Other httpds may provide a similar mechanism, which will need to be called out to in the `playout()` routine in `main.py`. + +## Authentication + +TBD diff --git a/backend/Admin.py b/backend/Admin.py new file mode 100644 index 0000000..b88f4c7 --- /dev/null +++ b/backend/Admin.py @@ -0,0 +1,9 @@ +import config +from utils import redirect + +def route(path, env, post): + user = config.Auth.get(env) + if not user: + return redirect("/") + # TBD + return ["Admin panel for " + user["user"], [('Content-Type','text/html')], "200 OK"] diff --git a/backend/ExternalAuth.py b/backend/ExternalAuth.py new file mode 100644 index 0000000..e6837ec --- /dev/null +++ b/backend/ExternalAuth.py @@ -0,0 +1,22 @@ +import config +import utils + +# This authentication provider assumes that the actual authentication is done in +# an upstream component such as the serving httpd. +# The authorized user is expected to be passed in the uwsgi environment variable REMOTE_USER. + +def login(env, post): + user = get(env) + if not User: + return utils.redirect(config.homepage) + + utils.ensure_user(user["user"]) + return utils.redirect("/admin") + +def get(env): + if env.get('REMOTE_USER', ''): + return {"user": env.get('REMOTE_USER', ''), "expire": None} + return None + +def logout(): + return False diff --git a/backend/LocalBasicAuth.py b/backend/LocalBasicAuth.py new file mode 100644 index 0000000..967707c --- /dev/null +++ b/backend/LocalBasicAuth.py @@ -0,0 +1,35 @@ +import config +import utils +import base64 +from passlib.apache import HtpasswdFile + +# This authentication provider reads a local Apache-style htpassword file +# and performs HTTP Basic authentication. + +passwd_file = ".htpasswd" +realm = "cargohold" + +def login(env, post): + auth = get(env) + + if not auth: + return ["Please authenticate", [("WWW-Authenticate",'Basic realm="' + realm + '"')], "401 Authenticate"] + + utils.ensure_user(auth["user"]) + return utils.redirect("/admin") + +def get(env): + auth = env.get("HTTP_AUTHORIZATION", "") + if auth and auth.startswith("Basic "): + auth = str(base64.b64decode(auth[6:]), "utf-8").split(":") + try: + ht = HtpasswdFile(passwd_file) + if ht.check_password(auth[0], auth[1]): + return {"user": auth[0], "expire": None} + except IOError: + print("LocalBasicAuth: Failed to read credentials file at " + passwd_file) + return None + +def logout(): + # TODO + return False diff --git a/backend/NoneAuth.py b/backend/NoneAuth.py new file mode 100644 index 0000000..0522e59 --- /dev/null +++ b/backend/NoneAuth.py @@ -0,0 +1,15 @@ +import config +import utils + +# This authentication does not support any login to the web administration interface. +# Use this if you only want to manage cargohold from the command line or have your +# own management interface. + +def login(env, post): + return utils.redirect(config.homepage) + +def get(env): + return None + +def logout(): + return False diff --git a/backend/RemoteCookieAuth.py b/backend/RemoteCookieAuth.py new file mode 100644 index 0000000..4f3ef32 --- /dev/null +++ b/backend/RemoteCookieAuth.py @@ -0,0 +1,68 @@ +import requests +import sqlite3 +import http.cookies as ck +import time + +import config +import utils + +# This authentication provider assumes that a cookie containing a session ID +# has been set by some unspecified means (e.g. a login panel). This session ID +# is verified for authorization by calling a centralized verification endpoint +# which returns any entitlements in JSON. +# Authorized sessions are cached in a table in the local cargohold database +# for a limited time. + +cookie_name = "session" +validator = "https://stumpf.es/auth/cargohold" +session_cache_interval = 60*15 + +db = sqlite3.connect(config.database, check_same_thread = False) + +def login(env, post): + # Check if already using a known session + user = get(env) + if user: + return utils.redirect("/admin") + + # Check if session cookie present + cookies = ck.SimpleCookie(env.get('HTTP_COOKIE', '')) + if cookie_name not in cookies: + return utils.redirect(config.homepage) + + # Check if session ID valid + req = requests.get(validator, headers = {"Cookie": cookie_name + "=" + cookies[cookie_name].value}) + auth = req.json() + + if "entitlement" in auth and auth["entitlement"]["service"] == "cargohold": + # Create the user if not known + utils.ensure_user(auth["user"]) + + # Add to session cache + db.cursor().execute("INSERT INTO sessions (id, user, expire) VALUES (:id, :user, :expire)", {"id": cookies[cookie_name].value, "user": auth["user"], "expire": int(time.time() + min(auth["expire_in"], session_cache_interval))}) + db.commit() + return utils.redirect("/admin") + + return utils.redirect(config.homepage) + +def get(env): + # Check if session cookie present + cookies = ck.SimpleCookie(env.get('HTTP_COOKIE', '')) + if cookie_name not in cookies: + return None + + # Check if session ID is in local cache + data = db.cursor().execute("SELECT user, expire FROM sessions WHERE id = :id", {"id": cookies[cookie_name].value}) + sess = data.fetchone() + if sess and sess[1] > time.time(): + return {"user": sess[0], "expire": sess[1]} + + if sess: + # Prune expired sessions + db.cursor().execute("DELETE FROM sessions WHERE expire < :time", {"time": time.time()}) + db.commit() + return None + +def logout(): + # TODO + return None diff --git a/backend/cargohold.sql b/backend/cargohold.sql index 5447325..2f0a1db 100644 --- a/backend/cargohold.sql +++ b/backend/cargohold.sql @@ -1,2 +1,3 @@ CREATE TABLE users (name TEXT NOT NULL UNIQUE PRIMARY KEY, storage INTEGER DEFAULT (0)); CREATE TABLE aliases (alias TEXT PRIMARY KEY UNIQUE NOT NULL, user TEXT REFERENCES users (name) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, real TEXT NOT NULL, access TEXT DEFAULT r, storage INTEGER, display TEXT); +CREATE TABLE sessions (id TEXT PRIMARY KEY UNIQUE NOT NULL, user TEXT REFERENCES users (name) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL, expire INTEGER); diff --git a/backend/config.py b/backend/config.py index effbf85..2de63bc 100644 --- a/backend/config.py +++ b/backend/config.py @@ -8,3 +8,5 @@ global_limit = 0 user_subdirs = True # Path to the backing database database = "cargohold.db3" +# Select the authentication provider for the web admin interface +import NoneAuth as Auth diff --git a/backend/main.py b/backend/main.py index d1042d8..2156d0e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,12 +5,12 @@ import os import sqlite3 import mimetypes +import Admin +from utils import redirect + def playout(filename, content = "text/html"): return ["", [('Content-Type', content if content else "application/octet-stream"), ("X-Accel-Redirect", filename)], None] -def redirect(target): - return ["", [('Content-Type','text/html'), ("Location", target)], "302 Redirect"] - def target_filename_internal(alias, filename): target = alias["path"] + "/" if filename: @@ -72,14 +72,21 @@ def upload(alias, post): return ["", [('Content-Type','text/html')], "500 Error"] def route(path, env, post): + if len(path) == 1 and not path[0]: + return config.Auth.login(env, post) + + if path[0] == "admin": + return Admin.route(path, env, post) + + if path[0] == "favicon.ico": + return playout("/assets/favicon.ico") + # Get mapped user/path/limits alias = resolve_alias(path[0]) if not alias: return redirect(config.homepage) - #print(json.dumps(alias)) - # Redirect if no slash after alias if len(path) == 1: return redirect(path[0] + "/"); diff --git a/backend/utils.py b/backend/utils.py new file mode 100644 index 0000000..ddb0adc --- /dev/null +++ b/backend/utils.py @@ -0,0 +1,12 @@ +import config + +def redirect(target): + return ["", [('Content-Type','text/html'), ("Location", target)], "302 Redirect"] + +def ensure_user(name): + # TODO + return + +def is_user(name): + # TODO + return False -- cgit v1.2.3