Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Normalize OSF Verification Key [#OSF-6560] #5964

Merged
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
c1bf666
Add verification_key_v2 which contains key, username and expiration t…
cslzchen Jul 12, 2016
16bab4f
Add generate_verification_key_v2().
cslzchen Jul 12, 2016
01c28f7
Reorder imports order for auth core.
cslzchen Jul 12, 2016
9a6b2ba
Forgot and reset password now use verfication key version 2.
cslzchen Jul 12, 2016
7437496
Remove username from verification_key_v2, invalid verification_key_v2…
cslzchen Jul 12, 2016
ae0c145
Add expiration check for user unclaimed_records.
cslzchen Jul 13, 2016
7ecf373
Add `expires` to contributor claim.
cslzchen Jul 15, 2016
b1a6581
Temporarily remove `expires` check for unclaimed_records. [skip ci]
cslzchen Jul 15, 2016
c9dc259
Renew email verifications token when resending confirmation. [skip ci]
cslzchen Jul 15, 2016
c8a0fac
Add empty claim_user_form_get and claim_user_form_post. [skip ci]
cslzchen Jul 17, 2016
f46c8a0
Add expiration time to settings and enable contributor claim check. […
cslzchen Jul 19, 2016
d459d8f
Revert "Add empty claim_user_form_get and claim_user_form_post. [skip…
cslzchen Jul 19, 2016
2e4ea05
Fix web_url_for('reset_password_get') in conference views.
cslzchen Jul 20, 2016
617d2eb
Update tests for forgot and reset password, add attribute and key che…
cslzchen Jul 20, 2016
6be7705
Raise ValueError in get_claim_url if no record on a given project.
cslzchen Jul 20, 2016
e538758
Merge remote-tracking branch 'upstream/develop' into feature/verifica…
cslzchen Jul 20, 2016
93d947e
Fix conflicts and merge remote-tracking branch 'upstream/develop' int…
cslzchen Aug 4, 2016
d6e09ab
Minor improvement and fixes after fixing conflicts.
cslzchen Aug 4, 2016
a3114d4
start from longze's base
chennan47 Aug 8, 2016
9ceb096
fix the unclaimed contributor reset passowrd
chennan47 Aug 8, 2016
7968675
Merge remote-tracking branch 'upstream' into feature/verification-key.
cslzchen Aug 8, 2016
810abe3
merge
chennan47 Aug 8, 2016
12d1308
merge
chennan47 Aug 8, 2016
9f3b5c5
Merge remote-tracking branch 'upstream/develop' into feature/verifica…
cslzchen Aug 8, 2016
28f8f83
merge from longze
chennan47 Aug 8, 2016
52836d8
Merge pull request #11 from chennan47/feature/reset_password.
cslzchen Aug 8, 2016
96b0c5e
Fix conflicts and merge remote-tracking branch 'upstream/develop' int…
cslzchen Aug 31, 2016
1311351
Use email instead of username in `get_user()`. [skip ci]
cslzchen Aug 31, 2016
f627c6b
Normalize verification key generation based on type.
cslzchen Sep 1, 2016
8ad4a94
Forgot/Reset password use normalized verification key: [skip ci]
cslzchen Sep 1, 2016
042e2ca
Improve comment and logic for claim contributor-ship:
cslzchen Sep 1, 2016
6dd18d2
Claim contributor-ship uses normalized verification key.
cslzchen Sep 1, 2016
0ca06e0
Send/Resend confirmation uses normalized verification key. [skip ci]
cslzchen Sep 1, 2016
d9a5d64
Remove deprecated code.
cslzchen Sep 2, 2016
7e862d9
Update unit tests.
cslzchen Sep 2, 2016
576afb0
Temporary fix for #OSF-6673.
cslzchen Sep 2, 2016
ffc82ec
Minor sytle fix.
cslzchen Sep 6, 2016
6a95638
Merge remote-tracking branch 'upstream/develop' into feature/verifica…
cslzchen Sep 6, 2016
3385da7
Remove deprecated api call to `reset_password_post`.
cslzchen Sep 6, 2016
bb65649
Remove duplicated code for pushing status message.
cslzchen Sep 6, 2016
31d08eb
Improve error message with product.
cslzchen Sep 6, 2016
f357253
Remove `#TODO`.
cslzchen Sep 7, 2016
474c1a2
Fix conflicts and merge remote-tracking branch 'upstream/develop' int…
cslzchen Sep 20, 2016
d589f24
Remove unnecessary use of `generate_verification_key()`.
cslzchen Sep 20, 2016
c8bb69b
Update verification key generation in `auth.get_or_create_user()`.
cslzchen Sep 20, 2016
e3a4137
Update `auth.get_or_create_user()`:
cslzchen Sep 20, 2016
10419b7
Minor fixes.
cslzchen Sep 21, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 111 additions & 49 deletions framework/auth/core.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-

from copy import deepcopy
import datetime as dt
import itertools
import logging
import re
import urlparse
from copy import deepcopy

import bson
import pytz
Expand Down Expand Up @@ -42,17 +43,19 @@
logger = logging.getLogger(__name__)


# Hide implementation of token generation
def generate_confirm_token():
return security.random_string(30)


def generate_claim_token():
# verification key v1
def generate_verification_key():
return security.random_string(30)


def generate_verification_key():
return security.random_string(30)
# verification key v2, expires in 30 minutes
def generate_verification_key_v2():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker, but if the main purpose of this PR is to normalize (as much as possible) user / password confirmation flow, and you're already de-duplicating token generation, why not have a single helper function for this? e.g.

#### defaults.py
EXPIRATION_TIME_DICT = {
    'verify': 30,
    'confirm': 24 * 60,  # 24h in minutes
    'claim': 7 * 24 * 60  # 7d in minutes
}

### here
# Define constants (VERIFY = 'verify', etc.), to be imported elsewhere
def generate_verification_key(verification_type=None):
    token = security.random_string(30)
    if not verification_type:  # Assume v1
        return token
    expires = dt.datetime.utcnow() + dt.timedelta(minutes=settings.EXPIRATION_TIME_DICT[verification_type])
    return {
        'token': token,
        'expires': expires
    }

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works for forgot/reset password verification and contributor-ship claims. But for send/resend confirmation, token is used as key of the map. I will leave this unchanged.

User.email_verifications:
{
    <token> : {
        'email': <email address>,
        'expiration': <datetime>,
        'confirmed': whether user is confirmed or not
    }
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That could be handled by building email_verifications slightly differently in add_unconfirmed_email, i.e. the lines

token = generate_verification_key()

# handle when email_verifications is None
if not self.email_verifications:
    self.email_verifications = {}

# confirmed used to check if link has been clicked
self.email_verifications[token] = {'email': email,
    'confirmed': False}
self._set_email_token_expiration(token, expiration=expiration)

could be changed to something like

token_dict = generate_verification_key(type=CONFIRM)

# handle when email_verifications is None
if not self.email_verifications:
    self.email_verifications = {}

# confirmed used to check if link has been clicked
self.email_verifications[token_dict['token']] = {
    'email': email,
    'expires': token_dict['expires'],
    'confirmed': False
}

which also obviates the _set_email_token_expiration method. Again, not a blocker, but consider adding a TODO

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Since one of the goal for this PR is normalization, I will add this. :)

token = security.random_string(30)
expires = dt.datetime.utcnow() + dt.timedelta(minutes=settings.VERIFICATION_KEY_V2_EXPIRATION)
return {
'token': token,
'expires': expires,
}


def validate_history_item(item):
Expand Down Expand Up @@ -99,25 +102,60 @@ def validate_social(value):
validate_profile_websites(value.get('profileWebsites'))


def validate_user_with_verification_key(username=None, verification_key=None):
"""
Validate requests with username and one-time verification key.

:param username: user's username
:param verification_key: one-time verification key
:rtype: User or None
"""

if not username or not verification_key:
return None
user_obj = get_user(username=username)
if user_obj:
try:
if user_obj.verification_key_v2:
if user_obj.verification_key_v2['token'] == verification_key:
if user_obj.verification_key_v2['expires'] > dt.datetime.utcnow():
return user_obj
except AttributeError:
# if user does not have verification_key_v2
return None
return None


# TODO - rename to _get_current_user_from_session /HRYBACKI
def _get_current_user():
uid = session._get_current_object() and session.data.get('auth_user_id')
return User.load(uid)


# TODO: This should be a class method of User?
def get_user(email=None, password=None, verification_key=None):
"""Get an instance of User matching the provided params.

def get_user(email=None, password=None, verification_key=None, username=None):
"""
Get an instance of User matching the provided params. Here are all valid usages:
1. email and password
2. email
3. username (for verification key version 2)
4. verification key (for verification key version 1)

:param email: email
:param password: password
:param verification_key: verification key v1
:param username: username
:return: The instance of User requested
:rtype: User or None
"""
# tag: database
if password and not email:
raise AssertionError('If a password is provided, an email must also '
'be provided.')
raise AssertionError('If a password is provided, an email must also be provided.')

query_list = []

if username:
query_list.append(Q('username', 'eq', username))
if email:
email = email.strip().lower()
query_list.append(Q('emails', 'eq', email) | Q('username', 'eq', email))
Expand Down Expand Up @@ -265,13 +303,14 @@ class User(GuidStoredObject, AddonModelMixin):
is_invited = fields.BooleanField(default=False, index=True)

# Per-project unclaimed user data:
# TODO: add validation
# TODO: add a validation function that ensures that all required keys are present in the input values for that field
unclaimed_records = fields.DictionaryField(required=False)
# Format: {
# <project_id>: {
# 'name': <name that referrer provided>,
# 'referrer_id': <user ID of referrer>,
# 'token': <token used for verification urls>,
# 'token': <verification key v1, used for verification urls>,
# 'expires': <expiration time for this record>,
# 'email': <email the referrer provided or None>,
# 'claimer_email': <email the claimer entered or None>,
# 'last_sent': <timestamp of last email sent to referrer or None>
Expand All @@ -280,20 +319,27 @@ class User(GuidStoredObject, AddonModelMixin):
# }

# Time of last sent notification email to newly added contributors
contributor_added_email_records = fields.DictionaryField(default=dict)
# Format : {
# <project_id>: {
# 'last_sent': time.time()
# }
# ...
# }
contributor_added_email_records = fields.DictionaryField(default=dict)

# The user into which this account was merged
merged_by = fields.ForeignField('user', default=None, index=True)

# verification key used for resetting password
# verification key v1,
verification_key = fields.StringField()

# verification key v2, with expiration time and one-time only
verification_key_v2 = fields.DictionaryField(default=dict)
# Format: {
# 'token': <the verification key string>
# 'expires': <the expiration time for the key>
# }

email_last_sent = fields.DateTimeField()

# confirmed emails
Expand All @@ -306,8 +352,11 @@ class User(GuidStoredObject, AddonModelMixin):
# see also ``unconfirmed_emails``
email_verifications = fields.DictionaryField(default=dict)
# Format: {
# <token> : {'email': <email address>,
# 'expiration': <datetime>}
# <token> : {
# 'email': <email address>,
# 'expiration': <datetime>,
# 'confirmed': whether user is confirmed or not
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For CR: just updated the comments, no code change

# }
# }

# TODO remove this field once migration (scripts/migration/migrate_mailing_lists_to_mailchimp_fields.py)
Expand Down Expand Up @@ -601,7 +650,8 @@ def add_unclaimed_record(self, node, referrer, given_name, email=None):
record = {
'name': given_name,
'referrer_id': referrer_id,
'token': generate_confirm_token(),
'token': generate_verification_key(),
'expires': dt.datetime.utcnow() + dt.timedelta(days=settings.CLAIM_TOKEN_EXPIRATION),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With suggested refactor above, this could become record.update(generate_verification_key(type=CLAIM))

'email': clean_email
}
self.unclaimed_records[project_id] = record
Expand Down Expand Up @@ -633,32 +683,50 @@ def is_active(self):
self.is_confirmed)

def get_unclaimed_record(self, project_id):
"""Get an unclaimed record for a given project_id.
"""
Get an unclaimed record for a given project_id.

:raises: ValueError if there is no record for the given project.
"""
try:
return self.unclaimed_records[project_id]
except KeyError: # reraise as ValueError
except KeyError: # re-raise as ValueError
raise ValueError('No unclaimed record for user {self._id} on node {project_id}'
.format(**locals()))
.format(**locals()))

def verify_claim_token(self, token, project_id):
"""
Verify the claim token for this user for a given node
which she/he was added as a unregistered contributor for.

:param token: the verification token
:param project_id: the project node id
:rtype boolean
:return: whether or not a claim token is valid
"""
try:
record = self.get_unclaimed_record(project_id)
except ValueError: # No unclaimed record for given pid
return False
return record['token'] == token and record['expires'] > dt.datetime.utcnow()

def get_claim_url(self, project_id, external=False):
"""Return the URL that an unclaimed user should use to claim their
account. Return ``None`` if there is no unclaimed_record for the given
project ID.

:param project_id: The project ID for the unclaimed record
:raises: ValueError if a record doesn't exist for the given project ID
:rtype: dict
:returns: The unclaimed record for the project
"""
Return the URL that an unclaimed user should use to claim their account.
Raise `ValueError` if there is no unclaimed_record for the given project ID.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For CR: updated comments to match the actual behavior: raise exception instead of return None


:param project_id: the project ID for the unclaimed record
:param external:
:rtype: string
:returns: the claim url
:raises: ValueError if there is no record for the given project.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For CR: not sure why explicitly throw ValueError instead of return None, and there is a also unit test for this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit that included the unit test said nothing of the rationale for including it. ¯_(ツ)_/¯

"""
unclaimed_record = self.get_unclaimed_record(project_id)
uid = self._primary_key
base_url = settings.DOMAIN if external else '/'
unclaimed_record = self.get_unclaimed_record(project_id)
token = unclaimed_record['token']
return '{base_url}user/{uid}/{project_id}/claim/?token={token}'\
.format(**locals())
return '{base_url}user/{uid}/{project_id}/claim/?token={token}'.\
format(**locals())

def set_password(self, raw_password, notify=True):
"""Set the password for this user to the hash of ``raw_password``.
Expand Down Expand Up @@ -765,7 +833,7 @@ def add_unconfirmed_email(self, email, expiration=None):
if email in self.unconfirmed_emails:
self.remove_unconfirmed_email(email)

token = generate_confirm_token()
token = generate_verification_key()

# handle when email_verifications is None
if not self.email_verifications:
Expand Down Expand Up @@ -807,7 +875,7 @@ def _send_email_removal_confirmations(self, email):
removed_email=email,
security_addr='primary email address ({})'.format(self.username))

def get_confirmation_token(self, email, force=False):
def get_confirmation_token(self, email, force=False, renew=False):
"""Return the confirmation token for a given email.

:param str email: Email to get the token for.
Expand All @@ -823,6 +891,10 @@ def get_confirmation_token(self, email, force=False):
# Old records will not have an expiration key. If it's missing,
# assume the token is expired
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like, for old .verification_keys, we're assuming them to be expired and (hopefully) resending a confirmation email.

Other than for the password reset flow, when a v1 key is generated solely for communication with CAS, is there any time when a verification key will be generated without an expiration? If not, consider adding a 1 minute expiration to the keys sent to CAS, and deprecating the v1 StringField in favor of a DictionaryField of the same name (requires migration)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I will update CAS to check expiration as well.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mfraezz

I am not going to add this for CAS in this PR. The verification key is used immediately and distroyed. In addition, don't want CAS to block this.

expiration = info.get('expiration')
if renew:
new_token = self.add_unconfirmed_email(email)
self.save()
return new_token
if not expiration or (expiration and expiration < dt.datetime.utcnow()):
if not force:
raise ExpiredTokenError('Token for email "{0}" is expired'.format(email))
Expand All @@ -833,14 +905,14 @@ def get_confirmation_token(self, email, force=False):
return token
raise KeyError('No confirmation token for email "{0}"'.format(email))

def get_confirmation_url(self, email, external=True, force=False):
def get_confirmation_url(self, email, external=True, force=False, renew=False):
"""Return the confirmation url for a given email.

:raises: ExpiredTokenError if trying to access a token that is expired.
:raises: KeyError if there is no token for the email.
"""
base = settings.DOMAIN if external else '/'
token = self.get_confirmation_token(email, force=force)
token = self.get_confirmation_token(email, force=force, renew=renew)
return '{0}confirm/{1}/{2}/'.format(base, self._primary_key, token)

def get_unconfirmed_email_for_token(self, token):
Expand Down Expand Up @@ -875,16 +947,6 @@ def clean_email_verifications(self, given_token=None):
email_verifications.pop(token)
self.email_verifications = email_verifications

def verify_claim_token(self, token, project_id):
"""Return whether or not a claim token is valid for this user for
a given node which they were added as a unregistered contributor for.
"""
try:
record = self.get_unclaimed_record(project_id)
except ValueError: # No unclaimed record for given pid
return False
return record['token'] == token

def confirm_email(self, token, merge=False):
"""Confirm the email address associated with the token"""
email = self.get_unconfirmed_email_for_token(token)
Expand Down
Loading