Add authentication structure, with fake and htpasswd methods.
authorGuillaume Ayoub <guillaume.ayoub@kozea.fr>
Thu, 21 Jan 2010 17:52:53 +0000 (18:52 +0100)
committerGuillaume Ayoub <guillaume.ayoub@kozea.fr>
Thu, 21 Jan 2010 17:52:53 +0000 (18:52 +0100)
TODO
radicale/__init__.py
radicale/acl/__init__.py
radicale/acl/fake.py
radicale/acl/htpasswd.py
radicale/config.py

diff --git a/TODO b/TODO
index 0e7806846e18a15454f0d2fd2415abf31472a379..12621ff0c8f0c14eb62389c6704a4a5f83942906 100644 (file)
--- 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
index 7d0a8cf244834d643568cbe374ff1960e633fdef..1aa266c56c1b222c480252c42b23ebb520aaf023 100644 (file)
@@ -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"]))
index 26d69f676a0fa15333619fd7fb50a62fa51d4ec8..28c91d173c773b20ab4edeee52891ee4e2f53587 100644 (file)
@@ -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"))
index 03da9ddacc6176dfe0a81a94b9797c6dc12ef576..60fdc25cde6c2f3c71438b09fe58c11d91ee74e1 100644 (file)
 """
 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
index 60b1ff69e77cf5b6a7f0d8dd2fc1fa74801a42d8..ee0945e7a8092a4c2669e0f77c8885aaaa3fad56 100644 (file)
 """
 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
index c03896651619b50936d3f7ac8741e27d7e2b4e16..6de04dc550f762b5ee7b64fb8e287ed74c81611b 100644 (file)
@@ -62,7 +62,8 @@ _initial = {
         },
     "acl": {
         "type": "fake",
-        #"filename": "/etc/radicale/users",
+        "filename": "/etc/radicale/users",
+        "encryption": "crypt",
         },
     "support": {
         "type": "plain",