aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcbdev <cb@cbcdn.com>2022-07-04 19:21:34 +0200
committercbdev <cb@cbcdn.com>2022-07-04 19:21:34 +0200
commit507f0d060da30a8f65bec8b2ac1f08837d536b4c (patch)
treeb2c5a1f9cc5080fb7f2586e2371fce443e43fbaa
parent4369d6a7f024f9ebf3c5f41a8fe17bfc65d0a820 (diff)
downloadcargohold-507f0d060da30a8f65bec8b2ac1f08837d536b4c.tar.gz
cargohold-507f0d060da30a8f65bec8b2ac1f08837d536b4c.tar.bz2
cargohold-507f0d060da30a8f65bec8b2ac1f08837d536b4c.zip
Implement authentication modules for admin panel
-rw-r--r--README.md6
-rw-r--r--backend/Admin.py9
-rw-r--r--backend/ExternalAuth.py22
-rw-r--r--backend/LocalBasicAuth.py35
-rw-r--r--backend/NoneAuth.py15
-rw-r--r--backend/RemoteCookieAuth.py68
-rw-r--r--backend/cargohold.sql1
-rw-r--r--backend/config.py2
-rw-r--r--backend/main.py17
-rw-r--r--backend/utils.py12
10 files changed, 181 insertions, 6 deletions
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