Skip to content
Snippets Groups Projects
activation_backends.py 19.77 KiB
# 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
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import ugettext as _

from astakos.im import models
from astakos.im import functions
from astakos.im import settings
from astakos.im import forms

import astakos.im.messages as astakos_messages

import datetime
import logging
import re
import json

logger = logging.getLogger(__name__)


def get_backend():
    """
    Returns an instance of an activation backend,
    according to the INVITATIONS_ENABLED setting
    (if True returns ``astakos.im.activation_backends.InvitationsBackend``
    and if False
    returns ``astakos.im.activation_backends.SimpleBackend``).

    If the backend cannot be located
    ``django.core.exceptions.ImproperlyConfigured`` is raised.
    """
    module = 'astakos.im.activation_backends'
    prefix = 'Invitations' if settings.INVITATIONS_ENABLED else 'Simple'
    backend_class_name = '%sBackend' % prefix
    try:
        mod = import_module(module)
    except ImportError, e:
        raise ImproperlyConfigured(
            'Error loading activation backend %s: "%s"' % (module, e))
    try:
        backend_class = getattr(mod, backend_class_name)
    except AttributeError:
        raise ImproperlyConfigured(
            'Module "%s" does not define a activation backend named "%s"' % (
                module, backend_class_name))
    return backend_class(settings.MODERATION_ENABLED)


class ActivationBackend(object):
    """
    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.create_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

    def _is_preaccepted(self, user):
        """
        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:
            if re.match(pattern, user.email):
                return 'email'

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

        return False

    def get_signup_form(self, provider='local', initial_data=None, **kwargs):
        """
        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.
        """
        main = provider.capitalize() if provider == 'local' else 'ThirdParty'
        suffix = 'UserCreationForm'
        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()
        functions.enable_base_project(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
        user.save()
        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


class InvitationsBackend(ActivationBackend):
    """
    A activation backend which implements the following workflow: a user
    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.
    """

    def get_signup_form(self, invitation, provider='local', initial_data=None,
                        instance=None):
        """
        Returns a form instance of the relevant class

        raises Invitation.DoesNotExist and ValueError if invitation is consumed
        or invitation username is reserved.
        """
        self.invitation = invitation
        prefix = 'Invited' if invitation else ''
        main = provider.capitalize() if provider == 'local' else 'ThirdParty'
        suffix = 'UserCreationForm'
        formclass = getattr(forms, '%s%s%s' % (prefix, main, suffix))
        return formclass(initial_data, instance=instance)

    def get_signup_initial_data(self, request, provider):
        """
        Returns the necassary activation form depending the user is invited or
        not.

        Throws Invitation.DoesNotExist in case ``code`` is not valid.
        """
        invitation = self.invitation
        initial_data = None
        if request.method == 'GET':
            if invitation:
                first, last = models.split_realname(invitation.realname)
                initial_data = {'email': invitation.username,
                                'inviter': invitation.inviter.realname,
                                'first_name': first,
                                'last_name': last,
                                'provider': provider}
        else:
            if provider == request.POST.get('provider', ''):
                initial_data = request.POST
        return initial_data

    def _is_preaccepted(self, user):
        """
        Extends _is_preaccepted and if there is a valid, not-consumed
        invitation code for the specific user returns True else returns False.
        """
        preaccepted = super(InvitationsBackend, self)._is_preaccepted(user)
        if preaccepted:
            return preaccepted
        invitation = self.invitation
        if not invitation:
            if not self.moderation_enabled:
                return 'auto_moderation'
        if invitation.username == user.email and not invitation.is_consumed:
            invitation.consume()
            return 'invitation'
        return False


class SimpleBackend(ActivationBackend):
    """
    The common activation backend.
    """

# shortcut
ActivationResultStatus = ActivationBackend.Result


class ActivationResult(object):

    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'
    }

    def __init__(self, status, message=None):
        if message is None:
            message = self.MESSAGE_BY_STATUS.get(status)

        self.message = message
        self.status = status

    def status_display(self):
        return self.STATUS_DISPLAY.get(self.status)

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

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