auth_oidc: avoid user messages with prompt=none related errors (#72538) #38
|
@ -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
|
||||
|
||||
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,
|
||||
bdauvergne
commented
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):
|
||||
bdauvergne
commented
et là 👍
au lieu de regarder dans data. et là 👍
(authenticator,) = self.get_typed_references(Authenticator)
au lieu de regarder dans data.
pmarillonnet
commented
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 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…
bdauvergne
commented
Il faut juste caster l'authenticateur pour avoir le bon content-type, donc > 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]`.
pmarillonnet
commented
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.
pmarillonnet
commented
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 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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
bdauvergne
commented
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)
|
||||
|
||||
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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()
|
||||
|
|
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.
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).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.