htman/htman.py

315 lines
10 KiB
Python

#!/usr/bin/env python3
"""
A web interface for managing htpasswd files.
Copyright 2010 Lars Kruse <devel@sumpfralle.de>
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 <http://www.gnu.org/licenses/>.
"""
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)