#!/usr/bin/env python3 """ A web interface for managing htpasswd files. Copyright 2010 Lars Kruse This module is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This module is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with This module. If not, see . """ import configparser import re import os import sys import bottle import genshi.template import genshi.filters import htpasswd BASE_DIR = os.path.dirname(__file__) CONFIG_FILE_LOCATIONS = [ os.path.join(BASE_DIR, "htman.conf"), '/etc/htman/htman.conf', ] TEMPLATES_DIR = os.path.join(BASE_DIR, 'templates') REGEX = { "username": r"[a-zA-Z0-9_\-\.]+$", "password": r"[a-zA-Z0-9_\-\.%\$_\;,<>=+\[\]\{\}\(\)#\'\"\/\&\*@]+$", "mapping": r"[a-zA-Z0-9_\-\.]+$", } # use the environment variable HTMAN_CONFIG as the config file location if the # variable is defined if "HTMAN_CONFIG" in os.environ: CONFIG_FILE_LOCATIONS = [os.environ["HTMAN_CONFIG"]] @bottle.route('') def redirect_frontpage(): return bottle.redirect(web_defaults["base_url"]) @bottle.route('/') def show_frontpage(): values = web_defaults.copy() return render("frontpage.html", **values) @bottle.route('/password') def change_password_get(zone=None): return change_password(zone) @bottle.route('/password', method='POST') def change_password(zone=None): zone = bottle.request.forms.get('zone') username = bottle.request.forms.get('username') old_password = bottle.request.forms.get('old_password') new_password = bottle.request.forms.get('new_password') new_password2 = bottle.request.forms.get('new_password2') if zone: zone = zone.strip() if username: username = username.strip() if old_password: old_password = old_password.strip() if new_password: new_password = new_password.strip() if new_password2: new_password2 = new_password2.strip() values = web_defaults.copy() values["error"] = None values["success"] = None input_data = {"zone": zone, "username": username} verified = False if is_zone_valid(zone): htdb = get_htpasswd(zone) if htdb and username and (username in htdb.get_usernames()) and \ htdb.verify(username, old_password): verified = True if old_password is None: # first visit of this page: no action, no errors pass elif new_password != new_password2: values["error"] = "New passwords do not match." elif not new_password: values["error"] = "No new password given." elif verified: htdb.update(username, new_password) htdb.save() values["success"] = "Password changed successfully." else: values["error"] = ( "Authentication error: zone, username or password is invalid.") return render("password_change.html", input_data=input_data, **values) @bottle.route('/admin') def show_files(): values = web_defaults.copy() # The template expects a list of tuples: (mapping name, admin-url). # We assume, that the admin-url is just below the main admin URL. Thus # there is no need for generating a specific URL. all_zones = sorted(get_mapping().keys()) values["mapping"] = [ (zone_name, "%s%s/%s" % ( web_defaults["base_url"], "admin/manage", zone_name)) for zone_name in all_zones ] return render("list_mappings.html", **values) def is_zone_valid(zone): return ( zone and (zone in get_mapping()) and re.match(REGEX["mapping"], zone) ) @bottle.route('/manage') def show_htpasswd(zone=None): return select_htpasswd(zone) @bottle.route('/manage', method='POST') def select_htpasswd(zone=None): if not zone: zone = bottle.request.forms.get('zone') if not is_zone_valid(zone): return bottle.redirect(web_defaults["base_url"]) else: # do a redirect: this allows the webserver to check the permissions # for this URL return bottle.redirect( "%smanage/%s" % (web_defaults["base_url"], str(zone))) def get_htpasswd(zone, auto_create=False): htpasswd_file = get_htpasswd_filename(zone) do_create_file = auto_create and not os.path.isfile(htpasswd_file) try: return htpasswd.HtpasswdFile(htpasswd_file, create=do_create_file) except IOError: return None @bottle.route('/admin/manage/:zone') @bottle.route('/manage/:zone') def mange_htpasswd_get(zone=None): return manage_htpasswd(zone) # the alternative "/admin" URL allows to define super-user htaccess rules via # Apache's "Location" directive @bottle.route('/admin/manage/:zone', method='POST') @bottle.route('/manage/:zone', method='POST') def manage_htpasswd(zone=None): action = bottle.request.forms.get('action') username = bottle.request.forms.get('username') password = bottle.request.forms.get('password') values = web_defaults.copy() values["error"] = None values["success"] = None if not is_zone_valid(zone): return bottle.redirect(web_defaults["base_url"]) values["zone"] = zone htdb = get_htpasswd(zone, auto_create=True) if not htdb: values["error"] = "Failed to read htpasswd file" values["usernames"] = [] else: if action is None: # just show the current state pass elif not username: values["error"] = "The username may not be empty!" elif not re.match(REGEX["username"], username): values["error"] = "The username contains invalid characters!" elif action == "del": if username in htdb.get_usernames(): htdb.delete(username) htdb.save() values["success"] = "User removed" else: values["error"] = "The user does not exist!" elif not password: values["error"] = "The password may not be empty!" elif not re.match(REGEX["password"], password): values["error"] = "The password contains invalid characters!" elif action == "update": if username in htdb.get_usernames(): htdb.update(username, password) htdb.save() values["success"] = "Password changed" else: values["error"] = "The user does not exist!" elif action == "new": if username not in htdb.get_usernames(): htdb.update(username, password) htdb.save() values["success"] = "User added" else: values["error"] = "The user exists already!" else: values["error"] = "Invalid action" values["usernames"] = htdb.get_usernames() # show the current htpasswd file return render("manage_users.html", **values) def get_config(): config = configparser.ConfigParser() for filename in CONFIG_FILE_LOCATIONS: if os.path.isfile(filename): try: config.read(filename) except IOError: # ignore errors pass else: return config print( "Failed to load config file from %s" % str(CONFIG_FILE_LOCATIONS), file=sys.stderr ) sys.exit(1) def get_mapping(): try: mapping_file = config.get("Locations", "mapping") except configparser.NoOptionError: print >>sys.stderr, "The location of the mapping file is not " \ "defined in the config file: [Locations] -> mapping" sys.exit(2) if not os.path.isfile(mapping_file): print >>sys.stderr, "The mapping file does not exist: %s" \ % str(mapping_file) sys.exit(2) # read the mapping file try: input_data = open(mapping_file, "r").readlines() except IOError: print >>sys.stderr, "Failed to open mapping file: %s" % mapping_file sys.exit(4) mapping = {} for line in input_data: if line.startswith("#"): continue if "=" in line: name, location = line.split("=", 1) name = name.strip() location = location.strip() # ignore invalid mapping names if re.match(REGEX["mapping"], name): mapping[name] = location return mapping def get_htpasswd_filename(zone): basename = mapping[zone] if basename and (basename != os.path.abspath(basename)): # relative filename in mapping file # let's try the htpasswd_directory setting try: htpasswd_directory = config.get("Locations", "htpasswd_directory") return os.path.join(htpasswd_directory, basename) except configparser.NoOptionError: return os.path.abspath(basename) else: return basename def get_templates_dir(config): try: templates_dir = config.get("Locations", "templates") except configparser.NoOptionError: # use the default templates_dir = TEMPLATES_DIR if not os.path.isdir(templates_dir): print >>sys.stderr, "Templates directory not found: %s" % templates_dir sys.exit(3) return os.path.abspath(templates_dir) def render(filename, input_data=None, **values): stream = loader.load(filename).generate(**values) if input_data is not None: stream |= genshi.filters.HTMLFormFiller(data=input_data) return stream.render("html", doctype="html") # *** Initialization *** config = get_config() # defaults to the standard TEMPLATES_DIR mapping = get_mapping() templates_dir = get_templates_dir(config) web_defaults = dict(config.items("Web")) if not web_defaults["base_url"].endswith("/"): web_defaults["base_url"] += "/" loader = genshi.template.TemplateLoader([templates_dir], auto_reload=False)