-
Notifications
You must be signed in to change notification settings - Fork 19
/
Copy pathauthenticator.py
2687 lines (2296 loc) · 110 KB
/
authenticator.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import datetime
import importlib
import json
import logging
import re
import urllib
from abc import ABCMeta
import flask
import jwt
from flask import (
redirect,
url_for)
from flask_babel import lazy_gettext as _
from money import Money
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.sql.expression import or_
from werkzeug.datastructures import Headers
from api.adobe_vendor_id import AuthdataUtility
from api.annotations import AnnotationWriter
from api.announcements import Announcements
from api.custom_patron_catalog import CustomPatronCatalog
from api.opds import LibraryAnnotator
from api.saml.configuration import SAMLConfiguration
from config import (
Configuration,
CannotLoadConfiguration,
IntegrationException,
)
from core.model import (
get_one,
get_one_or_create,
CirculationEvent,
ConfigurationSetting,
Credential,
DataSource,
ExternalIntegration,
Library,
Patron,
PatronProfileStorage,
Session,
)
from core.opds import OPDSFeed
from core.selftest import (
HasSelfTests,
)
from core.user_profile import ProfileController
from core.util.authentication_for_opds import (
AuthenticationForOPDSDocument,
OPDSAuthenticationFlow,
)
from core.util.http import RemoteIntegrationException
from core.util.problem_detail import (
ProblemDetail,
json as pd_json,
)
from problem_details import *
from util.patron import PatronUtility
class CannotCreateLocalPatron(Exception):
"""A remote system provided information about a patron, but we could
not put it into our database schema.
Probably because it was too vague.
"""
class PatronData(object):
"""A container for basic information about a patron.
Like Metadata and CirculationData, this offers a layer of
abstraction between various account managment systems and the
circulation manager database. Unlike with those classes, some of
this data cannot be written to the database for data retention
reasons. But it can be passed from the account management system
to the client application.
"""
# Used to distinguish between "value has been unset" and "value
# has not changed".
class NoValue(object):
def __nonzero__(self):
"""We want this object to act like None or False."""
return False
NO_VALUE = NoValue()
# Reasons why a patron might be blocked.
UNKNOWN_BLOCK = 'unknown'
CARD_REPORTED_LOST = 'card reported lost'
EXCESSIVE_FINES = 'excessive fines'
EXCESSIVE_FEES = 'excessive fees'
NO_BORROWING_PRIVILEGES = 'no borrowing privileges'
TOO_MANY_LOANS = 'too many active loans'
TOO_MANY_RENEWALS = 'too many renewals'
TOO_MANY_OVERDUE = 'too many items overdue'
TOO_MANY_LOST = 'too many items lost'
# Patron is being billed for too many items (as opposed to
# excessive fines, which means patron's fines have exceeded a
# certain amount).
TOO_MANY_ITEMS_BILLED = 'too many items billed'
# Patron was asked to return an item so someone else could borrow it,
# but didn't return the item.
RECALL_OVERDUE = 'recall overdue'
def __init__(self,
permanent_id=None,
authorization_identifier=None,
username=None,
personal_name=None,
email_address=None,
authorization_expires=None,
external_type=None,
fines=None,
block_reason=None,
library_identifier=None,
neighborhood=None,
cached_neighborhood=None,
complete=True,
):
"""Store basic information about a patron.
:param permanent_id: A unique and unchanging identifier for
the patron, as used by the account management system and
probably never seen by the patron. This is not required, but
it is very useful to have because other identifiers tend to
change.
:param authorization_identifier: One or more assigned
identifiers (usually numeric) the patron may use to identify
themselves. This may be a list, because patrons may have
multiple authorization identifiers. For example, an NYPL
patron may have an NYPL library card, a Brooklyn Public
Library card, and an IDNYC card: three different barcodes that
all authenticate the same patron.
The circulation manager does the best it can to maintain
continuity of the patron's identity in the face of changes to
this list. The two assumptions made are:
1) A patron tends to pick one of their authorization
identifiers and stick with it until it stops working, rather
than switching back and forth. This identifier is the one
stored in Patron.authorization_identifier.
2) In the absence of any other information, the authorization
identifier at the _beginning_ of this list is the one that
should be stored in Patron.authorization_identifier.
:param username: An identifier (usually alphanumeric) chosen
by the patron and used to identify themselves.
:param personal_name: The name of the patron. This information
is not stored in the circulation manager database but may be
passed on to the client.
:param authorization_expires: The date, if any, at which the patron's
authorization to borrow items from the library expires.
:param external_type: A string classifying the patron
according to some library-specific scheme.
:param fines: A Money object representing the amount the
patron owes in fines. Note that only the value portion of the
Money object will be stored in the database; the currency portion
will be ignored. (e.g. "20 USD" will become 20)
:param block_reason: A string indicating why the patron is
blocked from borrowing items. (Even if this is set to None, it
may turn out the patron cannot borrow items because their card
has expired or their fines are excessive.)
:param library_identifier: A string pulled from the ILS that
is used to determine if this user belongs to the current library.
:param neighborhood: A string pulled from the ILS that
identifies the patron's geographic location in a deliberately
imprecise way that makes sense to the library -- maybe the
patron's ZIP code or the name of their home branch. This data
is never stored in a way that can be associated with an
individual patron. Depending on library policy, this data may
be associated with circulation events -- but a circulation
event is not associated with the patron who triggered it.
:param cached_neighborhood: This is the same as neighborhood,
but it _will_ be cached in the patron's database record, for
up to twelve hours. This should only be used by ILS systems
that would have performance problems fetching patron
neighborhood on demand.
If cached_neighborhood is set but neighborhood is not,
cached_neighborhood will be used as neighborhood.
:param complete: Does this PatronData represent the most
complete data we are likely to get for this patron from this
data source, or is it an abbreviated version of more complete
data we could get some other way?
"""
self.permanent_id = permanent_id
self.set_authorization_identifier(authorization_identifier)
self.username = username
self.authorization_expires = authorization_expires
self.external_type = external_type
self.fines = fines
self.block_reason = block_reason
self.library_identifier = library_identifier
self.complete = complete
# We do not store personal_name in the database, but we provide
# it to the client if possible.
self.personal_name = personal_name
# We do not store email address in the database, but we need
# to have it available for notifications.
self.email_address = email_address
# If cached_neighborhood (cached in the database) is provided
# but neighborhood (destroyed at end of request) is not, use
# cached_neighborhood as neighborhood.
self.neighborhood = neighborhood or cached_neighborhood
self.cached_neighborhood = cached_neighborhood
def __eq__(self, other):
"""
Compares two PatronData objects
:param other: PatronData object
:type other: PatronData
:return: Boolean value indicating whether two items are equal
:rtype: bool
"""
if not isinstance(other, PatronData):
return False
return \
self.permanent_id == other.permanent_id and \
self.username == other.username and \
self.authorization_expires == other.authorization_expires and \
self.external_type == other.external_type and \
self.fines == other.fines and \
self.block_reason == other.block_reason and \
self.library_identifier == other.library_identifier and \
self.complete == other.complete and \
self.personal_name == other.personal_name and \
self.email_address == other.email_address and \
self.neighborhood == other.neighborhood and \
self.cached_neighborhood == other.cached_neighborhood
def __repr__(self):
return "<PatronData permanent_id=%r authorization_identifier=%r username=%r>" % (
self.permanent_id, self.authorization_identifier,
self.username
)
@hybrid_property
def fines(self):
return self._fines
@fines.setter
def fines(self, value):
"""When setting patron fines, only store the numeric portion of
a Money object.
"""
if isinstance(value, Money):
value = value.amount
self._fines = value
def apply(self, patron):
"""Take the portion of this data that can be stored in the database
and write it to the given Patron record.
"""
# First, handle the easy stuff -- everything except authorization
# identifier.
self.set_value(patron, 'external_identifier', self.permanent_id)
self.set_value(patron, 'username', self.username)
self.set_value(patron, 'external_type', self.external_type)
self.set_value(patron, 'authorization_expires',
self.authorization_expires)
self.set_value(patron, 'fines', self.fines)
self.set_value(patron, 'block_reason', self.block_reason)
self.set_value(patron, 'cached_neighborhood', self.cached_neighborhood)
# Patron neighborhood (not a database field) is set as a
# convenience.
patron.neighborhood = self.neighborhood or self.cached_neighborhood
# Now handle authorization identifier.
if self.complete:
# We have a complete picture of data from the ILS,
# so we can be comfortable setting the authorization
# identifier if necessary.
if (patron.authorization_identifier is None or
patron.authorization_identifier not in
self.authorization_identifiers):
# The patron's authorization_identifier is not set, or is
# set to a value that is no longer valid. Set it again.
self.set_value(patron, 'authorization_identifier',
self.authorization_identifier)
elif patron.authorization_identifier != self.authorization_identifier:
# It looks like we need to change
# Patron.authorization_identifier. However, we do not
# have a complete picture of the patron's record. We don't
# know if the current identifier is better than the one
# the patron provided.
# However, we can provisionally
# Patron.authorization_identifier if it's not already set.
if not patron.authorization_identifier:
self.set_value(patron, 'authorization_identifier',
self.authorization_identifier)
if patron.username and self.authorization_identifier == patron.username:
# This should be fine. It looks like the patron's
# .authorization_identifier is set to their barcode,
# and they authenticated with their username. In this
# case we can be confident there is no need to change
# Patron.authorization_identifier.
pass
else:
# We don't know what's going on and we need to sync
# with the remote ASAP.
patron.last_external_sync = None
# Note that we do not store personal_name or email_address in the
# database model.
if self.complete:
# We got a complete dataset from the ILS, which is what an
# external sync does, so we can reset the timer on
# external sync.
patron.last_external_sync = datetime.datetime.utcnow()
def set_value(self, patron, field_name, value):
if value is None:
# Do nothing
return
elif value is self.NO_VALUE:
# Unset a previous value.
value = None
setattr(patron, field_name, value)
def get_or_create_patron(self, _db, library_id, analytics=None):
"""Create a Patron with this information.
TODO: I'm concerned in the general case with race
conditions. It's theoretically possible that two newly created
patrons could have the same username or authorization
identifier, violating a uniqueness constraint. This could
happen if one was identified by permanent ID and the other had
no permanent ID and was identified by username. (This would
only come up if the authentication provider has permanent IDs
for some patrons but not others.)
Something similar can happen if the authentication provider
provides username and authorization identifier, but not
permanent ID, and the patron's authorization identifier (but
not their username) changes while two different circulation
manager authentication requests are pending.
When these race conditions do happen, I think the worst that
will happen is the second request will fail. But it's very
important that authorization providers give some unique,
preferably unchanging way of identifying patrons.
:param library_id: Database ID of the Library with which this
patron is associated.
:param analytics: Analytics instance to track the new patron
creation event.
"""
# We must be very careful when checking whether the patron
# already exists because three different fields might be in use
# as the patron identifier.
if self.permanent_id:
search_by = dict(external_identifier=self.permanent_id)
elif self.username:
search_by = dict(username=self.username)
elif self.authorization_identifier:
search_by = dict(
authorization_identifier=self.authorization_identifier
)
else:
raise CannotCreateLocalPatron(
"Cannot create patron without some way of identifying them uniquely."
)
search_by['library_id'] = library_id
__transaction = _db.begin_nested()
patron, is_new = get_one_or_create(_db, Patron, **search_by)
if is_new and analytics:
# Send out an analytics event to record the fact
# that a new patron was created.
analytics.collect_event(patron.library, None,
CirculationEvent.NEW_PATRON)
# This makes sure the Patron is brought into sync with the
# other fields of this PatronData object, regardless of
# whether or not it is newly created.
if patron:
self.apply(patron)
__transaction.commit()
return patron, is_new
@property
def to_response_parameters(self):
"""Return information about this patron which the client might
find useful.
This information will be sent to the client immediately after
a patron's credentials are verified by an OAuth provider.
"""
if self.personal_name:
return dict(name=self.personal_name)
return {}
@property
def to_dict(self):
"""Convert the information in this PatronData to a dictionary
which can be converted to JSON and sent out to a client.
"""
def scrub(value, default=None):
if value is self.NO_VALUE:
return default
return value
data = dict(
permanent_id=self.permanent_id,
authorization_identifier=self.authorization_identifier,
username=self.username,
external_type=self.external_type,
block_reason=self.block_reason,
personal_name=self.personal_name,
email_address = self.email_address
)
data = dict((k, scrub(v)) for k, v in data.items())
# Handle the data items that aren't just strings.
# A date
expires = scrub(self.authorization_expires)
if expires:
expires = self.authorization_expires.strftime("%Y-%m-%d")
data['authorization_expires'] = expires
# A Money
fines = scrub(self.fines)
if fines is not None:
fines = str(fines)
data['fines'] = fines
# A list
data['authorization_identifiers'] = scrub(
self.authorization_identifiers, []
)
return data
def set_authorization_identifier(self, authorization_identifier):
"""Helper method to set both .authorization_identifier
and .authorization_identifiers appropriately.
"""
# The first authorization identifier in the list is the one
# we should use for Patron.authorization_identifier, assuming
# Patron.authorization_identifier needs to be updated.
if isinstance(authorization_identifier, list):
authorization_identifiers = authorization_identifier
authorization_identifier = authorization_identifiers[0]
elif authorization_identifier is None:
authorization_identifiers = []
authorization_identifier = None
elif authorization_identifier is self.NO_VALUE:
authorization_identifiers = []
authorization_identifier = self.NO_VALUE
else:
authorization_identifiers = [authorization_identifier]
self.authorization_identifier = authorization_identifier
self.authorization_identifiers = authorization_identifiers
class CirculationPatronProfileStorage(PatronProfileStorage):
"""A patron profile storage that can also provide short client tokens"""
@property
def profile_document(self):
doc = super(CirculationPatronProfileStorage, self).profile_document
drm = []
links = []
device_link = {}
authdata = AuthdataUtility.from_config(self.patron.library)
if authdata:
vendor_id, token = authdata.short_client_token_for_patron(self.patron)
adobe_drm = {}
adobe_drm['drm:vendor'] = vendor_id
adobe_drm['drm:clientToken'] = token
adobe_drm['drm:scheme'] = "http://librarysimplified.org/terms/drm/scheme/ACS"
drm.append(adobe_drm)
device_link['rel'] = 'http://librarysimplified.org/terms/drm/rel/devices'
device_link['href'] = self.url_for(
"adobe_drm_devices", library_short_name=self.patron.library.short_name, _external=True
)
links.append(device_link)
annotations_link = dict(
rel="http://www.w3.org/ns/oa#annotationService",
type=AnnotationWriter.CONTENT_TYPE,
href=self.url_for('annotations', library_short_name=self.patron.library.short_name, _external=True)
)
links.append(annotations_link)
doc['links'] = links
if drm:
doc['drm'] = drm
return doc
class Authenticator(object):
"""Route requests to the appropriate LibraryAuthenticator.
"""
def __init__(self, _db, analytics=None):
self.library_authenticators = {}
self.populate_authenticators(_db, analytics)
@property
def current_library_short_name(self):
return flask.request.library.short_name
def populate_authenticators(self, _db, analytics):
for library in _db.query(Library):
self.library_authenticators[library.short_name] = LibraryAuthenticator.from_config(_db, library, analytics)
def invoke_authenticator_method(self, method_name, *args, **kwargs):
short_name = self.current_library_short_name
if short_name not in self.library_authenticators:
return LIBRARY_NOT_FOUND
return getattr(self.library_authenticators[short_name], method_name)(*args, **kwargs)
def authenticated_patron(self, _db, header):
return self.invoke_authenticator_method("authenticated_patron", _db, header)
def create_authentication_document(self):
return self.invoke_authenticator_method("create_authentication_document")
def create_authentication_headers(self):
return self.invoke_authenticator_method("create_authentication_headers")
def get_credential_from_header(self, header):
return self.invoke_authenticator_method("get_credential_from_header", header)
def create_bearer_token(self, *args, **kwargs):
return self.invoke_authenticator_method(
"create_bearer_token", *args, **kwargs
)
def oauth_provider_lookup(self, *args, **kwargs):
return self.invoke_authenticator_method(
"oauth_provider_lookup", *args, **kwargs
)
def saml_provider_lookup(self, *args, **kwargs):
return self.invoke_authenticator_method(
"saml_provider_lookup", *args, **kwargs
)
def decode_bearer_token(self, *args, **kwargs):
return self.invoke_authenticator_method(
"decode_bearer_token", *args, **kwargs
)
class LibraryAuthenticator(object):
"""Use the registered AuthenticationProviders to turn incoming
credentials into Patron objects.
"""
@classmethod
def from_config(cls, _db, library, analytics=None, custom_catalog_source=CustomPatronCatalog):
"""Initialize an Authenticator for the given Library based on its
configured ExternalIntegrations.
:param custom_catalog_source: The lookup class for CustomPatronCatalogs.
Intended for mocking during tests.
"""
custom_catalog = custom_catalog_source.for_library(library)
# Start with an empty list of authenticators.
authenticator = cls(
_db=_db, library=library,
authentication_document_annotator=custom_catalog
)
# Find all of this library's ExternalIntegrations set up with
# the goal of authenticating patrons.
integrations = ExternalIntegration.for_library_and_goal(
_db, library, ExternalIntegration.PATRON_AUTH_GOAL
)
# Turn each such ExternalIntegration into an
# AuthenticationProvider.
for integration in integrations:
try:
authenticator.register_provider(integration, analytics)
except (ImportError, CannotLoadConfiguration), e:
# These are the two types of error that might be caused
# by misconfiguration, as opposed to bad code.
logging.error(
"Error registering authentication provider %r (%s)",
integration.name, integration.protocol,
exc_info=e
)
authenticator.initialization_exceptions[integration.id] = e
if authenticator.oauth_providers_by_name or authenticator.saml_providers_by_name:
# NOTE: this will immediately commit the database session,
# which may not be what you want during a test. To avoid
# this, you can create the bearer token signing secret as
# a regular site-wide ConfigurationSetting.
authenticator.bearer_token_signing_secret = BearerTokenSigner.bearer_token_signing_secret(
_db
)
authenticator.assert_ready_for_token_signing()
return authenticator
def __init__(self, _db, library, basic_auth_provider=None,
oauth_providers=None,
saml_providers=None,
bearer_token_signing_secret=None,
authentication_document_annotator=None,
):
"""Initialize a LibraryAuthenticator from a list of AuthenticationProviders.
:param _db: A database session (probably a scoped session, which is
why we can't derive it from `library`)
:param library: The Library to which this LibraryAuthenticator guards
access.
:param basic_auth_provider: The AuthenticatonProvider that handles
HTTP Basic Auth requests.
:param oauth_providers: A list of AuthenticationProviders that handle
OAuth requests.
:param saml_providers: A list of AuthenticationProviders that handle
SAML requests.
:param bearer_token_signing_secret: The secret to use when
signing JWTs for use as bearer tokens.
"""
self._db = _db
self.library_id = library.id
self.library_uuid = library.uuid
self.library_name = library.name
self.library_short_name = library.short_name
self.authentication_document_annotator=authentication_document_annotator
self.basic_auth_provider = basic_auth_provider
self.oauth_providers_by_name = dict()
self.saml_providers_by_name = dict()
self.bearer_token_signing_secret = bearer_token_signing_secret
self.initialization_exceptions = dict()
# Make sure there's a public/private key pair for this
# library. This makes it possible to register the library with
# discovery services. Store the public key here for
# convenience; leave the private key in the database.
self.public_key, ignore = self.key_pair
if oauth_providers:
for provider in oauth_providers:
self.oauth_providers_by_name[provider.NAME] = provider
if saml_providers:
for provider in saml_providers:
self.saml_providers_by_name[provider.NAME] = provider
self.assert_ready_for_token_signing()
@property
def supports_patron_authentication(self):
"""Does this library have any way of authenticating patrons at all?"""
if self.basic_auth_provider or self.oauth_providers_by_name or self.saml_providers_by_name:
return True
return False
@property
def identifies_individuals(self):
"""Does this library require that individual patrons be identified?
Most libraries require authentication as an individual. Some
libraries don't identify patrons at all; others may have a way
of identifying the patron population without identifying
individuals, such as an IP gate.
If some of a library's authentication mechanisms identify individuals,
and others do not, the library does not identify individuals.
"""
if not self.supports_patron_authentication:
return False
matches = list(self.providers)
return matches and all(
[x.IDENTIFIES_INDIVIDUALS for x in matches]
)
@property
def library(self):
return Library.by_id(self._db, self.library_id)
def assert_ready_for_token_signing(self):
"""If this LibraryAuthenticator has OAuth providers, ensure that it
also has a secret it can use to sign bearer tokens.
"""
if self.oauth_providers_by_name and not self.bearer_token_signing_secret:
raise CannotLoadConfiguration(
_("OAuth providers are configured, but secret for signing bearer tokens is not.")
)
if self.saml_providers_by_name and not self.bearer_token_signing_secret:
raise CannotLoadConfiguration(
_("SAML providers are configured, but secret for signing bearer tokens is not.")
)
def register_provider(self, integration, analytics=None):
"""Turn an ExternalIntegration object into an AuthenticationProvider
object, and register it.
:param integration: An ExternalIntegration that configures
a way of authenticating patrons.
"""
if integration.goal != integration.PATRON_AUTH_GOAL:
raise CannotLoadConfiguration(
"Was asked to register an integration with goal=%s as though it were a way of authenticating patrons." % integration.goal
)
library = self.library
if library not in integration.libraries:
raise CannotLoadConfiguration(
"Was asked to register an integration with library %s, which doesn't use it." % library.name
)
module_name = integration.protocol
if not module_name:
# This should be impossible since protocol is not nullable.
raise CannotLoadConfiguration(
"Authentication provider configuration does not specify protocol."
)
provider_module = importlib.import_module(module_name)
provider_class = getattr(provider_module, "AuthenticationProvider", None)
if not provider_class:
raise CannotLoadConfiguration(
"Loaded module %s but could not find a class called AuthenticationProvider inside." % module_name
)
try:
provider = provider_class(self.library, integration, analytics)
except RemoteIntegrationException, e:
raise CannotLoadConfiguration(
"Could not instantiate %s authentication provider for library %s, possibly due to a network connection problem." % (
provider_class, self.library.short_name
)
)
return
if issubclass(provider_class, BasicAuthenticationProvider):
self.register_basic_auth_provider(provider)
# TODO: Run a self-test, or at least check that we have
# the ability to run one.
elif issubclass(provider_class, OAuthAuthenticationProvider):
self.register_oauth_provider(provider)
elif issubclass(provider_class, BaseSAMLAuthenticationProvider):
self.register_saml_provider(provider)
else:
raise CannotLoadConfiguration(
"Authentication provider %s is neither a BasicAuthenticationProvider nor an OAuthAuthenticationProvider. I can create it, but not sure where to put it." % provider_class
)
def register_basic_auth_provider(self, provider):
if (self.basic_auth_provider != None
and self.basic_auth_provider != provider):
raise CannotLoadConfiguration(
"Two basic auth providers configured"
)
self.basic_auth_provider = provider
def register_oauth_provider(self, provider):
already_registered = self.oauth_providers_by_name.get(
provider.NAME
)
if already_registered and already_registered != provider:
raise CannotLoadConfiguration(
'Two different OAuth providers claim the name "%s"' % (
provider.NAME
)
)
self.oauth_providers_by_name[provider.NAME] = provider
def register_saml_provider(self, provider):
already_registered = self.saml_providers_by_name.get(
provider.NAME
)
if already_registered and already_registered != provider:
raise CannotLoadConfiguration(
'Two different SAML providers claim the name "%s"' % (
provider.NAME
)
)
self.saml_providers_by_name[provider.NAME] = provider
@property
def providers(self):
"""An iterator over all registered AuthenticationProviders."""
if self.basic_auth_provider:
yield self.basic_auth_provider
for provider in self.oauth_providers_by_name.values():
yield provider
for provider in self.saml_providers_by_name.values():
yield provider
def authenticated_patron(self, _db, header):
"""Go from an Authorization header value to a Patron object.
:param header: If Basic Auth is in use, this is a dictionary
with 'user' and 'password' components, derived from the HTTP
header `Authorization`. Otherwise, this is the literal value
of the `Authorization` HTTP header.
:return: A Patron, if one can be authenticated. None, if the
credentials do not authenticate any particular patron. A
ProblemDetail if an error occurs.
"""
if (self.basic_auth_provider
and isinstance(header, dict) and 'username' in header):
# The patron wants to authenticate with the
# BasicAuthenticationProvider.
return self.basic_auth_provider.authenticated_patron(_db, header)
elif (self.oauth_providers_by_name
and isinstance(header, basestring)
and 'bearer' in header.lower()):
# The patron wants to use an
# OAuthAuthenticationProvider. Figure out which one.
try:
provider_name, provider_token = self.decode_bearer_token_from_header(
header
)
except jwt.exceptions.InvalidTokenError, e:
return INVALID_OAUTH_BEARER_TOKEN
provider = self.oauth_provider_lookup(provider_name)
if isinstance(provider, ProblemDetail):
# There was a problem turning the provider name into
# a registered OAuthAuthenticationProvider.
return provider
# Ask the OAuthAuthenticationProvider to turn its token
# into a Patron.
return provider.authenticated_patron(_db, provider_token)
elif (self.saml_providers_by_name
and isinstance(header, basestring)
and 'bearer' in header.lower()):
# The patron wants to use an
# SAMLAuthenticationProvider. Figure out which one.
try:
provider_name, provider_token = self.decode_bearer_token_from_header(
header
)
except jwt.exceptions.InvalidTokenError, e:
return INVALID_SAML_BEARER_TOKEN
provider = self.saml_provider_lookup(provider_name)
if isinstance(provider, ProblemDetail):
# There was a problem turning the provider name into
# a registered SAMLAuthenticationProvider.
return provider
# Ask the SAMLAuthenticationProvider to turn its token
# into a Patron.
return provider.authenticated_patron(_db, provider_token)
# We were unable to determine what was going on with the
# Authenticate header.
return UNSUPPORTED_AUTHENTICATION_MECHANISM
def get_credential_from_header(self, header):
"""Extract a password credential from a WWW-Authenticate header
(or equivalent).
This is used to pass on a patron's credential to a content provider,
such as Overdrive, which performs independent validation of
a patron's credentials.
:return: The patron's password, or None if not available.
"""
credential = None
for provider in self.providers:
credential = provider.get_credential_from_header(header)
if credential is not None:
break
return credential
def oauth_provider_lookup(self, provider_name):
"""Look up the OAuthAuthenticationProvider with the given name. If that
doesn't work, return an appropriate ProblemDetail.
"""
if not self.oauth_providers_by_name:
# We don't support OAuth at all.
return UNKNOWN_OAUTH_PROVIDER.detailed(
_("No OAuth providers are configured.")
)
if (not provider_name
or not provider_name in self.oauth_providers_by_name):
# The patron neglected to specify a provider, or specified
# one we don't support.
possibilities = ", ".join(self.oauth_providers_by_name.keys())
return UNKNOWN_OAUTH_PROVIDER.detailed(
UNKNOWN_OAUTH_PROVIDER.detail +
_(" The known providers are: %s") % possibilities
)
return self.oauth_providers_by_name[provider_name]
def saml_provider_lookup(self, provider_name):
"""Look up the SAMLAuthenticationProvider with the given name. If that
doesn't work, return an appropriate ProblemDetail.
"""
if not self.saml_providers_by_name:
# We don't support OAuth at all.
return UNKNOWN_SAML_PROVIDER.detailed(
_("No SAML providers are configured.")
)
if (not provider_name
or not provider_name in self.saml_providers_by_name):
# The patron neglected to specify a provider, or specified
# one we don't support.
possibilities = ", ".join(self.saml_providers_by_name.keys())
return UNKNOWN_SAML_PROVIDER.detailed(
UNKNOWN_SAML_PROVIDER.detail +
_(" The known providers are: %s") % possibilities
)
return self.saml_providers_by_name[provider_name]
def create_bearer_token(self, provider_name, provider_token):
"""Create a JSON web token with the given provider name and access
token.
The patron will use this as a bearer token in lieu of the
token we got from their OAuth provider. The big advantage of
this token is that it tells us _which_ OAuth provider the
patron authenticated against.
When the patron uses the bearer token in the Authenticate header,
it will be decoded with `decode_bearer_token_from_header`.
"""
payload = dict(
token=provider_token,
# I'm not sure this is the correct way to use an
# Issuer claim (https://tools.ietf.org/html/rfc7519#section-4.1.1).
# Maybe we should use something custom instead.
iss=provider_name,
)
return jwt.encode(
payload, self.bearer_token_signing_secret, algorithm='HS256'
)
def decode_bearer_token_from_header(self, header):
"""Extract auth provider name and access token from an Authenticate
header value.
"""
simplified_token = header.split(' ')[1]
return self.decode_bearer_token(simplified_token)
def decode_bearer_token(self, token):
"""Extract auth provider name and access token from JSON web token."""
decoded = jwt.decode(token, self.bearer_token_signing_secret,
algorithms=['HS256'])
provider_name = decoded['iss']
token = decoded['token']
return (provider_name, token)
def authentication_document_url(self, library):
"""Return the URL of the authentication document for the
given library.
"""
return url_for(
"authentication_document", library_short_name=library.short_name,
_external=True
)
def create_authentication_document(self):