activation_backends.py 20 KB
Newer Older
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Copyright 2011 GRNET S.A. All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
#   1. Redistributions of source code must retain the above
#      copyright notice, this list of conditions and the following
#      disclaimer.
#
#   2. Redistributions in binary form must reproduce the above
#      copyright notice, this list of conditions and the following
#      disclaimer in the documentation and/or other materials
#      provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# The views and conclusions contained in the software and
# documentation are those of the authors and should not be
# interpreted as representing official policies, either expressed
# or implied, of GRNET S.A.

from django.utils.importlib import import_module
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
35
from django.core.exceptions import ImproperlyConfigured
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
36
from django.utils.translation import ugettext as _
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
37

38
from astakos.im.models import AstakosUser
39
40
41
42
from astakos.im import functions
from astakos.im import settings
from astakos.im import forms

43
from astakos.im.quotas import qh_sync_user
44
45

import astakos.im.messages as astakos_messages
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
46

47
import datetime
48
import logging
49
import re
50
import json
51

52
53
logger = logging.getLogger(__name__)

54

55
def get_backend():
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
56
    """
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
57
    Returns an instance of an activation backend,
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
58
    according to the INVITATIONS_ENABLED setting
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
59
60
    (if True returns ``astakos.im.activation_backends.InvitationsBackend``
    and if False
61
    returns ``astakos.im.activation_backends.SimpleBackend``).
62

Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
63
64
    If the backend cannot be located
    ``django.core.exceptions.ImproperlyConfigured`` is raised.
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
65
    """
66
    module = 'astakos.im.activation_backends'
67
    prefix = 'Invitations' if settings.INVITATIONS_ENABLED else 'Simple'
68
    backend_class_name = '%sBackend' % prefix
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
69
70
71
    try:
        mod = import_module(module)
    except ImportError, e:
72
73
        raise ImproperlyConfigured(
            'Error loading activation backend %s: "%s"' % (module, e))
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
74
75
76
    try:
        backend_class = getattr(mod, backend_class_name)
    except AttributeError:
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
77
78
79
        raise ImproperlyConfigured(
            'Module "%s" does not define a activation backend named "%s"' % (
                module, backend_class_name))
80
    return backend_class(settings.MODERATION_ENABLED)
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
81

82

83
class ActivationBackend(object):
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
    """
    ActivationBackend handles user verification/activation.

    Example usage::
    >>> # it is wise to not instantiate a backend class directly but use
    >>> # get_backend method instead.
    >>> backend = get_backend()
    >>> formCls = backend.get_signup_form(request.POST)
    >>> if form.is_valid():
    >>>     user = form.save(commit=False)
    >>>     # this creates auth provider objects
    >>>     form.store_user(user)
    >>>     activation = backend.handle_registration(user)
    >>>     # activation.status is one of backend.Result.{*} activation result
    >>>     # types
    >>>
    >>>     # sending activation notifications is not done automatically
    >>>     # we need to call send_result_notifications
    >>>     backend.send_result_notifications(activation)
    >>>     return HttpResponse(activation.message)
    """

    verification_template_name = 'im/activation_email.txt'
    greeting_template_name = 'im/welcome_email.txt'
    pending_moderation_template_name = \
        'im/account_pending_moderation_notification.txt'
    activated_email_template_name = 'im/account_activated_notification.txt'

    class Result:
        # user created, email verification sent
        PENDING_VERIFICATION = 1
        # email verified
        PENDING_MODERATION = 2
        # user moderated
        ACCEPTED = 3
        # user rejected
        REJECTED = 4
        # inactive user activated
        ACTIVATED = 5
        # active user deactivated
        DEACTIVATED = 6
        # something went wrong
        ERROR = -1

    def __init__(self, moderation_enabled):
        self.moderation_enabled = moderation_enabled
130

131
    def _is_preaccepted(self, user):
132
133
134
135
136
137
138
139
140
141
142
143
        """
        Decide whether user should be automatically moderated. The method gets
        called only when self.moderation_enabled is set to True.

        The method returns False or a string identifier which later will be
        stored in user's accepted_policy field. This is helpfull for
        administrators to be aware of the reason a created user was
        automatically activated.
        """

        # check preaccepted mail patterns
        for pattern in settings.RE_USER_EMAIL_PATTERNS:
144
            if re.match(pattern, user.email):
145
146
147
148
149
150
                return 'email'

        # provider automoderate policy is on
        if user.get_auth_provider().get_automoderate_policy:
            return 'auth_provider_%s' % user.get_auth_provider().module

151
        return False
152

153
    def get_signup_form(self, provider='local', initial_data=None, **kwargs):
154
        """
155
156
157
158
        Returns a form instance for the type of registration the user chosen.
        This can be either a LocalUserCreationForm for classic method signups
        or ThirdPartyUserCreationForm for users who chosen to signup using a
        federated login method.
159
160
        """
        main = provider.capitalize() if provider == 'local' else 'ThirdParty'
161
        suffix = 'UserCreationForm'
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
        formclass = getattr(forms, '%s%s' % (main, suffix))
        kwargs['provider'] = provider
        return formclass(initial_data, **kwargs)

    def prepare_user(self, user, email_verified=None):
        """
        Initialization of a newly registered user. The method sets email
        verification code. If email_verified is set to True we automatically
        process user through the verification step.
        """
        logger.info("Initializing user registration %s", user.log_display)

        if not email_verified:
            email_verified = settings.SKIP_EMAIL_VERIFICATION

        user.renew_verification_code()
        user.save()

        if email_verified:
            logger.info("Auto verifying user email. %s",
                        user.log_display)
            return self.verify_user(user,
                                    user.verification_code)

        return ActivationResult(self.Result.PENDING_VERIFICATION)

    def verify_user(self, user, verification_code):
        """
        Process user verification using provided verification_code. This
        should take place in user activation view. If no moderation is enabled
        we automatically process user through activation process.
        """
        logger.info("Verifying user: %s", user.log_display)

        if user.email_verified:
            logger.warning("User email already verified: %s",
                           user.log_display)
            msg = astakos_messages.ACCOUNT_ALREADY_VERIFIED
            return ActivationResult(self.Result.ERROR, msg)

        if user.verification_code and \
                user.verification_code == verification_code:
            user.email_verified = True
            user.verified_at = datetime.datetime.now()
            # invalidate previous code
            user.renew_verification_code()
            user.save()
            logger.info("User email verified: %s", user.log_display)
        else:
            logger.error("User email verification failed "
                         "(invalid verification code): %s", user.log_display)
            msg = astakos_messages.VERIFICATION_FAILED
            return ActivationResult(self.Result.ERROR, msg)

        if not self.moderation_enabled:
            logger.warning("User preaccepted (%s): %s", 'auto_moderation',
                           user.log_display)
            return self.accept_user(user, policy='auto_moderation')

        preaccepted = self._is_preaccepted(user)
        if preaccepted:
            logger.warning("User preaccepted (%s): %s", preaccepted,
                           user.log_display)
            return self.accept_user(user, policy=preaccepted)

        if user.moderated:
            # set moderated to false because accept_user will return error
            # result otherwise.
            user.moderated = False
            return self.accept_user(user, policy='already_moderated')
        else:
            return ActivationResult(self.Result.PENDING_MODERATION)

    def accept_user(self, user, policy='manual'):
        logger.info("Moderating user: %s", user.log_display)
        if user.moderated and user.is_active:
            logger.warning("User already accepted, moderation"
                           " skipped: %s", user.log_display)
            msg = _(astakos_messages.ACCOUNT_ALREADY_MODERATED)
            return ActivationResult(self.Result.ERROR, msg)

        if not user.email_verified:
            logger.warning("Cannot accept unverified user: %s",
                           user.log_display)
            msg = _(astakos_messages.ACCOUNT_NOT_VERIFIED)
            return ActivationResult(self.Result.ERROR, msg)

        # store a snapshot of user details by the time he
        # got accepted.
        if not user.accepted_email:
            user.accepted_email = user.email
        user.accepted_policy = policy
        user.moderated = True
        user.moderated_at = datetime.datetime.now()
        user.moderated_data = json.dumps(user.__dict__,
                                         default=lambda obj:
                                         str(obj))
        user.save()
        qh_sync_user(user)

        if user.is_rejected:
            logger.warning("User has previously been "
                           "rejected, reseting rejection state: %s",
                           user.log_display)
            user.is_rejected = False
            user.rejected_at = None

        user.save()
        logger.info("User accepted: %s", user.log_display)
        self.activate_user(user)
        return ActivationResult(self.Result.ACCEPTED)

    def activate_user(self, user):
        if not user.email_verified:
            msg = _(astakos_messages.ACCOUNT_NOT_VERIFIED)
            return ActivationResult(self.Result.ERROR, msg)

        if not user.moderated:
            msg = _(astakos_messages.ACCOUNT_NOT_MODERATED)
            return ActivationResult(self.Result.ERROR, msg)

        if user.is_rejected:
            msg = _(astakos_messages.ACCOUNT_REJECTED)
            return ActivationResult(self.Result.ERROR, msg)

        if user.is_active:
            msg = _(astakos_messages.ACCOUNT_ALREADY_ACTIVE)
            return ActivationResult(self.Result.ERROR, msg)

        user.is_active = True
        user.deactivated_reason = None
        user.deactivated_at = None
        user.save()
        logger.info("User activated: %s", user.log_display)
        return ActivationResult(self.Result.ACTIVATED)

    def deactivate_user(self, user, reason=''):
        user.is_active = False
        user.deactivated_reason = reason
        if user.is_active:
            user.deactivated_at = datetime.datetime.now()
        user.save()
        logger.info("User deactivated: %s", user.log_display)
        return ActivationResult(self.Result.DEACTIVATED)

    def reject_user(self, user, reason):
        logger.info("Rejecting user: %s", user.log_display)
        if user.moderated:
            logger.warning("User already moderated: %s", user.log_display)
            msg = _(astakos_messages.ACCOUNT_ALREADY_MODERATED)
            return ActivationResult(self.Result.ERROR, msg)

        if user.is_active:
            logger.warning("Cannot reject unverified user: %s",
                           user.log_display)
            msg = _(astakos_messages.ACCOUNT_NOT_VERIFIED)
            return ActivationResult(self.Result.ERROR, msg)

        if not user.email_verified:
            logger.warning("Cannot reject unverified user: %s",
                           user.log_display)
            msg = _(astakos_messages.ACCOUNT_NOT_VERIFIED)
            return ActivationResult(self.Result.ERROR, msg)

        user.moderated = True
        user.moderated_at = datetime.datetime.now()
        user.moderated_data = json.dumps(user.__dict__,
                                         default=lambda obj:
                                         str(obj))
        user.is_rejected = True
        user.rejected_reason = reason
333
        user.save()
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
        logger.info("User rejected: %s", user.log_display)
        return ActivationResult(self.Result.REJECTED)

    def handle_registration(self, user, email_verified=False):
        logger.info("Handling new user registration: %s", user.log_display)
        return self.prepare_user(user, email_verified=email_verified)

    def handle_verification(self, user, activation_code):
        logger.info("Handling user email verirfication: %s", user.log_display)
        return self.verify_user(user, activation_code)

    def handle_moderation(self, user, accept=True, reject_reason=None):
        logger.info("Handling user moderation (%r): %s",
                    accept, user.log_display)
        if accept:
            return self.accept_user(user)
        else:
            return self.reject_user(user, reject_reason)

    def send_user_verification_email(self, user):
        if user.is_active:
            raise Exception("User already active")

        # invalidate previous code
        user.renew_verification_code()
        user.save()
        functions.send_verification(user)
        user.activation_sent = datetime.datetime.now()
        user.save()

    def send_result_notifications(self, result, user):
        """
        Send corresponding notifications based on the status of activation
        result.

        Result.PENDING_VERIRFICATION
            * Send user the email verification url

        Result.PENDING_MODERATION
            * Notify admin for account moderation

        Result.ACCEPTED
            * Send user greeting notification

        Result.REJECTED
            * Send nothing
        """
        if result.status == self.Result.PENDING_VERIFICATION:
            logger.info("Sending notifications for user"
                        " creation: %s", user.log_display)
            # email user that contains the activation link
            self.send_user_verification_email(user)
            # TODO: optionally notify admins for new accounts

        if result.status == self.Result.PENDING_MODERATION:
            logger.info("Sending notifications for user"
                        " verification: %s", user.log_display)
            functions.send_account_pending_moderation_notification(user,
                                        self.pending_moderation_template_name)
            # TODO: notify user

        if result.status == self.Result.ACCEPTED:
            logger.info("Sending notifications for user"
                        " moderation: %s", user.log_display)
            functions.send_account_activated_notification(user,
                                         self.activated_email_template_name)
            functions.send_greeting(user,
                                    self.greeting_template_name)
            # TODO: notify admins

        if result.status == self.Result.REJECTED:
            logger.info("Sending notifications for user"
                        " rejection: %s", user.log_display)
            # TODO: notify user and admins
408

409

410
class InvitationsBackend(ActivationBackend):
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
411
    """
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
412
    A activation backend which implements the following workflow: a user
413
414
415
416
    supplies the necessary registation information, if the request contains a
    valid inivation code the user is automatically activated otherwise an
    inactive user account is created and the user is going to receive an email
    as soon as an administrator activates his/her account.
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
417
    """
418

Kostas Papadimitriou's avatar
Kostas Papadimitriou committed
419
    def get_signup_form(self, invitation, provider='local', initial_data=None,
420
                        instance=None):
421
        """
422
        Returns a form instance of the relevant class
423

424
425
        raises Invitation.DoesNotExist and ValueError if invitation is consumed
        or invitation username is reserved.
426
        """
427
        self.invitation = invitation
428
        prefix = 'Invited' if invitation else ''
429
        main = provider.capitalize() if provider == 'local' else 'ThirdParty'
430
        suffix = 'UserCreationForm'
431
432
        formclass = getattr(forms, '%s%s%s' % (prefix, main, suffix))
        return formclass(initial_data, instance=instance)
433

434
    def get_signup_initial_data(self, request, provider):
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
435
        """
436
437
        Returns the necassary activation form depending the user is invited or
        not.
438

Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
439
440
        Throws Invitation.DoesNotExist in case ``code`` is not valid.
        """
441
        invitation = self.invitation
442
        initial_data = None
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
443
        if request.method == 'GET':
444
            if invitation:
445
446
                # create a tmp user with the invitation realname
                # to extract first and last name
447
448
449
450
451
452
                u = AstakosUser(realname=invitation.realname)
                initial_data = {'email': invitation.username,
                                'inviter': invitation.inviter.realname,
                                'first_name': u.first_name,
                                'last_name': u.last_name,
                                'provider': provider}
453
454
455
456
        else:
            if provider == request.POST.get('provider', ''):
                initial_data = request.POST
        return initial_data
457

Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
458
459
    def _is_preaccepted(self, user):
        """
460
461
        Extends _is_preaccepted and if there is a valid, not-consumed
        invitation code for the specific user returns True else returns False.
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
462
        """
463
464
465
        preaccepted = super(InvitationsBackend, self)._is_preaccepted(user)
        if preaccepted:
            return preaccepted
466
        invitation = self.invitation
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
467
        if not invitation:
468
469
            if not self.moderation_enabled:
                return 'auto_moderation'
470
471
        if invitation.username == user.email and not invitation.is_consumed:
            invitation.consume()
472
            return 'invitation'
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
473
        return False
474

475

476
class SimpleBackend(ActivationBackend):
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
477
    """
478
    The common activation backend.
Sofia Papagiannaki's avatar
Sofia Papagiannaki committed
479
    """
480

481
482
# shortcut
ActivationResultStatus = ActivationBackend.Result
483

484

485
486
class ActivationResult(object):

487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
    MESSAGE_BY_STATUS = {
        ActivationResultStatus.PENDING_VERIFICATION:
        _(astakos_messages.VERIFICATION_SENT),
        ActivationResultStatus.PENDING_MODERATION:
        _(astakos_messages.NOTIFICATION_SENT),
        ActivationResultStatus.ACCEPTED:
        _(astakos_messages.ACCOUNT_ACTIVATED),
        ActivationResultStatus.ACTIVATED:
        _(astakos_messages.ACCOUNT_ACTIVATED),
        ActivationResultStatus.DEACTIVATED:
        _(astakos_messages.ACCOUNT_DEACTIVATED),
        ActivationResultStatus.ERROR:
        _(astakos_messages.GENERIC_ERROR)
    }

    STATUS_DISPLAY = {
        ActivationResultStatus.PENDING_VERIFICATION: 'PENDING_VERIFICATION',
        ActivationResultStatus.PENDING_MODERATION: 'PENDING_MODERATION',
        ActivationResultStatus.ACCEPTED: 'ACCEPTED',
        ActivationResultStatus.ACTIVATED: 'ACTIVATED',
        ActivationResultStatus.DEACTIVATED: 'DEACTIVATED',
        ActivationResultStatus.ERROR: 'ERROR'
    }
510

511
512
513
514
515
516
    def __init__(self, status, message=None):
        if message is None:
            message = self.MESSAGE_BY_STATUS.get(status)

        self.message = message
        self.status = status
517

518
519
    def status_display(self):
        return self.STATUS_DISPLAY.get(self.status)
520

521
522
523
    def __repr__(self):
        return "ActivationResult [%s]: %s" % (self.status_display(),
                                              self.message)
524

525
526
    def is_error(self):
        return self.status == ActivationResultStatus.ERROR