django-channels add user to multiple groups on connect - django

So I have my models like this.
class Box(models.Model):
objects = models.Manager()
name = models.CharField(max_length=100)
owner = models.ForeignKey('users.User', on_delete=models.CASCADE)
REQUIRED_FIELDS = [name, owner, icon]
class User(AbstractBaseUser):
objects = BaseUserManager()
email = models.EmailField(unique=True)
username = models.CharField(max_length=32, validators=[MinLengthValidator(2)], unique=True)
avatar = models.ImageField(upload_to='avatars/', default='avatars/default.jpg')
REQUIRED_FIELDS = [username, email]
class Member(models.Model):
objects = models.Manager()
box = models.ForeignKey('uploads.Box', on_delete=models.CASCADE, editable=False)
user = models.ForeignKey('users.User', on_delete=models.CASCADE, editable=False)
roles = models.ManyToManyField('roles.Role', through='roles.MemberRole')
invite = models.ForeignKey('users.Invite', null=True, blank=True, on_delete=models.CASCADE)
REQUIRED_FIELDS = [box, user]
I have a websockets framework with routing like this.
websocket_urlpatterns = [
path('gateway/', GatewayEventsConsumer.as_asgi()),
]
class GatewayEventsConsumer(AsyncWebsocketConsumer):
"""
An ASGI consumer for gateway event sending. Any authenticated
user can connect to this consumer. Users receive personalized events
based on the permissions they have.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
async def connect(self):
user = self.scope['user']
if user.is_anonymous:
# close the connection if the
# user isn't authenticated yet
await self.close()
for member in user.member_set.all():
# put user into groups according to the boxes
# they are a part of. Additional groups would be
# added mid-way if the user joins or creates
# a box during the lifetime of this connection
await self.channel_layer.group_add(member.box.id, self.channel_name)
await self.channel_layer.group_add(self.scope['user'].id, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
for member in self.scope['user'].member_set.all():
# remove user from groups according to the boxes they
# are a part of. Additional groups would be
# removed mid-way if the user leaves or gets kicked
# out of a box during the lifetime of this connection
await self.channel_layer.group_discard(member.box.id, self.channel_name)
await self.channel_layer.group_discard(self.scope['user'].id, self.channel_name)
async def fire_event(self, event: dict):
formatted = {
'data': event['data'],
'event': event['event'],
}
box = event.get('box', None)
channel = event.get('overwrite_channel', None)
listener_perms = event.get('listener_permissions', [])
if not listener_perms or not box:
# box would be none if the event was user-specific
# don't need to check permissions. Fan-out event
# directly before checking member-permissions
return await self.send(text_data=json.dumps(formatted))
member = self.scope['user'].member_set.get(box=box)
if listener_perms in member.get_permissions(channel):
# check for permissions directly without any extra context
# validation. Because data-binding is outbound permission
# checking is not complex, unlike rest-framework checking
await self.send(text_data=json.dumps(formatted))
this is how I send ws messages.
(using django signals)
#receiver(post_delete, sender=Upload)
def on_upload_delete(instance=None, **kwargs) -> None:
async_to_sync(channel_layer.group_send)(
instance.box.id,
{
'type': 'fire_event',
'event': 'UPLOAD_DELETE',
'listener_permissions': ['READ_UPLOADS'],
'overwrite_channel': instance.channel,
'box': instance.box,
'data': PartialUploadSerializer(instance).data
}
)
The api needs to send box-specific events, so I have different groups for boxes.
Users which connect to these groups will receive the events they need.
So, when the user connects to the "gateway", I add the user to all the boxes they are a part of, (plus a private group to send user-specific information)
On disconnect, I remove them from the same.
However, I am facing issues here.
An example,
when an user joins a box during the scope of the connection, they would not receive the events that are being sent for that particular box.
when an user leaves a box during the scope of the connection, they would still receive events from that particular box.
Any ways to fix these issues?
relevant github discussion is here.

You can do this by adding 2 handlers similar to fire-event.
The first one adds a user to a group
The second one deletes a user from a group.
Then using Django Signals, send a websocket message to those handlers whenever a user becomes a box member or leaves the box

Related

DRY-rest-permissions somehow does not check my object permissions except global permissions

I have recently started implementing dry-rest-permissions, but I can't seem to get it to check the has_object_permissions, it appears that only the global permissions work for me.
I am fairly new to implementing permissions and this is my first time implementing DRY-rest-permissions and have only recently started coding in django rest framework, so apologies for the lack of knowledge in advance.
At the moment I am trying to delete a company object by simply having a user call a URL, that URL then gets the current user's active_company and then deletes it only if the current user is the active_company's company_owner.
But what I discovered, is that I somehow can't get has_object_permissions to work anywhere?
I have noticed that if I delete has_write_permission(request), and hit the company_delete URL it gives me the following error:
'<class 'company.models.Company'>' does not have 'has_write_permission' or 'has_company_delete_permission' defined.
This means that it doesn't even look for the has_object_company_delete_permission. Meaning it only checks the global permissions rather than any of the object permissions, what am I possibly doing wrong here?
My model:
class Company(models.Model):
company_name = models.CharField(max_length=100)
company_orders = models.IntegerField(blank=True, null=True)
company_icon = models.ImageField(
upload_to='media/company_icon', blank=True)
company_owner = models.ForeignKey(
User, on_delete=models.SET_NULL, blank=True, null=True)
company_employees = models.ManyToManyField(
User, blank=True, null=True, related_name="company_employees")
def __str__(self):
return self.company_name
#staticmethod
def has_write_permission(request):
return False
def has_object_company_delete_permission(self, request):
return self.company_owner == request.user
My views
class CompanyView(viewsets.ModelViewSet): # made for viewing details
permission_classes = (DRYPermissions, )
queryset = Company.objects.all()
serializer_class = CompanySerializer
def create(self, request):
try:
company_name = request.data['company_name']
company_orders = request.data['company_orders']
company_owner = request.data['company_owner']
company_owner_obj = User.objects.get(id=company_owner)
company = Company(company_name=company_name,
company_orders=company_orders, company_owner=company_owner_obj)
company.save()
except Exception as error:
response = {
'error': str(error)
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
response = {
'message': 'Company created'
}
return Response(response, status=status.HTTP_201_CREATED)
def company_details(self, request):
try:
company_id = request.user.active_company.id
company = Company.objects.get(id=company_id)
serialized_data = CompanySerializer(company)
except Exception as error:
response = {
'error': str(error)
}
return Response(response)
return Response(serialized_data.data)
def company_edit(self, request, **kwargs):
try:
company_id = request.user.active_company.id
company = Company.objects.get(id=company_id)
serializer = CompanySerializer(
company, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
except Exception as error:
response = {
'message': str(error)
}
return Response(response)
response = {
'message': 'Edited Successfully'
}
return Response(response)
def company_delete(self, request):
try:
company_id = request.user.active_company.id
company = Company.objects.filter(id=company_id)
company.delete()
except Exception as error:
response = {
'message': str(error)
}
return Response(response)
response = {
'message': 'Deleted Successfully'
}
return Response(response)
My urls
urlpatterns = [
# Company URLs
path('company_create/',
CompanyView.as_view({'post': 'create'}), name='company_create'), # Create company
path('company_edit/',
CompanyView.as_view(), name='company_edit'), # Edit company details
path('company_delete/',
CompanyView.as_view({'delete': 'company_delete'}), name='company_delete'), # Delete company
path('company_details/',
CompanyView.as_view({'get': 'company_details'}), name='company_details'), # get company details (owner, employees etc)
]
My serializer
class CompanySerializer(serializers.ModelSerializer):
company_owner = LimitedUserSerializer(read_only=True)
class Meta:
model = Company
fields = ['id', 'company_name', 'company_orders',
'company_icon', 'company_owner']
As described in this part of the documentation the Global permissions are always checked first and Object permissions are checked ONLY if global permissions pass.
Documentation Source:
DRY Rest Permissions allows you to define both global and object level permissions.
Global permissions are always checked first and define the ability of a user to take an action on an entire model. For example you can define whether a user has the ability to update any projects from the database.
Object permissions are checked if global permissions pass and define whether a user has the ability to perform a specific action on a single object. These are also known as row level permissions. Note: list and create actions are the only standard actions that are only global. There is no such object level permission call because they are whole table actions.
In this context you have multiple problem actually that you should correct:
Make sure has_write_permission return True for all users that own their active companies
Make sure to rename has_object_company_delete_permission since we don't need the name of the model inside the function name
Example:
#staticmethod
def has_write_permission(request):
# Everybody can create/update/delete if no specific rule says otherwise
return True
def has_object_delete_permission(self, request):
# Only owner can delete
return self.company_owner == request.user
def has_object_update_permission(self, request):
# Only owner can update
return self.company_owner == request.user
Output:
Everybody can create
Only owner can update
Only owner can delete
I know that it seems a little bit overkill just to delete an object, but with some experience it allow you to clearly define ans setup permission but also to easily share the generic rules with the Frontend by using DryPermissionsField and DRYGlobalPermissionsField
PS: This answer came from my origin answer on Github to allow people finding a solution easily from StackOverFlow
It appears that it somehow does not check for object permissions in any of my custom actions, thus adding the following check in either the get_object() or the custom action itself fixes this.
self.check_object_permissions(self.request, obtainedObject)
An example of what it looks like in my code:
#action(detail=True, methods=['patch'], pk=None)
def company_edit(self, request, **kwargs):
try:
company_id = request.user.active_company.id
company = Company.objects.get(id=company_id)
serializer = CompanySerializer(
company, data=request.data, partial=True)
self.check_object_permissions(self.request, company)
if serializer.is_valid():
serializer.save()
except Exception as error:
response = {
'message': str(error)
}
return Response(response)
response = {
'message': 'Edited Successfully'
}
return Response(response)

Is it possible to make email confirmation key shorter with dj-rest-auth?

I am making a rest api for mobile application. I am using dj-rest-auth package for the overall authentication process. Overall auth functionality works fine but One thing I want to modify is to make the email confirmation key shorter.
User will get email like this.
Please use this key to confirm your email.
MjE:1l7ZhR:f6U2RWlx2kEJY2jXzFuAuKpKclNyc3MpaKmeiEFGp3Y
In my email verify api user need to enter this whole key for verification.
Is there any way to make this key shorter so that it will be good from user perspective(I think) ?
I have made my custom adapter here.
class MyAdapter(DefaultAccountAdapter):
def send_confirmation_mail(self, request, emailconfirmation, signup):
current_site = get_current_site(request)
activate_url = self.get_email_confirmation_url(
request,
emailconfirmation)
ctx = {
"user": emailconfirmation.email_address.user,
"activate_url": activate_url,
"current_site": current_site,
"key": emailconfirmation.key,
}
Looking at your code I guess you are using django-allauth (https://github.com/pennersr/django-allauth).
In this example I assumed that user is authenticated and verifcation code doesn't have to be unique.
If you want to create a shorter key and store it in db you should create custom model where key fits your needs (4 digit in this example - I assumed your user put in manually):
class ActivationKey(models.Model):
user = models.ForeignKey(User, verbose_name=_("user"), on_delete=models.CASCADE)
key = models.PositiveSmallIntegerField(_("key"))
created_at = models.DateTimeField(auto_now_add=True)
#classmethod
def create(cls, user):
key = random.randint(1000, 9999)
return cls._default_manager.create(user=user, key=key)
We can generate key after user creation (override save() or use signal or create before sending email). As you pointed you can override send_confirmation_mail so for simplicity in this example I use it:
class MyAdapter(DefaultAccountAdapter):
def send_confirmation_mail(self, request, emailconfirmation, signup):
current_site = get_current_site(request)
ctx = {
"user": emailconfirmation.email_address.user,
"activate_url": reverse("account_confirm_key"), # depends on you - if you need it at all
"current_site": current_site,
"key": ActivationKey.objects.create(user=user) # create key
}
if signup:
email_template = "account/email/email_confirmation_signup"
else:
email_template = "account/email/email_confirmation"
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
Next you should create custom endpoint for confirm code and activate user:
class VerifyUserView(APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request): # or change to get if more proper to you
key = request.data.get("key")
if not key:
return Response({"error": _("Key is missing")}, status=status.HTTP_400_BAD_REQUEST)
if not ActivationCode.objects.filter(user=request.user, key=key).exists():
return Response({"error": _("Wrong activation key")}, status=status.HTTP_400_BAD_REQUEST)
get_adapter(request).confirm_email(request, user.email) # confirm method from adapter
return Response({"status": "ok"})
And remember about settings:
ACCOUNT_ADAPTER = MyAdapter
About django-allauth:
Default configuration (https://dj-rest-auth.readthedocs.io/en/latest/configuration.html) for email verification is ACCOUNT_EMAIL_CONFIRMATION_HMAC = True which means django would generate HMAC (https://en.wikipedia.org/wiki/HMAC) key.
If you change option to False key will be generated and stored in EmailConfirmation model: https://github.com/pennersr/django-allauth/blob/da5ccdcf171e32ab1a438add3af38957f5a0659a/allauth/account/models.py#L100 but probably too long for you - 64 chars
Another option is that you create short link (e.g bit.ly) for user without changing anything. Or maybe if this is for mobile application you can create deeplink, so after clicking confirmation link (even long) user will be redirect to mobile app and then mobile app send request to backend?

Django channels custom permissions system

So I have a system where users can be part of models called boxes through the model member.
Member models have their own set of roles which in turn have their own permissions.
I have specific methods which determine which set of permissions a member has in a box.
So now I have a websocket group named 'box_{box_id}' to which members can connect. Outbound events such as box related model creation are sent to this group.
However, some members should not listen to certain events sent based on the permissions they have.
This is a sample message that would be sent to the group which denotes an event
{'event': EVENT TYPE,
'data': EVENT DATA}
So now, for example, an user cannot listen to the event with type UPLOAD_CREATE if he doesnt have READ_UPLOADS permissions in the box
How can I implement such checks using django channels?
EDIT
class LocalEventsConsumer(AsyncWebsocketConsumer):
"""
An ASGI consumer for box-specific (local) event sending.
Any valid member for the given box can connect to this consumer.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.box_id = self.scope['url_route']['kwargs']['box_id']
self.events_group_name = 'box_%s_events' % self.box_id
self.overwrites_cache = {}
self.permissions_cache = set()
# need to update cache on role and overwrite updates
async def connect(self):
try:
# we cache the member object on connection
# to help check permissions later on during
# firing of events
member_kwargs = {
'user': self.scope['user'],
'box__id': self.box_id,
}
self.member = api_models.Member.objects.get(**member_kwargs)
self.permissions_cache = self.member.base_permissions
except ObjectDoesNotExist:
# we reject the connection if the
# box-id passed in the url was invalid
# or the user isn't a member of the box yet
await self.close()
await self.channel_layer.group_add(self.events_group_name, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.events_group_name, self.channel_name)
async def fire_event(self, event: dict):
member_permissions = self.get_event_permissions(event)
required_permissions = event.pop('listener_permissions', set())
if required_permissions in member_permissions:
await self.send(event)
def get_event_permissions(self, event):
# handle permission caching throughout
# the life of the user's connection
overwrite_channel = event['data'].get('channel', None)
overwrite_cache = self.overwrites_cache.get(overwrite_channel.id, None)
if not overwrite_channel:
# calculate overwrites if the event data at hand
# has a channel attribute. We would need to calculate
# overwrites only when channel-specific events are
# triggered, like UPLOAD_CREATE and OVERWRITE_DELETE
return self.permissions_cache
if not overwrite_cache:
overwrite_cache = self.member.permissions.get_overwrites(overwrite_channel)
self.overwrites_cache[overwrite_channel.id] = overwrite_cache
return overwrite_cache
#receiver(post_delete, sender=api_models.MemberRole)
#receiver(post_save, sender=api_models.MemberRole)
def update_permissions_cache(self, instance=None, **kwargs):
if instance.member == self.member:
self.permissions_cache = self.member.base_permissions
#receiver(post_delete, sender=api_models.Overwrite)
#receiver(post_save, sender=api_models.Overwrite)
def update_overwrites_cache(self, instance=None, **kwargs):
overwrite_cache = self.overwrites_cache.get(instance.channel, None)
if instance.role in self.member.roles.all() and overwrite_cache:
self.overwrites_cache[instance.channel] = self.member.permissions.get_overwrites(instance.channel)
this is my current consumer. I use the fire_event type outside the consumer. However, everytime I need to get the permissions, I need to make a trip to the database. Therefore, I've implemented this permission caching system to mitigate the same. Should the same be altered?
You can check for these permissions in the method that sends the data to the client. Since they all belong to the same channel group, you cannot filter out at the level of sending to the group, at least to the best of my knowledge. So you can do something that like this:
def receive(self, event):
# update box
...
# notify the members
self.channel_layer.group_send(
f'box_{self.box.id}',
{'type': 'notify_box_update', 'event': EVENT TYPE, 'data': EVENT DATA},
)
def notify_box_update(event):
if has_permission(self.user, event['event'], self.box):
self.send(event)
Here, the notify event is sent to the group via the channel_layer but only users with the proper permission get it sent to them downstream. You can implement the has_permission method somewhere in your code to check for the permission given the user, box and event type.

Notifications system for User into ManytoMany field

I got an issue I was wondering is there is a way to send a notification from Many to Many to each new user who are integrated to Reservation (notification has to be sent only to new users who belongs to friends). At the Reservation creation, friends == Null and then admin can add some user.
Does anyone has an idea about how I can manage to do it?
Assume user == reservation admin and friends are user who can only have access to the reservation.
class Reservation(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE)
friends = models.ManyToManyField(User,blank=True,related_name='friends_reservation')
def notifs_reservation(sender, instance, *args, **kwargs):
reservation = instance
sender = reservation.user
n_seller = reservation.friends
notify = Notification(reservation=reservation, sender=sender, user=n_seller, notification_type=7)
notify.save()
send_mail(
subject = "Lorem",
message = render_to_string('notifs_reservation.html', {
'sender': reservation.user,
'n_userb': n_seller,
'channel': reservation.slug,
}),
from_email="example#example.co",
recipient_list=[n_seller.email]
)
post_save.connect(notifs_reservation, sender=Reservation)

Custom Authentication for non-user connection with Django Rest Framework

I have enabled user authentication with DRF using TokenAuthentication
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication'
),
'DEFAULT_MODEL_SERIALIZER_CLASS':
'rest_framework.serializers.ModelSerializer',
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.AllowAny',
),
#'EXCEPTION_HANDLER': 'apps.core.exceptions.custom_exception_handler'
}
I have the following model:
class Device(CreationModificationMixin):
"""
Contains devices (WW controllers). A device may be associated with the Owner
"""
_STATUSES = (
('A', 'Active'), # when everything is okay
('I', 'Inactive'), # when we got nothing from SPA controllers for X minutes
('F', 'Failure'), # when controller says it has issues
)
_TYPES = (
('S', 'Spa'),
('P', 'Pool'),
)
udid = models.CharField(max_length=255, verbose_name="Unique ID / MAC Address", help_text="MAC Address of WiFi controller", unique=True, null=False, blank=False, db_index=True)
type = models.CharField(max_length=1, choices=_TYPES, null=False, blank=False)
title = models.CharField(max_length=255, null=False, blank=False, db_index=True)
status = models.CharField(max_length=1, default='A', choices=_STATUSES)
pinged = models.DateTimeField(null=True)
owner = models.ForeignKey(Owner, verbose_name="Owner", null=True, blank=True, db_index=True)
def __str__(self):
return self.udid
This represents hardware device that will be sending discrete requests to API endpoints, therefore I need to authenticate each request and ideally with token based identification, like
POST /api/devices/login
{
udid: '...mac address...',
hash: '...sha256...hash string',
time: '2015-01-01 12:24:30'
}
hash will be calculated on device side as sha256(salt + udid + current_time)
the same hash will be calculated on DRF side inside /login to compare and generate token that will be saved in REDIS and returned back with response.
All future requests will be passing this token as a header, which will be checked in custom Permission class.
my questions:
I'd like to set a custom property on request class, like
request.device, request.device.is_authenticated()
Where should I put this functionality?
Do you see something wrong in my approach? Maybe a recommendation for improvements?
As #daniel-van-flymen pointed out, it's probably not a good idea to return a device instead of a user. So what I did was create a DeviceUser class that extends django.contrib.auth.models.AnonymousUserand return that in my custom authentication (devices are essentially anonymous users, after all).
from myapp.models import Device
from rest_framework import authentication
from django.contrib.auth.models import AnonymousUser
from rest_framework.exceptions import AuthenticationFailed
class DeviceUser(AnonymousUser):
def __init__(self, device):
self.device = device
#property
def is_authenticated(self):
return True
class DeviceAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
udid = request.META.get("HTTP_X_UDID", None)
if not udid:
return None
try:
device = Device.objects.get(udid=udid)
except Device.DoesNotExist:
raise AuthenticationFailed("Invalid UDID")
if not device.active:
raise AuthenticationFailed("Device is inactive or deleted")
request.device = device
return (DeviceUser(device), None)
This code lives in myapp.authentication, you can then add the following to your settings:
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"myapp.authentication.DeviceAuthentication",
)
}
A couple of notes from your original spec: I've modified the request in the authenticator to include the device, so you can do request.device.is_authenticated; however, the user will be a DeviceUser so you could also do request.user.device.is_authenticated (so long as you do the appropriate checks for the device attribute).
Your original spec also asked to implement TokenAuthentication, and it is possible to subclass this authentication class to use it more directly; for simplicity, I'm just having the device include the X-UDID header in their request.
Also note that as with the token authentication mechanism, you must use this method with HTTPS, otherwise the UDID will be sent in plain text, allowing someone to impersonate a device.
You can subclass DRF's BaseAuthentication class and override the .authenticate(self, request) method.
On successful auth this function should return (device, None). This will set device object in request.user property.
You can implement is_authenticated() in your Device model class.
class APICustomAuthentication(BaseAuthentication):
---
def authenticate(self, request):
----
return (device, None) # on successful authentication
Add APICustomAuthentication to 'DEFAULT_AUTHENTICATION_CLASSES'
in settings.
More details are available here