Separation of authentication and authorization. Separation of read and write authorization.

Static test strategies for authentication. Barely tested. Use at your own risk!
This commit is contained in:
Matthias Jordan 2012-08-03 13:10:20 +02:00
parent 83baebd750
commit e40e68b528
14 changed files with 478 additions and 132 deletions

View File

@ -46,7 +46,7 @@ except ImportError:
from urlparse import urlparse
# pylint: enable=F0401,E0611
from radicale import acl, config, ical, log, storage, xmlutils
from radicale import config, ical, log, storage, xmlutils, access
VERSION = "git"
@ -119,7 +119,7 @@ class Application(object):
def __init__(self):
"""Initialize application."""
super(Application, self).__init__()
self.acl = acl.load()
access.load()
storage.load()
self.encoding = config.get("encoding", "request")
if config.getboolean("logging", "full_environment"):
@ -189,15 +189,17 @@ class Application(object):
else:
content = None
path = environ["PATH_INFO"]
# Find collection(s)
items = ical.Collection.from_path(
environ["PATH_INFO"], environ.get("HTTP_DEPTH", "0"))
path, environ.get("HTTP_DEPTH", "0"))
# Get function corresponding to method
function = getattr(self, environ["REQUEST_METHOD"].lower())
# Check rights
if not items or not self.acl or function == self.options:
if not items or not access or function == self.options:
# No collection, or no acl, or OPTIONS request: don't check rights
status, headers, answer = function(environ, items, content, None)
else:
@ -211,51 +213,50 @@ class Application(object):
else:
user = password = None
last_allowed = None
if access.is_authenticated(user, password):
collections = []
for collection in items:
log.LOGGER.debug("Testing %s" % (collection.name))
if not isinstance(collection, ical.Collection):
if last_allowed:
log.LOGGER.info("not a collection: " + collection.name)
# collections.append(collection)
elif access.may_read(user, collection) or access.may_write(user, collection):
log.LOGGER.info("Has access to " + collection.name)
collections.append(collection)
continue
if collection.owner in acl.PUBLIC_USERS:
log.LOGGER.info("Public collection")
collections.append(collection)
last_allowed = True
else:
log.LOGGER.info(
"Checking rights for collection owned by %s" % (
collection.owner or "nobody"))
if self.acl.has_right(collection.owner, user, password):
log.LOGGER.info(
"%s allowed" % (user or "Anonymous user"))
collections.append(collection)
last_allowed = True
else:
log.LOGGER.info(
"%s refused" % (user or "Anonymous user"))
last_allowed = False
if collections:
# Collections found
status, headers, answer = function(
environ, collections, content, user)
elif user and last_allowed is None:
else:
# Good user and no collections found, redirect user to home
location = "/%s/" % str(quote(user))
if path != location:
log.LOGGER.info("redirecting to %s" % location)
status = client.FOUND
headers = {"Location": location}
answer = "Redirecting to %s" % location
else:
# Send answer anyway since else we're getting into a redirect loop
status, headers, answer = function(
environ, collections, content, user)
else:
# Unknown or unauthorized user
log.LOGGER.info(
"%s refused" % (user or "Anonymous user"))
status = client.UNAUTHORIZED
headers = {
"WWW-Authenticate":
"Basic realm=\"Radicale Server - Password Required\""}
answer = None
# Set content length
if answer:
log.LOGGER.debug(
@ -270,6 +271,17 @@ class Application(object):
# Return response content
return [answer] if answer else []
def response_not_allowed(self):
headers = {
"WWW-Authenticate":
"Basic realm=\"Radicale Server - Password Required\""}
return client.FORBIDDEN, headers, None
# All these functions must have the same parameters, some are useless
# pylint: disable=W0612,W0613,R0201
@ -290,12 +302,20 @@ class Application(object):
etag = environ.get("HTTP_IF_MATCH", item.etag).replace("\\", "")
if etag == item.etag:
# No ETag precondition or precondition verified, delete item
if access.may_write(user, collection):
answer = xmlutils.delete(environ["PATH_INFO"], collection)
return client.OK, {}, answer
else:
return self.response_not_allowed()
# No item or ETag precondition not verified, do not delete item
return client.PRECONDITION_FAILED, {}, None
def get(self, environ, collections, content, user):
"""Manage GET request.
@ -317,21 +337,28 @@ class Application(object):
# Get collection item
item = collection.get_item(item_name)
if item:
if access.may_read(user, collection):
items = collection.timezones
items.append(item)
answer_text = ical.serialize(
collection.tag, collection.headers, items)
etag = item.etag
else:
return self.response_not_allowed()
else:
return client.GONE, {}, None
else:
# Create the collection if it does not exist
if not collection.exists:
if not collection.exists and access.may_write(user, collection):
log.LOGGER.debug("creating collection " + collection.name)
collection.write()
if access.may_read(user, collection):
# Get whole collection
answer_text = collection.text
etag = collection.etag
else:
return self.response_not_allowed()
headers = {
"Content-Type": collection.mimetype,
@ -340,11 +367,18 @@ class Application(object):
answer = answer_text.encode(self.encoding)
return client.OK, headers, answer
def head(self, environ, collections, content, user):
"""Manage HEAD request."""
status, headers, answer = self.get(environ, collections, content, user)
return status, headers, None
def mkcalendar(self, environ, collections, content, user):
"""Manage MKCALENDAR request."""
collection = collections[0]
@ -356,9 +390,15 @@ class Application(object):
with collection.props as collection_props:
for key, value in props.items():
collection_props[key] = value
if access.may_write(user, collection):
collection.write()
else:
return self.response_not_allowed()
return client.CREATED, {}, None
def mkcol(self, environ, collections, content, user):
"""Manage MKCOL request."""
collection = collections[0]
@ -366,9 +406,15 @@ class Application(object):
with collection.props as collection_props:
for key, value in props.items():
collection_props[key] = value
if access.may_write(user, collection):
collection.write()
else:
return self.response_not_allowed()
return client.CREATED, {}, None
def move(self, environ, collections, content, user):
"""Manage MOVE request."""
from_collection = collections[0]
@ -384,9 +430,12 @@ class Application(object):
to_path, to_name = to_url.rstrip("/").rsplit("/", 1)
to_collection = ical.Collection.from_path(
to_path, depth="0")[0]
if access.may_write(user, to_collection) and access.may_write(user.from_collection):
to_collection.append(to_name, item.text)
from_collection.remove(from_name)
return client.CREATED, {}, None
else:
return self.response_not_allowed()
else:
# Remote destination server, not supported
return client.BAD_GATEWAY, {}, None
@ -397,6 +446,10 @@ class Application(object):
# Moving collections, not supported
return client.FORBIDDEN, {}, None
def options(self, environ, collections, content, user):
"""Manage OPTIONS request."""
headers = {
@ -405,6 +458,10 @@ class Application(object):
"DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol"}
return client.OK, headers, None
def propfind(self, environ, collections, content, user):
"""Manage PROPFIND request."""
headers = {
@ -414,6 +471,10 @@ class Application(object):
environ["PATH_INFO"], content, collections, user)
return client.MULTI_STATUS, headers, answer
def proppatch(self, environ, collections, content, user):
"""Manage PROPPATCH request."""
collection = collections[0]
@ -423,6 +484,10 @@ class Application(object):
"Content-Type": "text/xml"}
return client.MULTI_STATUS, headers, answer
def put(self, environ, collections, content, user):
"""Manage PUT request."""
collection = collections[0]
@ -439,8 +504,9 @@ class Application(object):
# Case 1: No item and no ETag precondition: Add new item
# Case 2: Item and ETag precondition verified: Modify item
# Case 3: Item and no Etag precondition: Force modifying item
if access.may_write(user, collection):
xmlutils.put(environ["PATH_INFO"], content, collection)
status = client.NO_CONTENT if item else client.CREATED
status = client.CREATED
# Try to return the etag in the header
# If the added item does't have the same name as the one given by
# the client, then there's no obvious way to generate an etag, we
@ -448,16 +514,26 @@ class Application(object):
new_item = collection.get_item(item_name)
if new_item:
headers["ETag"] = new_item.etag
else:
return self.response_not_allowed()
else:
# PUT rejected in all other cases
status = client.PRECONDITION_FAILED
return status, headers, None
def report(self, environ, collections, content, user):
"""Manage REPORT request."""
collection = collections[0]
headers = {"Content-Type": "text/xml"}
if access.may_read(user, collection):
answer = xmlutils.report(environ["PATH_INFO"], content, collection)
return client.MULTI_STATUS, headers, answer
else:
return self.response_not_allowed()
# pylint: enable=W0612,W0613,R0201

67
radicale/access.py Normal file
View File

@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
#
# This file is part of Radicale Server - Calendar Server
# Copyright © 2011-2012 Guillaume Ayoub
#
# This library 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 library 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 Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale access module.
Manages access to collections.
"""
import os
import sys
from radicale import acl, authorization, log
def load():
log.LOGGER.debug("access.load()")
global aacl ; aacl = acl.load()
global aauthorization ; aauthorization = authorization.load()
def is_authenticated(user, password):
if (not user):
# No user given
return False
return aacl.is_authenticated(user, password)
def may_read(user, collection):
"""Check if the user is allowed to read the collection"""
user_authorized = aauthorization.read_authorized(user, collection)
log.LOGGER.debug("read %s %s -- %i" % (user, collection.owner, user_authorized))
return user_authorized
def may_write(user, collection):
"""Check if the user is allowed to write the collection"""
user_authorized = aauthorization.write_authorized(user, collection)
log.LOGGER.debug("write %s %s -- %i" % (user, collection.owner, user_authorized))
return user_authorized

View File

@ -38,11 +38,8 @@ IMAP_SERVER = config.get("acl", "imap_auth_host_name")
IMAP_SERVER_PORT = config.get("acl", "imap_auth_host_port")
def has_right(owner, user, password):
def is_authenticated(user, password):
"""Check if ``user``/``password`` couple is valid."""
if not user or (owner not in acl.PRIVATE_USERS and user != owner):
# No user given, or owner is not private and is not user, forbidden
return False
log.LOGGER.debug(
"[IMAP ACL] Connecting to %s:%s." % (IMAP_SERVER, IMAP_SERVER_PORT,))

View File

@ -38,14 +38,10 @@ PASSWORD = config.get("acl", "ldap_password")
SCOPE = getattr(ldap, "SCOPE_%s" % config.get("acl", "ldap_scope").upper())
def has_right(owner, user, password):
def is_authenticated(user, password):
"""Check if ``user``/``password`` couple is valid."""
global CONNEXION
if not user or (owner not in acl.PRIVATE_USERS and user != owner):
# No user given, or owner is not private and is not user, forbidden
return False
try:
CONNEXION.whoami_s()
except:

View File

@ -33,11 +33,8 @@ from radicale import acl, config, log
GROUP_MEMBERSHIP = config.get("acl", "pam_group_membership")
def has_right(owner, user, password):
def is_authenticated(user, password):
"""Check if ``user``/``password`` couple is valid."""
if not user or (owner not in acl.PRIVATE_USERS and user != owner):
# No user given, or owner is not private and is not user, forbidden
return False
# Check whether the user exists in the PAM system
try:
@ -50,7 +47,7 @@ def has_right(owner, user, password):
# Check whether the group exists
try:
members = grp.getgrnam(GROUP_MEMBERSHIP).gr_mem
members = grp.getgrnam(GROUP_MEMBERSHIP)
except KeyError:
log.LOGGER.debug(
"The PAM membership required group (%s) doesn't exist" %

View File

@ -19,19 +19,20 @@
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Users and rights management.
Users management.
ACL is basically the wrong name here since this package deals with authenticating users.
The authorization part is done in the package "authorization".
This module loads a list of users with access rights, according to the acl
configuration.
"""
from radicale import config
PUBLIC_USERS = []
PRIVATE_USERS = []
from radicale import config, log
CONFIG_PREFIX = "acl"
def _config_users(name):
"""Get an iterable of strings from the configuraton string [acl] ``name``.
@ -40,18 +41,17 @@ def _config_users(name):
stripped at the beginning and at the end of the values.
"""
for user in config.get("acl", name).split(","):
for user in config.get(CONFIG_PREFIX, name).split(","):
user = user.strip()
yield None if user == "None" else user
def load():
"""Load list of available ACL managers."""
acl_type = config.get("acl", "type")
acl_type = config.get(CONFIG_PREFIX, "type")
log.LOGGER.debug("acl_type = " + acl_type)
if acl_type == "None":
return None
else:
PUBLIC_USERS.extend(_config_users("public_users"))
PRIVATE_USERS.extend(_config_users("private_users"))
module = __import__("acl.%s" % acl_type, globals=globals(), level=2)
return getattr(module, acl_type)

View File

@ -29,14 +29,11 @@ from radicale import acl, config, log
COURIER_SOCKET = config.get("acl", "courier_socket")
def has_right(owner, user, password):
def is_authenticated(user, password):
"""Check if ``user``/``password`` couple is valid."""
if not user or (owner not in acl.PRIVATE_USERS and user != owner):
# No user given, or owner is not private and is not user, forbidden
return False
line = "%s\nlogin\n%s\n%s" % (sys.argv[0], user, password)
line = "AUTH %i\n%s" % (len(line), line)
line = "%i\n%s" % (len(line), line)
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(COURIER_SOCKET)
@ -51,13 +48,7 @@ def has_right(owner, user, password):
log.LOGGER.debug("Got Courier socket response: %r" % data)
# Address, HOME, GID, and either UID or USERNAME are mandatory in resposne
# see http://www.courier-mta.org/authlib/README_authlib.html#authpipeproto
for line in data.split():
if 'GID' in line:
return True
# default is reject
# this alleviates the problem of a possibly empty reply from courier authlib
# see http://www.courier-mta.org/authlib/README_authlib.html#authpipeproto
if repr(data) == "FAIL":
return False
return True

View File

@ -58,11 +58,11 @@ def _sha1(hash_value, password):
return sha1.digest() == base64.b64decode(hash_value)
def has_right(owner, user, password):
def is_authenticated(user, password):
"""Check if ``user``/``password`` couple is valid."""
for line in open(FILENAME).readlines():
if line.strip():
login, hash_value = line.strip().split(":")
if login == user and (owner in acl.PRIVATE_USERS or owner == user):
if login == user:
return globals()["_%s" % ENCRYPTION](hash_value, password)
return False

View File

@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
#
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008-2012 Guillaume Ayoub
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
#
# This library 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 library 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 Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Users and rights management.
This module loads a list of users with access rights, according to the acl
configuration.
"""
from radicale import config, log
AUTHORIZATION_PREFIX = "authorization"
PUBLIC_USERS = []
PRIVATE_USERS = []
def _config_users(name):
"""Get an iterable of strings from the configuraton string [acl] ``name``.
The values must be separated by a comma. The whitespace characters are
stripped at the beginning and at the end of the values.
"""
for user in config.get(AUTHORIZATION_PREFIX, name).split(","):
user = user.strip()
yield None if user == "None" else user
def load():
"""Load list of available ACL managers."""
PUBLIC_USERS.extend(_config_users("public_users"))
PRIVATE_USERS.extend(_config_users("private_users"))
authorization_type = config.get(AUTHORIZATION_PREFIX, "type")
log.LOGGER.debug("auth type = " + authorization_type)
if authorization_type == "None":
return None
else:
module = __import__("authorization.%s" % authorization_type, globals=globals(), level=2)
return getattr(module, authorization_type)
def may_read(user, collection):
if (collection.owner not in PRIVATE_USERS and user != collection.owner):
# owner is not private and is not user, forbidden
return False
return read_authorized(user, collection)
def may_write(user, collection):
return write_authorized(user, collection)

View File

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
#
# This file is part of Radicale Server - Calendar Server
# Copyright © 2011-2012 Guillaume Ayoub
#
# This library 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 library 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 Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale authorization module.
Manages who is authorized to access a collection.
The policy here is that all authenticated users
have read and write access to all collections.
"""
import os
import sys
from radicale import authorization, config, log
def read_authorized(user, collection):
"""Check if the user is allowed to read the collection"""
log.LOGGER.debug("read_authorized '" + user + "' in '" + collection.name + "'");
return True
def write_authorized(user, collection):
"""Check if the user is allowed to write the collection"""
log.LOGGER.debug("write_authorized '" + user + "' in '" + collection.name + "'");
return True

View File

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
#
# This file is part of Radicale Server - Calendar Server
# Copyright © 2011-2012 Guillaume Ayoub
#
# This library 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 library 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 Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale authorization module.
Manages who is authorized to access a collection.
The policy here is that owners have read and write access
to their own collections.
"""
import os
import sys
from radicale import authorization, config, log
def read_authorized(user, collection):
"""Check if the user is allowed to read the collection"""
log.LOGGER.debug("read_authorized '" + user + "' in '" + collection.owner + "/" + collection.name + "'");
return user == collection.owner
def write_authorized(user, collection):
"""Check if the user is allowed to write the collection"""
log.LOGGER.debug("write_authorized '" + user + "' in '" + collection.owner + "/" + collection.name + "'");
return user == collection.owner

View File

@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
#
# This file is part of Radicale Server - Calendar Server
# Copyright © 2011-2012 Guillaume Ayoub
#
# This library 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 library 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 Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale authorization module.
Manages who is authorized to access a collection.
The policy is that the owner may read and write in
all collections and some special rights are hardcoded.
"""
import os
import sys
from radicale import authorization, config, log
from radicale.authorization import owneronly
def read_authorized(user, collection):
"""Check if the user is allowed to read the collection"""
log.LOGGER.debug("read_authorized '" + user + "' in '" + collection.owner + "/" + collection.name + "'");
if owneronly.read_authorized(user, collection):
return True
if user == "user1" and collection.owner == "user2" and collection.name == "user2sharedwithuser1":
return True
if user == "user2" and collection.owner == "user1" and collection.name == "user1sharedwithuser2":
return True
return False
def write_authorized(user, collection):
"""Check if the user is allowed to write the collection"""
log.LOGGER.debug("write_authorized '" + user + "' in '" + collection.owner + "/" + collection.name + "'");
if owneronly.write_authorized(user, collection):
return True
if user == "user1" and collection.owner == "user2" and collection.name == "user2sharedwithuser1":
return True
if user == "user2" and collection.owner == "user1" and collection.name == "user1sharedwithuser2":
return False
return False

View File

@ -53,10 +53,8 @@ INITIAL_CONFIG = {
"type": "None",
"public_users": "public",
"private_users": "private",
"htpasswd_filename": "/etc/radicale/users",
"htpasswd_encryption": "crypt",
"imap_auth_host_name": "localhost",
"imap_auth_host_port": "143",
"httpasswd_filename": "/etc/radicale/users",
"httpasswd_encryption": "crypt",
"ldap_url": "ldap://localhost:389/",
"ldap_base": "ou=users,dc=example,dc=com",
"ldap_attribute": "uid",

View File

@ -35,7 +35,7 @@ except ImportError:
import re
import xml.etree.ElementTree as ET
from radicale import client, config, ical
from radicale import client, config, ical, access
NAMESPACES = {
@ -200,6 +200,7 @@ def propfind(path, xml_request, collections, user=None):
multistatus = ET.Element(_tag("D", "multistatus"))
for collection in collections:
if access.may_read(user, collection):
response = _propfind_response(path, collection, props, user)
multistatus.append(response)
@ -283,13 +284,11 @@ def _propfind_response(path, item, props, user):
if item.is_principal:
tag = ET.Element(_tag("D", "principal"))
element.append(tag)
if item.is_leaf(item.path) or (
not item.exists and item.resource_type):
# 2nd case happens when the collection is not stored yet,
# but the resource type is guessed
if item.resource_type == "addressbook":
tag = ET.Element(_tag("CR", item.resource_type))
else:
if item.is_leaf(item.path):
tag = ET.Element(_tag("C", item.resource_type))
element.append(tag)
if not item.exists and item.resource_type:
# Collection not stored yet, but guessed resource type
tag = ET.Element(_tag("C", item.resource_type))
element.append(tag)
tag = ET.Element(_tag("D", "collection"))
@ -301,8 +300,6 @@ def _propfind_response(path, item, props, user):
elif tag == _tag("C", "calendar-timezone"):
element.text = ical.serialize(
item.tag, item.headers, item.timezones)
elif tag == _tag("D", "displayname"):
element.text = item.name
else:
human_tag = _tag_from_clark(tag)
if human_tag in collection_props:
@ -434,15 +431,8 @@ def report(path, xml_request, collection):
in root.findall(_tag("D", "href")))
else:
hreferences = (path,)
# TODO: handle other filters
# TODO: handle the nested comp-filters correctly
# Read rfc4791-9.7.1 for info
tag_filters = set(
element.get("name") for element
in root.findall(".//%s" % _tag("C", "comp-filter")))
else:
hreferences = ()
tag_filters = None
# Writing answer
multistatus = ET.Element(_tag("D", "multistatus"))
@ -465,9 +455,6 @@ def report(path, xml_request, collection):
items = collection.components
for item in items:
if tag_filters and item.tag not in tag_filters:
continue
response = ET.Element(_tag("D", "response"))
multistatus.append(response)