Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resets failed login count on password reset from any source #1387

Merged
merged 3 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 30 additions & 27 deletions api/main/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ def authenticate(self, request, username=None, password=None, **kwargs):
user.save()
lockout_lifted = user.last_failed_login + LOCKOUT_TIME
time_left = lockout_lifted - now
msg = f" *SECURITY ALERT:* Attempt to login during lockout"
msg += f" User={user}/{user.id}"
msg += f" Attempt count = {user.failed_login_count}"
msg += f" Lockout will be lifted in '{time_left}' at '{lockout_lifted}'"
msg = (
f" *SECURITY ALERT:* Attempt to login during lockout: User={user}/{user.id}, "
f" Attempt count = {user.failed_login_count}. Lockout will be lifted in "
f"'{time_left}' at '{lockout_lifted}'"
)
Notify.notify_admin_msg(msg)
# Run the default password hasher once to reduce the timing
# difference (#20760).
Expand All @@ -59,30 +60,32 @@ def authenticate(self, request, username=None, password=None, **kwargs):
if user.check_password(password) and self.user_can_authenticate(user):
user.last_login = datetime.now(timezone.utc)
if user.failed_login_count >= LOCKOUT_LIMIT:
msg = "Login proceeded after lock expiry"
msg += f" User={user}/{user.id}"
msg = "Login proceeded after lock expiry User={user}/{user.id}"
Notify.notify_admin_msg(msg)
user.failed_login_count = 0
user.save()
return user
else:
user.last_failed_login = datetime.now(timezone.utc)
user.failed_login_count += 1
user.save()
if user.failed_login_count >= LOCKOUT_LIMIT:
msg = f"*SECURITY ALERT:* Bad Login Attempt for {user}/{user.id}"
msg += f" Attempt count = {user.failed_login_count}"
Notify.notify_admin_msg(msg)
# Send an email if the failed login count has been reached.
if (user.failed_login_count == LOCKOUT_LIMIT) and settings.TATOR_EMAIL_ENABLED:
get_email_service().email(
sender=settings.TATOR_EMAIL_SENDER,
recipients=[user.email],
title=f"Tator account has been locked",
text="This message is to notify you that your Tator account (username "
f"{user.username}) has been locked due to {LOCKOUT_LIMIT} failed logins. "
"Your account will be unlocked automatically after 10 minutes, or you "
"can unlock your account now by resetting your password. To reset your "
"password, follow the procedure described here:\n\n"
"https://tator.io/tutorials/2021-06-11-reset-your-password/",
)

user.last_failed_login = datetime.now(timezone.utc)
user.failed_login_count += 1
user.save()
if user.failed_login_count >= LOCKOUT_LIMIT:
msg = (
f"*SECURITY ALERT:* Bad Login Attempt for {user}/{user.id}. Attempt count = "
f"{user.failed_login_count}"
)
Notify.notify_admin_msg(msg)
# Send an email if the failed login count has been reached.
email_service = get_email_service()
if user.failed_login_count == LOCKOUT_LIMIT and email_service:
email_service.email(
sender=settings.TATOR_EMAIL_SENDER,
recipients=[user.email],
title=f"Tator account has been locked",
text="This message is to notify you that your Tator account (username "
f"{user.username}) has been locked due to {LOCKOUT_LIMIT} failed logins. "
"Your account will be unlocked automatically after 10 minutes, or you "
"can unlock your account now by resetting your password. To reset your "
"password, follow the procedure described here:\n\n"
"https://tator.io/tutorials/2021-06-11-reset-your-password/",
)
112 changes: 56 additions & 56 deletions api/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,36 +372,32 @@ def get_description(self):
)


@receiver(post_save, sender=User)
def user_save(sender, instance, created, **kwargs):
def block_user_save_email(instance, method, *args, **kwargs):
# Create random attribute name with static prefix for determining if this is the root trigger of
# this signal
attr_prefix = "_saving_"
random_attr = f"{attr_prefix}{''.join(random.sample(string.ascii_lowercase, 16))}"

# Adds random attribute to suppress email from save during creation, then removes it
setattr(instance, random_attr, True)
getattr(instance, method)(*args, **kwargs)
delattr(instance, random_attr)


@receiver(post_save, sender=User)
def user_save(sender, instance, created, **kwargs):
if os.getenv("COGNITO_ENABLED") == "TRUE":
if created:
# Adds random attribute to suppress email from save during creation, then removes it
setattr(instance, random_attr, True)
instance.move_to_cognito()
delattr(instance, random_attr)
block_user_save_email(instance, "move_to_cognito")
else:
TatorCognito().update_attributes(instance)
if created:
if instance.username:
instance.username = instance.username.strip()

# Adds random attribute to suppress email from save during creation, then removes it
setattr(instance, random_attr, True)
instance.save()
delattr(instance, random_attr)
block_user_save_email(instance, "save")
if settings.SAML_ENABLED and not instance.email:
instance.email = instance.username

# Adds random attribute to suppress email from save during creation, then removes it
setattr(instance, random_attr, True)
instance.save()
delattr(instance, random_attr)
block_user_save_email(instance, "save")
invites = Invitation.objects.filter(email=instance.email, status="Pending")
if (invites.count() == 0) and (os.getenv("AUTOCREATE_ORGANIZATIONS")):
organization = Organization.objects.create(name=f"{instance}'s Team")
Expand All @@ -413,49 +409,53 @@ def user_pre_save(sender, instance, **kwargs):
# Prefix for random attribute name to determine if this is the root trigger of this signal
attr_prefix = "_saving_"
user_desc = instance.get_description()
is_root = all(not attr.startswith(attr_prefix) for attr in dir(instance))
created = not instance.pk

if created:
msg = (
f"You are being notified that a new user {instance} (username {instance.username}, "
f"email {instance.email}) has been added to the Tator deployment with the following "
f"attributes:\n\n{user_desc}"
)
is_monitored = True
else:
msg = (
f"You are being notified that an existing user {instance} been modified with the "
f"following values:\n\n{user_desc}"
)

# Only send an email if this is the root `post_save` trigger, i.e. does not have a random
# attribute added to it, and is a modification of a monitored field.
original_instance = type(instance).objects.get(pk=instance.id)
monitored_fields = [
"username",
"first_name",
"last_name",
"email",
"is_staff",
"profile",
"password",
]
is_monitored = any(
getattr(instance, fieldname, None) != getattr(original_instance, fieldname, None)
for fieldname in monitored_fields
)
if all(not attr.startswith(attr_prefix) for attr in dir(instance)):
created = not instance.pk
if created:
msg = (
f"You are being notified that a new user {instance} (username {instance.username}, "
f"email {instance.email}) has been added to the Tator deployment with the "
f"following attributes:\n\n{user_desc}"
)
is_monitored = True
password_modified = False
else:
msg = (
f"You are being notified that an existing user {instance} been modified with the "
f"following values:\n\n{user_desc}"
)

if is_root and is_monitored:
logger.info(msg)
email_service = get_email_service()
if email_service:
email_service.email_staff(
sender=settings.TATOR_EMAIL_SENDER,
title=f"{'Created' if created else 'Modified'} user",
text=msg,
# Only send an email if this is the root `pre_save` trigger, i.e. does not have a random
# attribute added to it, and is a modification of a monitored field.
original_instance = type(instance).objects.get(pk=instance.id)
monitored_fields = [
"username",
"first_name",
"last_name",
"email",
"is_staff",
"profile",
"password",
]
password_modified = instance.password != original_instance.password
is_monitored = password_modified or any(
getattr(instance, fieldname, None) != getattr(original_instance, fieldname, None)
for fieldname in monitored_fields
)

if is_monitored:
if password_modified:
instance.failed_login_count = 0
block_user_save_email(instance, "save")
Comment on lines +440 to +449
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the part that does the reset, for anyone reviewing.

logger.info(msg)
email_service = get_email_service()
if email_service:
email_service.email_staff(
sender=settings.TATOR_EMAIL_SENDER,
title=f"{'Created' if created else 'Modified'} user",
text=msg,
)


@receiver(post_delete, sender=User)
def user_post_delete(sender, instance, **kwargs):
Expand Down
25 changes: 11 additions & 14 deletions api/main/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,38 +12,35 @@


class Notify:
@staticmethod
def notification_enabled():
"""Returns true if notification is enabled"""
return settings.TATOR_SLACK_TOKEN and settings.TATOR_SLACK_CHANNEL

def notify_admin_msg(msg):
@classmethod
def notify_admin_msg(cls, msg):
"""Sends a given message to administrators"""
try:
if Notify.notification_enabled():
if cls.notification_enabled():
client = slack.WebClient(token=settings.TATOR_SLACK_TOKEN)
response = client.chat_postMessage(channel=settings.TATOR_SLACK_CHANNEL, text=msg)
if response["ok"]:
return True
else:
return False
return bool(response["ok"])
except:
logger.warning("Slack Comms failed")
logger.warning("Slack Comms failed", exc_info=True)

return False

def notify_admin_file(title, content):
@classmethod
def notify_admin_file(cls, title, content):
"""Send a given file to administrators"""
try:
if Notify.notification_enabled():
if cls.notification_enabled():
client = slack.WebClient(token=settings.TATOR_SLACK_TOKEN)
response = client.files_upload(
channels=settings.TATOR_SLACK_CHANNEL, content=content, title=title
)
if response["ok"]:
return True
else:
return False
return bool(response["ok"])
except:
logger.warning("Slack Comms failed")
logger.warning("Slack Comms failed", exc_info=True)

return False
1 change: 0 additions & 1 deletion api/main/rest/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
)
from ..schema import MediaListSchema, MediaDetailSchema, parse
from ..schema.components import media as media_schema
from ..notify import Notify
from ..download import download_file
from ..store import get_tator_store, get_storage_lookup
from ..cache import TatorCache
Expand Down
30 changes: 15 additions & 15 deletions api/main/rest/password_reset.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import uuid
import os
import logging
import uuid

from django.db import transaction
from django.conf import settings
from django.http import Http404

from ..models import PasswordReset
from ..models import User
from ..schema import PasswordResetListSchema
from ..mail import get_email_service
from ..models import PasswordReset, User
from ..schema import PasswordResetListSchema

from ._base_views import BaseListView

Expand All @@ -31,17 +27,21 @@ def _post(self, params):
raise RuntimeError(f"Email {email} is in use by multiple users!")
user = users[0]
reset = PasswordReset(user=user, reset_token=uuid.uuid1())
url = f"{os.getenv('MAIN_HOST')}/password-reset?reset_token={reset.reset_token}&user={user.id}"
if settings.TATOR_EMAIL_ENABLED:
get_email_service().email(
url = f"{settings.PROTO}://{settings.MAIN_HOST}/password-reset?reset_token={reset.reset_token}&user={user.id}"
email_service = get_email_service()
if email_service:
text = (
f"A password reset has been requested for this email address ({email}). If you did "
f"not initiate the reset this message can be ignored. To reset your password, "
f"please visit: \n\n{url}\n\nThis URL will expire in 24 hours."
)
failure_msg = f"Unable to send email to {email}! Password reset creation failed."
email_service.email(
sender=settings.TATOR_EMAIL_SENDER,
recipients=[email],
title=f"Tator password reset",
text=f"A password reset has been requested for this email address ({email}). "
f"If you did not initiate the reset this message can be ignored. "
f"To reset your password, please visit: \n\n{url}\n\n"
"This URL will expire in 24 hours.",
raise_on_failure=f"Unable to send email to {email}! Password reset creation failed.",
text=text,
raise_on_failure=failure_msg,
)
else:
raise RuntimeError(
Expand Down
1 change: 0 additions & 1 deletion api/main/rest/transcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from ..models import Media
from ..schema import TranscodeListSchema
from ..schema import TranscodeDetailSchema
from ..notify import Notify

from .media import _create_media
from ._util import url_to_key
Expand Down
Loading