Permalink
Browse files

bug 855384 - Use native LDAP lookup to look up group membership, r=ad…

…rian
  • Loading branch information...
1 parent 9be7ec9 commit 6ff76fcaffd767bec9f4105a8fef1a2098a18fba @peterbe peterbe committed Apr 11, 2013
View
@@ -34,12 +34,16 @@ if [ ! -d "$WORKSPACE/vendor" ]; then
fi
source $VENV/bin/activate
+
pip install -q -r requirements/dev.txt
-pip install -I --install-option="--home=`pwd`/vendor-local" \
- -r requirements/compiled.txt
pip install -I --install-option="--home=`pwd`/vendor-local" \
-r requirements/prod.txt
+# because `python-ldap` is stupid and tries to re-install setuptools if you
+# use the `-I` flag (aka `--ignore-installed`) we don't use it for
+# `requirements/compiled.txt`
+pip install --install-option="--home=`pwd`/vendor-local" \
+ -r requirements/compiled.txt
cp crashstats/settings/local.py-dist crashstats/settings/local.py
echo "# enabled by force by jenkins.sh" >> crashstats/settings/local.py
No changes.
@@ -0,0 +1,13 @@
+from django.core.management.base import BaseCommand
+from crashstats.auth.views import in_allowed_group
+
+
+class Command(BaseCommand): # pragma: no cover
+
+ args = 'emailaddress'
+
+ def handle(self, mail, **options):
+ if in_allowed_group(mail):
+ print "YES!"
+ else:
+ print "No go :("
@@ -1,4 +1,6 @@
+import mock
from nose.tools import eq_, ok_
+
from django.conf import settings
from django.test import TestCase
@@ -8,6 +10,21 @@
class TestViews(TestCase):
+
+ def setUp(self):
+ super(TestViews, self).setUp()
+
+ self.ldap_patcher = mock.patch('ldap.initialize')
+ self.initialize = self.ldap_patcher.start()
+ self.connection = mock.MagicMock('connection')
+ self.connection.set_option = mock.MagicMock()
+ self.connection.simple_bind_s = mock.MagicMock()
+ self.initialize.return_value = self.connection
+
+ def tearDown(self):
+ super(TestViews, self).tearDown()
+ self.ldap_patcher.stop()
+
def _login_attempt(self, email, assertion='fakeassertion123'):
with mock_browserid(email):
r = self.client.post(reverse('auth:mozilla_browserid_verify'),
@@ -33,11 +50,32 @@ def test_bad_verification(self):
ok_(self._home_url in response['Location'])
def test_bad_email(self):
- response = self._login_attempt('tmickel@mit.edu')
+ def search_s(base, scope, filterstr, *args, **kwargs):
+ return []
+ self.connection.search_s = mock.MagicMock(side_effect=search_s)
+
+ response = self._login_attempt('closebut@notcloseengouh.com')
eq_(response.status_code, 302)
ok_(self._home_url in response['Location'])
def test_good_email(self):
- response = self._login_attempt(settings.ALLOWED_PERSONA_EMAILS[0])
+ result = {
+ 'abc123': {'uid': 'abc123', 'mail': 'peter@example.com'},
+ }
+
+ def search_s(base, scope, filterstr, *args, **kwargs):
+ if 'ou=groups' in base:
+ group_name = settings.LDAP_GROUP_NAMES[0]
+ if ('peter@example.com' in filterstr and
+ 'cn=%s' % group_name in filterstr):
+ return result.items()
+ else:
+ # basic lookup
+ if 'peter@example.com' in filterstr:
+ return result.items()
+ return []
+
+ self.connection.search_s = mock.MagicMock(side_effect=search_s)
+ response = self._login_attempt('peter@example.com')
eq_(response.status_code, 302)
ok_(self._home_url in response['Location'])
View
@@ -1,15 +1,100 @@
+import re
+import logging
+
+import ldap
+from ldap.filter import filter_format
+
from django.conf import settings
from django.contrib import auth
from django.shortcuts import redirect
from django.views.decorators.http import require_POST
from django.core.urlresolvers import reverse
from django.contrib import messages
+from django.core.exceptions import SuspiciousOperation
from django_browserid.base import get_audience
from django_browserid.auth import verify
from django_browserid.forms import BrowserIDForm
+def in_allowed_group(mail):
+ """Return True if the email address is in one of the
+ settings.LDAP_GROUP_NAMES groups.
+ """
+
+ def make_search_filter(data, any_parameter=False):
+ params = []
+ for key, value in data.items():
+ if not isinstance(value, (list, tuple)):
+ value = [value]
+ for v in value:
+ if not v:
+ v = 'TRUE'
+ params.append(filter_format('(%s=%s)', (key, v)))
+ search_filter = ''.join(params)
+ if len(params) > 1:
+ if any_parameter:
+ search_filter = '(|%s)' % search_filter
+ else:
+ search_filter = '(&%s)' % search_filter
+ return search_filter
+
+ conn = ldap.initialize(settings.LDAP_SERVER_URI)
+ conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
+ for opt, value in getattr(settings, 'LDAP_GLOBAL_OPTIONS', {}).items():
+ conn.set_option(opt, value)
+ conn.simple_bind_s(
+ settings.LDAP_BIND_DN,
+ settings.LDAP_BIND_PASSWORD
+ )
+
+ mail_filter = make_search_filter(dict(mail=mail))
+ alias_filter = make_search_filter(dict(emailAlias=mail))
+ search_filter = '(|%s%s)' % (mail_filter, alias_filter)
+
+ rs = conn.search_s(
+ settings.LDAP_SEARCH_BASE_USER,
+ ldap.SCOPE_SUBTREE,
+ search_filter,
+ ['uid']
+ )
+ # `rs` is an iterator, so we can't simply do `rs[0]` on it
+ for uid, result in rs:
+ break
+ else:
+ # exit early
+ return False
+
+ # because the original mail could have been an alias,
+ # switch to the real one
+ try:
+ mail = re.findall('mail=(.*?),', uid)[0]
+ except IndexError:
+ # can't use alias, but that's ok
+ pass
+
+ search_filter1 = make_search_filter(dict(cn=settings.LDAP_GROUP_NAMES))
+ _template_data = {'mail': mail, 'uid': uid}
+ search_filter2 = make_search_filter({
+ 'memberUID': [uid, mail],
+ 'member': [x % _template_data for x in
+ settings.LDAP_GROUP_QUERIES],
+ }, any_parameter=True)
+ search_filter = '(&%s%s)' % (search_filter1, search_filter2)
+
+ rs = conn.search_s(
+ settings.LDAP_SEARCH_BASE_GROUP,
+ ldap.SCOPE_SUBTREE,
+ search_filter,
+ ['cn']
+ )
+
+ for __ in rs:
+ return True
+
+ return False
+
+
@require_POST
def mozilla_browserid_verify(request):
"""Custom BrowserID verifier for mozilla addresses."""
@@ -20,13 +105,29 @@ def mozilla_browserid_verify(request):
assertion = form.cleaned_data['assertion']
audience = get_audience(request)
result = verify(assertion, audience)
- if not settings.ALLOWED_PERSONA_EMAILS: # pragma: no cover
- raise ValueError(
- "No emails set up in `settings.ALLOWED_PERSONA_EMAILS`"
- )
+
+ for name in ('LDAP_BIND_DN', 'LDAP_BIND_PASSWORD', 'LDAP_GROUP_NAMES'):
+ if not getattr(settings, name, None): # pragma: no cover
+ raise ValueError(
+ "Not configured `settings.%s`" % name
+ )
if result:
- if result['email'] in settings.ALLOWED_PERSONA_EMAILS:
+ allowed = in_allowed_group(result['email'])
+ debug_email_addresses = getattr(
+ settings,
+ 'DEBUG_LDAP_EMAIL_ADDRESSES',
+ []
+ )
+ if debug_email_addresses and not settings.DEBUG:
+ raise SuspiciousOperation(
+ "Can't debug login when NOT in DEBUG mode"
+ )
+ if allowed or result['email'] in debug_email_addresses:
+ if allowed:
+ logging.info('%r is in an allowed group', result['email'])
+ else:
+ logging.info('%r allowed for debugging', result['email'])
user = auth.authenticate(assertion=assertion,
audience=audience)
auth.login(request, user)
@@ -35,6 +136,8 @@ def mozilla_browserid_verify(request):
'You have successfully logged in.'
)
else:
+ if not allowed:
+ logging.info('%r NOT in an allowed group', result['email'])
messages.error(
request,
"You logged in as %s but you don't have sufficient "
@@ -227,3 +227,22 @@
# this is the max length of signatures in forms
SIGNATURE_MAX_LENGTH = 255
+
+# We use django.contrib.messages for login, so let's use SessionStorage
+# to avoid byte-big messages as cookies
+MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
+
+# LDAP related settings
+# feel free to override these in settings/local.py
+LDAP_SERVER_URI = 'ldap://pm-ns.mozilla.org'
+# search base where querys start
+LDAP_SEARCH_BASE_USER = 'dc=mozilla'
+LDAP_SEARCH_BASE_GROUP = 'ou=groups,dc=mozilla'
+# groups you must belong to to be able log in
+LDAP_GROUP_NAMES = ['CrashReportsAdmin']
+# list of group queries that is intersected wih the `LDAP_GROUP_NAMES` search
+LDAP_GROUP_QUERIES = [
+ 'mail=%(mail)s,o=com,dc=mozilla',
+ 'mail=%(mail)s,o=org,dc=mozilla',
+ 'mail=%(mail)s,o=net,dc=mozillacom',
+]
@@ -42,10 +42,6 @@ CACHES = {
}
}
-ALLOWED_PERSONA_EMAILS = (
- # fill this in
-)
-
TIME_ZONE = 'UTC'
#DATABASES = {
@@ -125,3 +121,15 @@ SECRET_KEY = 'you must change this'
#RAVEN_CONFIG = {
# 'dsn': '' # see https://errormill.mozilla.org/
#}
+
+
+# These must be set to be able log in
+LDAP_BIND_DN = '' # e.g. 'uid=binduser,ou=logins,dc=mozilla'
+LDAP_BIND_PASSWORD = ''
+# optionally...
+#LDAP_SERVER_URI =
+#LDAP_GROUP_NAMES =
+#LDAP_GLOBAL_OPTIONS = {...} # e.g. `{ldap.OPT_DEBUG_LEVEL: 4095}`
+
+# if you want to debug logging in without belong to a real LDAP group...
+#DEBUG_LDAP_EMAIL_ADDRESSES = [...] # for debugging ONLY
View
@@ -1,6 +1,7 @@
#!/usr/bin/env python
import os
import sys
+import site
# Edit this if necessary or override the variable in your environment.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'crashstats.settings')
@@ -23,5 +24,14 @@
manage.setup_environ(__file__, more_pythonic=True)
+# FIXME funfactory should add this too
+# (see https://github.com/mozilla/funfactory/pull/50)
+site.addsitedir(
+ os.path.abspath(
+ manage.path('vendor-local/lib64/python')
+ )
+)
+
+
if __name__ == "__main__":
manage.main()
@@ -1,4 +1,4 @@
Jinja2==2.5.5
psycopg2==2.4.5
py-bcrypt==0.2
-
+python-ldap==2.4.10
View
@@ -15,10 +15,6 @@
# actually go out on the internet when `request.get` should always be mocked
MWARE_BASE_URL = 'http://shouldnotactuallybeused'
-ALLOWED_PERSONA_EMAILS = (
- 'kai@ro.com',
-)
-
STATSD_CLIENT = 'django_statsd.clients.null'
DATABASES = {
@@ -42,3 +38,9 @@
# don't accidentally send anything to sentry whilst running tests
RAVEN_CONFIG = {}
SENTRY_DSN = None
+
+
+# make sure we have some, but mocked, LDAP credentials
+LDAP_SERVER_URI = 'ldap://something.mozilla.org'
+LDAP_BIND_DN = 'uid=binduser,ou=logins,dc=mozilla'
+LDAP_BIND_PASSWORD = 'secret'
@@ -8,9 +8,5 @@ site.addsitedir(os.path.abspath(os.path.join(wsgidir, '../')))
# manage adds /apps, /lib, and /vendor to the Python path.
import manage
-# FIXME funfactory should add this too
-site.addsitedir(os.path.abspath(
- os.path.join(wsgidir, '../vendor-local/lib64/python')))
-
import django.core.handlers.wsgi
application = django.core.handlers.wsgi.WSGIHandler()

0 comments on commit 6ff76fc

Please sign in to comment.