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)