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] ORCID login [#OSF-5162, 6881, 6885] #6192

Merged
merged 49 commits into from
Aug 26, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
aa34e8f
ORCID Login: update `User` model.
cslzchen Aug 19, 2016
4985794
Add support for OSF to handle cas service ticket from oauth login.
cslzchen Aug 19, 2016
aa7f563
Add `oauth_user_email_[get|post]` for first-time oauth-login users to…
cslzchen Aug 19, 2016
cecc0bd
Update `User.oauth`, `User.create_unconfirmed()` and `get_user()`.
cslzchen Aug 20, 2016
ee563c4
Update `oauth_user_email_[get|post]`:
cslzchen Aug 20, 2016
5bc7939
Implement account creation for new user login thorugh oauth. [skip ci]
cslzchen Aug 21, 2016
609fcac
Refactor `oauth` to `external_identiy`:
cslzchen Aug 22, 2016
e09480f
Refactor `User.external_identity` to include `status`:
cslzchen Aug 22, 2016
ce1a2c8
Update auth logic with new `User.external_identity.status`:
cslzchen Aug 22, 2016
296fcef
Partially implement account linking for external login. [skip ci]
cslzchen Aug 22, 2016
3e7ddde
Fully implement account linking logic for external login and update a…
cslzchen Aug 22, 2016
99aa7c9
Merge remote-tracking branch 'cos/pr/6177' into HEAD
mfraezz Aug 23, 2016
0c44ee0
Add external_identity to default keys in test
mfraezz Aug 23, 2016
76c21d7
Fix method name
mfraezz Aug 23, 2016
65bdf8c
Normalize key
mfraezz Aug 23, 2016
fb16f2e
Add auth tests
mfraezz Aug 23, 2016
15d22de
Add model tests
mfraezz Aug 23, 2016
fd8a93b
Use service-specific verification email templates
mfraezz Aug 23, 2016
88e59cc
Add tests for external_login_confirm_email
mfraezz Aug 23, 2016
150575c
Add template for link success
mfraezz Aug 23, 2016
63e45da
Update `external_first_login_authenticate()` to match CAS service res…
cslzchen Aug 23, 2016
2e03a84
Add valid external identity providers list and map profile name to pr…
cslzchen Aug 23, 2016
853ac3a
Update `make_response_from_ticket()` for new CAS service response:
cslzchen Aug 23, 2016
550a9ef
Merge remote-tracking branch 'upstream/feature/orcid-login' into feat…
cslzchen Aug 23, 2016
5fd21e6
Several minor updates:
cslzchen Aug 23, 2016
e2a4add
Support user merging for external_identities
mfraezz Aug 23, 2016
fbdbc1c
Merge remote-tracking branch 'cos/pr/6198' into feature/orcid-tests
mfraezz Aug 23, 2016
2412c25
Remove unnecessary kwarg
mfraezz Aug 23, 2016
396ea4b
Update tests
mfraezz Aug 23, 2016
d17eefd
Update forgotten test
mfraezz Aug 23, 2016
69a9e30
Add test for `external_login_email_get()`.
cslzchen Aug 23, 2016
85ef767
Several minor updates:
cslzchen Aug 23, 2016
3a71b0d
Add test for `external_login_email_get()`.
cslzchen Aug 23, 2016
8debe14
Merge remote-tracking branch 'upstream/feature/orcid-login' into feat…
cslzchen Aug 23, 2016
cfbbf33
Fix old `User.external_identity` model to support for merging externa…
cslzchen Aug 23, 2016
94f6b71
User can add more than one external id (fix the overwrite issue).
cslzchen Aug 23, 2016
3fa7b15
Fix key error in `User.external_identity`.
cslzchen Aug 24, 2016
fe6b1f8
Merge remote-tracking branch 'cos/pr/6200' into HEAD
mfraezz Aug 24, 2016
a0a88dd
Remove duplicate save call
mfraezz Aug 24, 2016
7d845e0
Fix external_identity preservation during merge
mfraezz Aug 24, 2016
0aa08eb
Add merge test for external_identity
mfraezz Aug 24, 2016
967d4c6
external account cancel option to avoid login loop
icereval Aug 24, 2016
86724d3
Prevent possible KeyError if fields omitted
mfraezz Aug 24, 2016
5ac4c83
[Feature] Add support for revoking ORCID login (#6212)
mfraezz Aug 25, 2016
19e7300
Send PATCH instead of DELETE
mfraezz Aug 25, 2016
94ba9b4
Add social linking language to email
mfraezz Aug 25, 2016
7f0f43f
Improve security of external identities
mfraezz Aug 25, 2016
b0f9129
Ensure unreg users are registered on confirm
mfraezz Aug 25, 2016
442cf3c
Add defaults for cas_response names
mfraezz Aug 25, 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
22 changes: 22 additions & 0 deletions framework/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
'get_user',
'check_password',
'authenticate',
'external_first_login_authenticate',
'logout',
'register_unconfirmed',
]
Expand Down Expand Up @@ -51,6 +52,27 @@ def authenticate(user, access_token, response):
return response


def external_first_login_authenticate(user, response):
"""
Create a special unauthenticated session for user login through external identity provider for the first time.

:param user: the user with external credential
:param response: the response to return
:return: the response
"""

data = session.data if session._get_current_object() else {}
data.update({
'auth_user_external_id_provider': user['external_id_provider'],
'auth_user_external_id': user['external_id'],
'auth_user_fullname': user['fullname'],
'auth_user_access_token': user['access_token'],
'auth_user_external_first_login': True,
})
response = create_session(response, data=data)
return response


def logout():
"""Clear users' session(s) and log them out of OSF."""

Expand Down
100 changes: 91 additions & 9 deletions framework/auth/cas.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import requests

from framework.auth import User
from framework.auth import authenticate
from framework.auth import authenticate, external_first_login_authenticate
from framework.auth.core import get_user
from framework.flask import redirect
from framework.exceptions import HTTPError
from website import settings
Expand Down Expand Up @@ -235,7 +236,7 @@ def make_response_from_ticket(ticket, service_url):

:param str ticket: CAS service ticket
:param str service_url: Service URL from which the authentication request originates
:return:
:return: redirect response
"""

service_furl = furl.furl(service_url)
Expand All @@ -244,11 +245,92 @@ def make_response_from_ticket(ticket, service_url):
client = get_client()
cas_resp = client.service_validate(ticket, service_furl.url)
if cas_resp.authenticated:
user = User.load(cas_resp.user)
# if we successfully authenticate and a verification key is present, invalidate it
if user.verification_key:
user.verification_key = None
user.save()
return authenticate(user, access_token=cas_resp.attributes['accessToken'], response=redirect(service_furl.url))
# Ticket could not be validated, unauthorized.
user, external_credential, action = get_user_from_cas_resp(cas_resp)
# user found and authenticated
if user and action == 'authenticate':
# if we successfully authenticate and a verification key is present, invalidate it
if user.verification_key:
user.verification_key = None
user.save()
return authenticate(
user,
cas_resp.attributes['accessToken'],
redirect(service_furl.url)
)
# first time login from external identity provider
if not user and external_credential and action == 'external_first_login':
from website.util import web_url_for
# TODO: [#OSF-6935] verify both names are in attributes, which should be handled in CAS
user = {
'external_id_provider': external_credential['provider'],
'external_id': external_credential['id'],
'fullname': '{} {}'.format(cas_resp.attributes.get('given-names', ''), cas_resp.attributes.get('family-name', '')),
'access_token': cas_resp.attributes['accessToken'],
}
return external_first_login_authenticate(
user,
redirect(web_url_for('external_login_email_get'))
)
# Unauthorized: ticket could not be validated, or user does not exist.
return redirect(service_furl.url)


def get_user_from_cas_resp(cas_resp):
"""
Given a CAS service validation response, attempt to retrieve user information and next action.

:param cas_resp: the cas service validation response
:return: the user, the external_credential, and the next action
"""

if cas_resp.user:
user = User.load(cas_resp.user)
# cas returns a valid OSF user id
if user:
return user, None, 'authenticate'
# cas does not return a valid OSF user id
else:
external_credential = validate_external_credential(cas_resp.user)
# invalid cas response
if not external_credential:
return None, None, None
# cas returns a valid external credential
user = get_user(external_id_provider=external_credential['provider'],
external_id=external_credential['id'])
# existing user found
if user:
return user, external_credential, 'authenticate'
# user first time login through external identity provider
else:
return None, external_credential, 'external_first_login'


def validate_external_credential(external_credential):
"""
Validate the external credential, a string which is composed of the profile name and the technical identifier
of the external provider, separated by `#`. Return the provider and id on success.

:param external_credential: the external credential string
:return: provider and id

"""
# wrong format
if not external_credential or '#' not in external_credential:
return False

profile_name, technical_id = external_credential.split('#', 1)

# invalid external identity provider
if profile_name not in settings.EXTERNAL_IDENTITY_PROFILE:
return False

# invalid external id
if len(technical_id) <= 0:
return False

provider = settings.EXTERNAL_IDENTITY_PROFILE[profile_name]

return {
'provider': provider,
'id': technical_id,
}
86 changes: 68 additions & 18 deletions framework/auth/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,32 @@ def _get_current_user():


# 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.

:return: The instance of User requested
:rtype: User or None
def get_user(email=None, password=None, verification_key=None, external_id_provider=None, external_id=None):
"""
Get an instance of User matching the provided params.

1. email
2. email and password
3. verification_key
4. oauth_provider and oauth_id

:param email: user's email
:param password: user's password
:param verification_key: the verification key
:param external_id_provider: the oauth provider
:param external_id: the oauth id
:rtype bool
"""
# 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 email:
email = email.strip().lower()
query_list.append(Q('emails', 'eq', email) | Q('username', 'eq', email))

if password:
password = password.strip()
try:
Expand All @@ -134,8 +145,13 @@ def get_user(email=None, password=None, verification_key=None):
if user and not user.check_password(password):
return False
return user

if verification_key:
query_list.append(Q('verification_key', 'eq', verification_key))

if external_id_provider and external_id:
query_list.append(Q('external_identity.{}.{}'.format(external_id_provider, external_id), 'eq', 'VERIFIED'))

try:
query = query_list[0]
for query_part in query_list[1:]:
Expand Down Expand Up @@ -350,6 +366,16 @@ class User(GuidStoredObject, AddonModelMixin):
family_name = fields.StringField()
suffix = fields.StringField()

# identity for user logged in through external idp
external_identity = fields.DictionaryField()
# Format: {
# <external_id_provider>: {
# <external_id>: <status from ('VERIFIED, 'CREATE', 'LINK')>,
# ...
# },
# ...
# }

# Employment history
jobs = fields.DictionaryField(list=True, validate=validate_history_item)
# Format: {
Expand Down Expand Up @@ -481,16 +507,18 @@ def create(cls, username, password, fullname):
return user

@classmethod
def create_unconfirmed(cls, username, password, fullname, do_confirm=True,
campaign=None):
def create_unconfirmed(cls, username, password, fullname, external_identity=None,
do_confirm=True, campaign=None):
"""Create a new user who has begun registration but needs to verify
their primary email address (username).
"""
user = cls.create(username, password, fullname)
user.add_unconfirmed_email(username)
user.add_unconfirmed_email(username, external_identity=external_identity)
user.is_registered = False
if external_identity:
user.external_identity.update(external_identity)
if campaign:
# needed to prevent cirular import
# needed to prevent circular import
from framework.auth.campaigns import system_tag_for_campaign # skipci
user.system_tags.append(system_tag_for_campaign(campaign))
return user
Expand Down Expand Up @@ -751,7 +779,7 @@ def _set_email_token_expiration(self, token, expiration=None):
self.email_verifications[token]['expiration'] = expiration
return expiration

def add_unconfirmed_email(self, email, expiration=None):
def add_unconfirmed_email(self, email, expiration=None, external_identity=None):
"""Add an email verification token for a given email."""

# TODO: This is technically not compliant with RFC 822, which requires
Expand All @@ -761,7 +789,7 @@ def add_unconfirmed_email(self, email, expiration=None):
# ref: https://tools.ietf.org/html/rfc822#section-6
email = email.lower().strip()

if email in self.emails:
if not external_identity and email in self.emails:
raise ValueError('Email already confirmed to this user.')

utils.validate_email(email)
Expand All @@ -777,8 +805,11 @@ def add_unconfirmed_email(self, email, expiration=None):
self.email_verifications = {}

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

Expand Down Expand Up @@ -838,15 +869,19 @@ 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, external_id_provider=None):
"""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)
return '{0}confirm/{1}/{2}/'.format(base, self._primary_key, token)

if external_id_provider:
return '{0}confirm/external/{1}/{2}/'.format(base, self._primary_key, token)
else:
return '{0}confirm/{1}/{2}/'.format(base, self._primary_key, token)

def get_unconfirmed_email_for_token(self, token):
"""Return email if valid.
Expand Down Expand Up @@ -1369,6 +1404,21 @@ def merge_user(self, user):
self.affiliated_institutions.append(institution)
user._affiliated_institutions = []

for service in user.external_identity:
for service_id in user.external_identity[service].iterkeys():
if not (service_id in self.external_identity.get(service, '') and self.external_identity[service][service_id] == 'VERIFIED'):
# Prevent 'CREATE', merging user has already been created.
status = 'VERIFIED' if user.external_identity[service][service_id] == 'VERIFIED' else 'LINK'
if self.external_identity.get(service):
self.external_identity[service].update(
{service_id: status}
)
else:
self.external_identity[service] = {
service_id: status
}
user.external_identity = {}

# FOREIGN FIELDS
for watched in user.watched:
if watched not in self.watched:
Expand Down
1 change: 1 addition & 0 deletions framework/auth/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ class ResetPasswordForm(Form):
class SetEmailAndPasswordForm(ResetPasswordForm):
token = HiddenField()


class SignInForm(Form):
username = email_field
password = password_field
Expand Down
22 changes: 22 additions & 0 deletions framework/auth/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import re
from nameparser.parser import HumanName
from modularodm.exceptions import ValidationError
from modularodm import Q

# email verification adopted from django. For licence information, see NOTICE
USER_REGEX = re.compile(
Expand Down Expand Up @@ -75,3 +76,24 @@ def privacy_info_handle(info, anonymous, name=False):
if anonymous:
return 'A user' if name else ''
return info


def ensure_external_identity_uniqueness(provider, identity, user=None):
from framework.auth.core import User # avoid circular import

users_with_identity = User.find(Q('external_identity.{}.{}'.format(provider, identity), 'ne', None))
for existing_user in users_with_identity:
if user and user._id == existing_user._id:
continue
if existing_user.external_identity[provider][identity] == 'VERIFIED':
if user and user.external_identity.get(provider, {}).get(identity, {}):
user.external_identity[provider].pop(identity)
if user.external_identity[provider] == {}:
user.external_identity.pop(provider)
user.save() # Note: This won't work in v2 because it rolls back transactions when status >= 400
raise ValidationError('Another user has already claimed this external identity')
existing_user.external_identity[provider].pop(identity)
if existing_user.external_identity[provider] == {}:
existing_user.external_identity.pop(provider)
existing_user.save()
return
Loading