Commit 7319c9be authored by Kostas Papadimitriou's avatar Kostas Papadimitriou

astakos: User activation flow improvements

Major refactoring on user email verification/activation process
---------------------------------------------------------------
Activation logic moved from dispersed code in functions/view modules to
ActivationBackend methods. All user activation handling code in astakos views
and command line utilities was updated to use activation backend instances.

User moderation takes place right after user has verified the email address used
during the signup process. This solves issues caused when users signed up using
an existing but not yet verified email, causing invalidation of previously
moderated accounts.

A bunch of new fields added in AstakosUser model. Those fields added to clear up
a bit the identification of user status at a given time and additionaly keep
track of when specific user actions took place as a reference for
administrators. The following section contains detailed description of each
introduced field.

Introduced AstakosUser fields
-----------------------------

Fields get properly set across sigup/activation/moderation processes.

* verification_code
  Unique identifier used instead of user auth token in user email
  verification url. This is initially set when user signup and gets updated
  each time a new verification mail is sent (requested either by admin or user)

* verified_at
  The date user email got verified.

* moderated
  Whether or not the used passed through moderation process.

* moderated_at
  The date user got moderated.

* moderated_data
  A snapshot of user instance by the time of moderation (in json format).

* accepted_policy
  A string to identify if user was automatically moderated/accepted.

* accepted_email
  The email used during user activation.

* deactivated_reason
  Reason user got deactivated, provided by the administrator.

* deactivated_at
  Date user got deactivated.

* activated_at
  Date user got activated.

* is_rejected
  Whether or not account was rejected.

South data migration included.
******************************

Handles user entries as follows

Users with no activation_sent date
----------------------------------
- Generate and fill verification_code field.
- Once user will visit the activation url an additional moderation step
  will be required to activate the user.

Users with verified email which are not active
----------------------------------------------
- Set moderated to True
- Set is_active to False
- Set moderated_at to user.auth_token_created
- Set accepted_email to user.email
- Set accepted_policy to 'migration'
- Set deactivated_reason to "migration"
- Set deactivated_at to user.updated

Users with verified email which are active
------------------------------------------
- Set moderated to True
- Set moderated_at to user.auth_token_created
- Set accepted_policy to 'migration'
- Set accepted_email to user.email
- Set verified_at to user.moderated_at

Users with no verified email and activation_sent set
----------------------------------------------------
- Set moderated to True
- Set moderated_at to user.updated
- Set verification_code to user.auth_token (to avoid invalidating old
  activation urls)

Updated management commands
***************************
- New options --pending-moderation, --pending-verification added in `user-list`
  command.
- New fields verified/moderated included in `user-list` command.
- New moderation options `--accept`/`--reject` added in `user-modify` command.
  `--reject` can optionally be combined with `--reject-reason`.

Other changes
*************
- Cleaned up explicit smtp error handling when sending email notifications.
- Prevent already signed in users from using an account activation url
- Allow user to logout even when latest terms where not accepted
- Renamed templates
    * helpdesk_notification.txt -> account_activated_notification.txt
    * account_creation_notification.txt ->
        account_pending_moderation_notification.txt
- Updated im tests
parent f8e6c3cb
......@@ -87,9 +87,10 @@ DOMAIN_VALUE_REGEX = re.compile(
r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}$',
re.IGNORECASE)
class StoreUserMixin(object):
def store_user(self, user, request):
def store_user(self, user, request=None):
"""
WARNING: this should be wrapped inside a transactional view/method.
"""
......@@ -128,9 +129,16 @@ class LocalUserCreationForm(UserCreationForm, StoreUserMixin):
Changes the order of fields, and removes the username field.
"""
request = kwargs.pop('request', None)
provider = kwargs.pop('provider', 'local')
# we only use LocalUserCreationForm for local provider
if not provider == 'local':
raise Exception('Invalid provider')
if request:
self.ip = request.META.get('REMOTE_ADDR',
request.META.get('HTTP_X_REAL_IP', None))
request.META.get('HTTP_X_REAL_IP',
None))
super(LocalUserCreationForm, self).__init__(*args, **kwargs)
self.fields.keyOrder = ['email', 'first_name', 'last_name',
......@@ -181,7 +189,7 @@ class LocalUserCreationForm(UserCreationForm, StoreUserMixin):
if not check.is_valid:
raise forms.ValidationError(_(astakos_messages.CAPTCHA_VALIDATION_ERR))
def post_store_user(self, user, request):
def post_store_user(self, user, request=None):
"""
Interface method for descendant backends to be able to do stuff within
the transaction enabled by store_user.
......@@ -195,10 +203,11 @@ class LocalUserCreationForm(UserCreationForm, StoreUserMixin):
save behavior is complete.
"""
user = super(LocalUserCreationForm, self).save(commit=False)
user.date_signed_terms = datetime.now()
user.renew_token()
if commit:
user.save()
logger.log(LOGGING_LEVEL, 'Created user %s' % user.email)
logger.info('Created user %s', user.log_display)
return user
......@@ -231,34 +240,32 @@ class InvitedLocalUserCreationForm(LocalUserCreationForm):
class ThirdPartyUserCreationForm(forms.ModelForm, StoreUserMixin):
id = forms.CharField(
widget=forms.HiddenInput(),
label='',
required=False
)
third_party_identifier = forms.CharField(
widget=forms.HiddenInput(),
label=''
)
email = forms.EmailField(
label='Contact email',
help_text = 'This is needed for contact purposes. ' \
'It doesn't need to be the same with the one you ' \
help_text='This is needed for contact purposes. '
'It doesn't need to be the same with the one you '
'provided to login previously. '
)
class Meta:
model = AstakosUser
fields = ['id', 'email', 'third_party_identifier',
'first_name', 'last_name', 'has_signed_terms']
fields = ['email', 'first_name', 'last_name', 'has_signed_terms']
def __init__(self, *args, **kwargs):
"""
Changes the order of fields, and removes the username field.
"""
self.request = kwargs.get('request', None)
if self.request:
kwargs.pop('request')
self.provider = kwargs.pop('provider', None)
if not self.provider or self.provider == 'local':
raise Exception('Invalid provider, %r' % self.provider)
# ThirdPartyUserCreationForm should always get instantiated with
# a third_party_token value
self.third_party_token = kwargs.pop('third_party_token', None)
if not self.third_party_token:
raise Exception('ThirdPartyUserCreationForm'
' requires third_party_token')
super(ThirdPartyUserCreationForm, self).__init__(*args, **kwargs)
......@@ -278,12 +285,12 @@ class ThirdPartyUserCreationForm(forms.ModelForm, StoreUserMixin):
if not email:
raise forms.ValidationError(_(astakos_messages.REQUIRED_FIELD))
if reserved_verified_email(email):
provider_id = self.request.REQUEST.get('provider', 'local')
provider_id = self.provider
provider = auth_providers.get_provider(provider_id)
extra_message = provider.get_add_to_existing_account_msg
raise forms.ValidationError(mark_safe(_(astakos_messages.EMAIL_USED) + ' ' +
extra_message))
raise forms.ValidationError(mark_safe(
_(astakos_messages.EMAIL_USED) + ' ' + extra_message))
return email
def clean_has_signed_terms(self):
......@@ -292,11 +299,11 @@ class ThirdPartyUserCreationForm(forms.ModelForm, StoreUserMixin):
raise forms.ValidationError(_(astakos_messages.SIGN_TERMS))
return has_signed_terms
def post_store_user(self, user, request):
pending = PendingThirdPartyUser.objects.get(
token=request.POST.get('third_party_token'),
third_party_identifier=
self.cleaned_data.get('third_party_identifier'))
def _get_pending_user(self):
return PendingThirdPartyUser.objects.get(token=self.third_party_token)
def post_store_user(self, user, request=None):
pending = self._get_pending_user()
provider = pending.get_provider(user)
provider.add_to_user()
pending.delete()
......@@ -305,9 +312,10 @@ class ThirdPartyUserCreationForm(forms.ModelForm, StoreUserMixin):
user = super(ThirdPartyUserCreationForm, self).save(commit=False)
user.set_unusable_password()
user.renew_token()
user.date_signed_terms = datetime.now()
if commit:
user.save()
logger.log(LOGGING_LEVEL, 'Created user %s' % user.email)
logger.info('Created user %s' % user.log_display)
return user
......@@ -409,8 +417,8 @@ class LoginForm(AuthenticationForm):
user = AstakosUser.objects.get_by_identifier(username)
if not user.has_auth_provider('local'):
provider = auth_providers.get_provider('local', user)
raise forms.ValidationError(
provider.get_login_disabled_msg)
msg = provider.get_login_disabled_msg
raise forms.ValidationError(mark_safe(msg))
except AstakosUser.DoesNotExist:
pass
......@@ -515,16 +523,17 @@ class ExtendedPasswordResetForm(PasswordResetForm):
user = AstakosUser.objects.get_by_identifier(email)
self.users_cache = [user]
if not user.is_active:
raise forms.ValidationError(user.get_inactive_message('local'))
msg = mark_safe(user.get_inactive_message('local'))
raise forms.ValidationError(msg)
provider = auth_providers.get_provider('local', user)
if not user.has_usable_password():
msg = provider.get_unusable_password_msg
raise forms.ValidationError(msg)
raise forms.ValidationError(mark_safe(msg))
if not user.can_change_password():
msg = provider.get_cannot_change_password_msg
raise forms.ValidationError(msg)
raise forms.ValidationError(mark_safe(msg))
except AstakosUser.DoesNotExist:
raise forms.ValidationError(_(astakos_messages.EMAIL_UNKNOWN))
......@@ -597,6 +606,13 @@ class SignApprovalTermsForm(forms.ModelForm):
raise forms.ValidationError(_(astakos_messages.SIGN_TERMS))
return has_signed_terms
def save(self, commit=True):
user = super(SignApprovalTermsForm, self).save(commit)
user.date_signed_terms = datetime.now()
if commit:
user.save()
return user
class InvitationForm(forms.ModelForm):
......
......@@ -61,15 +61,14 @@ from astakos.im.settings import (
EMAIL_CHANGE_EMAIL_SUBJECT,
PROJECT_CREATION_SUBJECT, PROJECT_APPROVED_SUBJECT,
PROJECT_TERMINATION_SUBJECT, PROJECT_SUSPENSION_SUBJECT,
PROJECT_MEMBERSHIP_CHANGE_SUBJECT,
)
PROJECT_MEMBERSHIP_CHANGE_SUBJECT)
from astakos.im.notifications import build_notification, NotificationError
from astakos.im.models import (
AstakosUser, Invitation, ProjectMembership, ProjectApplication, Project,
UserSetting,
get_resource_names, new_chain)
from astakos.im.quotas import (qh_sync_user, qh_sync_project,
register_pending_apps)
from astakos.im.quotas import (qh_sync_user, qh_sync_users,
register_pending_apps, qh_sync_project)
from astakos.im.project_notif import (
membership_change_notify, membership_enroll_notify,
membership_request_notify, membership_leave_request_notify,
......@@ -101,12 +100,8 @@ def logout(request, *args, **kwargs):
def send_verification(user, template_name='im/activation_email.txt'):
"""
Send email to user to verify his/her email and activate his/her account.
Raises SendVerificationError
"""
url = '%s?auth=%s&next=%s' % (join_urls(BASEURL, reverse('activate')),
quote(user.auth_token),
quote(join_urls(BASEURL, reverse('index'))))
url = join_urls(BASEURL, user.get_activation_url(nxt=reverse('index')))
message = render_to_string(template_name, {
'user': user,
'url': url,
......@@ -114,82 +109,75 @@ def send_verification(user, template_name='im/activation_email.txt'):
'site_name': SITENAME,
'support': CONTACT_EMAIL})
sender = settings.SERVER_EMAIL
try:
send_mail(_(VERIFICATION_EMAIL_SUBJECT), message, sender, [user.email],
connection=get_connection())
except (SMTPException, socket.error) as e:
logger.exception(e)
raise SendVerificationError()
else:
msg = 'Sent activation %s' % user.email
logger.log(LOGGING_LEVEL, msg)
def send_activation(user, template_name='im/activation_email.txt'):
send_verification(user, template_name)
user.activation_sent = datetime.now()
user.save()
send_mail(_(VERIFICATION_EMAIL_SUBJECT), message, sender, [user.email],
connection=get_connection())
logger.info("Sent user verirfication email: %s", user.log_display)
def _send_admin_notification(template_name,
dictionary=None,
context=None,
user=None,
msg="",
subject='alpha2 testing notification',):
"""
Send notification email to settings.HELPDESK + settings.MANAGERS.
Raises SendNotificationError
Send notification email to settings.HELPDESK + settings.MANAGERS +
settings.ADMINS.
"""
dictionary = dictionary or {}
message = render_to_string(template_name, dictionary)
if context is None:
context = {}
if not 'user' in context:
context['user'] = user
message = render_to_string(template_name, context)
sender = settings.SERVER_EMAIL
recipient_list = [e[1] for e in settings.HELPDESK + settings.MANAGERS]
try:
send_mail(subject, message, sender, recipient_list,
connection=get_connection())
except (SMTPException, socket.error) as e:
logger.exception(e)
raise SendNotificationError()
recipient_list = [e[1] for e in settings.HELPDESK +
settings.MANAGERS + settings.ADMINS]
send_mail(subject, message, sender, recipient_list,
connection=get_connection())
if user:
msg = 'Sent admin notification (%s) for user %s' % (msg,
user.log_display)
else:
user = dictionary.get('user')
msg = 'Sent admin notification for user %s' % user.log_display
logger.log(LOGGING_LEVEL, msg)
msg = 'Sent admin notification (%s)' % msg
logger.log(LOGGING_LEVEL, msg)
def send_account_creation_notification(template_name, dictionary=None):
user = dictionary.get('user')
def send_account_pending_moderation_notification(
user,
template_name='im/account_pending_moderation_notification.txt'):
"""
Notify admins that a new user has verified his email address and moderation
step is required to activate his account.
"""
subject = _(ACCOUNT_CREATION_SUBJECT) % {'user': user.email}
return _send_admin_notification(template_name, dictionary, subject=subject)
return _send_admin_notification(template_name, {}, subject=subject,
user=user, msg="account creation")
def send_helpdesk_notification(user, template_name='im/helpdesk_notification.txt'):
def send_account_activated_notification(
user,
template_name='im/account_activated_notification.txt'):
"""
Send email to settings.HELPDESK list to notify for a new user activation.
Raises SendNotificationError
Send email to settings.HELPDESK + settings.MANAGERES + settings.ADMINS
lists to notify that a new account has been accepted and activated.
"""
message = render_to_string(
template_name,
{'user': user}
)
sender = settings.SERVER_EMAIL
recipient_list = [e[1] for e in settings.HELPDESK + settings.MANAGERS]
try:
send_mail(_(HELPDESK_NOTIFICATION_EMAIL_SUBJECT) % {'user': user.email},
message, sender, recipient_list, connection=get_connection())
except (SMTPException, socket.error) as e:
logger.exception(e)
raise SendNotificationError()
else:
msg = 'Sent helpdesk admin notification for %s' % user.email
logger.log(LOGGING_LEVEL, msg)
recipient_list = [e[1] for e in settings.HELPDESK +
settings.MANAGERS + settings.ADMINS]
send_mail(_(HELPDESK_NOTIFICATION_EMAIL_SUBJECT) % {'user': user.email},
message, sender, recipient_list, connection=get_connection())
msg = 'Sent helpdesk admin notification for %s' % user.email
logger.log(LOGGING_LEVEL, msg)
def send_invitation(invitation, template_name='im/invitation.txt'):
"""
Send invitation email.
Raises SendInvitationError
"""
subject = _(INVITATION_EMAIL_SUBJECT)
url = '%s?code=%d' % (join_urls(BASEURL, reverse('index')), invitation.code)
......@@ -200,23 +188,18 @@ def send_invitation(invitation, template_name='im/invitation.txt'):
'site_name': SITENAME,
'support': CONTACT_EMAIL})
sender = settings.SERVER_EMAIL
try:
send_mail(subject, message, sender, [invitation.username],
connection=get_connection())
except (SMTPException, socket.error) as e:
logger.exception(e)
raise SendInvitationError()
else:
msg = 'Sent invitation %s' % invitation
logger.log(LOGGING_LEVEL, msg)
inviter_invitations = invitation.inviter.invitations
invitation.inviter.invitations = max(0, inviter_invitations - 1)
invitation.inviter.save()
send_mail(subject, message, sender, [invitation.username],
connection=get_connection())
msg = 'Sent invitation %s' % invitation
logger.log(LOGGING_LEVEL, msg)
inviter_invitations = invitation.inviter.invitations
invitation.inviter.invitations = max(0, inviter_invitations - 1)
invitation.inviter.save()
def send_greeting(user, email_template_name='im/welcome_email.txt'):
"""
Send welcome email.
Send welcome email to an accepted/activated user.
Raises SMTPException, socket.error
"""
......@@ -228,15 +211,10 @@ def send_greeting(user, email_template_name='im/welcome_email.txt'):
'site_name': SITENAME,
'support': CONTACT_EMAIL})
sender = settings.SERVER_EMAIL
try:
send_mail(subject, message, sender, [user.email],
connection=get_connection())
except (SMTPException, socket.error) as e:
logger.exception(e)
raise SendGreetingError()
else:
msg = 'Sent greeting %s' % user.log_display
logger.log(LOGGING_LEVEL, msg)
send_mail(subject, message, sender, [user.email],
connection=get_connection())
msg = 'Sent greeting %s' % user.log_display
logger.log(LOGGING_LEVEL, msg)
def send_feedback(msg, data, user, email_template_name='im/feedback_mail.txt'):
......@@ -278,29 +256,6 @@ def send_change_email(
logger.log(LOGGING_LEVEL, msg)
def activate(
user,
email_template_name='im/welcome_email.txt',
helpdesk_email_template_name='im/helpdesk_notification.txt',
verify_email=False):
"""
Activates the specific user and sends email.
Raises SendGreetingError, ValidationError
"""
user.is_active = True
user.email_verified = True
if not user.activation_sent:
user.activation_sent = datetime.now()
user.save()
qh_sync_user(user)
send_helpdesk_notification(user, helpdesk_email_template_name)
send_greeting(user, email_template_name)
def deactivate(user):
user.is_active = False
user.save()
def invite(inviter, email, realname):
inv = Invitation(inviter=inviter, username=email, realname=realname)
inv.save()
......@@ -308,68 +263,6 @@ def invite(inviter, email, realname):
inviter.invitations = max(0, inviter.invitations - 1)
inviter.save()
def switch_account_to_shibboleth(user, local_user,
greeting_template_name='im/welcome_email.txt'):
try:
provider = user.provider
except AttributeError:
return
else:
if not provider == 'shibboleth':
return
user.delete()
local_user.provider = 'shibboleth'
local_user.third_party_identifier = user.third_party_identifier
local_user.save()
send_greeting(local_user, greeting_template_name)
return local_user
class SendMailError(Exception):
pass
class SendAdminNotificationError(SendMailError):
def __init__(self):
self.message = _(astakos_messages.ADMIN_NOTIFICATION_SEND_ERR)
super(SendAdminNotificationError, self).__init__()
class SendVerificationError(SendMailError):
def __init__(self):
self.message = _(astakos_messages.VERIFICATION_SEND_ERR)
super(SendVerificationError, self).__init__()
class SendInvitationError(SendMailError):
def __init__(self):
self.message = _(astakos_messages.INVITATION_SEND_ERR)
super(SendInvitationError, self).__init__()
class SendGreetingError(SendMailError):
def __init__(self):
self.message = _(astakos_messages.GREETING_SEND_ERR)
super(SendGreetingError, self).__init__()
class SendFeedbackError(SendMailError):
def __init__(self):
self.message = _(astakos_messages.FEEDBACK_SEND_ERR)
super(SendFeedbackError, self).__init__()
class ChangeEmailError(SendMailError):
def __init__(self):
self.message = _(astakos_messages.CHANGE_EMAIL_SEND_ERR)
super(ChangeEmailError, self).__init__()
class SendNotificationError(SendMailError):
def __init__(self):
self.message = _(astakos_messages.NOTIFICATION_SEND_ERR)
super(SendNotificationError, self).__init__()
### PROJECT FUNCTIONS ###
......
......@@ -33,7 +33,8 @@
from django.core.management.base import BaseCommand, CommandError
from astakos.im.functions import send_activation, SendMailError
from astakos.im import activation_backends
activation_backend = activation_backends.get_backend()
from ._common import get_user
......@@ -51,13 +52,13 @@ class Command(BaseCommand):
if not user:
self.stderr.write("Unknown user '%s'\n" % (email_or_id,))
continue
if user.email_verified and user.is_active:
if user.email_verified:
self.stderr.write(
"Already active user '%s'\n" % (email_or_id,))
"User email already verified '%s'\n" % (user.email,))
continue
try:
send_activation(user)
activation_backend.send_user_verification_email(user)
except SendMailError, e:
raise CommandError(e.message)
......
......@@ -56,6 +56,10 @@ class Command(ListCommand):
'id': ('id', ('The id of the user')),
'real name': ('realname', 'The name of the user'),
'active': ('is_active', 'Whether the user is active or not'),
'verified':
('email_verified', 'Whether the user has a verified email address'),
'moderated':
('moderated', 'Account moderated'),
'admin': ('is_superuser', 'Whether the user is admin or not'),
'uuid': ('uuid', 'The uuid of the user'),
'providers': (get_providers,
......@@ -66,7 +70,8 @@ class Command(ListCommand):
'groups': (get_groups, 'The groups of the user')
}
fields = ['id', 'real name', 'active', 'admin', 'uuid']
fields = ['id', 'real name', 'active', 'verified', 'moderated', 'admin',
'uuid']
option_list = ListCommand.option_list + (
make_option('-p',
......@@ -94,6 +99,16 @@ class Command(ListCommand):
dest='active',
default=False,
help="Display only active users"),
make_option('--pending-moderation',
action='store_true',
dest='pending_moderation',
default=False,
help="Display unmoderated users"),
make_option('--pending-verification',
action='store_true',
dest='pending_verification',
default=False,
help="Display unverified users"),
make_option("--displayname",
dest="displayname",
action="store_true",
......@@ -112,6 +127,13 @@ class Command(ListCommand):
if options['active']:
self.filters['is_active'] = True
if options['pending_moderation']:
self.filters['email_verified'] = True
self.filters['moderated'] = False
if options['pending_verification']:
self.filters['email_verified'] = False
if options['auth_providers']:
self.fields.extend(['providers'])
......
......@@ -31,6 +31,8 @@
# interpreted as representing official policies, either expressed
# or implied, of GRNET S.A.
import string
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError
......@@ -38,11 +40,12 @@ from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
from astakos.im.models import AstakosUser
from astakos.im.functions import (activate, deactivate)
from astakos.im import quotas