import json
import os
from flask_babel import lazy_gettext as lgt
from api.authenticator import (
OAuthAuthenticationProvider,
OAuthController,
PatronData,
)
from core.model import ExternalIntegration
from core.util.http import HTTP
from core.util.problem_detail import ProblemDetail
from core.util.string_helpers import base64
from api.problem_details import INVALID_CREDENTIALS
UNSUPPORTED_CLEVER_USER_TYPE = ProblemDetail(
"http://librarysimplified.org/terms/problem/unsupported-clever-user-type",
401,
lgt("Your Clever user type is not supported."),
lgt("Your Clever user type is not supported. You can request a code from First Book instead"),
)
CLEVER_NOT_ELIGIBLE = ProblemDetail(
"http://librarysimplified.org/terms/problem/clever-not-eligible",
401,
lgt("Your Clever account is not eligible to access this application."),
lgt("Your Clever account is not eligible to access this application."),
)
CLEVER_UNKNOWN_SCHOOL = ProblemDetail(
"http://librarysimplified.org/terms/problem/clever-unknown-school",
401,
lgt("Clever did not provide the necessary information about your school to verify eligibility."),
lgt("Clever did not provide the necessary information about your school to verify eligibility."),
)
# Load Title I NCES ID data from json.
TITLE_I_NCES_IDS = None
clever_dir = os.path.split(__file__)[0]
with open('%s/title_i.json' % clever_dir) as f:
json_data = f.read()
TITLE_I_NCES_IDS = json.loads(json_data)
# NCES ID not guaranteed, and returns empty string
# TODO fix this for production need to check and reject empty nces id
TITLE_I_NCES_IDS.append('')
CLEVER_GRADE_TO_EXTERNAL_TYPE_MAP = {
"InfantToddler": "E", # Early
"Preschool": "E",
"PreKindergarten": "E",
"TransitionalKindergarten": "E",
"Kindergarten": "E",
"1": "E",
"2": "E",
"3": "E",
"4": "E",
"5": "E",
"6": "M", # Middle
"7": "M",
"8": "M",
"9": "H", # High
"10": "H",
"11": "H",
"12": "H",
"13": "H",
"PostGraduate": "H",
"Other": None, # Indeterminate
"Ungraded": None,
}
[docs]def external_type_from_clever_grade(grade):
"""Maps a 'grade' value returned by the Clever API for student users to an external_type"""
return CLEVER_GRADE_TO_EXTERNAL_TYPE_MAP.get(grade, None)
[docs]class CleverAuthenticationAPI(OAuthAuthenticationProvider):
URI = "http://librarysimplified.org/terms/auth/clever"
NAME = 'Clever'
DESCRIPTION = lgt("""
An authentication service for Open eBooks that uses Clever as an
OAuth provider.""")
LOGIN_BUTTON_IMAGE = "CleverLoginButton280.png"
SETTINGS = [
{"key": ExternalIntegration.USERNAME,
"label": lgt("Client ID"), "required": True},
{"key": ExternalIntegration.PASSWORD, "label": lgt(
"Client Secret"), "required": True},
] + OAuthAuthenticationProvider.SETTINGS
# Unlike other authentication providers, external type regular expression
# doesn't make sense for Clever. This removes the LIBRARY_SETTINGS from the
# parent class.
LIBRARY_SETTINGS = []
TOKEN_TYPE = "Clever token"
TOKEN_DATA_SOURCE_NAME = 'Clever'
EXTERNAL_AUTHENTICATE_URL = (
"https://clever.com/oauth/authorize?response_type=code&client_id=%(client_id)s&redirect_uri=%(oauth_callback_url)s&state=%(state)s"
)
CLEVER_TOKEN_URL = "https://clever.com/oauth/tokens"
# Not all calls should be made to a versioned endpoint. Please see the
# Clever API documentation when adding a new endpoint.
CLEVER_API_BASE_URL = "https://api.clever.com"
CLEVER_API_VERSION = "3.0"
CLEVER_API_VERSIONED_URL = f"{CLEVER_API_BASE_URL}/v{CLEVER_API_VERSION}"
# To check Title I status we need state, which is associated with
# a school in Clever's API. Any users at the district-level will
# need to get a code from First Book instead.
SUPPORTED_USER_TYPES = ['student', 'teacher', ]
# Begin implementations of OAuthAuthenticationProvider abstract
# methods.
[docs] def oauth_callback(self, _db, code):
"""Verify the incoming parameters with the OAuth provider. Exchange
the authorization code for an access token. Create or look up
appropriate database records.
:param code: The authorization code generated by the
authorization server, as per section 4.1.2 of RFC 6749. This
method will exchange the authorization code for an access
token.
:return: A ProblemDetail if there's a problem. Otherwise, a
3-tuple (Credential, Patron, PatronData). The Credential
contains the access token provided by the OAuth provider. The
Patron object represents the authenticated Patron, and the
PatronData object includes information about the patron
obtained from the OAuth provider which cannot be stored in the
circulation manager's database, but which should be passed on
to the client.
"""
# Ask the OAuth provider to verify the code that was passed
# in. This will give us a bearer token we can use to look up
# detailed patron information.
token = self.remote_exchange_code_for_bearer_token(_db, code)
if isinstance(token, ProblemDetail):
return token
# Now that we have a bearer token, use it to look up patron
# information.
patrondata = self.remote_patron_lookup(token)
if isinstance(patrondata, ProblemDetail):
return patrondata
# Convert the PatronData into a Patron object.
patron, is_new = patrondata.get_or_create_patron(_db, self.library_id)
if is_new:
patrondata.is_new = True
# Create a credential for the Patron.
credential, is_new = self.create_token(_db, patron, token)
return credential, patron, patrondata
# End implementations of OAuthAuthenticationProvider abstract
# methods.
[docs] def remote_exchange_code_for_bearer_token(self, _db, code):
"""Ask the OAuth provider to convert a code (passed in to the OAuth
callback) into a bearer token.
We can use the bearer token to act on behalf of a specific
patron. It also gives us confidence that the patron
authenticated correctly with Clever.
:return: A ProblemDetail if there's a problem; otherwise, the
bearer token.
"""
payload = self._remote_exchange_payload(_db, code)
authorization = base64.b64encode(
self.client_id + ":" + self.client_secret)
headers = {
'Authorization': 'Basic %s' % authorization,
'content-type': 'application/json',
'accept': 'application/json'
}
response = self._get_token(payload, headers)
invalid = INVALID_CREDENTIALS.detailed(
lgt("A valid Clever login is required."))
if not response:
return invalid
token = response.get('access_token', None)
if not token:
return invalid
return token
def _remote_exchange_payload(self, _db, code):
library = self.library(_db)
return dict(
code=code,
grant_type='authorization_code',
redirect_uri=OAuthController.oauth_authentication_callback_url(
library.short_name
)
)
[docs] def remote_patron_lookup(self, token):
"""Use a bearer token for a patron to look up that patron's Clever
record through the Clever API.
This is the only method that has access to a patron's personal
information as provided by Clever. Here's an inventory of the
information we process and what happens to it:
* The Clever 'id' associated with this patron is passed out of
this method through the PatronData object, and persisted to
two database fields: 'patrons.external_identifier' and
'patrons.authorization_identifier'.
As far as we know, the Clever ID is an opaque reference
which uniquely identifies a given patron but contains no
personal information about them.
* If the patron is a student, their grade level
("Kindergarten" through "12") is converted into an Open
eBooks patron type ("E" for "Early Grades", "M" for "Middle
Grades", or "H" for "High School"). This is stored in the
PatronData object returned from this method, and persisted
to the database field 'patrons.external_type'. If the patron
is not a student, their Open eBooks patron type is set to
"A" for "All Access").
This system does not track a patron's grade level or store
it in the database. Only the coarser-grained Open eBooks
patron type is tracked. This is used to show age-appropriate
books to the patron.
* The internal Clever ID of the patron's school is used to
make a _second_ Clever API request to get information about
the school. From that, we get the school's NCES ID, which we
cross-check against data we've gathered separately to
validate the school's Title I status. The school ID and NCES
ID are not stored in the PatronData object or persisted to
the database. Any patron who ends up in the database is
presumed to have passed this check.
To summarize, an opaque ID associated with the patron is
persisted to the database, as is a coarse-grained indicator of
the patron's age. No other information about the patron makes
it out of this method.
:return: A ProblemDetail if there's a problem. Otherwise, a PatronData
with the data listed above.
"""
bearer_headers = {'Authorization': 'Bearer %s' % token}
result = self._get(self.CLEVER_API_VERSIONED_URL +
'/me', bearer_headers)
data = result.get('data', {}) or {}
identifier = data.get('id', None)
if not identifier:
return INVALID_CREDENTIALS.detailed(lgt("A valid Clever login is required."))
links = result['links']
user_link = [link for link in links if link['rel']
== 'canonical'][0]['uri']
district_link = [link for link in links if link['rel']
== 'district'][0]['uri']
# The canonical link includes the API version, so we use the base URL.
user = self._get(self.CLEVER_API_BASE_URL + user_link, bearer_headers)
user_data = user['data']
user_role_dict = user_data.get('roles')
user_types = list(user_role_dict.keys())
if user_types[0] not in self.SUPPORTED_USER_TYPES:
return UNSUPPORTED_CLEVER_USER_TYPE
school_id = user_role_dict[user_types[0]]['school']
school = self._get(
f"{self.CLEVER_API_VERSIONED_URL}/schools/{school_id}", bearer_headers)
school_data = school['data']
school_nces_id = school_data['nces_id']
# TODO: check student free and reduced lunch status as well
if not school_nces_id:
# Set empty for demo district
district = self._get(self.CLEVER_API_BASE_URL +
district_link, bearer_headers)
district_data = district['data']
district_name = district_data['name']
# Reject non-Demo blank nces_id's
if not '#DEMO' in district_name:
self.log.error(
"No NCES ID found in Clever school data: %s", repr(school))
return CLEVER_UNKNOWN_SCHOOL
if school_nces_id not in TITLE_I_NCES_IDS:
self.log.info("%s didn't match a Title I NCES ID", school_nces_id)
return CLEVER_NOT_ELIGIBLE
external_type = None
if user_types[0] == 'student':
# We need to be able to assign an external_type to students, so that they
# get the correct content level. To do so we rely on the grade field in the
# user data we get back from Clever. Their API doesn't guarantee that the
# grade field is present, so we supply a default.
try:
student_grade = user_data['roles'][user_types[0]]['grade']
except:
student_grade = None
if not student_grade: # If no grade was supplied, log the school/student
msg = (f"CLEVER_UNKNOWN_PATRON_GRADE: School with NCES ID {school_nces_id} "
f"did not supply grade for student {user_data.get('id')}")
self.log.info(msg)
# If we can't determine a type from the grade level, set to "A"
external_type = external_type_from_clever_grade(student_grade)
else:
external_type = "A" # Non-students get content level "A"
patrondata = PatronData(
permanent_id=identifier,
authorization_identifier=identifier,
external_type=external_type,
complete=True,
is_new=False,
)
return patrondata
def _get_token(self, payload, headers):
response = HTTP.post_with_timeout(
self.CLEVER_TOKEN_URL, json.dumps(payload), headers=headers
)
return response.json()
def _get(self, url, headers):
return HTTP.get_with_timeout(url, headers=headers).json()
AuthenticationProvider = CleverAuthenticationAPI