diff --git a/apps/users/backends.py b/apps/users/backends.py new file mode 100644 index 0000000..0db0b90 --- /dev/null +++ b/apps/users/backends.py @@ -0,0 +1,137 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + +UserModel = get_user_model() + +class OTPBackend(ModelBackend): + + """ + Authenticates against settings.AUTH_USER_MODEL. + """ + + def authenticate(self, request, phone_number=None, otp=None, **kwargs): + if phone_number is None or otp is None: + return + + try: + user = UserModel.objects.get(phone_number=phone_number) + # user = UserModel._default_manager.get_by_natural_key(phone_number) + except UserModel.DoesNotExist: + return + + else: + if user.check_otp(otp) and self.user_can_authenticate(user): + return user + + def user_can_authenticate(self, user): + """ + Reject users with is_active=False. Custom user models that don't have + that attribute are allowed. + """ + is_active = getattr(user, 'is_active', None) + return is_active or is_active is None + + # def _get_user_permissions(self, user_obj): + # return user_obj.user_permissions.all() + # + # def _get_group_permissions(self, user_obj): + # user_groups_field = get_user_model()._meta.get_field('groups') + # user_groups_query = 'group__%s' % user_groups_field.related_query_name() + # return Permission.objects.filter(**{user_groups_query: user_obj}) + # + # def _get_permissions(self, user_obj, obj, from_name): + # """ + # Return the permissions of `user_obj` from `from_name`. `from_name` can + # be either "group" or "user" to return permissions from + # `_get_group_permissions` or `_get_user_permissions` respectively. + # """ + # if not user_obj.is_active or user_obj.is_anonymous or obj is not None: + # return set() + # + # perm_cache_name = '_%s_perm_cache' % from_name + # if not hasattr(user_obj, perm_cache_name): + # if user_obj.is_superuser: + # perms = Permission.objects.all() + # else: + # perms = getattr(self, '_get_%s_permissions' % from_name)(user_obj) + # perms = perms.values_list('content_type__app_label', 'codename').order_by() + # setattr(user_obj, perm_cache_name, {"%s.%s" % (ct, name) for ct, name in perms}) + # return getattr(user_obj, perm_cache_name) + # + # def get_user_permissions(self, user_obj, obj=None): + # """ + # Return a set of permission strings the user `user_obj` has from their + # `user_permissions`. + # """ + # return self._get_permissions(user_obj, obj, 'user') + # + # def get_group_permissions(self, user_obj, obj=None): + # """ + # Return a set of permission strings the user `user_obj` has from the + # groups they belong. + # """ + # return self._get_permissions(user_obj, obj, 'group') + # + # def get_all_permissions(self, user_obj, obj=None): + # if not user_obj.is_active or user_obj.is_anonymous or obj is not None: + # return set() + # if not hasattr(user_obj, '_perm_cache'): + # user_obj._perm_cache = super().get_all_permissions(user_obj) + # return user_obj._perm_cache + # + # def has_perm(self, user_obj, perm, obj=None): + # return user_obj.is_active and super().has_perm(user_obj, perm, obj=obj) + # + # def has_module_perms(self, user_obj, app_label): + # """ + # Return True if user_obj has any permissions in the given app_label. + # """ + # return user_obj.is_active and any( + # perm[:perm.index('.')] == app_label + # for perm in self.get_all_permissions(user_obj) + # ) + # + # def with_perm(self, perm, is_active=True, include_superusers=True, obj=None): + # """ + # Return users that have permission "perm". By default, filter out + # inactive users and include superusers. + # """ + # if isinstance(perm, str): + # try: + # app_label, codename = perm.split('.') + # except ValueError: + # raise ValueError( + # 'Permission name should be in the form ' + # 'app_label.permission_codename.' + # ) + # elif not isinstance(perm, Permission): + # raise TypeError( + # 'The `perm` argument must be a string or a permission instance.' + # ) + # + # UserModel = get_user_model() + # if obj is not None: + # return UserModel._default_manager.none() + # + # permission_q = Q(group__user=OuterRef('pk')) | Q(user=OuterRef('pk')) + # if isinstance(perm, Permission): + # permission_q &= Q(pk=perm.pk) + # else: + # permission_q &= Q(codename=codename, content_type__app_label=app_label) + # + # user_q = Exists(Permission.objects.filter(permission_q)) + # if include_superusers: + # user_q |= Q(is_superuser=True) + # if is_active is not None: + # user_q &= Q(is_active=is_active) + # + # return UserModel._default_manager.filter(user_q) + # + # def get_user(self, user_id): + # try: + # user = UserModel._default_manager.get(pk=user_id) + # except UserModel.DoesNotExist: + # return None + # return user if self.user_can_authenticate(user) else None + # + # diff --git a/apps/users/forms.py b/apps/users/forms.py new file mode 100644 index 0000000..c44d2f1 --- /dev/null +++ b/apps/users/forms.py @@ -0,0 +1,84 @@ +from django import forms +from django.contrib.auth import (authenticate, get_user_model) +from django.contrib.auth.forms import UsernameField +from django.utils.text import capfirst +from django.utils.translation import gettext_lazy as _ + +UserModel = get_user_model() + +class OTPAuthenticationForm(forms.Form): + """ + Base class for authenticating users. Extend this to get a form that accepts + username/password logins. + """ + phone_number = UsernameField(widget=forms.TextInput(attrs={'autofocus': True})) + otp = forms.CharField( + label=_("otp"), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + + error_messages = { + 'invalid_login': _( + "Please enter a correct %(phone_number)s and otp. Note that both " + "fields may be case-sensitive." + ), + 'inactive': _("This account is inactive."), + } + + def __init__(self, request=None, *args, **kwargs): + """ + The 'request' parameter is set for custom auth use by subclasses. + The form data comes in via the standard 'data' kwarg. + """ + self.request = request + self.user_cache = None + super().__init__(*args, **kwargs) + + # Set the max length and label for the "username" field. + self.phone_number_field = UserModel._meta.get_field('phone_number') + phone_number_max_length = self.phone_number_field.max_length or 24 + self.fields['phone_number'].max_length = phone_number_max_length + self.fields['phone_number'].widget.attrs['maxlength'] = phone_number_max_length + if self.fields['phone_number'].label is None: + self.fields['phone_number'].label = capfirst(self.phone_number_field.verbose_name) + + def clean(self): + phone_number = self.cleaned_data.get('phone_number') + otp = self.cleaned_data.get('otp') + + if phone_number is not None and otp: + self.user_cache = authenticate(self.request, phone_number=phone_number, otp=otp) + if self.user_cache is None: + raise self.get_invalid_login_error() + else: + self.confirm_login_allowed(self.user_cache) + + return self.cleaned_data + + def confirm_login_allowed(self, user): + """ + Controls whether the given User may log in. This is a policy setting, + independent of end-user authentication. This default behavior is to + allow login by active users, and reject login by inactive users. + + If the given user cannot log in, this method should raise a + ``forms.ValidationError``. + + If the given user may log in, this method should return None. + """ + if not user.is_active: + raise forms.ValidationError( + self.error_messages['inactive'], + code='inactive', + ) + + def get_user(self): + return self.user_cache + + def get_invalid_login_error(self): + return forms.ValidationError( + self.error_messages['invalid_login'], + code='invalid_login', + params={'phone_number': self.phone_number_field.verbose_name}, + ) diff --git a/apps/users/views.py b/apps/users/views.py index e05f862..791589e 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -1,10 +1,19 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required +from django.contrib.auth.views import LoginView +from django.shortcuts import render from django.utils import timezone -from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope, IsAuthenticatedOrTokenHasScope +from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope from rest_framework import generics, permissions, status from rest_framework.response import Response +from apps.users.forms import OTPAuthenticationForm from apps.users.models import User -from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, ChangePasswordSerializer +from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, \ + ChangePasswordSerializer + +UserModel = get_user_model() + class UserListView(generics.ListAPIView): @@ -67,3 +76,15 @@ class ChangePasswordView(generics.UpdateAPIView): return Response({"state": 'success'}, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +@login_required +def home(request): + return render(request, 'registration/profile.html') + + +class OTPLoginView(LoginView): + """ + Display the login form and handle the login action. + """ + form_class = OTPAuthenticationForm diff --git a/gooyal_accounts/settings.py b/gooyal_accounts/settings.py index ea100cf..94b25b7 100644 --- a/gooyal_accounts/settings.py +++ b/gooyal_accounts/settings.py @@ -94,6 +94,11 @@ TEMPLATES = [ }, ] +AUTHENTICATION_BACKENDS = ( + 'apps.users.backends.OTPBackend', + 'django.contrib.auth.backends.ModelBackend', +) + WSGI_APPLICATION = 'gooyal_accounts.wsgi.application' # Database @@ -144,6 +149,11 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.2/howto/static-files/ +# LOGIN_URL = '/accounts/login/' +# LOGIN_REDIRECT_URL = '/accounts/profile/' + +LOGIN_URL = '/login/' +LOGIN_REDIRECT_URL = '/' AUTH_USER_MODEL = 'users.User' CORS_ORIGIN_ALLOW_ALL = True diff --git a/gooyal_accounts/urls.py b/gooyal_accounts/urls.py index f372e79..3c7cbe7 100644 --- a/gooyal_accounts/urls.py +++ b/gooyal_accounts/urls.py @@ -15,17 +15,23 @@ Including another URLconf """ from django.conf import settings from django.conf.urls.static import static +from django.contrib.auth.views import LogoutView from django.urls import path, include from django.contrib import admin from apps.transactions.views import TransactionList, TransactionDetail, TransactionPay, TransactionReceipt -from apps.users.views import UserListView, UserDetailView, AccountView, RequestOTPView, ChangePasswordView +from apps.users.views import UserListView, UserDetailView, AccountView, RequestOTPView, ChangePasswordView, \ + OTPLoginView, home from django.contrib.auth import urls as auth_urls # Setup the URLs and include login URLs for the browsable API. urlpatterns = [ path('admin/', admin.site.urls), - path('accounts/', include(auth_urls)), + # path('accounts/', include(auth_urls)), + path('login/', OTPLoginView.as_view(), name='login'), + path('logout/', LogoutView.as_view(), name='logout'), + path('', home, name='home'), + path('oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')), path('users/', UserListView.as_view()), diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..46e4085 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,57 @@ +{% with site_title="Gooyal Accounts" %} + + {% load static %} + {# {% load jalali_tags %}#} + {% load i18n %} + +
+ +Your username and password didn't match. Please try again.
+Your phone number and otp didn't match. Please try again.
{% endif %} {% if next %} @@ -20,13 +20,13 @@| {{ form.username.label_tag }} | -{{ form.username }} | +{{ form.phone_number.label_tag }} | +{{ form.phone_number }} |
| {{ form.password.label_tag }} | -{{ form.password }} | +{{ form.otp.label_tag }} | +{{ form.otp }} |
Hello {{ user.username }}!
+your phone number {{ user.phone_number }}!
+ +{% endblock %} \ No newline at end of file