Refactor and enhance device management and authentication features
Some checks failed
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 4m12s

- Updated the `reverse_dhivehi_string` function to correct the range for combining characters.
- Added new device handling in the health check view and integrated the `add_new_devices_to_omada` task.
- Improved date handling in `CreateTemporaryUserView` to ensure proper string conversion.
- Enhanced OTP sending by converting mobile numbers to strings.
- Implemented MAC address validation in the `Device` model using a custom validator.
- Removed unnecessary fields from the `CreateDeviceSerializer`.
- Normalized MAC address format in the `DeviceListCreateAPIView`.
- Updated the `djangopasswordlessknox` package to improve code consistency and readability.
- Added migration to enforce MAC address validation in the database.
This commit is contained in:
i701 2025-04-25 14:37:27 +05:00
parent 0f19f0c15c
commit 83db42cc60
Signed by: i701
GPG Key ID: 54A0DA1E26D8E587
24 changed files with 475 additions and 209 deletions

View File

@ -47,3 +47,8 @@ curl -X POST http://localhost:4000/api/auth/login/ \
```
expected response: `{"message":"Unable to log in with provided credentials."}`
5. For Celery to work run the worker and the beat
```
celery -A apibase worker --loglevel=info
celery -A apibase beat --loglevel=info
```

View File

@ -27,6 +27,8 @@ class IsStaffEditorPermission(permissions.DjangoModelPermissions):
# Check permissions based on the request method
perms = self.perms_map.get(request.method, [])
perms = [perm % {'app_label': app_label, 'model_name': model_name} for perm in perms]
perms = [
perm % {"app_label": app_label, "model_name": model_name} for perm in perms
]
return request.user.has_perms(perms)

View File

@ -13,7 +13,7 @@ if not api_url or not api_key:
)
def send_otp(mobile: str, otp: int, message: str):
def send_otp(mobile: str, otp: str, message: str):
if not api_url or not api_key:
logger.debug("Failed to send SMS. Missing SMS_API_URL or SMS_API_KEY.")
return False

View File

@ -14,6 +14,27 @@ logger = logging.getLogger(__name__)
env.read_env(os.path.join(BASE_DIR, ".env"))
PERSON_VERIFY_BASE_URL = env.str("PERSON_VERIFY_BASE_URL")
OMADA_PROXY_API_KEY = env.str("OMADA_PROXY_API_KEY")
OMADA_PROXY_URL = env("OMADA_PROXY_URL")
OMADA_SITE_ID = env(
"OMADA_SITE_ID",
)
OMADA_GROUP_ID = env(
"OMADA_GROUP_ID",
)
if not OMADA_SITE_ID:
raise ValueError(
"OMADA_SITE_ID is not set. Please set it in your environment variables."
)
if not OMADA_GROUP_ID:
raise ValueError(
"OMADA_GROUP_ID is not set. Please set it in your environment variables."
)
if not OMADA_PROXY_URL:
raise ValueError(
"OMADA_PROXY_URL is not set. Please set it in your environment variables."
)
@shared_task
@ -54,6 +75,81 @@ def deactivate_expired_devices():
}
def get_existing_omada_devices():
"""
Get existing Omada devices from the database.
:return: List of existing device names.
"""
try:
response = requests.get(
f"{OMADA_PROXY_URL}/9fd0cffa3475a74ae4e4d37de0d12414/api/v2/sites/66dcddb804aa0d2978cf145f/setting/profiles/groups",
headers={"X-API-Key": str(OMADA_PROXY_API_KEY)},
)
print("Response: ", response.status_code)
data = response.json()
existing_devices = []
if "result" in data and len(data["result"]["data"]) > 0:
last_entry = data["result"]["data"][-1]
print("Last Entry: ", last_entry)
if "macAddressList" in last_entry:
existing_devices = last_entry["macAddressList"]
print(existing_devices)
return existing_devices
except requests.RequestException as e:
print(f"Error fetching existing devices: {e}")
return []
@shared_task
def add_new_devices_to_omada(new_devices: list[dict]):
"""
Add new devices to Omada.
:param new_devices: List of new device names to add.
"""
try:
PAYLOAD = {
"name": "REGISTERED_DEVICES",
"type": 2,
"resource": 0,
"ipList": None,
"ipv6List": None,
"macAddressList": None,
"portList": None,
"countryList": None,
"portType": None,
"portMaskList": None,
"domainNamePort": None,
}
existing_devices = get_existing_omada_devices()
PAYLOAD["macAddressList"] = existing_devices
print("Payload with existing devices: ", PAYLOAD)
for device in new_devices:
print("Device in loop: ", device)
PAYLOAD["macAddressList"].append(
{
"macAddress": device["mac"],
"name": device["name"],
}
)
print("New Payload: ", PAYLOAD)
print(
f"{OMADA_PROXY_URL}/9fd0cffa3475a74ae4e4d37de0d12414/api/v2/sites/{OMADA_SITE_ID}/setting/profiles/groups/2/{OMADA_GROUP_ID}"
)
response = requests.patch(
f"{OMADA_PROXY_URL}/9fd0cffa3475a74ae4e4d37de0d12414/api/v2/sites/{OMADA_SITE_ID}/setting/profiles/groups/2/{OMADA_GROUP_ID}",
headers={"X-API-Key": str(OMADA_PROXY_API_KEY)},
json=PAYLOAD,
)
print("Response: ", response.status_code)
if response.status_code == 200:
print("Devices successfully added.")
print(response.json())
else:
print(f"Failed to add devices: {response.text}")
except requests.RequestException as e:
print(f"Error adding devices: {e}")
def verify_user_with_person_api_task(user_id: int):
"""
Verify the user with the Person API.

View File

@ -18,7 +18,7 @@ def reverse_dhivehi_string(input_str):
i = 0
while i < len(reversed_str):
# Check if current character is a combining character
if i + 1 < len(reversed_str) and "\u0300" <= reversed_str[i + 1] <= "\u036F":
if i + 1 < len(reversed_str) and "\u0300" <= reversed_str[i + 1] <= "\u036f":
# If next character is a combining mark, add it before the base character
corrected_chars.append(reversed_str[i + 1] + reversed_str[i])
i += 2

View File

@ -1,4 +1,5 @@
# django imports
import pprint
from django.contrib.auth import login
# rest_framework imports
@ -31,7 +32,8 @@ from typing import cast, Dict, Any
from django.core.mail import send_mail
from django.db.models import Q
from api.sms import send_otp
from .tasks import add, deactivate_expired_devices
from .tasks import add, add_new_devices_to_omada
from devices.models import Device
# local apps import
from .serializers import (
@ -60,7 +62,8 @@ class ErrorMessages:
@api_view(["GET"])
def healthcheck(request):
add.delay(1, 2)
deactivate_expired_devices.delay()
# devices = Device.objects.filter(is_active=False).values()
# add_new_devices_to_omada.delay(new_devices=list(devices))
return Response({"status": "Good"}, status=status.HTTP_200_OK)
@ -117,7 +120,7 @@ class CreateTemporaryUserView(generics.CreateAPIView):
current_date = timezone.now()
try:
dob = timezone.datetime.strptime(dob, "%Y-%m-%d").date()
dob = timezone.datetime.strptime(str(dob), "%Y-%m-%d").date()
except ValueError:
return Response(
{"message": "Invalid date format for DOB. Use YYYY-MM-DD."}, status=400
@ -193,7 +196,7 @@ class CreateTemporaryUserView(generics.CreateAPIView):
formatted_time = otp_expiry.strftime("%d/%m/%Y %H:%M:%S")
otp = temp_user.generate_otp()
send_otp(
temp_user.t_mobile,
str(temp_user.t_mobile),
otp,
f"Your Registration SARLink OTP: {otp}. \nExpires at {formatted_time}. \n\n- SAR Link",
)
@ -263,7 +266,7 @@ class VerifyOTPView(generics.GenericAPIView):
User.objects.create_user(
first_name=temp_user.t_first_name,
last_name=temp_user.t_last_name,
username=temp_user.t_username,
username=str(temp_user.t_username),
password="",
address=temp_user.t_address,
mobile=temp_user.t_mobile,

View File

@ -1,32 +1,25 @@
# Create your views here.
# billing/views.py
import os
from datetime import timedelta
from django.utils import timezone
import requests
from django.utils import timezone
from django.utils.timezone import localtime
from rest_framework import generics, status
from rest_framework.response import Response
from api.mixins import StaffEditorPermissionMixin
from api.tasks import add_new_devices_to_omada
from apibase.env import BASE_DIR, env
from .models import Device, Payment
from .serializers import PaymentSerializer, UpdatePaymentSerializer
from apibase.env import env, BASE_DIR
from django.utils.timezone import localtime
import os
env.read_env(os.path.join(BASE_DIR, ".env"))
PAYMENT_BASE_URL = env("PAYMENT_BASE_URL", default=None)
OMADA_PROXY_URL = env("OMADA_PROXY_URL", default=None)
if not OMADA_PROXY_URL:
raise ValueError(
"OMADA_PROXY_URL is not set. Please set it in your environment variables."
)
if not PAYMENT_BASE_URL:
raise ValueError(
"PAYMENT_BASE_URL is not set. Please set it in your environment variables."
@ -180,10 +173,19 @@ class VerifyPaymentView(StaffEditorPermissionMixin, generics.UpdateAPIView):
registered=True,
)
# Need to add to omada if its a new device and not an existing device
device_list = []
for device in devices:
device_list.append(
{
"mac": device.mac,
"name": device.name,
}
)
if not device.registered:
# Add to omada
pass
add_new_devices_to_omada.delay(new_devices=device_list)
device.registered = True
device.save()
return Response(
{"message": f"Payment verified successfully using [{method}]."}
@ -242,12 +244,3 @@ class DeletePaymentView(StaffEditorPermissionMixin, generics.DestroyAPIView):
devices = instance.devices.all()
devices.update(is_active=False, expiry_date=None, has_a_pending_payment=False)
return super().delete(request, *args, **kwargs)
def get_existing_devices(OMADA_SITE_ID: str, OMADA_GROUP_ID: str):
# Get existing devices from omada
response = requests.get(
f"{OMADA_PROXY_URL}/{OMADA_GROUP_ID}/api/v2/sites/{OMADA_SITE_ID}/setting/profiles/groups",
)
response.raise_for_status()
return response.json()

View File

@ -5,7 +5,9 @@ from .models import Device
class DeviceFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr="icontains")
mac = django_filters.CharFilter(lookup_expr="icontains")
user = django_filters.CharFilter(field_name='user__last_name', lookup_expr="icontains")
user = django_filters.CharFilter(
field_name="user__last_name", lookup_expr="icontains"
)
class Meta:
model = Device

View File

@ -0,0 +1,20 @@
# Generated by Django 5.2 on 2025-04-25 08:42
import devices.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("devices", "0005_device_has_a_pending_payment"),
]
operations = [
migrations.AlterField(
model_name="device",
name="mac",
field=models.CharField(
max_length=255, validators=[devices.models.validate_mac_address]
),
),
]

View File

@ -1,11 +1,26 @@
from django.db import models
from django.utils import timezone
from api.models import User
import regex
from django.core.exceptions import ValidationError
def validate_mac_address(value):
if not regex.match(r"^([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}$", value):
raise ValidationError(
"This field accepts a valid MAC address in the format XX-XX-XX-XX-XX-XX using '-' as the separator."
)
return value
class Device(models.Model):
name = models.CharField(max_length=255)
mac = models.CharField(max_length=255)
mac = models.CharField(
max_length=255,
validators=[
validate_mac_address,
],
)
has_a_pending_payment = models.BooleanField(default=False)
reason_for_blocking = models.CharField(max_length=255, null=True, blank=True)
is_active = models.BooleanField(default=False)

View File

@ -7,14 +7,12 @@ from billing.models import Payment # Import the Payment model
class CreateDeviceSerializer(serializers.ModelSerializer):
name = serializers.CharField(required=True)
mac = serializers.CharField(required=True)
registered = serializers.BooleanField(required=True)
class Meta:
model = Device
fields = [
"name",
"mac",
"registered",
"blocked_by",
]
depth = 2

View File

@ -52,6 +52,11 @@ class DeviceListCreateAPIView(
return Response(
{"message": "Device with this mac address already exists."}, status=400
)
# Normalize MAC address to use "-" as separators
mac = re.sub(r"[^0-9A-Fa-f]", "-", mac).upper()
request.data["mac"] = mac
return super().create(request, *args, **kwargs)
def perform_create(self, serializer):

View File

@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
__title__ = 'djangopasswordlessknox'
__version__ = '1.4.0'
__author__ = 'Lijo'
__license__ = 'MIT'
__copyright__ = 'Copyright 2019 lijo'
__title__ = "djangopasswordlessknox"
__version__ = "1.4.0"
__author__ = "Lijo"
__license__ = "MIT"
__copyright__ = "Copyright 2019 lijo"
# Version synonym
VERSION = __version__
default_app_config = 'djangopasswordlessknox.apps.DrfpasswordlessConfig'
default_app_config = "djangopasswordlessknox.apps.DrfpasswordlessConfig"

View File

@ -1,3 +1,3 @@
VERSION = (1, 4, 0)
__version__ = '.'.join(map(str, VERSION))
__version__ = ".".join(map(str, VERSION))

View File

@ -7,20 +7,22 @@ class UserLinkMixin(object):
"""
A mixin to add a linkable list_display user field.
"""
LINK_TO_USER_FIELD = 'link_to_user'
LINK_TO_USER_FIELD = "link_to_user"
def link_to_user(self, obj):
link = reverse('admin:users_user_change', args=[obj.user.id])
return u'<a href={}>{}</a>'.format(link, obj.user.username)
link = reverse("admin:users_user_change", args=[obj.user.id])
return "<a href={}>{}</a>".format(link, obj.user.username)
link_to_user.allow_tags = True
link_to_user.short_description = 'User'
link_to_user.short_description = "User"
class AbstractCallbackTokenInline(admin.StackedInline):
max_num = 0
extra = 0
readonly_fields = ('created_at', 'key', 'is_active')
fields = ('created_at', 'user', 'key', 'is_active')
readonly_fields = ("created_at", "key", "is_active")
fields = ("created_at", "user", "key", "is_active")
class CallbackInline(AbstractCallbackTokenInline):
@ -28,7 +30,7 @@ class CallbackInline(AbstractCallbackTokenInline):
class AbstractCallbackTokenAdmin(UserLinkMixin, admin.ModelAdmin):
readonly_fields = ('created_at', 'user', 'key')
list_display = ('created_at', UserLinkMixin.LINK_TO_USER_FIELD, 'key', 'is_active')
fields = ('created_at', 'user', 'key', 'is_active')
readonly_fields = ("created_at", "user", "key")
list_display = ("created_at", UserLinkMixin.LINK_TO_USER_FIELD, "key", "is_active")
fields = ("created_at", "user", "key", "is_active")
extra = 0

View File

@ -1,8 +1,9 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class DrfpasswordlessConfig(AppConfig):
name = 'djangopasswordlessknox'
name = "djangopasswordlessknox"
verbose = _("DRF Passwordless")
def ready(self):

View File

@ -10,7 +10,6 @@ import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
@ -19,25 +18,47 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name='CallbackToken',
name="CallbackToken",
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_active', models.BooleanField(default=True)),
('to_alias', models.CharField(blank=True, max_length=40)),
('to_alias_type', models.CharField(blank=True, max_length=20)),
('key', models.CharField(default=djangopasswordlessknox.models.generate_numeric_token, max_length=6, unique=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("is_active", models.BooleanField(default=True)),
("to_alias", models.CharField(blank=True, max_length=40)),
("to_alias_type", models.CharField(blank=True, max_length=20)),
(
"key",
models.CharField(
default=djangopasswordlessknox.models.generate_numeric_token,
max_length=6,
unique=True,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'verbose_name': 'Callback Token',
'abstract': False,
'ordering': ['-id'],
'get_latest_by': 'created_at',
"verbose_name": "Callback Token",
"abstract": False,
"ordering": ["-id"],
"get_latest_by": "created_at",
},
),
migrations.AlterUniqueTogether(
name='callbacktoken',
unique_together=set([('key', 'is_active')]),
name="callbacktoken",
unique_together=set([("key", "is_active")]),
),
]

View File

@ -33,9 +33,14 @@ class AbstractBaseCallbackToken(models.Model):
When a new token is created, older ones of the same type are invalidated
via the pre_save signal in signals.py.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
id = models.UUIDField(
primary_key=True, default=uuid.uuid4, editable=False, unique=True
)
created_at = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name=None, on_delete=models.CASCADE)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, related_name=None, on_delete=models.CASCADE
)
is_active = models.BooleanField(default=True)
to_alias = models.CharField(blank=True, max_length=40)
to_alias_type = models.CharField(blank=True, max_length=20)
@ -44,9 +49,9 @@ class AbstractBaseCallbackToken(models.Model):
class Meta:
abstract = True
get_latest_by = 'created_at'
ordering = ['-id']
unique_together = (('key', 'is_active'),)
get_latest_by = "created_at"
ordering = ["-id"]
unique_together = (("key", "is_active"),)
def __str__(self):
return str(self.key)
@ -56,7 +61,8 @@ class CallbackToken(AbstractBaseCallbackToken):
"""
Generates a random six digit number to be returned.
"""
key = models.CharField(default=generate_numeric_token, max_length=6, unique=True)
class Meta(AbstractBaseCallbackToken.Meta):
verbose_name = 'Callback Token'
verbose_name = "Callback Token"

View File

@ -6,7 +6,11 @@ from django.core.validators import RegexValidator
from rest_framework import serializers
from djangopasswordlessknox.models import CallbackToken
from djangopasswordlessknox.settings import api_settings
from djangopasswordlessknox.utils import authenticate_by_token, verify_user_alias, validate_token_age
from djangopasswordlessknox.utils import (
authenticate_by_token,
verify_user_alias,
validate_token_age,
)
logger = logging.getLogger(__name__)
User = get_user_model()
@ -14,11 +18,11 @@ User = get_user_model()
class TokenField(serializers.CharField):
default_error_messages = {
'required': _('Invalid Token'),
'invalid': _('Invalid Token'),
'blank': _('Invalid Token'),
'max_length': _('Tokens are {max_length} digits long.'),
'min_length': _('Tokens are {min_length} digits long.')
"required": _("Invalid Token"),
"invalid": _("Invalid Token"),
"blank": _("Invalid Token"),
"max_length": _("Tokens are {max_length} digits long."),
"min_length": _("Tokens are {min_length} digits long."),
}
@ -48,10 +52,10 @@ class AbstractBaseAliasAuthenticationSerializer(serializers.Serializer):
except User.DoesNotExist:
# If no user is found, raise an error
msg = ""
if self.alias_type == 'email':
msg = _('No user found with this email.')
elif self.alias_type == 'mobile':
msg = _('No user found with this mobile number.')
if self.alias_type == "email":
msg = _("No user found with this email.")
elif self.alias_type == "mobile":
msg = _("No user found with this mobile number.")
raise serializers.ValidationError(msg)
else:
# If new aliases should not register new users.
@ -63,23 +67,23 @@ class AbstractBaseAliasAuthenticationSerializer(serializers.Serializer):
if user:
if not user.is_active:
# If valid, return attrs so we can create a token in our logic controller
msg = _('User account is disabled.')
msg = _("User account is disabled.")
raise serializers.ValidationError(msg)
else:
msg = _('No account is associated with this alias.')
msg = _("No account is associated with this alias.")
raise serializers.ValidationError(msg)
else:
msg = _('Missing %s.') % self.alias_type
msg = _("Missing %s.") % self.alias_type
raise serializers.ValidationError(msg)
attrs['user'] = user
attrs["user"] = user
return attrs
class EmailAuthSerializer(AbstractBaseAliasAuthenticationSerializer):
@property
def alias_type(self):
return 'email'
return "email"
email = serializers.EmailField()
@ -87,11 +91,13 @@ class EmailAuthSerializer(AbstractBaseAliasAuthenticationSerializer):
class MobileAuthSerializer(AbstractBaseAliasAuthenticationSerializer):
@property
def alias_type(self):
return 'mobile'
return "mobile"
phone_regex = RegexValidator(regex=r'^[7|9][0-9]{6}$',
phone_regex = RegexValidator(
regex=r"^[7|9][0-9]{6}$",
message="Mobile number must be entered in the format:"
" '7xxxxxx' or '9xxxxxx'.")
" '7xxxxxx' or '9xxxxxx'.",
)
mobile = serializers.CharField(validators=[phone_regex], max_length=15)
@ -105,14 +111,14 @@ class AbstractBaseAliasVerificationSerializer(serializers.Serializer):
Abstract class that returns a callback token based on the field given
Returns a token if valid, None or a message if not.
"""
@property
def alias_type(self):
# The alias type, either email or mobile
raise NotImplementedError
def validate(self, attrs):
msg = _('There was a problem with your request.')
msg = _("There was a problem with your request.")
if self.alias_type:
# Get request.user
@ -125,31 +131,31 @@ class AbstractBaseAliasVerificationSerializer(serializers.Serializer):
if user:
if not user.is_active:
# If valid, return attrs so we can create a token in our logic controller
msg = _('User account is disabled.')
msg = _("User account is disabled.")
else:
if hasattr(user, self.alias_type):
# Has the appropriate alias type
attrs['user'] = user
attrs["user"] = user
return attrs
else:
msg = _('This user doesn\'t have an %s.' % self.alias_type)
msg = _("This user doesn't have an %s." % self.alias_type)
raise serializers.ValidationError(msg)
else:
msg = _('Missing %s.') % self.alias_type
msg = _("Missing %s.") % self.alias_type
raise serializers.ValidationError(msg)
class EmailVerificationSerializer(AbstractBaseAliasVerificationSerializer):
@property
def alias_type(self):
return 'email'
return "email"
class MobileVerificationSerializer(AbstractBaseAliasVerificationSerializer):
@property
def alias_type(self):
return 'mobile'
return "mobile"
"""
@ -173,13 +179,13 @@ class AbstractBaseCallbackTokenSerializer(serializers.Serializer):
Abstract class inspired by DRF's own token serializer.
Returns a user if valid, None or a message if not.
"""
token = TokenField(min_length=6, max_length=6, validators=[token_age_validator])
class CallbackTokenAuthSerializer(AbstractBaseCallbackTokenSerializer):
def validate(self, attrs):
callback_token = attrs.get('token', None)
callback_token = attrs.get("token", None)
token = CallbackToken.objects.get(key=callback_token, is_active=True)
@ -189,27 +195,29 @@ class CallbackTokenAuthSerializer(AbstractBaseCallbackTokenSerializer):
user = authenticate_by_token(token)
if user:
if not user.is_active:
msg = _('User account is disabled.')
msg = _("User account is disabled.")
raise serializers.ValidationError(msg)
if api_settings.PASSWORDLESS_USER_MARK_EMAIL_VERIFIED \
or api_settings.PASSWORDLESS_USER_MARK_MOBILE_VERIFIED:
if (
api_settings.PASSWORDLESS_USER_MARK_EMAIL_VERIFIED
or api_settings.PASSWORDLESS_USER_MARK_MOBILE_VERIFIED
):
# Mark this alias as verified
user = User.objects.get(pk=token.user.pk)
success = verify_user_alias(user, token)
if success is False:
msg = _('Error validating user alias.')
msg = _("Error validating user alias.")
raise serializers.ValidationError(msg)
attrs['user'] = user
attrs["user"] = user
return attrs
else:
msg = _('Invalid Token')
msg = _("Invalid Token")
raise serializers.ValidationError(msg)
else:
msg = _('Missing authentication token.')
msg = _("Missing authentication token.")
raise serializers.ValidationError(msg)
@ -222,7 +230,7 @@ class CallbackTokenVerificationSerializer(AbstractBaseCallbackTokenSerializer):
def validate(self, attrs):
try:
user_id = self.context.get("user_id")
callback_token = attrs.get('token', None)
callback_token = attrs.get("token", None)
token = CallbackToken.objects.get(key=callback_token, is_active=True)
user = User.objects.get(pk=user_id)
@ -235,23 +243,31 @@ class CallbackTokenVerificationSerializer(AbstractBaseCallbackTokenSerializer):
if success is False:
logger.debug("djangopasswordlessknox: Error verifying alias.")
attrs['user'] = user
attrs["user"] = user
return attrs
else:
msg = _('This token is invalid. Try again later.')
logger.debug("djangopasswordlessknox: User token mismatch when verifying alias.")
msg = _("This token is invalid. Try again later.")
logger.debug(
"djangopasswordlessknox: User token mismatch when verifying alias."
)
except CallbackToken.DoesNotExist:
msg = _('Missing authentication token.')
logger.debug("djangopasswordlessknox: Tried to validate alias with bad token.")
msg = _("Missing authentication token.")
logger.debug(
"djangopasswordlessknox: Tried to validate alias with bad token."
)
pass
except User.DoesNotExist:
msg = _('Missing user.')
logger.debug("djangopasswordlessknox: Tried to validate alias with bad user.")
msg = _("Missing user.")
logger.debug(
"djangopasswordlessknox: Tried to validate alias with bad user."
)
pass
except PermissionDenied:
msg = _('Insufficient permissions.')
logger.debug("djangopasswordlessknox: Permission denied while validating alias.")
msg = _("Insufficient permissions.")
logger.debug(
"djangopasswordlessknox: Permission denied while validating alias."
)
pass
raise serializers.ValidationError(msg)

View File

@ -1,7 +1,7 @@
from djangopasswordlessknox.utils import (
create_callback_token_for_user,
send_email_with_callback_token,
send_sms_with_callback_token
send_sms_with_callback_token,
)
@ -10,9 +10,9 @@ class TokenService(object):
def send_token(user, alias_type, **message_payload):
token = create_callback_token_for_user(user, alias_type)
send_action = None
if alias_type == 'email':
if alias_type == "email":
send_action = send_email_with_callback_token
elif alias_type == 'mobile':
elif alias_type == "mobile":
send_action = send_sms_with_callback_token
if send_action is None:

View File

@ -17,7 +17,11 @@ def invalidate_previous_tokens(sender, instance, **kwargs):
"""
active_tokens = None
if isinstance(instance, CallbackToken):
active_tokens = CallbackToken.objects.active().filter(user=instance.user).exclude(id=instance.id)
active_tokens = (
CallbackToken.objects.active()
.filter(user=instance.user)
.exclude(id=instance.id)
)
# Invalidate tokens
if active_tokens:
@ -46,15 +50,15 @@ def update_alias_verification(sender, instance, **kwargs):
Optionally sends a verification token to the new endpoint.
"""
if isinstance(instance, User):
if instance.id:
if api_settings.PASSWORDLESS_USER_MARK_EMAIL_VERIFIED is True:
"""
For marking email aliases as not verified when a user changes it.
"""
email_field = api_settings.PASSWORDLESS_USER_EMAIL_FIELD_NAME
email_verified_field = api_settings.PASSWORDLESS_USER_EMAIL_VERIFIED_FIELD_NAME
email_verified_field = (
api_settings.PASSWORDLESS_USER_EMAIL_VERIFIED_FIELD_NAME
)
# Verify that this is an existing instance and not a new one.
try:
@ -62,24 +66,41 @@ def update_alias_verification(sender, instance, **kwargs):
instance_email = getattr(instance, email_field) # Incoming Email
old_email = getattr(user_old, email_field) # Pre-save object email
if instance_email != old_email and instance_email != "" and instance_email is not None:
if (
instance_email != old_email
and instance_email != ""
and instance_email is not None
):
# Email changed, verification should be flagged
setattr(instance, email_verified_field, False)
if api_settings.PASSWORDLESS_AUTO_SEND_VERIFICATION_TOKEN is True:
email_subject = api_settings.PASSWORDLESS_EMAIL_VERIFICATION_SUBJECT
if (
api_settings.PASSWORDLESS_AUTO_SEND_VERIFICATION_TOKEN
is True
):
email_subject = (
api_settings.PASSWORDLESS_EMAIL_VERIFICATION_SUBJECT
)
email_plaintext = api_settings.PASSWORDLESS_EMAIL_VERIFICATION_PLAINTEXT_MESSAGE
email_html = api_settings.PASSWORDLESS_EMAIL_VERIFICATION_TOKEN_HTML_TEMPLATE_NAME
message_payload = {'email_subject': email_subject,
'email_plaintext': email_plaintext,
'email_html': email_html}
success = TokenService.send_token(instance, 'email', **message_payload)
message_payload = {
"email_subject": email_subject,
"email_plaintext": email_plaintext,
"email_html": email_html,
}
success = TokenService.send_token(
instance, "email", **message_payload
)
if success:
logger.info('djangopasswordlessknox: Successfully sent email on updated address: %s'
% instance_email)
logger.info(
"djangopasswordlessknox: Successfully sent email on updated address: %s"
% instance_email
)
else:
logger.info('djangopasswordlessknox: Failed to send email to updated address: %s'
% instance_email)
logger.info(
"djangopasswordlessknox: Failed to send email to updated address: %s"
% instance_email
)
except User.DoesNotExist:
# User probably is just initially being created
@ -90,28 +111,45 @@ def update_alias_verification(sender, instance, **kwargs):
For marking mobile aliases as not verified when a user changes it.
"""
mobile_field = api_settings.PASSWORDLESS_USER_MOBILE_FIELD_NAME
mobile_verified_field = api_settings.PASSWORDLESS_USER_MOBILE_VERIFIED_FIELD_NAME
mobile_verified_field = (
api_settings.PASSWORDLESS_USER_MOBILE_VERIFIED_FIELD_NAME
)
# Verify that this is an existing instance and not a new one.
try:
user_old = User.objects.get(id=instance.id) # Pre-save object
instance_mobile = getattr(instance, mobile_field) # Incoming mobile
old_mobile = getattr(user_old, mobile_field) # Pre-save object mobile
old_mobile = getattr(
user_old, mobile_field
) # Pre-save object mobile
if instance_mobile != old_mobile and instance_mobile != "" and instance_mobile is not None:
if (
instance_mobile != old_mobile
and instance_mobile != ""
and instance_mobile is not None
):
# Mobile changed, verification should be flagged
setattr(instance, mobile_verified_field, False)
if api_settings.PASSWORDLESS_AUTO_SEND_VERIFICATION_TOKEN is True:
if (
api_settings.PASSWORDLESS_AUTO_SEND_VERIFICATION_TOKEN
is True
):
mobile_message = api_settings.PASSWORDLESS_MOBILE_MESSAGE
message_payload = {'mobile_message': mobile_message}
success = TokenService.send_token(instance, 'mobile', **message_payload)
message_payload = {"mobile_message": mobile_message}
success = TokenService.send_token(
instance, "mobile", **message_payload
)
if success:
logger.info('djangopasswordlessknox: Successfully sent SMS on updated mobile: %s'
% instance_mobile)
logger.info(
"djangopasswordlessknox: Successfully sent SMS on updated mobile: %s"
% instance_mobile
)
else:
logger.info('djangopasswordlessknox: Failed to send SMS to updated mobile: %s'
% instance_mobile)
logger.info(
"djangopasswordlessknox: Failed to send SMS to updated mobile: %s"
% instance_mobile
)
except User.DoesNotExist:
# User probably is just initially being created

View File

@ -9,10 +9,26 @@ from djangopasswordlessknox.views import (
)
urlpatterns = [
path('callback/auth/', ObtainAuthTokenFromCallbackToken.as_view(), name='auth_callback'),
path('auth/email/', ObtainEmailCallbackToken.as_view(), name='auth_email'),
path('auth/mobile/', ObtainMobileCallbackToken.as_view(), name='auth_mobile'),
path('callback/verify/', VerifyAliasFromCallbackToken.as_view(), name='verify_callback'),
path('verify/email/', ObtainEmailVerificationCallbackToken.as_view(), name='verify_email'),
path('verify/mobile/', ObtainMobileVerificationCallbackToken.as_view(), name='verify_mobile'),
path(
"callback/auth/",
ObtainAuthTokenFromCallbackToken.as_view(),
name="auth_callback",
),
path("auth/email/", ObtainEmailCallbackToken.as_view(), name="auth_email"),
path("auth/mobile/", ObtainMobileCallbackToken.as_view(), name="auth_mobile"),
path(
"callback/verify/",
VerifyAliasFromCallbackToken.as_view(),
name="verify_callback",
),
path(
"verify/email/",
ObtainEmailVerificationCallbackToken.as_view(),
name="verify_email",
),
path(
"verify/mobile/",
ObtainMobileVerificationCallbackToken.as_view(),
name="verify_mobile",
),
]

View File

@ -29,6 +29,7 @@ class AbstractBaseObtainCallbackToken(APIView):
"""
This returns a 6-digit callback token we can trade for a user's Auth Token.
"""
success_response = "A login token has been sent to you."
failure_response = "Unable to send you a login code. Try again later."
@ -49,12 +50,16 @@ class AbstractBaseObtainCallbackToken(APIView):
# Only allow auth types allowed in settings.
return Response(status=status.HTTP_404_NOT_FOUND)
serializer = self.serializer_class(data=request.data, context={'request': request})
serializer = self.serializer_class(
data=request.data, context={"request": request}
)
if serializer.is_valid(raise_exception=True):
# Validate -
user = serializer.validated_data['user']
user = serializer.validated_data["user"]
# Create and send callback token
success = TokenService.send_token(user, self.alias_type, **self.message_payload)
success = TokenService.send_token(
user, self.alias_type, **self.message_payload
)
# Respond With Success Or Failure of Sent
if success:
@ -63,67 +68,79 @@ class AbstractBaseObtainCallbackToken(APIView):
else:
status_code = status.HTTP_400_BAD_REQUEST
response_detail = self.failure_response
return Response({'detail': response_detail}, status=status_code)
return Response({"detail": response_detail}, status=status_code)
else:
return Response(serializer.error_messages, status=status.HTTP_400_BAD_REQUEST)
return Response(
serializer.error_messages, status=status.HTTP_400_BAD_REQUEST
)
class ObtainEmailCallbackToken(AbstractBaseObtainCallbackToken, generics.GenericAPIView):
class ObtainEmailCallbackToken(
AbstractBaseObtainCallbackToken, generics.GenericAPIView
):
permission_classes = (AllowAny,)
serializer_class = EmailAuthSerializer
success_response = "A login token has been sent to your email."
failure_response = "Unable to email you a login code. Try again later."
alias_type = 'email'
alias_type = "email"
email_subject = api_settings.PASSWORDLESS_EMAIL_SUBJECT
email_plaintext = api_settings.PASSWORDLESS_EMAIL_PLAINTEXT_MESSAGE
email_html = api_settings.PASSWORDLESS_EMAIL_TOKEN_HTML_TEMPLATE_NAME
message_payload = {'email_subject': email_subject,
'email_plaintext': email_plaintext,
'email_html': email_html}
message_payload = {
"email_subject": email_subject,
"email_plaintext": email_plaintext,
"email_html": email_html,
}
class ObtainMobileCallbackToken(AbstractBaseObtainCallbackToken, generics.GenericAPIView):
class ObtainMobileCallbackToken(
AbstractBaseObtainCallbackToken, generics.GenericAPIView
):
permission_classes = (AllowAny,)
serializer_class = MobileAuthSerializer
success_response = "We texted you a login code."
failure_response = "Unable to send you a login code. Try again later."
alias_type = 'mobile'
alias_type = "mobile"
mobile_message = api_settings.PASSWORDLESS_MOBILE_MESSAGE
message_payload = {'mobile_message': mobile_message}
message_payload = {"mobile_message": mobile_message}
class ObtainEmailVerificationCallbackToken(AbstractBaseObtainCallbackToken, generics.GenericAPIView):
class ObtainEmailVerificationCallbackToken(
AbstractBaseObtainCallbackToken, generics.GenericAPIView
):
permission_classes = (IsAuthenticated,)
serializer_class = EmailVerificationSerializer
success_response = "A verification token has been sent to your email."
failure_response = "Unable to email you a verification code. Try again later."
alias_type = 'email'
alias_type = "email"
email_subject = api_settings.PASSWORDLESS_EMAIL_VERIFICATION_SUBJECT
email_plaintext = api_settings.PASSWORDLESS_EMAIL_VERIFICATION_PLAINTEXT_MESSAGE
email_html = api_settings.PASSWORDLESS_EMAIL_VERIFICATION_TOKEN_HTML_TEMPLATE_NAME
message_payload = {
'email_subject': email_subject,
'email_plaintext': email_plaintext,
'email_html': email_html
"email_subject": email_subject,
"email_plaintext": email_plaintext,
"email_html": email_html,
}
class ObtainMobileVerificationCallbackToken(AbstractBaseObtainCallbackToken, generics.GenericAPIView):
class ObtainMobileVerificationCallbackToken(
AbstractBaseObtainCallbackToken, generics.GenericAPIView
):
permission_classes = (IsAuthenticated,)
serializer_class = MobileVerificationSerializer
success_response = "We texted you a verification code."
failure_response = "Unable to send you a verification code. Try again later."
alias_type = 'mobile'
alias_type = "mobile"
mobile_message = api_settings.PASSWORDLESS_MOBILE_MESSAGE
message_payload = {'mobile_message': mobile_message}
message_payload = {"mobile_message": mobile_message}
class AbstractBaseObtainAuthToken(APIView):
@ -131,10 +148,11 @@ class AbstractBaseObtainAuthToken(APIView):
This is a duplicate of rest_framework's own ObtainAuthToken method.
Instead, this returns an Auth Token based on our 6 digit callback token and source.
"""
serializer_class = None
def get_context(self):
return {'request': self.request, 'format': self.format_kwarg, 'view': self}
return {"request": self.request, "format": self.format_kwarg, "view": self}
def get_token_ttl(self):
return knox_settings.TOKEN_TTL
@ -155,43 +173,41 @@ class AbstractBaseObtainAuthToken(APIView):
def get_post_response_data(self, user, token, instance):
UserSerializer = self.get_user_serializer_class()
data = {
'expiry': self.format_expiry_datetime(instance.expiry),
'token': token
}
data = {"expiry": self.format_expiry_datetime(instance.expiry), "token": token}
if UserSerializer is not None:
data["user"] = UserSerializer(
user,
context=self.get_context()
).data
data["user"] = UserSerializer(user, context=self.get_context()).data
return data
def post(self, request, format=None):
token_limit_per_user = self.get_token_limit_per_user()
serializer = self.serializer_class(data=request.data)
if serializer.is_valid(raise_exception=True):
user = serializer.validated_data['user']
user = serializer.validated_data["user"]
if token_limit_per_user is not None:
now = timezone.now()
token = user.auth_token_set.filter(expiry__gt=now)
if token.count() >= token_limit_per_user:
return Response(
{"error": "Maximum amount of tokens allowed per user exceeded."},
status=status.HTTP_403_FORBIDDEN
{
"error": "Maximum amount of tokens allowed per user exceeded."
},
status=status.HTTP_403_FORBIDDEN,
)
token_ttl = self.get_token_ttl()
instance, token = AuthToken.objects.create(user, token_ttl)
user_logged_in.send(sender=user.__class__,
request=request, user=user)
user_logged_in.send(sender=user.__class__, request=request, user=user)
data = self.get_post_response_data(user, token, instance)
return Response(data)
class ObtainAuthTokenFromCallbackToken(AbstractBaseObtainAuthToken, generics.GenericAPIView):
class ObtainAuthTokenFromCallbackToken(
AbstractBaseObtainAuthToken, generics.GenericAPIView
):
"""
This is a duplicate of rest_framework's own ObtainAuthToken method.
Instead, this returns an Auth Token based on our callback token and source.
"""
permission_classes = (AllowAny,)
serializer_class = CallbackTokenAuthSerializer
@ -201,13 +217,23 @@ class VerifyAliasFromCallbackToken(APIView):
This verifies an alias on correct callback token entry using the same logic as auth.
Should be refactored at some point.
"""
serializer_class = CallbackTokenVerificationSerializer
def post(self, request, *args, **kwargs):
serializer = self.serializer_class(data=request.data, context={'user_id': self.request.user.id})
serializer = self.serializer_class(
data=request.data, context={"user_id": self.request.user.id}
)
if serializer.is_valid(raise_exception=True):
return Response({'detail': 'Alias verified.'}, status=status.HTTP_200_OK)
return Response({"detail": "Alias verified."}, status=status.HTTP_200_OK)
else:
logger.error("Couldn't verify unknown user. Errors on serializer: {}".format(serializer.error_messages))
logger.error(
"Couldn't verify unknown user. Errors on serializer: {}".format(
serializer.error_messages
)
)
return Response({'detail': 'We couldn\'t verify this alias. Try again later.'}, status.HTTP_400_BAD_REQUEST)
return Response(
{"detail": "We couldn't verify this alias. Try again later."},
status.HTTP_400_BAD_REQUEST,
)

View File

@ -6,6 +6,7 @@
"typeCheckingMode": "standard",
"reportArgumentType": "warning",
"reportUnusedVariable": "warning",
"reportFunctionMemberAccess": "none",
"exclude": [
"council-api/**/migrations",
"**/__pycache__",