Skip to content

Commit

Permalink
ZDL-104: Create django signal upon user login
Browse files Browse the repository at this point in the history
  • Loading branch information
RyanAquino committed Apr 6, 2022
1 parent d2e5d32 commit 6a54d54
Show file tree
Hide file tree
Showing 9 changed files with 136 additions and 16 deletions.
31 changes: 31 additions & 0 deletions authentication/aws_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import json
from datetime import date
from uuid import uuid4

import boto3
from django.conf import settings


class SNSOperations:
def __init__(self):
"""
Initialize AWS SNS client
"""
self.sns_client = boto3.client(
"sns",
region_name="us-east-1",
aws_access_key_id=settings.AWS_SECRET_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)

def publish_message(self, subject: str, message: dict):
"""
Publish message to AWS SNS Topic
"""
self.sns_client.publish(
TopicArn=settings.AWS_SNS_ARN,
Subject=subject,
Message=json.dumps(message),
MessageDeduplicationId=str(uuid4()),
MessageGroupId=f"group-{str(date.today())}",
)
44 changes: 44 additions & 0 deletions authentication/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from datetime import datetime

from django.contrib.auth.models import (
AbstractBaseUser,
BaseUserManager,
Group,
PermissionsMixin,
)
from django.contrib.auth.signals import user_logged_in, user_login_failed
from django.db import models
from django.dispatch import receiver
from rest_framework_simplejwt.tokens import RefreshToken

from authentication import aws_operations
from authentication.validators import AuthProviders, UserTokens


Expand Down Expand Up @@ -74,3 +79,42 @@ def tokens(self) -> UserTokens:

def __str__(self):
return self.email


@receiver(user_logged_in)
def on_login_success(sender, user, request, **kwargs):
user_agent = request.headers.get("User-Agent")
remote_address = request.META.get("REMOTE_ADDR")
current_utc_timestamp = datetime.today().utcnow().strftime("%Y-%m-%d %H:%M:%S")

message = {
"user_id": user.id,
"user_agent": user_agent,
"remote_address": remote_address,
"event": "login_success",
"timestamp": user.last_login.strftime("%Y-%m-%d %H:%M:%S"),
}

sns_operations = aws_operations.SNSOperations()
sns_operations.publish_message(
subject=f"login_event_{current_utc_timestamp}", message=message
)


@receiver(user_login_failed)
def on_login_failed(sender, request, **kwargs):
user_agent = request.headers.get("User-Agent")
remote_address = request.META.get("REMOTE_ADDR")
current_utc_timestamp = datetime.today().utcnow().strftime("%Y-%m-%d %H:%M:%S")

message = {
"user_id": 0,
"user_agent": user_agent,
"remote_address": remote_address,
"event": "login_failed",
"timestamp": current_utc_timestamp,
}
sns_operations = aws_operations.SNSOperations()
sns_operations.publish_message(
subject=f"login_event_{current_utc_timestamp}", message=message
)
13 changes: 9 additions & 4 deletions authentication/serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.contrib import auth
from django.contrib.auth import user_logged_in
from django.contrib.auth.models import update_last_login
from rest_framework import serializers
from rest_framework.exceptions import AuthenticationFailed
Expand Down Expand Up @@ -82,17 +82,22 @@ class Meta:
def validate(self, attrs) -> UserLogin:
email = attrs.get("email", "")
password = attrs.get("password", "")
request = self.context.get("request")

user = auth.authenticate(email=email, password=password)

if not user:
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
raise AuthenticationFailed("Invalid email/password")
else:
if not user.check_password(password):
raise AuthenticationFailed("Invalid email/password")

if user.auth_provider != AuthProviders.email.value:
raise AuthenticationFailed("Please login using your login provider.")

tokens = user.tokens()
update_last_login(sender=None, user=user)
user_logged_in.send(sender=user.__class__, request=request, user=user)

return UserLogin(
**{
Expand Down
42 changes: 31 additions & 11 deletions authentication/tests/test_auth_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime
from unittest.mock import patch

import pytest
from django.contrib.auth.models import Group
Expand Down Expand Up @@ -69,6 +70,24 @@ def test_supplier_register(logged_in_client):


@pytest.mark.django_db
def test_user_login_with_google_provider(client):
"""
Test User login with email on existing OAuth user
"""
user = UserFactory(
email="[email protected]", password="temp-password", auth_provider="google"
)
data = {"email": user.email, "password": "temp-password"}

response = client.post("/v1/auth/login/", data)
response_data = response.json()

assert response.status_code == 403
assert response_data == {"detail": "Please login using your login provider."}


@pytest.mark.django_db
@patch("botocore.client.BaseClient._make_api_call", lambda *args, **kwargs: None)
def test_user_login(client):
"""
Test User login
Expand All @@ -84,35 +103,34 @@ def test_user_login(client):


@pytest.mark.django_db
def test_user_login_with_google_provider(client):
@patch("botocore.client.BaseClient._make_api_call", lambda *args, **kwargs: None)
def test_invalid_email_credentials_user_login(client):
"""
Test User login with email on existing OAuth user
Test Failed User login
"""
user = UserFactory(
email="[email protected]", password="temp-password", auth_provider="google"
)
data = {"email": user.email, "password": "temp-password"}
data = {"email": "[email protected]", "password": "password"}

response = client.post("/v1/auth/login/", data)
response_data = response.json()

assert response.status_code == 403
assert response_data == {"detail": "Please login using your login provider."}
assert response.json() == {"detail": "Invalid email/password"}


@pytest.mark.django_db
def test_invalid_credentials_user_login(client):
@patch("botocore.client.BaseClient._make_api_call", lambda *args, **kwargs: None)
def test_invalid_password_credentials_user_login(client):
"""
Test Failed User login
"""
data = {"email": "[email protected]", "password": "password"}
user = UserFactory()
data = {"email": user.email, "password": "wrong-password"}

response = client.post("/v1/auth/login/", data)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid email/password"}


@pytest.mark.django_db
@patch("botocore.client.BaseClient._make_api_call", lambda *args, **kwargs: None)
def test_tokens(logged_in_client):
"""
Test refresh token
Expand All @@ -136,6 +154,7 @@ def test_tokens(logged_in_client):


@pytest.mark.django_db
@patch("botocore.client.BaseClient._make_api_call", lambda *args, **kwargs: None)
def test_refresh_token_with_access_token(logged_in_client):
"""
Test refresh token with access token should fail
Expand Down Expand Up @@ -251,6 +270,7 @@ def test_patch_profile_password_of_oauth_user_should_not_update():


@pytest.mark.django_db
@patch("botocore.client.BaseClient._make_api_call", lambda *args, **kwargs: None)
def test_login_should_update_last_login_date_time(client):
"""
Test User login should update last login date time
Expand Down
5 changes: 5 additions & 0 deletions authentication/tests/test_auth_api_throttling.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def teardown(self):
cache.clear()

@patch("rest_framework.throttling.SimpleRateThrottle.get_rate", lambda x: "10/day")
@patch("botocore.client.BaseClient._make_api_call", lambda *args, **kwargs: None)
def test_login_throttle_should_raise_error_on_3_failed_attempts_with_same_user(
self, client
):
Expand All @@ -32,6 +33,7 @@ def test_login_throttle_should_raise_error_on_3_failed_attempts_with_same_user(

# Test for overridden method in custom_throttle.py allow_request()
@patch("rest_framework.throttling.SimpleRateThrottle.get_rate", lambda x: None)
@patch("botocore.client.BaseClient._make_api_call", lambda *args, **kwargs: None)
def test_login_throttle_should_not_raise_error_given_rate_is_none(self, client):
"""
Test User login when rate is not set
Expand All @@ -46,6 +48,7 @@ def test_login_throttle_should_not_raise_error_given_rate_is_none(self, client):
@patch(
"rest_framework.throttling.AnonRateThrottle.get_cache_key", lambda x, y, z: None
)
@patch("botocore.client.BaseClient._make_api_call", lambda *args, **kwargs: None)
def test_login_throttle_should_not_raise_error_given_cache_key_is_none(
self, client
):
Expand All @@ -60,6 +63,7 @@ def test_login_throttle_should_not_raise_error_given_cache_key_is_none(

@patch("rest_framework.throttling.SimpleRateThrottle.get_rate", lambda x: "10/day")
@patch("rest_framework.throttling.SimpleRateThrottle.timer")
@patch("botocore.client.BaseClient._make_api_call", lambda *args, **kwargs: None)
def test_login_throttle_should_remove_cache_keys_with_past_datetime(
self, mocked_timer, client
):
Expand All @@ -79,6 +83,7 @@ def test_login_throttle_should_remove_cache_keys_with_past_datetime(
assert response.status_code == 403 and response.json()

@patch("rest_framework.throttling.SimpleRateThrottle.get_rate", lambda x: "10/day")
@patch("botocore.client.BaseClient._make_api_call", lambda *args, **kwargs: None)
def test_login_throttle_should_proceed_when_users_are_different(self, client):
"""
Test User login on 3 failed login attempts on different users should not raise any throttling errors
Expand Down
4 changes: 3 additions & 1 deletion authentication/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ class UserLoginView(GenericAPIView):
throttle_classes = [UserLoginRateThrottle]

def post(self, request):
serializer = self.serializer_class(data=request.data)
serializer = self.serializer_class(
data=request.data, context={"request": request}
)
serializer.is_valid(raise_exception=True)

return Response(serializer.data, status=status.HTTP_200_OK)
Expand Down
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ asgiref==3.5.0
atomicwrites==1.4.0
attrs==19.3.0
black==20.8b1
boto3==1.21.32
botocore==1.24.32
cachetools==5.0.0
certifi==2020.6.20
chardet==3.0.4
Expand Down Expand Up @@ -39,6 +41,7 @@ iniconfig==1.0.1
isort==5.10.1
itypes==1.2.0
Jinja2==3.0.3
jmespath==1.0.0
MarkupSafe==2.1.1
mccabe==0.6.1
more-itertools==8.5.0
Expand Down Expand Up @@ -72,6 +75,7 @@ rq==1.10.1
rsa==4.8
ruamel.yaml==0.16.12
ruamel.yaml.clib==0.2.2
s3transfer==0.5.2
six==1.15.0
sqlparse==0.3.1
text-unidecode==1.3
Expand Down
6 changes: 6 additions & 0 deletions zadalaAPI/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
import os

from zadala_config import (
AWS_SECRET_ACCESS_KEY,
AWS_SECRET_KEY_ID,
AWS_SNS_ARN,
EMAIL_HOST_PASSWORD,
EMAIL_HOST_PORT,
EMAIL_HOST_PROVIDER,
Expand Down Expand Up @@ -222,6 +225,9 @@

GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", GOOGLE_CLIENT_ID)
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", GOOGLE_CLIENT_SECRET)
AWS_SECRET_KEY_ID = os.environ.get("AWS_SECRET_KEY_ID", AWS_SECRET_KEY_ID)
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", AWS_SECRET_ACCESS_KEY)
AWS_SNS_ARN = os.environ.get("AWS_SNS_ARN", AWS_SNS_ARN)

# Django 3.2
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
Expand Down
3 changes: 3 additions & 0 deletions zadala_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@

GOOGLE_CLIENT_ID = "Google client ID"
GOOGLE_CLIENT_SECRET = "Google client secret"
AWS_SECRET_KEY_ID = "AWS secret key ID"
AWS_SECRET_ACCESS_KEY = "AWS secret access key"
AWS_SNS_ARN = "AWS SNS ARN"

0 comments on commit 6a54d54

Please sign in to comment.