1
1
"""
2
- SATOSA microservice that uses an identifier asserted by
2
+ SATOSA microservice that uses an identifier asserted by
3
3
the home organization SAML IdP as a key to search an LDAP
4
4
directory for a record and then consume attributes from
5
5
the record and assert them to the receiving SP.
16
16
17
17
logger = logging .getLogger (__name__ )
18
18
19
+ class LdapAttributeStoreException (Exception ):
20
+ def __init__ (self , value ):
21
+ self .value = value
22
+ def __str__ (self ):
23
+ return "LdapAttributeStoreException: {}" .format (self .value )
24
+
19
25
class LdapAttributeStore (satosa .micro_services .base .ResponseMicroService ):
20
26
"""
21
27
Use identifier provided by the backend authentication service
@@ -24,18 +30,44 @@ class LdapAttributeStore(satosa.micro_services.base.ResponseMicroService):
24
30
"""
25
31
logprefix = "LDAP_ATTRIBUTE_STORE:"
26
32
33
+ # Allowed configuration options for the microservice. Any key
34
+ # in the config dictionary passed into the __init__() method
35
+ # that is not a key in this dictionary is treated as the
36
+ # entityID for a per-SP configuration.
37
+ #
38
+ # The keys are the allowed configuration options. The values
39
+ # are a list of [default value, if required or not]. Note that
40
+ # required here means required as part of an effective
41
+ # configuration during a particular flow, so it applies
42
+ # to the default configuration overridden with any per-SP
43
+ # configuration details.
44
+ config_options = {
45
+ 'bind_dn' : {'default' : None , 'required' : True },
46
+ 'bind_password' : {'default' : None , 'required' : True },
47
+ 'clear_input_attributes' : {'default' : False , 'required' : False },
48
+ 'ignore' : {'default' : False , 'required' : False },
49
+ 'ldap_identifier_attribute' : {'default' : None , 'required' : True },
50
+ 'ldap_url' : {'default' : None , 'required' : True },
51
+ 'on_ldap_search_result_empty' : {'default' : None , 'required' : False },
52
+ 'ordered_identifier_candidates' : {'default' : None , 'required' : True },
53
+ 'search_base' : {'default' : None , 'required' : True },
54
+ 'search_return_attributes' : {'default' : None , 'required' : True },
55
+ 'user_id_from_attrs' : {'default' : [], 'required' : False }
56
+ }
57
+
27
58
def __init__ (self , config , * args , ** kwargs ):
28
59
super ().__init__ (* args , ** kwargs )
29
60
self .config = config
61
+ self .logprefix = LdapAttributeStore .logprefix
30
62
31
- def constructFilterValue (self , candidate , data ):
63
+ def _constructFilterValue (self , candidate , data ):
32
64
"""
33
65
Construct and return a LDAP directory search filter value from the
34
66
candidate identifier.
35
67
36
- Argument 'canidate' is a dictionary with one required key and
68
+ Argument 'canidate' is a dictionary with one required key and
37
69
two optional keys:
38
-
70
+
39
71
key required value
40
72
--------------- -------- ---------------------------------
41
73
attribute_names Y list of identifier names
@@ -50,7 +82,7 @@ def constructFilterValue(self, candidate, data):
50
82
If the attribute_names list consists of more than one identifier
51
83
name then the values of the identifiers will be concatenated together
52
84
to create the filter value.
53
-
85
+
54
86
If one of the identifier names in the attribute_names is the string
55
87
'name_id' then the NameID value with format name_id_format
56
88
will be concatenated to the filter value.
@@ -87,9 +119,9 @@ def constructFilterValue(self, candidate, data):
87
119
if candidate ['name_id_format' ] in name_id :
88
120
nameid_value = name_id [candidate ['name_id_format' ]]
89
121
90
- # Only add the NameID value asserted by the IdP if it is not already
122
+ # Only add the NameID value asserted by the IdP if it is not already
91
123
# in the list of values. This is necessary because some non-compliant IdPs
92
- # have been known, for example, to assert the value of eduPersonPrincipalName
124
+ # have been known, for example, to assert the value of eduPersonPrincipalName
93
125
# in the value for SAML2 persistent NameID as well as asserting
94
126
# eduPersonPrincipalName.
95
127
if nameid_value not in values :
@@ -122,21 +154,67 @@ def constructFilterValue(self, candidate, data):
122
154
123
155
return value
124
156
125
- def process (self , context , data ):
126
- logprefix = LdapAttributeStore .logprefix
127
- self .logprefix = logprefix
128
- self .context = context
157
+ def _copyConfigOptions (self , source , target ):
158
+ """
159
+ Copy allowed configuration options from the source
160
+ dictionary to the target dictionary.
161
+ """
162
+ for option in LdapAttributeStore .config_options :
163
+ if option in source :
164
+ target [option ] = source [option ]
165
+
166
+ def _getEffectiveConfig (self , entityID = None , state = None ):
167
+ """
168
+ Get the effective configuration for the SP with entityID
169
+ or the default configuration if no entityID.
170
+ """
171
+ logprefix = self .logprefix
172
+
173
+ # Set microservice defaults for available configuration options.
174
+ base_config = { key : value ['default' ] for key , value in LdapAttributeStore .config_options .items ()}
175
+ effective_config = copy .deepcopy (base_config )
176
+
177
+ # Process default input configuration to the microservice.
178
+ self ._copyConfigOptions (self .config , effective_config )
179
+ clean_for_logging = self ._hideConfigSecrets (effective_config )
180
+ satosa_logging (logger , logging .DEBUG , "{} Using default configuration {}" .format (logprefix , clean_for_logging ), state )
181
+
182
+ # Process per-SP input configuration to the microservice.
183
+ if entityID :
184
+ if entityID in self .config :
185
+ self ._copyConfigOptions (self .config [entityID ], effective_config )
186
+ clean_for_logging = self ._hideConfigSecrets (effective_config )
187
+ satosa_logging (logger , logging .DEBUG , "{} For SP {} using configuration {}" .format (logprefix , entityID , clean_for_logging ), state )
188
+
189
+ # Check effective configuration against required configuration details.
190
+ for config_opt , required in {key : value ['required' ] for key , value in LdapAttributeStore .config_options .items ()}.items ():
191
+ if required :
192
+ if not effective_config [config_opt ]:
193
+ raise LdapAttributeStoreException ("Configuration option {} is required but missing" .format (config_opt ))
194
+
195
+ return effective_config
196
+
197
+ def _hideConfigSecrets (self , config ):
198
+ """
199
+ Make a deep copy of the input config dictionary and
200
+ replace the bind password with a dummy string and
201
+ return the copy.
202
+ """
203
+ clean_config = copy .deepcopy (config )
204
+ if 'bind_password' in clean_config :
205
+ clean_config ['bind_password' ] = 'XXXXXXXX'
129
206
130
- # Initialize the configuration to use as the default configuration
131
- # that is passed during initialization.
132
- config = self .config
133
- configClean = copy .deepcopy (config )
134
- if 'bind_password' in configClean :
135
- configClean ['bind_password' ] = 'XXXXXXXX'
207
+ return clean_config
136
208
137
- satosa_logging (logger , logging .DEBUG , "{} Using default configuration {}" .format (logprefix , configClean ), context .state )
209
+ def process (self , context , data ):
210
+ """
211
+ Default interface for microservices. Process the input data for
212
+ the input context.
213
+ """
214
+ self .context = context
215
+ logprefix = self .logprefix
138
216
139
- # Find the entityID for the SP that initiated the flow
217
+ # Find the entityID for the SP that initiated the flow.
140
218
try :
141
219
spEntityID = context .state .state_dict ['SATOSA_BASE' ]['requester' ]
142
220
except KeyError as err :
@@ -145,73 +223,15 @@ def process(self, context, data):
145
223
146
224
satosa_logging (logger , logging .DEBUG , "{} entityID for the SP requester is {}" .format (logprefix , spEntityID ), context .state )
147
225
148
- # Examine our configuration to determine if there is a per-SP configuration
149
- if spEntityID in self .config :
150
- config = self .config [spEntityID ]
151
- configClean = copy .deepcopy (config )
152
- if 'bind_password' in configClean :
153
- configClean ['bind_password' ] = 'XXXXXXXX'
154
- satosa_logging (logger , logging .DEBUG , "{} For SP {} using configuration {}" .format (logprefix , spEntityID , configClean ), context .state )
155
-
156
- # Obtain configuration details from the per-SP configuration or the default configuration
226
+ # Get the effective configuration for the SP.
157
227
try :
158
- if 'ldap_url' in config :
159
- ldap_url = config ['ldap_url' ]
160
- else :
161
- ldap_url = self .config ['ldap_url' ]
162
- if 'bind_dn' in config :
163
- bind_dn = config ['bind_dn' ]
164
- else :
165
- bind_dn = self .config ['bind_dn' ]
166
- if 'bind_dn' in config :
167
- bind_password = config ['bind_password' ]
168
- else :
169
- bind_password = self .config ['bind_password' ]
170
- if 'search_base' in config :
171
- search_base = config ['search_base' ]
172
- else :
173
- search_base = self .config ['search_base' ]
174
- if 'search_return_attributes' in config :
175
- search_return_attributes = config ['search_return_attributes' ]
176
- else :
177
- search_return_attributes = self .config ['search_return_attributes' ]
178
- if 'ordered_identifier_candidates' in config :
179
- ordered_identifier_candidates = config ['ordered_identifier_candidates' ]
180
- else :
181
- ordered_identifier_candidates = self .config ['ordered_identifier_candidates' ]
182
- if 'ldap_identifier_attribute' in config :
183
- ldap_identifier_attribute = config ['ldap_identifier_attribute' ]
184
- else :
185
- ldap_identifier_attribute = self .config ['ldap_identifier_attribute' ]
186
- if 'clear_input_attributes' in config :
187
- clear_input_attributes = config ['clear_input_attributes' ]
188
- elif 'clear_input_attributes' in self .config :
189
- clear_input_attributes = self .config ['clear_input_attributes' ]
190
- else :
191
- clear_input_attributes = False
192
- if 'user_id_from_attrs' in config :
193
- user_id_from_attrs = config ['user_id_from_attrs' ]
194
- elif 'user_id_from_attrs' in self .config :
195
- user_id_from_attrs = self .config ['user_id_from_attrs' ]
196
- else :
197
- user_id_from_attrs = []
198
- if 'on_ldap_search_result_empty' in config :
199
- on_ldap_search_result_empty = config ['on_ldap_search_result_empty' ]
200
- elif 'on_ldap_search_result_empty' in self .config :
201
- on_ldap_search_result_empty = self .config ['on_ldap_search_result_empty' ]
202
- else :
203
- on_ldap_search_result_empty = None
204
- if 'ignore' in config :
205
- ignore = True
206
- else :
207
- ignore = False
208
-
209
- except KeyError as err :
210
- satosa_logging (logger , logging .ERROR , "{} Configuration '{}' is missing" .format (logprefix , err ), context .state )
228
+ config = self ._getEffectiveConfig (spEntityID , context .state )
229
+ except LdapAttributeStoreException as e :
230
+ satosa_logging (logger , logging .ERROR , "{} Caught exception: {}" .format (logprefix , e ), context .state )
211
231
return super ().process (context , data )
212
232
213
233
# Ignore this SP entirely if so configured.
214
- if ignore :
234
+ if config [ ' ignore' ] :
215
235
satosa_logging (logger , logging .INFO , "{} Ignoring SP {}" .format (logprefix , spEntityID ), None )
216
236
return super ().process (context , data )
217
237
@@ -221,8 +241,8 @@ def process(self, context, data):
221
241
222
242
# Loop over the configured list of identifiers from the IdP to consider and find
223
243
# asserted values to construct the ordered list of values for the LDAP search filters.
224
- for candidate in ordered_identifier_candidates :
225
- value = self .constructFilterValue (candidate , data )
244
+ for candidate in config [ ' ordered_identifier_candidates' ] :
245
+ value = self ._constructFilterValue (candidate , data )
226
246
227
247
# If we have constructed a non empty value then add it as the next filter value
228
248
# to use when searching for the user record.
@@ -235,22 +255,24 @@ def process(self, context, data):
235
255
record = None
236
256
237
257
try :
258
+ ldap_url = config ['ldap_url' ]
238
259
satosa_logging (logger , logging .DEBUG , "{} Using LDAP URL {}" .format (logprefix , ldap_url ), context .state )
239
260
server = ldap3 .Server (ldap_url )
240
261
262
+ bind_dn = config ['bind_dn' ]
241
263
satosa_logging (logger , logging .DEBUG , "{} Using bind DN {}" .format (logprefix , bind_dn ), context .state )
242
- connection = ldap3 .Connection (server , bind_dn , bind_password , auto_bind = True )
264
+ connection = ldap3 .Connection (server , bind_dn , config [ ' bind_password' ] , auto_bind = True )
243
265
satosa_logging (logger , logging .DEBUG , "{} Connected to LDAP server" .format (logprefix ), context .state )
244
266
245
267
for filterVal in filterValues :
246
268
if record :
247
269
break
248
270
249
- search_filter = '({0}={1})' .format (ldap_identifier_attribute , filterVal )
271
+ search_filter = '({0}={1})' .format (config [ ' ldap_identifier_attribute' ] , filterVal )
250
272
satosa_logging (logger , logging .DEBUG , "{} Constructed search filter {}" .format (logprefix , search_filter ), context .state )
251
273
252
274
satosa_logging (logger , logging .DEBUG , "{} Querying LDAP server..." .format (logprefix ), context .state )
253
- connection .search (search_base , search_filter , attributes = search_return_attributes .keys ())
275
+ connection .search (config [ ' search_base' ] , search_filter , attributes = config [ ' search_return_attributes' ] .keys ())
254
276
satosa_logging (logger , logging .DEBUG , "{} Done querying LDAP server" .format (logprefix ), context .state )
255
277
256
278
responses = connection .response
@@ -259,10 +281,10 @@ def process(self, context, data):
259
281
# for now consider only the first record found (if any)
260
282
if len (responses ) > 0 :
261
283
if len (responses ) > 1 :
262
- satosa_logging (logger , logging .WARN , "{} LDAP server returned {} records using IdP asserted attribute {}" .format (logprefix , len (responses ), identifier ), context .state )
284
+ satosa_logging (logger , logging .WARN , "{} LDAP server returned {} records using search filter value {}" .format (logprefix , len (responses ), filterVar ), context .state )
263
285
record = responses [0 ]
264
286
break
265
-
287
+
266
288
except Exception as err :
267
289
satosa_logging (logger , logging .ERROR , "{} Caught exception: {}" .format (logprefix , err ), context .state )
268
290
return super ().process (context , data )
@@ -273,7 +295,7 @@ def process(self, context, data):
273
295
274
296
# Before using a found record, if any, to populate attributes
275
297
# clear any attributes incoming to this microservice if so configured.
276
- if clear_input_attributes :
298
+ if config [ ' clear_input_attributes' ] :
277
299
satosa_logging (logger , logging .DEBUG , "{} Clearing values for these input attributes: {}" .format (logprefix , data .attributes ), context .state )
278
300
data .attributes = {}
279
301
@@ -283,6 +305,7 @@ def process(self, context, data):
283
305
satosa_logging (logger , logging .DEBUG , "{} Record with DN {} has attributes {}" .format (logprefix , record ["dn" ], record ["attributes" ]), context .state )
284
306
285
307
# Populate attributes as configured.
308
+ search_return_attributes = config ['search_return_attributes' ]
286
309
for attr in search_return_attributes .keys ():
287
310
if attr in record ["attributes" ]:
288
311
if record ["attributes" ][attr ]:
@@ -293,6 +316,7 @@ def process(self, context, data):
293
316
294
317
# Populate input for NameID if configured. SATOSA core does the hashing of input
295
318
# to create a persistent NameID.
319
+ user_id_from_attrs = config ['user_id_from_attrs' ]
296
320
if user_id_from_attrs :
297
321
userId = ""
298
322
for attr in user_id_from_attrs :
@@ -317,6 +341,7 @@ def process(self, context, data):
317
341
318
342
else :
319
343
satosa_logging (logger , logging .WARN , "{} No record found in LDAP so no attributes will be added" .format (logprefix ), context .state )
344
+ on_ldap_search_result_empty = config ['on_ldap_search_result_empty' ]
320
345
if on_ldap_search_result_empty :
321
346
# Redirect to the configured URL with
322
347
# the entityIDs for the target SP and IdP used by the user
0 commit comments