auth_oidc: migrate authenticator to database (#53902)

This commit is contained in:
Valentin Deniaud 2022-04-13 16:28:13 +02:00
parent 46c99d7816
commit 2c6b3d2e3a
14 changed files with 339 additions and 136 deletions

View File

@ -189,7 +189,6 @@ ACCOUNT_ACTIVATION_DAYS = 2
AUTH_USER_MODEL = 'custom_user.User' AUTH_USER_MODEL = 'custom_user.User'
AUTH_FRONTENDS = ( AUTH_FRONTENDS = (
'authentic2_auth_saml.authenticators.SAMLAuthenticator', 'authentic2_auth_saml.authenticators.SAMLAuthenticator',
'authentic2_auth_oidc.authenticators.OIDCAuthenticator',
'authentic2_auth_fc.authenticators.FcAuthenticator', 'authentic2_auth_fc.authenticators.FcAuthenticator',
) )

View File

@ -444,7 +444,7 @@ def login(request, template_name='authentic2/login.html', redirect_field_name=RE
if hasattr(authenticator, 'autorun'): if hasattr(authenticator, 'autorun'):
if 'message' in token: if 'message' in token:
messages.info(request, token['message']) messages.info(request, token['message'])
return authenticator.autorun(request, block['id']) return authenticator.autorun(request, block.get('id'))
# Old frontends API # Old frontends API
for block in blocks: for block in blocks:

View File

@ -1,64 +0,0 @@
# authentic2 - versatile identity manager
# Copyright (C) 2010-2019 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.shortcuts import render
from django.utils.translation import gettext_noop
from authentic2.authenticators import BaseAuthenticator
from authentic2.utils.misc import make_url, redirect_to_login
from . import app_settings, utils
from .models import OIDCProvider
class OIDCAuthenticator(BaseAuthenticator):
id = 'oidc'
how = ['oidc']
priority = 2
def enabled(self):
return app_settings.ENABLE and utils.has_providers()
def name(self):
return gettext_noop('OpenIDConnect')
def instances(self, request, *args, **kwargs):
for p in utils.get_providers(shown=True):
yield (p.slug, p)
def autorun(self, request, block_id):
auth_id, instance_slug = block_id.split('_')
assert auth_id == self.id
try:
provider = OIDCProvider.objects.get(slug=instance_slug)
except OIDCProvider.DoesNotExist():
return redirect_to_login(request)
return redirect_to_login(request, login_url='oidc-login', kwargs={'pk': provider.pk})
def login(self, request, *args, **kwargs):
context = kwargs.get('context', {})
if kwargs.get('instance'):
instance = kwargs['instance']
context['provider'] = instance
context['login_url'] = make_url(
'oidc-login', kwargs={'pk': instance.id}, request=request, keep_params=True
)
template_names = [
'authentic2_auth_oidc/login_%s.html' % instance.slug,
'authentic2_auth_oidc/login.html',
]
return render(request, template_names, context)

View File

@ -0,0 +1,32 @@
# authentic2 - versatile identity manager
# Copyright (C) 2010-2022 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django import forms
from authentic2.apps.authenticators.forms import AuthenticatorFormMixin
from .models import OIDCProvider
class OIDCProviderEditForm(AuthenticatorFormMixin, forms.ModelForm):
class Meta:
model = OIDCProvider
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['ou'].required = True
self.fields['ou'].empty_label = None

View File

@ -123,6 +123,9 @@ class Migration(migrations.Migration):
), ),
), ),
], ],
options={
'verbose_name': 'OpenIDConnect',
},
), ),
migrations.AddField( migrations.AddField(
model_name='oidcclaimmapping', model_name='oidcclaimmapping',

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.28 on 2022-04-13 14:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentic2_auth_oidc', '0008_auto_20201102_1142'),
]
operations = [
migrations.AddField(
model_name='oidcprovider',
name='baseauthenticator_ptr',
field=models.IntegerField(default=0),
preserve_default=False,
),
]

View File

@ -0,0 +1,43 @@
# Generated by Django 2.2.28 on 2022-04-13 14:22
from django.db import migrations
from django.utils.text import slugify
from authentic2 import app_settings as global_settings
from authentic2_auth_oidc import app_settings
def add_base_authenticators(apps, schema_editor):
kwargs_settings = getattr(global_settings, 'AUTH_FRONTENDS_KWARGS', {})
oidc_provider_settings = kwargs_settings.get('oidc', {})
show_condition = oidc_provider_settings.get('show_condition')
BaseAuthenticator = apps.get_model('authenticators', 'BaseAuthenticator')
OIDCProvider = apps.get_model('authentic2_auth_oidc', 'OIDCProvider')
for provider in OIDCProvider.objects.all():
if isinstance(show_condition, dict):
show_condition = show_condition.get(provider.slug, '')
base_authenticator = BaseAuthenticator.objects.create(
name=provider.name,
slug=provider.slug or slugify(provider.name),
ou=provider.ou,
enabled=provider.show and app_settings.ENABLE,
order=oidc_provider_settings.get('priority', 2),
show_condition=show_condition,
)
provider.baseauthenticator_ptr = base_authenticator.pk
provider.save()
class Migration(migrations.Migration):
dependencies = [
('authentic2_auth_oidc', '0009_oidcprovider_baseauthenticator_ptr'),
('authenticators', '0001_initial'),
]
operations = [
migrations.RunPython(add_base_authenticators, reverse_code=migrations.RunPython.noop),
]

View File

@ -0,0 +1,65 @@
# Generated by Django 2.2.28 on 2022-04-13 14:32
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.operations.base import Operation
class AlterModelBase(Operation):
def __init__(self, model_name, base_name):
self.model_name = model_name
self.base_name = base_name
def state_forwards(self, app_label, state):
model_state = state.models[app_label, self.model_name]
model_state.bases = (self.base_name,)
state.reload_model(app_label, self.model_name, delay=True)
def database_forwards(self, app_label, schema_editor, from_state, to_state):
pass
def database_backwards(self, app_label, schema_editor, from_state, to_state):
pass
class Migration(migrations.Migration):
dependencies = [
('authentic2_auth_oidc', '0010_auto_20220413_1622'),
]
operations = [
migrations.RemoveField(
model_name='oidcprovider',
name='id',
),
migrations.RemoveField(
model_name='oidcprovider',
name='name',
),
migrations.RemoveField(
model_name='oidcprovider',
name='ou',
),
migrations.RemoveField(
model_name='oidcprovider',
name='show',
),
migrations.RemoveField(
model_name='oidcprovider',
name='slug',
),
migrations.AlterField(
model_name='oidcprovider',
name='baseauthenticator_ptr',
field=models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to='authenticators.BaseAuthenticator',
),
),
AlterModelBase('oidcprovider', 'authenticators.baseauthenticator'),
]

View File

@ -21,10 +21,12 @@ from django.conf import settings
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from jwcrypto.jwk import InvalidJWKValue, JWKSet from jwcrypto.jwk import InvalidJWKValue, JWKSet
from authentic2.a2_rbac.models import OrganizationalUnit from authentic2.apps.authenticators.models import BaseAuthenticator
from authentic2.utils.misc import make_url, redirect_to_login
from authentic2.utils.template import validate_template from authentic2.utils.template import validate_template
from . import managers from . import managers
@ -38,7 +40,7 @@ def validate_jwkset(data):
raise ValidationError(_('Invalid JWKSet: %s') % e) raise ValidationError(_('Invalid JWKSet: %s') % e)
class OIDCProvider(models.Model): class OIDCProvider(BaseAuthenticator):
STRATEGY_CREATE = 'create' STRATEGY_CREATE = 'create'
STRATEGY_FIND_UUID = 'find-uuid' STRATEGY_FIND_UUID = 'find-uuid'
STRATEGY_FIND_USERNAME = 'find-username' STRATEGY_FIND_USERNAME = 'find-username'
@ -61,8 +63,6 @@ class OIDCProvider(models.Model):
(ALGO_EC, _('EC')), (ALGO_EC, _('EC')),
] ]
name = models.CharField(unique=True, max_length=128, verbose_name=_('name'))
slug = models.SlugField(unique=True, max_length=256, verbose_name=_('slug'), blank=True, null=True)
issuer = models.CharField(max_length=256, verbose_name=_('issuer'), unique=True, db_index=True) issuer = models.CharField(max_length=256, verbose_name=_('issuer'), unique=True, db_index=True)
client_id = models.CharField(max_length=128, default=uuid.uuid4, verbose_name=_('client id')) client_id = models.CharField(max_length=128, default=uuid.uuid4, verbose_name=_('client id'))
client_secret = models.CharField(max_length=128, default=uuid.uuid4, verbose_name=_('client secret')) client_secret = models.CharField(max_length=128, default=uuid.uuid4, verbose_name=_('client secret'))
@ -89,30 +89,44 @@ class OIDCProvider(models.Model):
# ou where new users should be created # ou where new users should be created
strategy = models.CharField(max_length=32, choices=STRATEGIES, verbose_name=_('strategy')) strategy = models.CharField(max_length=32, choices=STRATEGIES, verbose_name=_('strategy'))
ou = models.ForeignKey(
to=OrganizationalUnit, verbose_name=_('organizational unit'), on_delete=models.CASCADE
)
# policy # policy
max_auth_age = models.PositiveIntegerField( max_auth_age = models.PositiveIntegerField(
verbose_name=_('max authentication age'), blank=True, null=True verbose_name=_('max authentication age'), blank=True, null=True
) )
# hide OP from login page
show = models.BooleanField(verbose_name=_('show on login page'), blank=True, default=True)
# metadata # metadata
created = models.DateTimeField(verbose_name=_('created'), auto_now_add=True) created = models.DateTimeField(verbose_name=_('created'), auto_now_add=True)
modified = models.DateTimeField(verbose_name=_('modified'), auto_now=True) modified = models.DateTimeField(verbose_name=_('modified'), auto_now=True)
objects = managers.OIDCProviderManager() objects = managers.OIDCProviderManager()
type = 'oidc'
how = ['oidc']
description_fields = ['show_condition', 'issuer', 'scopes', 'strategy', 'created', 'modified']
class Meta:
verbose_name = _('OpenIDConnect')
@property
def manager_form_class(self):
from .forms import OIDCProviderEditForm
return OIDCProviderEditForm
@property @property
def jwkset(self): def jwkset(self):
if self.jwkset_json: if self.jwkset_json:
return JWKSet.from_json(json.dumps(self.jwkset_json)) return JWKSet.from_json(json.dumps(self.jwkset_json))
return None return None
def get_short_description(self):
if self.issuer and self.scopes:
return _('OIDC provider linked to issuer %(issuer)s with scopes %(scopes)s.') % {
'issuer': self.issuer,
'scopes': self.scopes.replace(' ', ', '),
}
def clean_fields(self, exclude=None): def clean_fields(self, exclude=None):
super().clean_fields(exclude=exclude) super().clean_fields(exclude=exclude)
exclude = exclude or [] exclude = exclude or []
@ -145,9 +159,6 @@ class OIDCProvider(models.Model):
% key_sig_mapping[self.idtoken_algo] % key_sig_mapping[self.idtoken_algo]
) )
def __str__(self):
return str(self.name)
def authorization_claims_parameter(self): def authorization_claims_parameter(self):
idtoken_claims = {} idtoken_claims = {}
userinfo_claims = {} userinfo_claims = {}
@ -165,6 +176,21 @@ class OIDCProvider(models.Model):
def __repr__(self): def __repr__(self):
return '<OIDCProvider %r>' % self.issuer return '<OIDCProvider %r>' % self.issuer
def autorun(self, request, *args):
return redirect_to_login(request, login_url='oidc-login', kwargs={'pk': self.pk})
def login(self, request, *args, **kwargs):
context = kwargs.get('context', {})
context['provider'] = self
context['login_url'] = make_url(
'oidc-login', kwargs={'pk': self.id}, request=request, keep_params=True
)
template_names = [
'authentic2_auth_oidc/login_%s.html' % self.slug,
'authentic2_auth_oidc/login.html',
]
return render(request, template_names, context)
class OIDCClaimMapping(models.Model): class OIDCClaimMapping(models.Model):
NOT_VERIFIED = 0 NOT_VERIFIED = 0

View File

@ -29,19 +29,9 @@ from authentic2.a2_rbac.utils import get_default_ou
from authentic2.models import Attribute from authentic2.models import Attribute
from authentic2.utils.cache import GlobalCache from authentic2.utils.cache import GlobalCache
from . import models
TIMEOUT = 1 TIMEOUT = 1
@GlobalCache(timeout=5, kwargs=['shown'])
def get_providers(shown=None):
qs = models.OIDCProvider.objects.all()
if shown is not None:
qs = qs.filter(show=shown)
return qs
@GlobalCache(timeout=TIMEOUT) @GlobalCache(timeout=TIMEOUT)
def get_attributes(): def get_attributes():
return Attribute.objects.all() return Attribute.objects.all()
@ -54,13 +44,6 @@ def get_provider(pk):
return get_object_or_404(models.OIDCProvider, pk=pk) return get_object_or_404(models.OIDCProvider, pk=pk)
@GlobalCache(timeout=TIMEOUT)
def has_providers():
from . import models
return models.OIDCProvider.objects.filter(show=True).exists()
@GlobalCache(timeout=TIMEOUT) @GlobalCache(timeout=TIMEOUT)
def get_provider_by_issuer(issuer): def get_provider_by_issuer(issuer):
from . import models from . import models

View File

@ -34,7 +34,7 @@ from authentic2.authentication import OIDCUser
from authentic2.manager.utils import get_ou_count from authentic2.manager.utils import get_ou_count
from authentic2.models import Attribute, Service from authentic2.models import Attribute, Service
from authentic2.utils.evaluate import BaseExpressionValidator from authentic2.utils.evaluate import BaseExpressionValidator
from authentic2_auth_oidc.utils import get_provider_by_issuer, get_providers, has_providers from authentic2_auth_oidc.utils import get_provider_by_issuer
from authentic2_idp_oidc.models import OIDCClient from authentic2_idp_oidc.models import OIDCClient
from . import utils from . import utils
@ -339,10 +339,8 @@ def clear_cache():
for cached_el in ( for cached_el in (
OrganizationalUnit.cached, OrganizationalUnit.cached,
a2_hooks.get_hooks, a2_hooks.get_hooks,
get_providers,
get_provider_by_issuer, get_provider_by_issuer,
get_ou_count, get_ou_count,
has_providers,
): ):
cached_el.cache.clear() cached_el.cache.clear()

View File

@ -44,14 +44,7 @@ from authentic2.models import Attribute, AttributeValue
from authentic2.utils.misc import last_authentication_event from authentic2.utils.misc import last_authentication_event
from authentic2_auth_oidc.backends import OIDCBackend from authentic2_auth_oidc.backends import OIDCBackend
from authentic2_auth_oidc.models import OIDCAccount, OIDCClaimMapping, OIDCProvider from authentic2_auth_oidc.models import OIDCAccount, OIDCClaimMapping, OIDCProvider
from authentic2_auth_oidc.utils import ( from authentic2_auth_oidc.utils import IDToken, IDTokenError, parse_id_token, register_issuer
IDToken,
IDTokenError,
get_providers,
has_providers,
parse_id_token,
register_issuer,
)
from . import utils from . import utils
@ -176,6 +169,7 @@ def make_oidc_provider(
ou=get_default_ou(), ou=get_default_ou(),
name=name, name=name,
slug=slug, slug=slug,
enabled=True,
issuer=issuer, issuer=issuer,
authorization_endpoint='%s/authorize' % issuer, authorization_endpoint='%s/authorize' % issuer,
token_endpoint='%s/token' % issuer, token_endpoint='%s/token' % issuer,
@ -412,6 +406,7 @@ def test_providers_on_login_page(oidc_provider, app):
ou=get_default_ou(), ou=get_default_ou(),
name='OIDCIDP 2', name='OIDCIDP 2',
slug='oidcidp-2', slug='oidcidp-2',
enabled=True,
issuer='https://idp2.example.com/', issuer='https://idp2.example.com/',
authorization_endpoint='https://idp2.example.com/authorize', authorization_endpoint='https://idp2.example.com/authorize',
token_endpoint='https://idp2.example.com/token', token_endpoint='https://idp2.example.com/token',
@ -431,47 +426,33 @@ def test_providers_on_login_page(oidc_provider, app):
def test_login_with_conditional_authenticators(oidc_provider, oidc_provider_jwkset, app, settings, caplog): def test_login_with_conditional_authenticators(oidc_provider, oidc_provider_jwkset, app, settings, caplog):
make_oidc_provider(name='My IDP', slug='myidp', jwkset=oidc_provider_jwkset) myidp = make_oidc_provider(name='My IDP', slug='myidp', jwkset=oidc_provider_jwkset)
response = app.get('/login/') response = app.get('/login/')
assert 'My IDP' in response assert 'My IDP' in response
assert 'Server' in response assert 'Server' in response
settings.AUTH_FRONTENDS_KWARGS = {'oidc': {'show_condition': {'myidp': 'remote_addr==\'0.0.0.0\''}}} myidp.show_condition = 'remote_addr==\'0.0.0.0\''
myidp.save()
response = app.get('/login/') response = app.get('/login/')
assert 'Server' in response assert 'Server' in response
assert 'My IDP' not in response assert 'My IDP' not in response
settings.AUTH_FRONTENDS_KWARGS = { oidc_provider.show_condition = 'remote_addr==\'127.0.0.1\''
'oidc': { oidc_provider.save()
'show_condition': {'myid': 'remote_addr==\'0.0.0.0\'', 'server': 'remote_addr==\'127.0.0.1\''}
}
}
response = app.get('/login/')
assert 'Server' in response
assert 'My IDP' in response
settings.AUTH_FRONTENDS_KWARGS = {
'oidc': {
'show_condition': {'myidp': 'remote_addr==\'0.0.0.0\'', 'server': 'remote_addr==\'127.0.0.1\''}
}
}
response = app.get('/login/') response = app.get('/login/')
assert 'Server' in response assert 'Server' in response
assert 'My IDP' not in response assert 'My IDP' not in response
settings.AUTH_FRONTENDS_KWARGS = {'oidc': {'show_condition': 'remote_addr==\'127.0.0.1\''}} myidp.show_condition = 'remote_addr==\'127.0.0.1\''
myidp.save()
response = app.get('/login/') response = app.get('/login/')
assert 'Server' in response assert 'Server' in response
assert 'My IDP' in response assert 'My IDP' in response
settings.AUTH_FRONTENDS_KWARGS = { myidp.show_condition = 'remote_addr==\'127.0.0.1\' and \'backoffice\' not in login_hint'
'oidc': { myidp.save()
'show_condition': { oidc_provider.show_condition = '\'backoffice\' in login_hint'
'myidp': 'remote_addr==\'127.0.0.1\' and \'backoffice\' not in login_hint', oidc_provider.save()
'server': '\'backoffice\' in login_hint',
}
}
}
response = app.get('/login/') response = app.get('/login/')
assert 'Server' not in response assert 'Server' not in response
assert 'My IDP' in response assert 'My IDP' in response
@ -631,12 +612,9 @@ def test_show_on_login_page(app, oidc_provider):
assert 'oidc-a-server' in response.text assert 'oidc-a-server' in response.text
# do not show this provider on login page anymore # do not show this provider on login page anymore
oidc_provider.show = False oidc_provider.enabled = False
oidc_provider.save() oidc_provider.save()
# we have a 5 seconds cache on list of providers, we have to work around it
get_providers.cache.clear()
has_providers.cache.clear()
response = app.get('/login/') response = app.get('/login/')
assert 'oidc-a-server' not in response.text assert 'oidc-a-server' not in response.text
@ -1091,3 +1069,52 @@ def test_auth_time_is_null(app, caplog, code, oidc_provider, oidc_provider_jwkse
): ):
response = app.get(login_callback_url(oidc_provider), params={'code': code, 'state': state}) response = app.get(login_callback_url(oidc_provider), params={'code': code, 'state': state})
assert User.objects.count() == 1 assert User.objects.count() == 1
@pytest.mark.parametrize(
'auth_frontend_kwargs',
[
{'oidc': {'priority': 3, 'show_condition': '"backoffice" not in login_hint'}},
{'oidc': {'show_condition': {'baz': '"backoffice" not in login_hint', 'bar': 'True'}}},
],
)
def test_oidc_provider_authenticator_data_migration(auth_frontend_kwargs, migration, settings):
settings.AUTH_FRONTENDS_KWARGS = auth_frontend_kwargs
app = 'authentic2_auth_oidc'
migrate_from = [(app, '0008_auto_20201102_1142')]
migrate_to = [(app, '0011_auto_20220413_1632')]
old_apps = migration.before(migrate_from)
OIDCProvider = old_apps.get_model(app, 'OIDCProvider')
OrganizationalUnit = old_apps.get_model('a2_rbac', 'OrganizationalUnit')
ou1 = OrganizationalUnit.objects.create(name='OU1', slug='ou1')
issuer = 'https://baz.example.com'
OIDCProvider.objects.create(
name='Baz',
slug='baz',
ou=ou1,
show=True,
issuer=issuer,
authorization_endpoint='%s/authorize' % issuer,
token_endpoint='%s/token' % issuer,
end_session_endpoint='%s/logout' % issuer,
userinfo_endpoint='%s/user_info' % issuer,
token_revocation_endpoint='%s/revoke' % issuer,
)
new_apps = migration.apply(migrate_to)
OIDCProvider = new_apps.get_model(app, 'OIDCProvider')
BaseAuthenticator = new_apps.get_model('authenticators', 'BaseAuthenticator')
authenticator = OIDCProvider.objects.get()
assert authenticator.name == 'Baz'
assert authenticator.slug == 'baz'
assert authenticator.ou.pk == ou1.pk
assert authenticator.enabled is True
assert authenticator.order == auth_frontend_kwargs['oidc'].get('priority', 2)
assert authenticator.show_condition == '"backoffice" not in login_hint'
assert authenticator.authorization_endpoint == '%s/authorize' % issuer
base_authenticator = BaseAuthenticator.objects.get()
assert authenticator.uuid == base_authenticator.uuid

View File

@ -263,6 +263,8 @@ def test_oidc_register_issuer(db, tmpdir, monkeypatch):
jwkset.add(JWK.generate(kty='EC', size=256, kid='tsrn')) jwkset.add(JWK.generate(kty='EC', size=256, kid='tsrn'))
return OIDCProvider.objects.create( return OIDCProvider.objects.create(
name=name, name=name,
slug='test',
enabled=True,
ou=ou, ou=ou,
issuer=issuer, issuer=issuer,
strategy='create', strategy='create',

View File

@ -14,6 +14,10 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
from authentic2_auth_oidc.models import OIDCProvider
from .utils import login, logout from .utils import login, logout
@ -74,3 +78,69 @@ def test_authenticators_password(app, superuser):
# cannot add another password authenticator # cannot add another password authenticator
resp = app.get('/manage/authenticators/add/') resp = app.get('/manage/authenticators/add/')
assert 'Password' not in resp.text assert 'Password' not in resp.text
@pytest.mark.freeze_time('2022-04-19 14:00')
def test_authenticators_oidc(app, superuser, ou1, ou2):
resp = login(app, superuser, path='/manage/authenticators/')
resp = resp.click('Add new authenticator')
resp.form['name'] = 'Test'
resp.form['authenticator'] = 'oidc'
resp.form['ou'] = ou1.pk
resp = resp.form.submit().follow()
assert OIDCProvider.objects.filter(slug='test').count() == 1
assert 'Created: April 19, 2022, 2 p.m.' in resp.text
assert 'Modified: April 19, 2022, 2 p.m.' in resp.text
assert 'Issuer' not in resp.text
resp = resp.click('Edit')
assert 'enabled' not in resp.form.fields
resp.form['issuer'] = 'https://oidc.example.com'
resp.form['scopes'] = 'profile email'
resp.form['strategy'] = 'create'
resp.form['authorization_endpoint'] = 'https://oidc.example.com/authorize'
resp.form['token_endpoint'] = 'https://oidc.example.com/token'
resp.form['userinfo_endpoint'] = 'https://oidc.example.com/user_info'
resp.form['idtoken_algo'] = 2
resp = resp.form.submit().follow()
assert 'Issuer: https://oidc.example.com' in resp.text
assert 'Scopes: profile email' in resp.text
resp = app.get('/manage/authenticators/')
assert 'OpenIDConnect - Test' in resp.text
assert 'class="section disabled"' in resp.text
assert 'OIDC provider linked to' not in resp.text
resp = resp.click('Configure', index=1)
resp = resp.click('Enable').follow()
assert 'Authenticator has been enabled.' in resp.text
resp = app.get('/manage/authenticators/')
assert 'class="section disabled"' not in resp.text
assert 'OIDC provider linked to https://oidc.example.com with scopes profile, email.' not in resp.text
# same name
resp = resp.click('Add new authenticator')
resp.form['name'] = 'test'
resp.form['authenticator'] = 'oidc'
resp.form['ou'] = ou1.pk
resp = resp.form.submit().follow()
assert OIDCProvider.objects.filter(slug='test-1').count() == 1
OIDCProvider.objects.filter(slug='test-1').delete()
# OU is required
resp = app.get('/manage/authenticators/add/')
resp.form['name'] = 'test'
resp.form['authenticator'] = 'oidc'
resp.form['ou'] = ''
resp = resp.form.submit()
assert 'This field is required' in resp.text
resp = app.get('/manage/authenticators/')
resp = resp.click('Configure', index=1)
resp = resp.click('Delete')
resp = resp.form.submit().follow()
assert not OIDCProvider.objects.filter(slug='test').exists()