From 1998dc3b088e2d1f8a8ab5ef6c29bc00618aa153 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Thu, 21 Jan 2010 18:52:53 +0100 Subject: [PATCH] Add authentication structure, with fake and htpasswd methods. --- TODO | 6 ++-- radicale/__init__.py | 72 +++++++++++++++++++++++++++++++++------- radicale/acl/__init__.py | 7 ++-- radicale/acl/fake.py | 11 +++--- radicale/acl/htpasswd.py | 37 +++++++++++++++++---- radicale/config.py | 3 +- 6 files changed, 105 insertions(+), 31 deletions(-) diff --git a/TODO b/TODO index 0e78068..12621ff 100644 --- a/TODO +++ b/TODO @@ -14,8 +14,8 @@ 0.2 === -* [DONE] SSL connections -* Authentications +* [DONE] SSL connection +* [DONE] Htpasswd authentication * [DONE] Daemon mode * [DONE] User configuration @@ -24,7 +24,7 @@ === * Calendar collections -* Windows and MacOS tested support +* [IN PROGRESS] Windows and MacOS tested support 1.0 diff --git a/radicale/__init__.py b/radicale/__init__.py index 7d0a8cf..1aa266c 100644 --- a/radicale/__init__.py +++ b/radicale/__init__.py @@ -35,6 +35,7 @@ should have been included in this package. # TODO: Manage errors (see xmlutils) +import base64 import socket try: from http import client, server @@ -42,11 +43,36 @@ except ImportError: import httplib as client import BaseHTTPServer as server -from radicale import config, support, xmlutils +from radicale import acl, config, support, xmlutils + +def check(request, function): + """Check if user has sufficient rights for performing ``request``.""" + authorization = request.headers.get("Authorization", None) + if authorization: + challenge = authorization.lstrip("Basic").strip().encode("ascii") + plain = request.decode(base64.b64decode(challenge)) + user, password = plain.split(":") + else: + user = password = None + + if request.server.acl.has_right(user, password): + function(request) + else: + request.send_response(client.UNAUTHORIZED) + request.send_header( + "WWW-Authenticate", + "Basic realm=\"Radicale Server - Password Required\"") + request.end_headers() + +# Decorator checking rights before performing request +check_rights = lambda function: lambda request: check(request, function) class HTTPServer(server.HTTPServer): """HTTP server.""" - pass + def __init__(self, address, handler): + """Create server.""" + server.HTTPServer.__init__(self, address, handler) + self.acl = acl.load() class HTTPSServer(HTTPServer): """HTTPS server.""" @@ -55,7 +81,7 @@ class HTTPSServer(HTTPServer): # Fails with Python 2.5, import if needed import ssl - super(HTTPSServer, self).__init__(address, handler) + HTTPServer.__init__(self, address, handler) self.socket = ssl.wrap_socket( socket.socket(self.address_family, self.socket_type), server_side=True, @@ -77,6 +103,30 @@ class CalendarHTTPHandler(server.BaseHTTPRequestHandler): cal = "%s/%s" % (path[0], path[1]) return calendar.Calendar("radicale", cal) + def decode(self, text): + """Try to decode text according to various parameters.""" + # List of charsets to try + charsets = [] + + # First append content charset given in the request + contentType = self.headers["Content-Type"] + if contentType and "charset=" in contentType: + charsets.append(contentType.split("charset=")[1].strip()) + # Then append default Radicale charset + charsets.append(self._encoding) + # Then append various fallbacks + charsets.append("utf-8") + charsets.append("iso8859-1") + + # Try to decode + for charset in charsets: + try: + return text.decode(charset) + except UnicodeDecodeError: + pass + raise UnicodeDecodeError + + @check_rights def do_GET(self): """Manage GET request.""" answer = self.calendar.vcalendar.encode(_encoding) @@ -86,9 +136,10 @@ class CalendarHTTPHandler(server.BaseHTTPRequestHandler): self.end_headers() self.wfile.write(answer) + @check_rights def do_DELETE(self): """Manage DELETE request.""" - obj = self.headers.get("if-match", None) + obj = self.headers.get("If-Match", None) answer = xmlutils.delete(obj, self.calendar, self.path) self.send_response(client.NO_CONTENT) @@ -114,20 +165,17 @@ class CalendarHTTPHandler(server.BaseHTTPRequestHandler): self.end_headers() self.wfile.write(answer) + @check_rights def do_PUT(self): """Manage PUT request.""" - # TODO: Improve charset detection - contentType = self.headers["content-type"] - if contentType and "charset=" in contentType: - charset = contentType.split("charset=")[1].strip() - else: - charset = self._encoding - ical_request = self.rfile.read(int(self.headers["Content-Length"])).decode(charset) - obj = self.headers.get("if-match", None) + ical_request = self.decode( + self.rfile.read(int(self.headers["Content-Length"]))) + obj = self.headers.get("If-Match", None) xmlutils.put(ical_request, self.calendar, self.path, obj) self.send_response(client.CREATED) + @check_rights def do_REPORT(self): """Manage REPORT request.""" xml_request = self.rfile.read(int(self.headers["Content-Length"])) diff --git a/radicale/acl/__init__.py b/radicale/acl/__init__.py index 26d69f6..28c91d1 100644 --- a/radicale/acl/__init__.py +++ b/radicale/acl/__init__.py @@ -27,6 +27,7 @@ configuration. from radicale import config -_acl = __import__(config.get("acl", "type"), locals(), globals()) - -users = _acl.users +def load(): + module = __import__("radicale.acl", globals(), locals(), + [config.get("acl", "type")]) + return getattr(module, config.get("acl", "type")) diff --git a/radicale/acl/fake.py b/radicale/acl/fake.py index 03da9dd..60fdc25 100644 --- a/radicale/acl/fake.py +++ b/radicale/acl/fake.py @@ -21,11 +21,10 @@ """ Fake ACL. -Just load the default user "radicale", with no rights management. +No rights management. + """ -from radicale import config - -def users(): - """Get the list of all users.""" - return ["radicale"] +def has_right(user, password): + """Check if ``user``/``password`` couple is valid.""" + return True diff --git a/radicale/acl/htpasswd.py b/radicale/acl/htpasswd.py index 60b1ff6..ee0945e 100644 --- a/radicale/acl/htpasswd.py +++ b/radicale/acl/htpasswd.py @@ -21,14 +21,39 @@ """ Htpasswd ACL. -Load the list of users according to the htpasswd configuration. +Load the list of login/password couples according a the configuration file +created by Apache ``htpasswd`` command. Plain-text, crypt and sha1 are +supported, but md5 is not (see ``htpasswd`` man page to understand why). + """ -# TODO: Manage rights +import base64 +import crypt +import hashlib from radicale import config -def users(): - """Get the list of all users.""" - return [line.split(":")[0] for line - in open(config.get("acl", "filename")).readlines()] +def _plain(hash, password): + return hash == password + +def _crypt(hash, password): + return crypt.crypt(password, hash) == hash + +def _sha1(hash, password): + hash = hash.lstrip("{SHA}").encode("ascii") + password = password.encode(config.get("encoding", "stock")) + sha1 = hashlib.sha1() + sha1.update(password) + return sha1.digest() == base64.b64decode(hash) + +_filename = config.get("acl", "filename") +_check_password = locals()["_%s" % config.get("acl", "encryption")] + +def has_right(user, password): + """Check if ``user``/``password`` couple is valid.""" + for line in open(_filename).readlines(): + if line.strip(): + login, hash = line.strip().split(":") + if login == user: + return _check_password(hash, password) + return False diff --git a/radicale/config.py b/radicale/config.py index c038966..6de04dc 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -62,7 +62,8 @@ _initial = { }, "acl": { "type": "fake", - #"filename": "/etc/radicale/users", + "filename": "/etc/radicale/users", + "encryption": "crypt", }, "support": { "type": "plain",