auth_oidc: avoid user messages with prompt=none related errors (#72538) #38

Merged
pmarillonnet merged 1 commits from wip/72538-auth-oidc-prompt-none-error into main 2023-04-19 12:59:12 +02:00
5 changed files with 85 additions and 30 deletions

View File

@ -18,6 +18,7 @@ from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_noop as N_
from authentic2.a2_rbac.models import OrganizationalUnit as OU
from authentic2.apps.authenticators.models import BaseAuthenticator
from authentic2.apps.journal.models import EventTypeDefinition
from authentic2.apps.journal.utils import Statistics, form_to_old_new
from authentic2.custom_user.models import User, get_attributes_map
@ -160,14 +161,28 @@ class UserLoginFailure(EventTypeWithService):
label = _('login failure')
@classmethod

Vu qu'on a des objets Authenticator , plutôt que authn_type j'aurai bien mis un authenticator_pk, ça permettra d'avoir une vue sur les erreurs d'un authenticateur dans l'admin, de les compter, etc...

À voir ensuite dans get_message pour produire un truc intelligible juste avec le type de l'authenticateur (si les messages ne sont pas magnifiques ça n'est pas bien grave).

Vu qu'on a des objets Authenticator , plutôt que authn_type j'aurai bien mis un authenticator_pk, ça permettra d'avoir une vue sur les erreurs d'un authenticateur dans l'admin, de les compter, etc... À voir ensuite dans get_message pour produire un truc intelligible juste avec le type de l'authenticateur (si les messages ne sont pas magnifiques ça n'est pas bien grave).

Et vu que c'est un objet en base tu peux juste le passer comme objet en référence. Aussi reason n'est pas utilisé ici, ne pas l'introduire, dans data on doit juste avoir username et/ou error.

Et vu que c'est un objet en base tu peux juste le passer comme objet en référence. Aussi reason n'est pas utilisé ici, ne pas l'introduire, dans data on doit juste avoir username et/ou error.

La signature finale ce serait record(cls, *, service, username=None, authenticator=None, error=None).

La signature finale ce serait `record(cls, *, service, username=None, authenticator=None, error=None)`.

Merci, c’est corrigé dans la branche.
J’ai laissé reason dans la signature, parce que cet argument pré-existe au patch (et que d’ailleurs il est utilisé pour la journalisation des erreurs de login dans l’authentification par mot de passe local).

Merci, c’est corrigé dans la branche. J’ai laissé `reason` dans la signature, parce que cet argument pré-existe au patch (et que d’ailleurs il est utilisé pour la journalisation des erreurs de login dans l’authentification par mot de passe local).

Ok pour reason, alors c'est error qui pourrait dégager, y en a un en trop de toute façon.

Ok pour reason, alors c'est error qui pourrait dégager, y en a un en trop de toute façon.

Virer error ne garder que reason, ça fait doublon.

Virer error ne garder que reason, ça fait doublon.
def record(cls, *, service, username, user, reason=None):
super().record(user=user, service=service, data={'username': username, 'reason': reason})
def record(cls, *, authenticator, service, username=None, user=None, reason=None):
super().record(
user=user,
service=service,
data={
'username': username,
'reason': reason,

Ne pas mettre authenticator dans data mais dans references, sinon on ne peut pas facilement faire de requête en base.

Ne pas mettre authenticator dans data mais dans references, sinon on ne peut pas facilement faire de requête en base.
},
references=[authenticator.baseauthenticator_ptr],
)
@classmethod
def get_message(cls, event, context):
Review

et là 👍

(authenticator,) = self.get_typed_references(Authenticator)

au lieu de regarder dans data.

et là 👍 (authenticator,) = self.get_typed_references(Authenticator) au lieu de regarder dans data.
Review

C’est fait dans la branche, mais visiblement le prefetch des références lors de l’affichage du journal se passe mal : https://jenkins.entrouvert.org/job/gitea/job/authentic/job/wip%252F72538-auth-oidc-prompt-none-error/11/console

Je ne connais pas ce code, je ne sais pas si c’est du au fait que dans le type d’événement on sait seulement qu’on a un BaseAuthenticator, mais que dans les appels à la journalisation c’est une de ses classes filles, et que par conséquent les ContentTypes ne concordent pas.

Bref ça marche pas en l’état, et j’ai un peu la flemme de déboguer EventQuerySet.prefetch_references pour comprendre ce qui ne va pas, alors que cette PR au départ se limite aux messages affichés à l’usager sur les retours d’erreur de connexion OIDC…

C’est fait dans la branche, mais visiblement le prefetch des références lors de l’affichage du journal se passe mal : https://jenkins.entrouvert.org/job/gitea/job/authentic/job/wip%252F72538-auth-oidc-prompt-none-error/11/console Je ne connais pas ce code, je ne sais pas si c’est du au fait que dans le type d’événement on sait seulement qu’on a un BaseAuthenticator, mais que dans les appels à la journalisation c’est une de ses classes filles, et que par conséquent les ContentTypes ne concordent pas. Bref ça marche pas en l’état, et j’ai un peu la flemme de déboguer `EventQuerySet.prefetch_references` pour comprendre ce qui ne va pas, alors que cette PR au départ se limite aux messages affichés à l’usager sur les retours d’erreur de connexion OIDC…
Review

C’est fait dans la branche, mais visiblement le prefetch des références lors de l’affichage du journal se passe mal : https://jenkins.entrouvert.org/job/gitea/job/authentic/job/wip%252F72538-auth-oidc-prompt-none-error/11/console

Je ne connais pas ce code, je ne sais pas si c’est du au fait que dans le type d’événement on sait seulement qu’on a un BaseAuthenticator, mais que dans les appels à la journalisation c’est une de ses classes filles, et que par conséquent les ContentTypes ne concordent pas.

Il faut juste caster l'authenticateur pour avoir le bon content-type, donc references=[authenticator.baseauthenticator_ptr].

> C’est fait dans la branche, mais visiblement le prefetch des références lors de l’affichage du journal se passe mal : https://jenkins.entrouvert.org/job/gitea/job/authentic/job/wip%252F72538-auth-oidc-prompt-none-error/11/console > > Je ne connais pas ce code, je ne sais pas si c’est du au fait que dans le type d’événement on sait seulement qu’on a un BaseAuthenticator, mais que dans les appels à la journalisation c’est une de ses classes filles, et que par conséquent les ContentTypes ne concordent pas. Il faut juste caster l'authenticateur pour avoir le bon content-type, donc `references=[authenticator.baseauthenticator_ptr]`.
Review

Ok oui en effet, bien vu. C’était ça et la nécessité d’ajouter l’authentificateur à chaque enregistrement d’un événement de type échec de connexion partout dans le code a2. J’en ai profité pour modifier la signature de la fonction pour ne pas donner de valeur nulle par défaut à l’authentificateur.

Le seul truc qui m’embête, c’est que sur les instances il y a déjà des événements de type échec de connexion, sauf référence vers un authentificateur. Il faut que je corrige le truc qui fait que ça crashe dans le manage lorsqu’on essaie d’afficher un tel événement.

Ok oui en effet, bien vu. C’était ça et la nécessité d’ajouter l’authentificateur à chaque enregistrement d’un événement de type échec de connexion partout dans le code a2. J’en ai profité pour modifier la signature de la fonction pour ne pas donner de valeur nulle par défaut à l’authentificateur. Le seul truc qui m’embête, c’est que sur les instances il y a déjà des événements de type échec de connexion, sauf référence vers un authentificateur. Il faut que je corrige le truc qui fait que ça crashe dans le manage lorsqu’on essaie d’afficher un tel événement.
Review

Le seul truc qui m’embête, c’est que sur les instances il y a déjà des événements de type échec de connexion, sauf référence vers un authentificateur. Il faut que je corrige le truc qui fait que ça crashe dans le manage lorsqu’on essaie d’afficher un tel événement.

Et en fait non, mauvaise connaissance de l’interface de gestion des événements et du passage d’objet en référence, je cherchais par erreur à en créer un avec references=[None].

La PR est bonne pour relecture.

> Le seul truc qui m’embête, c’est que sur les instances il y a déjà des événements de type échec de connexion, sauf référence vers un authentificateur. Il faut que je corrige le truc qui fait que ça crashe dans le manage lorsqu’on essaie d’afficher un tel événement. Et en fait non, mauvaise connaissance de l’interface de gestion des événements et du passage d’objet en référence, je cherchais par erreur à en créer un avec `references=[None]`. La PR est bonne pour relecture.
username = event.get_data('username')
reason = event.get_data('reason')
msg = _('login failure with username "{username}"').format(username=username)
(authenticator,) = event.get_typed_references(BaseAuthenticator)
if username:
msg = _('login failure with username "{username}"').format(username=username)
else:
msg = _('unknown failed login attempt')
if authenticator:
msg += _(' on authenticator {authenticator}').format(authenticator=authenticator)
if reason:
msg.append(_(' (reason: {reason})').format(reason=reason))
return msg

View File

@ -782,12 +782,13 @@ def login_password_login(request, authenticator, *args, **kwargs):
for user, failure_data in request.failed_logins.items():
request.journal.record(
'user.login.failure',
authenticator=authenticator,
user=user,
reason=failure_data.get('reason', None),
username=username,
)
elif username:
request.journal.record('user.login.failure', username=username)
request.journal.record('user.login.failure', authenticator=authenticator, username=username)
context['form'] = form
return render(request, 'authentic2/login_password_form.html', context)

View File

@ -63,6 +63,7 @@ def oidc_login(request, pk, next_url=None, passive=None, *args, **kwargs):
prompt.add('none')
else:
prompt.add('login')
state_content['prompt'] = list(prompt)
params = {
'client_id': provider.client_id,
'scope': ' '.join(scopes),
@ -168,7 +169,7 @@ class LoginCallback(View):
return response
if 'error' in request.GET: # error code path
return self.handle_error(request, provider)
return self.handle_error(request, provider, prompt=state_content.get('prompt') or [])
elif not code:
messages.warning(request, _('Missing code, report %s to an administrator') % request.request_id)
logger.warning('auth_oidc: missing code, %r', request.GET)
@ -309,7 +310,7 @@ class LoginCallback(View):
}
}
def handle_error(self, request, provider):
def handle_error(self, request, provider, prompt):
error = request.GET['error']
error_dict = self.errors.get(error, {})
level = error_dict.get('level', logging.WARNING)
@ -321,30 +322,40 @@ class LoginCallback(View):
log_msg += '"%s" (%s)' % (error_description, error)
else:
log_msg += error
if prompt:
log_msg += ' (prompt: %s)' % ','.join(prompt)
if error_url:
log_msg += ' see %s' % error_url
pmarillonnet marked this conversation as resolved Outdated

Remplace ça par un check sur prompt=none, plus général et plus pertinent dans le sens où on se décide par rapport à ce que le RP a demandé et pas uniquement par rapport

Remplace ça par un check sur prompt=none, plus général et plus pertinent dans le sens où on se décide par rapport à ce que le RP a demandé et pas uniquement par rapport
logger.log(level, log_msg)
if error_description:
message = _('%(error_description)s (%(error)s)') % {
'error_description': error_description,
'error': error,
}
messages.add_message(request, level, message)
else: # unexpected error code
message_params = {
'request_id': request.request_id,
'provider_name': provider and provider.name,
'error': error,
}
if provider:
message = _(
'Login with %(provider_name)s failed, report %(request_id)s to an administrator (%(error)s)'
)
else:
message = _('Login with OpenID Connect failed, report %s to an administrator. (%(error)s)')
if 'none' not in prompt:
if error_description:
message = _('%(error_description)s (%(error)s)') % {
'error_description': error_description,
'error': error,
}
messages.add_message(request, level, message)
else: # unexpected error code
message_params = {
'request_id': request.request_id,
'provider_name': provider and provider.name,
'error': error,
}
if provider:
message = _(
'Login with %(provider_name)s failed, report %(request_id)s to an administrator (%(error)s)'
)
else:
message = _(
'Login with OpenID Connect failed, report %s to an administrator. (%(error)s)'
)
messages.warning(request, message % message_params)
messages.warning(request, message % message_params)
request.journal.record(
'user.login.failure',
authenticator=provider,
reason=log_msg,
)
return self.continue_to_next_url(request)

View File

@ -42,6 +42,7 @@ from authentic2.a2_rbac.utils import get_default_ou
from authentic2.apps.authenticators.models import LoginPasswordAuthenticator
from authentic2.custom_user.models import DeletedUser
from authentic2.models import Attribute, AttributeValue
from authentic2.utils import crypto
from authentic2.utils.misc import last_authentication_event
from authentic2.views import passive_login
from authentic2_auth_oidc.backends import OIDCBackend
@ -559,6 +560,30 @@ def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, hooks):
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code):
response = app.get(login_callback_url(oidc_provider), params={'code': code, 'state': state})
assert not hooks.auth_oidc_backend_modify_user
assert len(utils.decode_cookie(app.cookies['messages'])) == 1
alt_state_content = crypto.loads(state)
alt_state_content['prompt'] = ['none']
with utils.check_log(caplog, 'consent_required'):
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce):
response = app.get(
login_callback_url(oidc_provider),
params={'error': 'consent_required', 'state': crypto.dumps(alt_state_content)},
)
# prompt=none, no message displayed to end user, no additional set cookie
assert len(utils.decode_cookie(app.cookies['messages'])) == 1
alt_state_content = crypto.loads(state)
alt_state_content['prompt'] = ['whatever'] # any value other than none
with utils.check_log(caplog, 'some_other_error'):
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce):
response = app.get(
login_callback_url(oidc_provider),
params={'error': 'some_other_error', 'state': crypto.dumps(alt_state_content)},
)
utils.assert_event(
'user.login.failure',
reason="auth_oidc: error received some_other_error (prompt: whatever)",
)
assert len(hooks.auth_oidc_backend_modify_user) == 0
with utils.check_log(caplog, 'created user'):
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce):
response = app.get(login_callback_url(oidc_provider), params={'code': code, 'state': state})

View File

@ -82,8 +82,8 @@ def events(db, superuser, freezer):
)
make("user.logout", user=user, session=session1)
make("user.login.failure", username="user")
make("user.login.failure", username="agent")
make("user.login.failure", authenticator=saml_authenticator, username="user")
make("user.login.failure", authenticator=saml_authenticator, username="agent")
make("user.login", user=user, session=session1, how="password")
make("user.password.change", user=user, session=session1)
edit_profile_form = mock.Mock(spec=["instance", "initial", "changed_data", "cleaned_data"])
@ -389,13 +389,13 @@ def test_global_journal(app, superuser, events):
'user': 'Johnny doe',
},
{
'message': 'login failure with username "user"',
'message': 'login failure with username "user" on authenticator base authenticator - saml',
'timestamp': 'Jan. 1, 2020, 3 a.m.',
'type': 'user.login.failure',
'user': '-',
},
{
'message': 'login failure with username "agent"',
'message': 'login failure with username "agent" on authenticator base authenticator - saml',
'timestamp': 'Jan. 1, 2020, 4 a.m.',
'type': 'user.login.failure',
'user': '-',
@ -1211,7 +1211,10 @@ def test_search(app, superuser, events):
assert [
list(map(text_content, p))
for p in zip(pq('tbody td.journal-list--user-column'), pq('tbody td.journal-list--message-column'))
] == [['agent', 'login using SAML'], ['-', 'login failure with username "agent"']]
] == [
['agent', 'login using SAML'],
['-', 'login failure with username "agent" on authenticator base authenticator - saml'],
]
response.form.set('search', 'uuid:%s event:reset' % events['user'].uuid)
response = response.form.submit()