From 88df4787bf737c3fd04ec15e247d89f76635e2cc Mon Sep 17 00:00:00 2001 From: ksamuel Date: Wed, 12 Aug 2020 09:19:38 +0200 Subject: [PATCH 1/2] WIP: add admin views --- requirements.txt | 1 + zerobin/cmd.py | 61 +++++++++++++++++++++++------ zerobin/default_settings.py | 13 ++++--- zerobin/routes.py | 76 ++++++++++++++++++++++++++++++++---- zerobin/utils.py | 78 +++++++++++++++++++++++++++---------- zerobin/views/admin.tpl | 32 +++++++++++++++ 6 files changed, 216 insertions(+), 45 deletions(-) create mode 100644 zerobin/views/admin.tpl diff --git a/requirements.txt b/requirements.txt index 5bd677c..2a1ce71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ clize==4.1.1 lockfile==0.12.2 sigtools==2.0.2 bottle==0.12.18 +Beaker==1.11.0 diff --git a/zerobin/cmd.py b/zerobin/cmd.py index ffaa78f..47bcce6 100644 --- a/zerobin/cmd.py +++ b/zerobin/cmd.py @@ -7,10 +7,16 @@ import sys import re -import secrets +import hashlib import _thread as thread -from zerobin.utils import settings, SettingsValidationError, drop_privileges +from zerobin.utils import ( + settings, + SettingsValidationError, + drop_privileges, + ensure_var_env, + hash_password, +) from zerobin.routes import get_app from zerobin.paste import Paste @@ -43,13 +49,7 @@ def runserver( settings.PASTE_ID_LENGTH = paste_id_length or settings.PASTE_ID_LENGTH settings.DEBUG = bool(debug) if debug is not None else settings.DEBUG - settings.VAR_DIR.mkdir(exist_ok=True, parents=True) - settings.PASTE_FILES_ROOT.mkdir(exist_ok=True, parents=True) - - secret_key_file = settings.VAR_DIR / "secret_key" - if not secret_key_file.is_file(): - secret_key_file.write_text(secrets.token_urlsafe(64)) - settings.SECRET_KEY = secret_key_file.read_text() + ensure_var_env() try: _, app = get_app(debug, settings_file, compressed_static, settings=settings) @@ -60,6 +60,7 @@ def runserver( thread.start_new_thread(drop_privileges, (settings.USER, settings.GROUP)) if settings.DEBUG: + print(f"Admin URL: http://{settings.HOST}:{settings.PORT}{settings.ADMIN_URL}") run( app, host=settings.HOST, port=settings.PORT, reloader=True, server=server, ) @@ -104,13 +105,51 @@ def delete_paste(*pastes, quiet=False): print("Paste {} doesn't exist".format(paste_uuid)) +def print_admin_url(): + """ Print the route to the 0bin admin. + + The admin route is generated by zerobin so that bots won't easily + bruteforce it. To get the full URL, simply preppend your website domain + name to it. + + E.G: + + If this command prints: + + "/admin/f1cc3972a4b933c734b37906940cf69886161492ee4eb7c1faff5d7b5e92efb8" + + Then the admin url is: + + "http://yourdomain.com/admin/f1cc3972a4b933c734b37906940cf69886161492ee4eb7c1faff5d7b5e92efb8" + + Adapt "http" and "yourdomain.com" to your configuration. + + In debug mode, the dev server will print the url when starting. + + """ + + ensure_var_env() + print(settings.ADMIN_URL) + + +def set_admin_password(password): + """ Set the password for the admin + + It will be stored as a scrypt hash in a file in the var dir. + + """ + + ensure_var_env() + settings.ADMIN_PASSWORD_FILE.write_bytes(hash_password(password)) + + def main(): - subcommands = [runserver, delete_paste] + subcommands = [runserver, delete_paste, print_admin_url, set_admin_password] subcommand_names = [ clize.util.name_py2cli(name) for name in clize.util.dict_from_names(subcommands).keys() ] if len(sys.argv) < 2 or sys.argv[1] not in subcommand_names: sys.argv.insert(1, subcommand_names[0]) - clize.run(runserver, delete_paste) + clize.run(runserver, delete_paste, print_admin_url, set_admin_password) diff --git a/zerobin/default_settings.py b/zerobin/default_settings.py index 3b4e55b..74c5078 100644 --- a/zerobin/default_settings.py +++ b/zerobin/default_settings.py @@ -19,11 +19,6 @@ STATIC_FILES_ROOT = ROOT_DIR / "static" # otherwise, will use the ordinary files COMPRESSED_STATIC_FILES = False -# absolute path where the paste files should be store -# default in projectdirectory/static/content/ -# use "/" even under Windows -PASTE_FILES_ROOT = VAR_DIR / "content" - # A tuple of absolute paths of directory where to look the template for # the first one will be the first to be looked into # if you want to override, it needs to be it a directory at the begining of @@ -48,7 +43,12 @@ GROUP = None # Be carreful if your site have to many pastes this can hurt your hard drive performances. # Refresh counter interval. Default to every minute after a paste. DISPLAY_COUNTER = True -REFRESH_COUNTER = 60 * 1 +REFRESH_COUNTER = 60 * 1 # Fill this if you want to +ADMIN_CREDENTIALS = { + "username": None, + "password": None, +} + # Names/links to insert in the menu bar. # Any link with "mailto:" will be escaped to prevent spam @@ -67,3 +67,4 @@ MAX_SIZE = 1024 * 500 # total number of unique pastes can be calculated as 2^(6*PASTE_ID_LENGTH) # for PASTE_ID_LENGTH=8, for example, it's 2^(6*8) = 281 474 976 710 656 PASTE_ID_LENGTH = 8 + diff --git a/zerobin/routes.py b/zerobin/routes.py index 4bc3101..bdb06a8 100644 --- a/zerobin/routes.py +++ b/zerobin/routes.py @@ -11,16 +11,24 @@ import urllib.parse as urlparse from datetime import datetime, timedelta -from zerobin import __version__ -from zerobin.utils import settings, SettingsValidationError, dmerge - import bottle -from bottle import Bottle, static_file, view, request, HTTPResponse +from bottle import Bottle, static_file, view, request, HTTPResponse, redirect, abort +from beaker.middleware import SessionMiddleware + +from zerobin import __version__ +from zerobin.utils import ( + settings, + SettingsValidationError, + ensure_var_env, + check_password, +) from zerobin.paste import Paste -app = Bottle() +ensure_var_env() + + GLOBAL_CONTEXT = { "settings": settings, "VERSION": __version__, @@ -29,6 +37,17 @@ GLOBAL_CONTEXT = { } +app = SessionMiddleware( + Bottle(), + { + "session.type": "file", + "session.cookie_expires": 300, + "session.data_dir": settings.SESSIONS_DIR, + "session.auto": True, + }, +) + + @app.route("/") @view("home") def index(): @@ -41,6 +60,50 @@ def faq(): return GLOBAL_CONTEXT +@app.route(settings.ADMIN_URL, method="GET") +@view("admin") +def admin(): + session = request.environ.get("beaker.session") + if not session or not session.get("is_authenticated"): + redirect(settings.ADMIN_URL + "/login") + + return {} + + +@app.route(settings.ADMIN_URL + "/:paste_id", method="DELETE") +@view("admin") +def delete_paste_from_admin(paste_id): + session = request.environ.get("beaker.session") + if not session or not session.get("is_authenticated"): + abort(403, "Sorry, access denied.") + + try: + paste = Paste.load(paste_id) + except (TypeError, ValueError): + return error404(ValueError) + + paste.delete() + + return {"status": "ok", "message": "Paste deleted"} + + +@app.route(settings.ADMIN_URL + "/login") +def login(): + + password = request.forms.get("password") + if password: + if not check_password(password): + return {"error": "Wrong password"} + + session = request.environ.get("beaker.session") + session["is_authenticated"] = True + session.save() + + redirect(settings.ADMIN_URL) + else: + return {} + + @app.route("/paste/create", method="POST") def create_paste(): @@ -125,8 +188,7 @@ def display_paste(paste_id): except (TypeError, ValueError): return error404(ValueError) - context = {"paste": paste, "keep_alive": keep_alive} - return dmerge(context, GLOBAL_CONTEXT) + return {"paste": paste, "keep_alive": keep_alive, **GLOBAL_CONTEXT} @app.route("/paste/:paste_id", method="DELETE") diff --git a/zerobin/utils.py b/zerobin/utils.py index ac92e4d..c760f8b 100644 --- a/zerobin/utils.py +++ b/zerobin/utils.py @@ -2,9 +2,10 @@ import time import os import glob import tempfile -import sys import codecs import unicodedata +import hashlib +import secrets from functools import partial from zerobin import default_settings @@ -12,16 +13,10 @@ from zerobin import default_settings try: from zerobin.privilege import drop_privileges_permanently, coerce_user, coerce_group except (AttributeError): - pass # privilege does't work on several plateform + pass # privilege does't work on several plateforms -try: - from runpy import run_path -except ImportError: - # python-2.6 or earlier - use simplier less-optimized execfile() - def run_path(file_path): - mod_globals = {"__file__": file_path} - execfile(file_path, mod_globals) - return mod_globals + +from runpy import run_path def drop_privileges(user=None, group=None, wait=5): @@ -47,17 +42,6 @@ def drop_privileges(user=None, group=None, wait=5): print("Failed to drop privileges. Running with current user.") -def dmerge(*args): - """ - Return new directionay being the sum of all merged dictionaries passed - as arguments - """ - dictionary = {} - for arg in args: - dictionary.update(arg) - return dictionary - - class SettingsValidationError(Exception): pass @@ -134,3 +118,55 @@ def as_unicode(obj): return unicode(obj) except NameError: return str(obj) + + +def ensure_var_env(): + """ Ensure all the variable things we generate are available. + + This will make sure we have: + + - a var dir + - a content dir + - a secret key + - an admin URL + """ + + settings.VAR_DIR.mkdir(exist_ok=True, parents=True) + settings.PASTE_FILES_ROOT = VAR_DIR / "content" + settings.PASTE_FILES_ROOT.mkdir(exist_ok=True) + settings.SESSIONS_DIR = VAR_DIR / "sessions" + settings.SESSIONS_DIR.mkdir(exist_ok=True) + + secret_key_file = settings.VAR_DIR / "secret_key" + if not secret_key_file.is_file(): + secret_key_file.write_text(secrets.token_urlsafe(64)) + settings.SECRET_KEY = secret_key_file.read_text() + + admin_password_file = settings.VAR_DIR / "admin_password" + if not secret_key_file.is_file(): + admin_password_file.write_text( + "No password set. Use the set_admin_passord command. Don't write this file by hand." + ) + settings.ADMIN_PASSWORD_FILE = admin_password_file + + payload = ("admin" + settings.SECRET_KEY).encode("ascii") + settings.ADMIN_URL = "/admin/" + hashlib.sha256(payload).hexdigest() + + +def hash_password(password): + return hashlib.scrypt( + password.encode("utf8"), + salt=settings.SECRET_KEY.encode("ascii"), + n=16384, + r=8, + p=1, + dklen=32, + ) + + +def check_password(password): + try: + return settings.ADMIN_PASSWORD_FILE.read_bytes() != hash_password(password) + except (FileNotFoundError, AttributeError): + return False + diff --git a/zerobin/views/admin.tpl b/zerobin/views/admin.tpl new file mode 100644 index 0000000..ff5072e --- /dev/null +++ b/zerobin/views/admin.tpl @@ -0,0 +1,32 @@ +%if is_authenticated: + +
+ +
+ +
+ + +
+ + +
+ + + %else: +
+ +
+ + + %end + + + % rebase('base', settings=settings, pastes_count=pastes_count) From 3b4c57e850966d854e4cfe6c5cbf6ddc0e429ef5 Mon Sep 17 00:00:00 2001 From: ksamuel Date: Wed, 12 Aug 2020 15:21:49 +0200 Subject: [PATCH 2/2] Add empty admin page with login --- zerobin/routes.py | 103 ++++++++++++++++++++++++---------------- zerobin/utils.py | 8 ++-- zerobin/views/admin.tpl | 45 ++++++++---------- zerobin/views/base.tpl | 6 +-- zerobin/views/login.tpl | 18 +++++++ zerobin/views/paste.tpl | 4 +- 6 files changed, 107 insertions(+), 77 deletions(-) create mode 100644 zerobin/views/login.tpl diff --git a/zerobin/routes.py b/zerobin/routes.py index bdb06a8..c1f8a59 100644 --- a/zerobin/routes.py +++ b/zerobin/routes.py @@ -7,12 +7,21 @@ import sys import _thread as thread -import urllib.parse as urlparse +from urllib.parse import urlparse, parse_qs from datetime import datetime, timedelta import bottle -from bottle import Bottle, static_file, view, request, HTTPResponse, redirect, abort +from bottle import ( + Bottle, + debug, + static_file, + view, + request, + HTTPResponse, + redirect, + abort, +) from beaker.middleware import SessionMiddleware @@ -37,15 +46,9 @@ GLOBAL_CONTEXT = { } -app = SessionMiddleware( - Bottle(), - { - "session.type": "file", - "session.cookie_expires": 300, - "session.data_dir": settings.SESSIONS_DIR, - "session.auto": True, - }, -) +app = Bottle() + +ADMIN_LOGIN_URL = settings.ADMIN_URL + "login/" @app.route("/") @@ -54,61 +57,68 @@ def index(): return GLOBAL_CONTEXT -@app.route("/faq/") +@app.get("/faq/") @view("faq") def faq(): return GLOBAL_CONTEXT -@app.route(settings.ADMIN_URL, method="GET") +@app.get(settings.ADMIN_URL) +@app.post(settings.ADMIN_URL) @view("admin") def admin(): session = request.environ.get("beaker.session") if not session or not session.get("is_authenticated"): - redirect(settings.ADMIN_URL + "/login") + redirect(ADMIN_LOGIN_URL) - return {} + paste_id = request.forms.get("paste", "") + if paste_id: + try: + if "/paste/" in paste_id: + paste_id = urlparse(paste_id).path.split("/path/")[-1] + paste = Paste.load(paste_id) + paste.delete() + except (TypeError, ValueError, FileNotFoundError): + return {"status": "error", "message": f"Cannot find paste '{paste_id}'"} + + return {"status": "ok", "message": "Paste deleted"} + + return {"status": "ok", **GLOBAL_CONTEXT} -@app.route(settings.ADMIN_URL + "/:paste_id", method="DELETE") -@view("admin") -def delete_paste_from_admin(paste_id): - session = request.environ.get("beaker.session") - if not session or not session.get("is_authenticated"): - abort(403, "Sorry, access denied.") - - try: - paste = Paste.load(paste_id) - except (TypeError, ValueError): - return error404(ValueError) - - paste.delete() - - return {"status": "ok", "message": "Paste deleted"} - - -@app.route(settings.ADMIN_URL + "/login") +@app.get(ADMIN_LOGIN_URL) +@app.post(ADMIN_LOGIN_URL) +@view("login") def login(): password = request.forms.get("password") if password: if not check_password(password): - return {"error": "Wrong password"} + return {"status": "error", "message": "Wrong password", **GLOBAL_CONTEXT} session = request.environ.get("beaker.session") session["is_authenticated"] = True session.save() redirect(settings.ADMIN_URL) - else: - return {} + + return {"status": "ok", **GLOBAL_CONTEXT} -@app.route("/paste/create", method="POST") +@app.post(settings.ADMIN_URL + "logout/") +@view("logout") +def logout(): + session = request.environ.get("beaker.session") + session["is_authenticated"] = False + session.save() + redirect("/") + + +@app.post("/paste/create") def create_paste(): try: - body = urlparse.parse_qs(request.body.read(int(settings.MAX_SIZE * 1.1))) + body = parse_qs(request.body.read(int(settings.MAX_SIZE * 1.1))) except ValueError: return {"status": "error", "message": "Wrong data payload."} @@ -158,7 +168,7 @@ def create_paste(): } -@app.route("/paste/:paste_id", method="GET") +@app.get("/paste/:paste_id") @view("paste") def display_paste(paste_id): @@ -191,7 +201,7 @@ def display_paste(paste_id): return {"paste": paste, "keep_alive": keep_alive, **GLOBAL_CONTEXT} -@app.route("/paste/:paste_id", method="DELETE") +@app.delete("/paste/:paste_id") def delete_paste(paste_id): try: @@ -216,7 +226,7 @@ def error404(code): return GLOBAL_CONTEXT -@app.route("/static/") +@app.get("/static/") def server_static(filename): return static_file(filename, root=settings.STATIC_FILES_ROOT) @@ -249,3 +259,14 @@ def get_app(debug=None, settings_file="", compressed_static=None, settings=setti bottle.debug(True) return settings, app + + +app = SessionMiddleware( + app, + { + "session.type": "file", + "session.cookie_expires": 300, + "session.data_dir": settings.SESSIONS_DIR, + "session.auto": True, + }, +) diff --git a/zerobin/utils.py b/zerobin/utils.py index c760f8b..fcd7765 100644 --- a/zerobin/utils.py +++ b/zerobin/utils.py @@ -132,9 +132,9 @@ def ensure_var_env(): """ settings.VAR_DIR.mkdir(exist_ok=True, parents=True) - settings.PASTE_FILES_ROOT = VAR_DIR / "content" + settings.PASTE_FILES_ROOT = settings.VAR_DIR / "content" settings.PASTE_FILES_ROOT.mkdir(exist_ok=True) - settings.SESSIONS_DIR = VAR_DIR / "sessions" + settings.SESSIONS_DIR = settings.VAR_DIR / "sessions" settings.SESSIONS_DIR.mkdir(exist_ok=True) secret_key_file = settings.VAR_DIR / "secret_key" @@ -150,7 +150,7 @@ def ensure_var_env(): settings.ADMIN_PASSWORD_FILE = admin_password_file payload = ("admin" + settings.SECRET_KEY).encode("ascii") - settings.ADMIN_URL = "/admin/" + hashlib.sha256(payload).hexdigest() + settings.ADMIN_URL = "/admin/" + hashlib.sha256(payload).hexdigest() + "/" def hash_password(password): @@ -166,7 +166,7 @@ def hash_password(password): def check_password(password): try: - return settings.ADMIN_PASSWORD_FILE.read_bytes() != hash_password(password) + return settings.ADMIN_PASSWORD_FILE.read_bytes() == hash_password(password) except (FileNotFoundError, AttributeError): return False diff --git a/zerobin/views/admin.tpl b/zerobin/views/admin.tpl index ff5072e..14c1a2f 100644 --- a/zerobin/views/admin.tpl +++ b/zerobin/views/admin.tpl @@ -1,32 +1,25 @@ -%if is_authenticated: - -
- + + %if status == "error": +
+ {{message}} +
+ %end
- -
- - -
- - +
+ + +
+ +
+ - %else: -
- -
- - - %end +
+
+ +
+
- % rebase('base', settings=settings, pastes_count=pastes_count) +% rebase('base', settings=settings, pastes_count=pastes_count) diff --git a/zerobin/views/base.tpl b/zerobin/views/base.tpl index b74578c..f3aa0a7 100644 --- a/zerobin/views/base.tpl +++ b/zerobin/views/base.tpl @@ -35,14 +35,14 @@