source: main/waeup.cas/trunk/waeup/cas/authenticators.py @ 10524

Last change on this file since 10524 was 10523, checked in by Henrik Bettermann, 11 years ago

Handling school markers in Moodle is a nightmare. We should think about giving up the option of handling several schools with only one Moodle instance.

File size: 9.4 KB
Line 
1"""Components that do the real authentication stuff.
2"""
3import re
4import sys
5from pkg_resources import iter_entry_points
6try:
7    import xmlrpclib                     # Python 2.x
8except ImportError:                      # pragma: no cover
9    import xmlrpc.client as xmlrpclib    # Python 3.x
10
11
12def get_all_authenticators():
13    """Get all authenticators registered via entry points.
14
15    To create an own authenticator, in the ``setup.py`` of your
16    package add a block like this::
17
18        entry_points='''
19           [waeup.cas.authenticators]
20           myname = my.pkg.my.module:MyAuthenticator
21           '''
22
23    where ``my.pkg.my.module`` must be a module importable at runtime
24    and ``MyAuthenticator`` must be some Authenticator class defined
25    in this module.
26
27    ``myname`` can be chosen as you like, but must not clash with
28    other ``waeup.cas.authenticators``. The name should contain ASCII
29    letters and numbers only, and start with a letter.
30    """
31    return dict(
32        [(x.name, x.load())
33         for x in iter_entry_points(group='waeup.cas.authenticators')])
34
35
36def get_authenticator(local_conf):
37    """Get an `Authenticator` configured by ``local_conf``.
38
39    `local_conf` must be a dictionary of settings.
40
41    An authenticator is identified by its entry-point name which must
42    be given as value of the ``auth`` key.
43
44    It is created passing all keys/values where key starts with
45    ``auth_``.
46
47    Returns a dict of remaining keys/values in `local_conf` with the
48    value of ``auth`` key replaced by an authenticator instance.
49    """
50    gen_opts, auth_opts = filter_auth_opts(local_conf)
51    if 'auth' in local_conf:
52        auth_dict = get_all_authenticators()
53        factory = auth_dict.get(local_conf['auth'])  # get authenticator
54        auth = factory(**auth_opts)
55        gen_opts.update(auth=auth)
56    return gen_opts
57
58
59def filter_auth_opts(local_conf):
60    """Filter out auth-related opts in `local_conf`, a dict.
61
62    Returns two dicts ``(<GENERAL_OPTS>, <AUTHENTICATOR_OPTS>)``
63    containing the general options and authenticator related options.
64
65    This is designed to work with typical paste.deploy config files
66    like this::
67
68      [foo]
69      foo = bar
70      auth = foo
71      auth_opt1 = bar
72
73    All settings not starting with `auth_` are put into the general
74    opts dict, while other are put into the authenticator opts dict.
75    """
76    auth_opts = {}
77    general_opts = {}
78    for name, value in local_conf.items():
79        if name.startswith('auth_'):
80            auth_opts[name] = value
81        else:
82            general_opts[name] = value
83    return general_opts, auth_opts
84
85
86class Authenticator(object):
87
88    #: The name of an authenticator
89    name = 'basic'
90
91    def __init__(self, **kw):
92        pass
93
94
95class DummyAuthenticator(Authenticator):
96    """A dummy authenticator for tests.
97
98    This authenticator does no real authentication. It simply approves
99    credentials ``('bird', 'bebop')`` and denies all other.
100    """
101
102    name = 'dummy'
103
104    def check_credentials(self, username='', password=''):
105        """If username is ``'bird'`` and password ``'bebop'`` check
106        will succeed.
107
108        Returns a tuple ``(<STATUS>, <REASON>)`` with ``<STATUS>``
109        being a boolean and ``<REASON>`` being a string giving the
110        reason why login failed (if so) or an empty string.
111        """
112        reason = ''
113        result = username == 'bird' and password == 'bebop'
114        if not result:
115            reason = 'Invalid username or password.'
116        return result, reason
117
118
119#: Regular expression matching a starting university marker like
120#: the string 'MA-' in 'MA-M121212' or 'MA-' in 'MA-APP-13123'
121RE_SCHOOL_MARKER = re.compile('^[^\-]+-')
122
123
124class KofaAuthenticator(Authenticator):
125    """Authenticate against a running Kofa instance.
126    """
127
128    name = 'kofa1'
129
130    def __init__(self, auth_backends="{}"):
131        try:
132            self.backends = eval(auth_backends)
133        except:
134            raise ValueError('auth_backends must be a '
135                             'valid Python expression.')
136        self._check_options()
137
138    def _check_options(self):
139        if not isinstance(self.backends, dict):
140            raise ValueError('Backends must be configured as dicts.')
141        for key, val in self.backends.items():
142            if not isinstance(val, dict):
143                raise ValueError(
144                    'Backend %s: config must be a dict' % key)
145            if not 'url' in val:
146                raise ValueError(
147                    'Backend %s: config must contain an `url` key.' % key)
148            if not 'marker' in val:
149                self.backends[key]['marker'] = '.+'
150            try:
151                re.compile(self.backends[key]['marker'])
152            except:
153                raise ValueError(
154                    'Backend %s: marker must be a valid regular expr.:' % (
155                        key,))
156
157    def check_credentials(self, username='', password=''):
158        """Do the real check.
159        """
160        for backend_name, backend in self.backends.items():
161            if backend['marker']:
162                if not re.match(backend['marker'], username):
163                    continue
164                # remove school marker
165                username = RE_SCHOOL_MARKER.sub('', username)
166            proxy = xmlrpclib.ServerProxy(
167                backend['url'], allow_none=True)
168            valid = proxy.check_applicant_credentials(username, password)
169            if valid is None:
170                valid = proxy.check_student_credentials(username, password)
171            if valid is not None:
172                return (True, '')
173        return (False, 'Invalid username or password.')
174
175class KofaMoodleAuthenticator(KofaAuthenticator):
176    """Authenticate against a running Kofa instance and transfer
177    data to Moodle.
178
179    Configuration of Moodle:
180    1. Set 'passwordpolicy' to No
181    2. Create external web service 'Kofa' with the following functions:
182      core_user_create_users, core_user_get_users,
183      core_user_update_users, enrol_manual_enrol_users
184    3. Create token for the admin user (no special web service user needed)
185      and for service 'Kofa'
186    4. Enable and configure CAS server authentication.
187      CAS protocol version is 1.0. Moodle expects SSL/TLS protocol.
188    """
189
190    name = 'kofa_moodle1'
191
192    def _create_user(self, username, userdata, moodle):
193        try:
194            # Usernames in Moodle must not contain uppercase
195            # letters even if extendedusernamechars is set True.
196            # Usernames in Moodle are case insensitive.
197            # In other words, Moodle changes usernames from
198            # uppercase to lowercase when you login.
199            result = moodle.core_user_create_users([
200                {'username':username.lower(),
201                 'password':'dummy',
202                 'firstname':userdata['firstname'],
203                 'lastname':userdata['lastname'],
204                 'email':userdata['email']}])
205        except xmlrpclib.Fault:
206            faultstring = sys.exc_info()[1].faultString
207            if 'Email address already exists' in faultstring:
208                return (False,
209                    'Another Moodle user is using the same email address.'
210                    ' Email addresses can\'t be used twice in Moodle.')
211            if not 'Username already exists' in faultstring:
212                return (False, faultstring)
213        try:
214            result = moodle.core_user_get_users([
215                {'key':'username', 'value':username}])
216        except xmlrpclib.Fault:
217            faultstring = sys.exc_info()[1].faultString
218            return (False, faultstring)
219        user_id = result['users'][0]['id']
220        # Due to a lack of Moodle (Moodle requires an LDAP
221        # connection) the authentication method can't
222        # be set when the user is created. It must be updated
223        # after creation.
224        try:
225            result = moodle.core_user_update_users([
226                {'id':user_id,'auth':'cas'}])
227        except xmlrpclib.Fault:
228            faultstring = sys.exc_info()[1].faultString
229            return (False, faultstring)
230        return (True, '')
231
232    def check_credentials(self, username='', password=''):
233        """Do the real check.
234        """
235        for backend_name, backend in self.backends.items():
236            if backend['marker']:
237                if not re.match(backend['marker'], username):
238                    continue
239                # remove school marker
240                kofa_username = RE_SCHOOL_MARKER.sub('', username)
241            else:
242                kofa_username = username
243            proxy = xmlrpclib.ServerProxy(
244                backend['url'], allow_none=True)
245            moodle = xmlrpclib.ServerProxy(
246                backend['moodle_url'], allow_none=True)
247            principal = proxy.check_applicant_credentials(kofa_username, password)
248            if principal is None:
249                principal = proxy.check_student_credentials(kofa_username, password)
250            if principal is None:
251                return (False, 'Invalid username or password')
252            if principal['type'] == 'student':
253                userdata = proxy.get_student_moodle_data(kofa_username)
254                return self._create_user(username, userdata, moodle)
255            if principal['type'] == 'applicant':
256                userdata = proxy.get_applicant_moodle_data(kofa_username)
257                return self._create_user(username, userdata, moodle)
258            return (False, 'User not eligible')
259        return (False, 'Invalid username or password')
260
Note: See TracBrowser for help on using the repository browser.