management: send sms alert to email-less inactive users (#85235)
gitea/authentic/pipeline/head This commit looks good Details

This commit is contained in:
Paul Marillonnet 2024-01-03 15:59:11 +01:00
parent ad8452b101
commit 836769345d
5 changed files with 163 additions and 34 deletions

View File

@ -312,15 +312,21 @@ class UserDeletionForInactivity(EventTypeWithService):
@classmethod
def record(cls, *, user, days_of_inactivity):
super().record(user=user, data={'days_of_inactivity': days_of_inactivity, 'email': user.email})
super().record(
user=user,
data={
'days_of_inactivity': days_of_inactivity,
'identifier': user.email or user.phone_identifier,
},
)
@classmethod
def get_message(cls, event, context):
days_of_inactivity = event.get_data('days_of_inactivity')
email = event.get_data('email')
identifier = event.get_data('identifier')
return _(
'user deletion after {days_of_inactivity} days of inactivity, notification sent to "{email}".'
).format(days_of_inactivity=days_of_inactivity, email=email)
'user deletion after {days_of_inactivity} days of inactivity, notification sent to "{identifier}".'
).format(days_of_inactivity=days_of_inactivity, identifier=identifier)
class UserServiceSSO(EventTypeWithHow):
@ -553,11 +559,10 @@ class UserNotificationInactivity(EventTypeDefinition):
@classmethod
def record(cls, *, user, days_of_inactivity, days_to_deletion):
assert user.email
data = {
'days_of_inactivity': days_of_inactivity,
'days_to_deletion': days_to_deletion,
'email': user.email,
'identifier': user.email or user.phone_identifier,
}
super().record(user=user, data=data)
@ -565,11 +570,13 @@ class UserNotificationInactivity(EventTypeDefinition):
def get_message(cls, event, context):
days_of_inactivity = event.get_data('days_of_inactivity')
days_to_deletion = event.get_data('days_to_deletion')
email = event.get_data('email')
identifier = event.get_data('identifier')
return _(
'notification sent to "{email}" after {days_of_inactivity} days of inactivity. '
'notification sent to "{identifier}" after {days_of_inactivity} days of inactivity. '
'Account will be deleted in {days_to_deletion} days.'
).format(days_of_inactivity=days_of_inactivity, days_to_deletion=days_to_deletion, email=email)
).format(
days_of_inactivity=days_of_inactivity, days_to_deletion=days_to_deletion, identifier=identifier
)
class UserNotificationActivity(EventTypeWithService):

View File

@ -31,7 +31,8 @@ from authentic2.a2_rbac.models import OrganizationalUnit
from authentic2.backends import get_user_queryset
from authentic2.backends.ldap_backend import LDAPBackend
from authentic2.journal_event_types import UserDeletionForInactivity, UserNotificationInactivity
from authentic2.utils.misc import send_templated_mail
from authentic2.utils import sms as utils_sms
from authentic2.utils.misc import get_password_authenticator, send_templated_mail
logger = logging.getLogger(__name__)
@ -48,6 +49,10 @@ class Command(BaseCommand):
3: logging.DEBUG,
}
def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False):
super().__init__(stdout=stdout, stderr=stderr, no_color=no_color, force_color=force_color)
self.is_phone_authn_active = get_password_authenticator().is_phone_authn_active
def add_arguments(self, parser):
parser.add_argument('--fake', action='store_true', help='do nothing', default=False)
@ -65,7 +70,9 @@ class Command(BaseCommand):
self.now = timezone.now()
realms = [block['realm'] for block in LDAPBackend.get_config() if block.get('realm')]
self.user_qs = get_user_queryset().exclude(email='').exclude(userexternalid__source__in=realms)
self.user_qs = get_user_queryset().exclude(userexternalid__source__in=realms)
if not self.is_phone_authn_active:
self.user_qs = self.user_qs.exclude(email='')
translation.activate(settings.LANGUAGE_CODE)
try:
@ -105,7 +112,11 @@ class Command(BaseCommand):
days_to_deletion = ou.clean_unused_accounts_deletion - ou.clean_unused_accounts_alert
for user in inactive_users_first_alert[:count]:
logger.info('%s last login %d days ago, sending alert', user, ou.clean_unused_accounts_alert)
self.send_alert(user, days_to_deletion=days_to_deletion, days_of_inactivity=alert_delay.days)
self.send_alert(
user,
days_to_deletion=days_to_deletion,
days_of_inactivity=alert_delay.days,
)
inactive_users_to_delete = inactive_users.filter(
(
@ -132,7 +143,7 @@ class Command(BaseCommand):
self.delete_user(
user,
days_of_inactivity=deletion_delay.days,
send_mail=user.last_login
send_notification=user.last_login
or not (getattr(user, 'oidc_account', None) or has_saml_identifiers),
)
@ -148,25 +159,46 @@ class Command(BaseCommand):
UserNotificationInactivity.record(
user=user, days_of_inactivity=days_of_inactivity, days_to_deletion=days_to_deletion
)
self.send_mail('authentic2/unused_account_alert', user, ctx)
if user.email:
self.send_mail('authentic2/unused_account_alert', user, ctx)
elif self.is_phone_authn_active and user.phone_identifier:
self.send_sms('authentic2/unused_account_alert_sms.txt', user, ctx)
else:
logger.debug('%s has no email or identifiable phone number, alert was not sent', user)
def send_mail(self, prefix, user, ctx):
if not user.email:
logger.debug('%s has no email, no mail sent', user)
else:
logger.debug('sending mail to %s', user.email)
if not self.fake:
logger.debug('sending mail to %s', user.email)
if not self.fake:
def send_mail():
send_templated_mail(user, prefix, ctx)
def send_mail():
send_templated_mail(user, prefix, ctx)
transaction.on_commit(send_mail)
transaction.on_commit(send_mail)
def delete_user(self, user, days_of_inactivity, send_mail=True):
def send_sms(self, template_name, user, ctx):
logger.debug('sending sms to %s', user.email)
if not self.fake:
def send_sms():
utils_sms.send_sms(
user.phone_identifier,
user.ou,
user=user,
template_names=(template_name,),
context=ctx,
kind=None,
)
transaction.on_commit(send_sms)
def delete_user(self, user, days_of_inactivity, send_notification=True):
ctx = {'user': user}
with transaction.atomic():
if send_mail:
self.send_mail('authentic2/unused_account_delete', user, ctx)
if send_notification:
if user.email:
self.send_mail('authentic2/unused_account_delete', user, ctx)
elif self.is_phone_authn_active and user.phone_identifier:
self.send_sms('authentic2/unused_account_delete_sms.txt', user, ctx)
if not self.fake:
UserDeletionForInactivity.record(user=user, days_of_inactivity=days_of_inactivity)
user.delete()

View File

@ -0,0 +1 @@
{% load i18n %}{% blocktrans %}Your account is inactive, please log in within {{ days_to_deletion }} days to {{ login_url }} to prevent its deletion.{% endblocktrans %}

View File

@ -0,0 +1 @@
{% load i18n %}{% blocktrans %}Your account was inactive and has therefore been deleted.{% endblocktrans %}

View File

@ -95,7 +95,7 @@ def test_clean_unused_account(db, simple_user, mailoutbox, freezer, settings):
assert len(mailoutbox) == 1
assert (
Event.objects.filter(
type__name='user.notification.inactivity', user=simple_user, data__email=simple_user.email
type__name='user.notification.inactivity', user=simple_user, data__identifier=simple_user.email
).count()
== 1
)
@ -115,7 +115,95 @@ def test_clean_unused_account(db, simple_user, mailoutbox, freezer, settings):
assert mailoutbox[-1].to == [email]
assert (
Event.objects.filter(
type__name='user.deletion.inactivity', user=simple_user, data__email=simple_user.email
type__name='user.deletion.inactivity', user=simple_user, data__identifier=simple_user.email
).count()
== 1
)
@responses.activate
def test_clean_unused_account_sms(db, nomail_user, mailoutbox, freezer, settings, phone_activated_authn):
settings.LDAP_AUTH_SETTINGS = [{'realm': 'ldap', 'url': 'ldap://ldap.com/', 'basedn': 'dc=ldap,dc=com'}]
settings.SMS_URL = 'https://foo.whatever.none/'
rsps = responses.post(
settings.SMS_URL,
json={
'headers': {
'content-type': 'application/json',
},
'status_code': 200,
'data': {},
},
)
ldap_user = User.objects.create(username='ldap-user', ou=nomail_user.ou)
ldap_user.attributes.phone = '+33122334455'
ldap_user.save()
oidc_user = User.objects.create(username='oidc-user', ou=nomail_user.ou)
oidc_user.attributes.phone = '+33122334456'
oidc_user.save()
saml_user = User.objects.create(username='saml-user', ou=nomail_user.ou)
saml_user.attributes.phone = '+33122334457'
saml_user.save()
UserExternalId.objects.create(user=ldap_user, source='ldap', external_id='whatever')
provider = OIDCProvider.objects.create(name='oidc', ou=nomail_user.ou)
OIDCAccount.objects.create(user=oidc_user, provider=provider, sub='1')
issuer = Issuer.objects.create(entity_id='https://idp1.example.com/', slug='idp1')
UserSAMLIdentifier.objects.create(user=saml_user, issuer=issuer, name_id='1234')
freezer.move_to('2018-01-01')
nomail_user.attributes.phone = '+33611223344'
nomail_user.save()
nomail_user.ou.clean_unused_accounts_alert = 2
nomail_user.ou.clean_unused_accounts_deletion = 3
nomail_user.ou.save()
last_login = now() - datetime.timedelta(days=2, seconds=30)
for user in (nomail_user, ldap_user, oidc_user, saml_user):
user.last_login = last_login
user.save()
call_command('clean-unused-accounts')
assert rsps.call_count == 1
assert 'Your account is inactive, please log in' in json.loads(rsps.calls[-1].request.body)['message']
# check message contains login url
assert 'https://testserver/login/' in json.loads(rsps.calls[-1].request.body)['message']
assert User.objects.count() == 4
assert len(mailoutbox) == 0
assert (
Event.objects.filter(
type__name='user.notification.inactivity',
user=nomail_user,
data__identifier=nomail_user.attributes.phone,
).count()
== 1
)
freezer.move_to('2018-01-01 12:00:00')
# no new sms, no deletion
call_command('clean-unused-accounts')
assert rsps.call_count == 1
assert User.objects.count() == 4
assert len(mailoutbox) == 0
freezer.move_to('2018-01-02')
call_command('clean-unused-accounts')
assert rsps.call_count == 2
assert (
'Your account was inactive and has therefore been deleted.'
in json.loads(rsps.calls[-1].request.body)['message']
)
assert User.objects.count() == 3
deleted_user = DeletedUser.objects.get()
assert deleted_user.old_user_id == nomail_user.id
assert (
Event.objects.filter(
type__name='user.deletion.inactivity',
user=nomail_user,
data__identifier=nomail_user.attributes.phone,
).count()
== 1
)
@ -257,7 +345,7 @@ def test_clean_unused_account_never_logged_in(app, db, simple_user, mailoutbox,
assert len(mailoutbox) == 2
assert (
Event.objects.filter(
type__name='user.deletion.inactivity', user=simple_user, data__email=simple_user.email
type__name='user.deletion.inactivity', user=simple_user, data__identifier=simple_user.email
).count()
== 1
)
@ -310,19 +398,19 @@ def test_clean_unused_federated_account_never_logged_in(app, db, simple_user, ma
assert len(mailoutbox) == 0
assert (
Event.objects.filter(
type__name='user.deletion.inactivity', user=simple_user, data__email=simple_user.email
type__name='user.deletion.inactivity', user=simple_user, data__identifier=simple_user.email
).count()
== 1
)
assert (
Event.objects.filter(
type__name='user.deletion.inactivity', user=ldap_user, data__email=ldap_user.email
type__name='user.deletion.inactivity', user=ldap_user, data__identifier=ldap_user.email
).count()
== 0
)
assert (
Event.objects.filter(
type__name='user.deletion.inactivity', user=saml_user, data__email=saml_user.email
type__name='user.deletion.inactivity', user=saml_user, data__identifier=saml_user.email
).count()
== 1
)
@ -337,7 +425,7 @@ def test_clean_unused_federated_account_never_logged_in(app, db, simple_user, ma
assert len(mailoutbox) == 0
assert (
Event.objects.filter(
type__name='user.deletion.inactivity', user=ldap_user, data__email=ldap_user.email
type__name='user.deletion.inactivity', user=ldap_user, data__identifier=ldap_user.email
).count()
== 0
)
@ -381,13 +469,13 @@ def test_clean_unused_federated_account_logged_in_untouched(app, db, simple_user
assert len(mailoutbox) == 0
assert (
Event.objects.filter(
type__name='user.deletion.inactivity', user=simple_user, data__email=simple_user.email
type__name='user.deletion.inactivity', user=simple_user, data__identifier=simple_user.email
).count()
== 0
)
assert (
Event.objects.filter(
type__name='user.deletion.inactivity', user=saml_user, data__email=saml_user.email
type__name='user.deletion.inactivity', user=saml_user, data__identifier=saml_user.email
).count()
== 0
)