14 |
14 |
# You should have received a copy of the GNU Affero General Public License
|
15 |
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
16 |
16 |
|
|
17 |
from __future__ import unicode_literals
|
|
18 |
|
17 |
19 |
import logging
|
18 |
20 |
|
|
21 |
from django.utils import six
|
|
22 |
from django.core.exceptions import MultipleObjectsReturned
|
|
23 |
from django.db.transaction import atomic
|
|
24 |
|
19 |
25 |
from mellon.adapters import DefaultAdapter, UserCreationError
|
20 |
26 |
from mellon.utils import get_setting
|
21 |
27 |
|
22 |
28 |
from authentic2 import utils
|
|
29 |
from authentic2.utils.evaluate import evaluate_condition
|
|
30 |
from authentic2.a2_rbac.models import Role, OrganizationalUnit as OU
|
23 |
31 |
|
24 |
32 |
logger = logging.getLogger('authentic2.auth_saml')
|
25 |
33 |
|
26 |
34 |
|
|
35 |
@six.python_2_unicode_compatible
|
|
36 |
class MappingError(Exception):
|
|
37 |
details = None
|
|
38 |
|
|
39 |
def __init__(self, message, details=None):
|
|
40 |
if details:
|
|
41 |
self.details = details
|
|
42 |
super(MappingError, self).__init__(message)
|
|
43 |
|
|
44 |
def __str__(self):
|
|
45 |
s = six.text_type(self.args[0])
|
|
46 |
if self.details:
|
|
47 |
s += ' ' + repr(self.details)
|
|
48 |
return s
|
|
49 |
|
|
50 |
|
|
51 |
class SamlConditionContextProxy(object):
|
|
52 |
def __init__(self, saml_attributes):
|
|
53 |
self.saml_attributes = saml_attributes
|
|
54 |
|
|
55 |
def __getitem__(self, key):
|
|
56 |
if key.endswith('__list'):
|
|
57 |
return self.saml_attributes[key[:-len('__list')]]
|
|
58 |
else:
|
|
59 |
v = self.saml_attributes[key]
|
|
60 |
if isinstance(v, list):
|
|
61 |
return v[0] if v else None
|
|
62 |
else:
|
|
63 |
return v
|
|
64 |
|
|
65 |
|
27 |
66 |
class AuthenticAdapter(DefaultAdapter):
|
28 |
67 |
def create_user(self, user_class):
|
29 |
68 |
return user_class.objects.create()
|
30 |
69 |
|
31 |
70 |
def finish_create_user(self, idp, saml_attributes, user):
|
32 |
|
self.provision_a2_attributes(user, idp, saml_attributes, do_raise=True)
|
|
71 |
try:
|
|
72 |
self.provision_a2_attributes(user, idp, saml_attributes)
|
|
73 |
except MappingError as e:
|
|
74 |
raise UserCreationError('user creation failed on a mandatory mapping action: %s' % e)
|
33 |
75 |
|
34 |
76 |
def provision(self, user, idp, saml_attributes):
|
35 |
77 |
super(AuthenticAdapter, self).provision(user, idp, saml_attributes)
|
36 |
|
self.provision_a2_attributes(user, idp, saml_attributes)
|
|
78 |
try:
|
|
79 |
self.provision_a2_attributes(user, idp, saml_attributes)
|
|
80 |
except MappingError as e:
|
|
81 |
logger.warning('auth_saml: failure during attribute provisionning %s', e)
|
37 |
82 |
|
38 |
|
def provision_a2_attributes(self, user, idp, saml_attributes, do_raise=False):
|
|
83 |
@atomic
|
|
84 |
def provision_a2_attributes(self, user, idp, saml_attributes):
|
39 |
85 |
'''Copy incoming SAML attributes to user attributes, A2_ATTRIBUTE_MAPPING must be a list of
|
40 |
86 |
dictionaries like:
|
41 |
87 |
|
... | ... | |
50 |
96 |
mandatory, login will fail.
|
51 |
97 |
'''
|
52 |
98 |
|
|
99 |
saml_attributes = saml_attributes.copy()
|
53 |
100 |
attribute_mapping = get_setting(idp, 'A2_ATTRIBUTE_MAPPING', [])
|
|
101 |
|
|
102 |
if not attribute_mapping:
|
|
103 |
return
|
|
104 |
|
|
105 |
if not isinstance(attribute_mapping, list):
|
|
106 |
raise MappingError('invalid A2_ATTRIBUTE_MAPPING')
|
|
107 |
|
54 |
108 |
user_modified = False
|
55 |
109 |
for mapping in attribute_mapping:
|
56 |
|
attribute = mapping['attribute']
|
57 |
|
saml_attribute = mapping['saml_attribute']
|
58 |
|
mandatory = mapping.get('mandatory', False)
|
59 |
|
logger.debug('auth_saml: trying mapping attribute from %r to %r', saml_attribute, attribute,
|
60 |
|
extra={'user': user})
|
61 |
|
if saml_attribute not in saml_attributes:
|
62 |
|
if mandatory:
|
63 |
|
logger.error('auth_saml: mandatory saml attribute %r is missing', saml_attribute,
|
64 |
|
extra={'attributes': repr(saml_attributes), 'user': user})
|
65 |
|
if do_raise:
|
66 |
|
raise UserCreationError('missing saml_attribute %r' % saml_attribute)
|
67 |
|
else:
|
68 |
|
logger.debug('auth_saml: saml_attribute %r not found', saml_attribute, extra={'user': user})
|
69 |
|
continue
|
|
110 |
if not isinstance(mapping, dict):
|
|
111 |
raise MappingError('invalid mapping action', details={'mapping': mapping})
|
|
112 |
action = mapping.get('action', 'set-attribute')
|
|
113 |
mandatory = mapping.get('mandatory', False) is True
|
|
114 |
method = None
|
|
115 |
if isinstance(action, six.string_types):
|
|
116 |
try:
|
|
117 |
method = getattr(self, 'action_' + action.replace('-', '_'))
|
|
118 |
except AttributeError:
|
|
119 |
pass
|
|
120 |
if not method:
|
|
121 |
raise MappingError('invalid action %r' % action)
|
70 |
122 |
try:
|
71 |
|
value = saml_attributes[saml_attribute]
|
72 |
|
if self.set_user_attribute(user, attribute, value):
|
|
123 |
logger.debug('auth_saml: applying provisionning mapping %s', mapping)
|
|
124 |
if method(user, idp, saml_attributes, mapping):
|
73 |
125 |
user_modified = True
|
74 |
|
except Exception as e:
|
75 |
|
logger.error(u'failed to set attribute %r from saml attribute %r with value %r: %s',
|
76 |
|
attribute, saml_attribute, value, e,
|
77 |
|
extra={'attributes': repr(saml_attributes), 'user': user})
|
78 |
|
if mandatory and do_raise:
|
79 |
|
raise UserCreationError('could not set attribute %s' % attribute, e)
|
|
126 |
except MappingError as e:
|
|
127 |
if mandatory:
|
|
128 |
# it's mandatory, provisionning should fail completely
|
|
129 |
raise e
|
|
130 |
else:
|
|
131 |
logger.debug('auth_saml: action mapping %r failed: %s', mapping, e)
|
|
132 |
|
80 |
133 |
if user_modified:
|
81 |
134 |
user.save()
|
82 |
135 |
|
|
136 |
def action_rename(self, user, idp, saml_attributes, mapping):
|
|
137 |
from_name = mapping.get('from')
|
|
138 |
if not from_name or not isinstance(from_name, six.string_types):
|
|
139 |
raise MappingError('missing from in rename')
|
|
140 |
to_name = mapping.get('to')
|
|
141 |
if not to_name or not isinstance(to_name, six.string_types):
|
|
142 |
raise MappingError('missing to in rename')
|
|
143 |
if from_name in saml_attributes:
|
|
144 |
saml_attributes[to_name] = saml_attributes[from_name]
|
|
145 |
|
|
146 |
def action_set_attribute(self, user, idp, saml_attributes, mapping):
|
|
147 |
attribute = mapping.get('attribute')
|
|
148 |
if not attribute or not isinstance(attribute, six.string_types):
|
|
149 |
raise MappingError('missing attribute key')
|
|
150 |
|
|
151 |
saml_attribute = mapping.get('saml_attribute')
|
|
152 |
if not saml_attribute or not isinstance(saml_attribute, six.string_types):
|
|
153 |
raise MappingError('missing saml_attribute key')
|
|
154 |
|
|
155 |
if saml_attribute not in saml_attributes:
|
|
156 |
raise MappingError('unknown saml_attribute', details={'saml_attribute': saml_attribute})
|
|
157 |
value = saml_attributes[saml_attribute]
|
|
158 |
return self.set_user_attribute(user, attribute, value)
|
|
159 |
|
83 |
160 |
def set_user_attribute(self, user, attribute, value):
|
84 |
161 |
if isinstance(value, list):
|
85 |
162 |
if len(value) > 1:
|
86 |
|
raise ValueError('too much values')
|
|
163 |
raise MappingError('too much values')
|
87 |
164 |
value = value[0]
|
88 |
165 |
if attribute in ('first_name', 'last_name', 'email', 'username'):
|
89 |
166 |
if getattr(user, attribute) != value:
|
... | ... | |
97 |
174 |
return True
|
98 |
175 |
return False
|
99 |
176 |
|
|
177 |
def get_ou(self, role_desc):
|
|
178 |
ou_desc = role_desc.get('ou')
|
|
179 |
if ou_desc is None:
|
|
180 |
return None
|
|
181 |
if not isinstance(ou_desc, dict):
|
|
182 |
raise MappingError('invalid ou description')
|
|
183 |
slug = ou_desc.get('slug')
|
|
184 |
name = ou_desc.get('name')
|
|
185 |
if slug:
|
|
186 |
if not isinstance(slug, six.string_types):
|
|
187 |
raise MappingError('invalid ou.slug in ou description')
|
|
188 |
try:
|
|
189 |
return OU.objects.get(slug=slug)
|
|
190 |
except OU.DoesNotExist:
|
|
191 |
raise MappingError('unknown ou', details={'slug': slug})
|
|
192 |
elif name:
|
|
193 |
if not isinstance(name, six.string_types):
|
|
194 |
raise MappingError('invalid ou.slug in ou description')
|
|
195 |
try:
|
|
196 |
return OU.objects.get(name=name)
|
|
197 |
except OU.DoesNotExist:
|
|
198 |
raise MappingError('unknown ou', details={'name': name})
|
|
199 |
else:
|
|
200 |
raise MappingError('invalid ou description')
|
|
201 |
|
|
202 |
def get_role(self, mapping):
|
|
203 |
role_desc = mapping.get('role')
|
|
204 |
if not role_desc or not isinstance(role_desc, dict):
|
|
205 |
raise MappingError('missing or invalid role description')
|
|
206 |
slug = role_desc.get('slug')
|
|
207 |
name = role_desc.get('name')
|
|
208 |
ou = self.get_ou(role_desc)
|
|
209 |
|
|
210 |
kwargs = {}
|
|
211 |
if ou:
|
|
212 |
kwargs['ou'] = ou
|
|
213 |
|
|
214 |
if slug:
|
|
215 |
if not isinstance(slug, six.string_types):
|
|
216 |
raise MappingError('invalid role slug', details={'slug': slug})
|
|
217 |
kwargs['slug'] = slug
|
|
218 |
elif name:
|
|
219 |
if not isinstance(name, six.string_types):
|
|
220 |
raise MappingError('invalid role name', details={'name': name})
|
|
221 |
kwargs['name'] = name
|
|
222 |
else:
|
|
223 |
raise MappingError('invalid role description')
|
|
224 |
|
|
225 |
try:
|
|
226 |
return Role.objects.get(**kwargs)
|
|
227 |
except Role.DoesNotExist:
|
|
228 |
raise MappingError('unknown role', details=kwargs)
|
|
229 |
except MultipleObjectsReturned:
|
|
230 |
raise MappingError('ambiuous role description', details=kwargs)
|
|
231 |
|
|
232 |
def evaluate_condition(self, user, saml_attributes, mapping):
|
|
233 |
condition = mapping.get('condition')
|
|
234 |
if condition is None:
|
|
235 |
return True
|
|
236 |
if not isinstance(condition, six.string_types):
|
|
237 |
raise MappingError('invalid condition')
|
|
238 |
try:
|
|
239 |
# use a proxy to simplify condition expressions as subscript is forbidden
|
|
240 |
# you can write "email == 'a@b.com'" but also "'a@b.com' in email__list"
|
|
241 |
value = evaluate_condition(condition, SamlConditionContextProxy(saml_attributes))
|
|
242 |
logger.debug('auth_saml: condition %r is %s', condition, value, extra={'user': user})
|
|
243 |
return value
|
|
244 |
except Exception as e:
|
|
245 |
raise MappingError('condition evaluation failed', details={'error': six.text_type(e)})
|
|
246 |
|
|
247 |
def action_toggle_role(self, user, idp, saml_attributes, mapping):
|
|
248 |
role = self.get_role(mapping)
|
|
249 |
if self.evaluate_condition(user, saml_attributes, mapping):
|
|
250 |
if role not in user.roles.all():
|
|
251 |
logger.info('auth_saml: adding role "%s"', role, extra={'user': user})
|
|
252 |
user.roles.add(role)
|
|
253 |
else:
|
|
254 |
if role in user.roles.all():
|
|
255 |
logger.info('auth_saml: removing role "%s"', role, extra={'user': user})
|
|
256 |
user.roles.remove(role)
|
|
257 |
|
100 |
258 |
def auth_login(self, request, user):
|
101 |
259 |
utils.login(request, user, 'saml')
|