0001-misc-add-support-for-enable-conditions-on-authentica.patch
src/authentic2/app_settings.py | ||
---|---|---|
239 | 239 |
A2_AUTH_PASSWORD_ENABLE=Setting( |
240 | 240 |
default=True, |
241 | 241 |
definition='Activate login/password authentication', names=('AUTH_PASSWORD',)), |
242 |
A2_AUTH_MODULES_CONDITIONS=Setting(default={}, definition='Modules filters'), |
|
242 | 243 |
A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING=Setting( |
243 | 244 |
default=0, |
244 | 245 |
definition='Failure count before logging a warning to ' |
src/authentic2/authenticators.py | ||
---|---|---|
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 | ||
18 |
import logging |
|
19 | ||
17 | 20 |
from django.db.models import Count |
18 | 21 |
from django.shortcuts import render |
19 | 22 |
from django.utils.translation import ugettext as _, ugettext_lazy |
23 |
from django.template import Template, TemplateSyntaxError, RequestContext |
|
20 | 24 | |
21 | 25 |
from authentic2.a2_rbac.models import OrganizationalUnit as OU, Role |
22 | 26 |
from authentic2.custom_user.models import User |
27 | ||
23 | 28 |
from . import views, app_settings, utils, constants, models |
24 | 29 |
from .forms import authentication as authentication_forms |
25 | 30 | |
26 | 31 | |
27 |
class LoginPasswordAuthenticator(object): |
|
32 |
class BaseAuthenticator(object): |
|
33 | ||
34 |
def eval_condition(self, condition, request): |
|
35 |
try: |
|
36 |
template = Template("{%% load authentic2 %%}{%% if %s %%}OK{%% endif %%}" % condition) |
|
37 |
return template.render(RequestContext(request)) == "OK" |
|
38 |
except TemplateSyntaxError as e: |
|
39 |
logger = logging.getLogger() |
|
40 |
logger.error(e) |
|
41 |
return False |
|
42 | ||
43 |
def is_condition_enabled(self, request): |
|
44 |
if not (app_settings.A2_AUTH_MODULES_CONDITIONS and |
|
45 |
self.id in app_settings.A2_AUTH_MODULES_CONDITIONS): |
|
46 |
return True |
|
47 |
return self.eval_condition(app_settings.A2_AUTH_MODULES_CONDITIONS[self.id], |
|
48 |
request) |
|
49 | ||
50 | ||
51 |
class LoginPasswordAuthenticator(BaseAuthenticator): |
|
28 | 52 |
submit_name = 'login-password-submit' |
29 | 53 | |
30 |
def enabled(self): |
|
31 |
return app_settings.A2_AUTH_PASSWORD_ENABLE |
|
54 |
def enabled(self, request=None): |
|
55 |
if not app_settings.A2_AUTH_PASSWORD_ENABLE: |
|
56 |
return False |
|
57 |
return self.is_condition_enabled(request) |
|
32 | 58 | |
33 | 59 |
def name(self): |
34 | 60 |
return ugettext_lazy('Password') |
35 | 61 | |
62 |
@property |
|
36 | 63 |
def id(self): |
37 | 64 |
return 'password' |
38 | 65 |
src/authentic2/idp/saml/saml2_endpoints.py | ||
---|---|---|
83 | 83 |
send_soap_request, get_saml2_query_request, \ |
84 | 84 |
get_saml2_request_message_async_binding, create_saml2_server, \ |
85 | 85 |
get_saml2_metadata, get_sp_options_policy, \ |
86 |
get_entity_id, AUTHENTIC_SAME_ID_SENTINEL |
|
86 |
get_entity_id, AUTHENTIC_SAME_ID_SENTINEL, \ |
|
87 |
get_extension_node |
|
87 | 88 |
import authentic2.saml.saml2utils as saml2utils |
88 | 89 |
from authentic2.idp.saml.common import kill_django_sessions |
89 | 90 |
from authentic2.constants import NONCE_FIELD_NAME |
... | ... | |
534 | 535 |
name_id_policy = login.request.nameIdPolicy |
535 | 536 |
name_id_policy.format = NAME_ID_FORMATS[nid_format]['samlv2'] |
536 | 537 |
logger.debug('set nameID policy format %s', nid_format) |
538 |
hint = get_extension_node(login.request.extensions, 'login-hint') |
|
539 |
if hint is not None: |
|
540 |
request.session['login-hint'] = hint.text |
|
537 | 541 |
return sso_after_process_request(request, login, nid_format=nid_format) |
538 | 542 | |
539 | 543 |
src/authentic2/saml/common.py | ||
---|---|---|
18 | 18 |
import logging |
19 | 19 |
import re |
20 | 20 |
import datetime |
21 |
import xml.etree.ElementTree as ET |
|
21 | 22 | |
22 | 23 |
import requests |
23 | 24 | |
... | ... | |
39 | 40 |
from authentic2.idp.saml import app_settings |
40 | 41 |
from .. import nonce |
41 | 42 | |
43 | ||
44 | ||
45 |
EO_NS = 'https://www.entrouvert.com/' |
|
46 | ||
42 | 47 |
AUTHENTIC_STATUS_CODE_NS = "http://authentic.entrouvert.org/status_code/" |
43 | 48 |
AUTHENTIC_SAME_ID_SENTINEL = 'urn:authentic.entrouvert.org:same-as-provider-entity-id' |
44 | 49 |
AUTHENTIC_STATUS_CODE_UNKNOWN_PROVIDER = AUTHENTIC_STATUS_CODE_NS + "UnknownProvider" |
... | ... | |
586 | 591 |
if session_not_on_or_afters: |
587 | 592 |
return six.moves.reduce(min, session_not_on_or_afters) |
588 | 593 |
return None |
594 | ||
595 |
def get_extension_node(extensions, node, ns=EO_NS): |
|
596 |
if extensions is not None: |
|
597 |
root = ET.fromstring(extensions.dump()) |
|
598 |
return root.find('{%s}%s' % (ns, node)) |
|
599 |
return None |
src/authentic2/templatetags/authentic2.py | ||
---|---|---|
1 |
from django.template import Library |
|
2 | ||
3 |
register = Library() |
|
4 | ||
5 |
@register.filter |
|
6 |
def is_for_backoffice(request): |
|
7 |
return request.session.get('login-hint') == 'backoffice' |
src/authentic2/utils/__init__.py | ||
---|---|---|
169 | 169 |
return cls() |
170 | 170 | |
171 | 171 | |
172 |
def get_backends(setting_name='IDP_BACKENDS'): |
|
172 |
def get_backends(setting_name='IDP_BACKENDS', request=None):
|
|
173 | 173 |
'''Return the list of enabled cleaned backends.''' |
174 | 174 |
backends = [] |
175 | 175 |
for backend_path in getattr(app_settings, setting_name): |
... | ... | |
178 | 178 |
backend_path, kwargs = backend_path |
179 | 179 |
backend = load_backend(backend_path) |
180 | 180 |
# If no enabled method is defined on the backend, backend enabled by default. |
181 |
if hasattr(backend, 'enabled') and not backend.enabled(): |
|
181 |
if hasattr(backend, 'enabled') and not backend.enabled(request):
|
|
182 | 182 |
continue |
183 | 183 |
kwargs_settings = getattr(app_settings, setting_name + '_KWARGS', {}) |
184 | 184 |
if backend_path in kwargs_settings: |
src/authentic2/views.py | ||
---|---|---|
278 | 278 |
redirect_to = settings.LOGIN_REDIRECT_URL |
279 | 279 |
nonce = request.GET.get(constants.NONCE_FIELD_NAME) |
280 | 280 | |
281 |
authenticators = utils.get_backends('AUTH_FRONTENDS') |
|
281 |
authenticators = utils.get_backends('AUTH_FRONTENDS', request)
|
|
282 | 282 | |
283 | 283 |
blocks = [] |
284 | 284 |
src/authentic2_auth_fc/authenticators.py | ||
---|---|---|
19 | 19 |
from django.template.response import TemplateResponse |
20 | 20 | |
21 | 21 |
from authentic2 import app_settings as a2_app_settings, utils as a2_utils |
22 |
from authentic2.authenticators import BaseAuthenticator |
|
22 | 23 | |
23 | 24 |
from . import app_settings |
24 | 25 | |
25 | 26 | |
26 |
class FcAuthenticator(object): |
|
27 |
def enabled(self): |
|
28 |
return app_settings.enable |
|
27 |
class FcAuthenticator(BaseAuthenticator): |
|
28 |
def enabled(self, request): |
|
29 |
if not app_settings.enable: |
|
30 |
return False |
|
31 |
return self.is_condition_enabled(request) |
|
29 | 32 | |
30 | 33 |
def name(self): |
31 | 34 |
return gettext_noop('FranceConnect') |
32 | 35 | |
36 |
@property |
|
33 | 37 |
def id(self): |
34 | 38 |
return 'fc' |
35 | 39 |
src/authentic2_auth_oidc/authenticators.py | ||
---|---|---|
18 | 18 |
from django.shortcuts import render |
19 | 19 | |
20 | 20 |
from . import app_settings, utils |
21 |
from authentic2 import app_settings as a2_settings |
|
21 | 22 |
from authentic2.utils import make_url |
23 |
from authentic2.authenticators import BaseAuthenticator |
|
22 | 24 | |
23 | 25 | |
24 |
class OIDCAuthenticator(object):
|
|
25 |
def enabled(self): |
|
26 |
class OIDCAuthenticator(BaseAuthenticator):
|
|
27 |
def enabled(self, request=None):
|
|
26 | 28 |
return app_settings.ENABLE and utils.has_providers() |
27 | 29 | |
28 | 30 |
def name(self): |
29 | 31 |
return gettext_noop('OpenIDConnect') |
30 | 32 | |
33 |
@property |
|
31 | 34 |
def id(self): |
32 | 35 |
return 'oidc' |
33 | 36 | |
37 |
def filter_by_condition(self, request, providers): |
|
38 |
slugs = [] |
|
39 |
if a2_settings.A2_AUTH_MODULES_CONDITIONS: |
|
40 |
for key, condition in a2_settings.A2_AUTH_MODULES_CONDITIONS.items(): |
|
41 |
if '/' not in key: |
|
42 |
continue |
|
43 |
id_, slug = key.split('/', 1) |
|
44 |
if self.id != id_: |
|
45 |
continue |
|
46 |
if not self.eval_condition(condition, request): |
|
47 |
slugs.append(slug) |
|
48 |
if slugs: |
|
49 |
providers = providers.exclude(slug__in=slugs) |
|
50 |
return providers |
|
51 | ||
34 | 52 |
def instances(self, request, *args, **kwargs): |
35 |
for p in utils.get_providers(shown=True): |
|
53 |
providers = self.filter_by_condition(request, utils.get_providers(shown=True)) |
|
54 |
for p in providers: |
|
36 | 55 |
yield (p.slug, p) |
37 | 56 | |
38 | 57 |
def login(self, request, *args, **kwargs): |
src/authentic2_auth_saml/authenticators.py | ||
---|---|---|
20 | 20 |
from mellon.utils import get_idp, get_idps |
21 | 21 | |
22 | 22 |
from authentic2.utils import redirect_to_login |
23 |
from authentic2.authenticators import BaseAuthenticator |
|
24 | ||
23 | 25 | |
24 | 26 |
from . import app_settings |
25 | 27 | |
26 | 28 | |
27 |
class SAMLAuthenticator(object): |
|
28 |
id = 'saml' |
|
29 |
class SAMLAuthenticator(BaseAuthenticator): |
|
30 | ||
31 |
def enabled(self, request=None): |
|
32 |
if not app_settings.enable: |
|
33 |
return False |
|
34 |
if not list(get_idps()): |
|
35 |
return False |
|
36 |
return self.is_condition_enabled(request) |
|
29 | 37 | |
30 |
def enabled(self): |
|
31 |
return app_settings.enable and list(get_idps()) |
|
38 |
@property |
|
39 |
def id(self): |
|
40 |
return 'saml' |
|
32 | 41 | |
33 | 42 |
def name(self): |
34 | 43 |
return gettext_noop('SAML') |
tests/test_auth_oidc.py | ||
---|---|---|
130 | 130 |
provider = OIDCProvider.objects.create( |
131 | 131 |
ou=get_default_ou(), |
132 | 132 |
name='OIDIDP', |
133 |
slug='oididp', |
|
133 | 134 |
issuer='http://server.example.com', |
134 | 135 |
authorization_endpoint='https://server.example.com/authorize', |
135 | 136 |
token_endpoint='https://server.example.com/token', |
... | ... | |
331 | 332 |
assert response.pyquery('p#oidc-p-oidcidp-2') |
332 | 333 | |
333 | 334 | |
335 |
def test_login_with_conditionnal_authenticators(oidc_provider, app, settings, caplog): |
|
336 | ||
337 |
OIDCProvider.objects.create( |
|
338 |
id=2, |
|
339 |
ou=get_default_ou(), |
|
340 |
name='My IDP', |
|
341 |
slug='my-idp', |
|
342 |
issuer='https://idp2.example.com/', |
|
343 |
authorization_endpoint='https://idp2.example.com/authorize', |
|
344 |
token_endpoint='https://idp2.example.com/token', |
|
345 |
end_session_endpoint='https://idp2.example.com/logout', |
|
346 |
userinfo_endpoint='https://idp*é.example.com/user_info', |
|
347 |
token_revocation_endpoint='https://idp2.example.com/revoke', |
|
348 |
max_auth_age=10, |
|
349 |
strategy=OIDCProvider.STRATEGY_CREATE, |
|
350 |
jwkset_json=None, |
|
351 |
idtoken_algo=OIDCProvider.ALGO_RSA, |
|
352 |
claims_parameter_supported=False |
|
353 |
) |
|
354 | ||
355 |
response = app.get('/login/') |
|
356 |
session = app.session |
|
357 |
session['login-hint'] = 'backoffice' |
|
358 |
session.save() |
|
359 | ||
360 |
settings.A2_AUTH_MODULES_CONDITIONS = {'oidc/my-idp': 'request|is_for_backoffice'} |
|
361 |
response = app.get('/login/') |
|
362 |
assert 'My IDP' in response |
|
363 |
assert 'OIDIDP' in response |
|
364 | ||
365 |
settings.A2_AUTH_MODULES_CONDITIONS = {'oidc/my-idp': 'not request|is_for_backoffice'} |
|
366 |
response = app.get('/login/') |
|
367 |
assert 'OIDIDP' in response |
|
368 |
assert 'My IDP' not in response |
|
369 | ||
370 |
settings.A2_AUTH_MODULES_CONDITIONS = {'oidc/my-idp': 'not request|is_for_backoffice', |
|
371 |
'oidc/oididp': 'not request|is_for_backoffice'} |
|
372 |
response = app.get('/login/') |
|
373 |
assert 'OIDIDP' not in response |
|
374 |
assert 'My IDP' not in response |
|
375 | ||
376 | ||
334 | 377 | |
335 | 378 | |
336 | 379 |
def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, login_url, login_callback_url, hooks): |
tests/test_login.py | ||
---|---|---|
21 | 21 | |
22 | 22 |
from authentic2 import models |
23 | 23 | |
24 |
from utils import login |
|
24 |
from utils import login, check_log
|
|
25 | 25 | |
26 | 26 | |
27 | 27 |
def test_login_inactive_user(db, app): |
... | ... | |
50 | 50 |
assert '_auth_user_id' not in app.session |
51 | 51 | |
52 | 52 | |
53 |
def test_login_with_conditionnal_enabled_authenticators(db, app, settings, caplog): |
|
54 |
settings.A2_AUTH_MODULES_CONDITIONS = {'password': 'request|is_for_backoffice'} |
|
55 |
# open the page first time so session cookie can be set |
|
56 |
response = app.get('/login/') |
|
57 |
session = app.session |
|
58 |
session['login-hint'] = 'backoffice' |
|
59 |
session.save() |
|
60 |
response = app.get('/login/') |
|
61 |
assert 'name="login-password-submit"' in response |
|
62 | ||
63 |
settings.A2_AUTH_MODULES_CONDITIONS = {'password': 'not request|is_for_backoffice'} |
|
64 |
response = app.get('/login/') |
|
65 |
# login form must not be displayed |
|
66 |
assert 'name="login-password-submit"' not in response |
|
67 |
assert len(caplog.records) == 0 |
|
68 |
# set a condition with error |
|
69 |
with check_log(caplog, 'Invalid filter: \'is\''): |
|
70 |
settings.A2_AUTH_MODULES_CONDITIONS = {'password': 'not request|is'} |
|
71 |
response = app.get('/login/') |
|
72 |
assert 'name="login-password-submit"' not in response |
|
73 | ||
74 | ||
53 | 75 |
def test_registration_url_on_login_page(db, app): |
54 | 76 |
response = app.get('/login/?next=/whatever') |
55 | 77 |
assert 'register/?next=/whatever"' in response |
56 |
- |