user login via otp

This commit is contained in:
mahdavi 2020-03-30 11:29:03 +04:30
parent 6e6f83fad0
commit 7041dfe511
13 changed files with 432 additions and 22 deletions

137
apps/users/backends.py Normal file
View file

@ -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
#
#

84
apps/users/forms.py Normal file
View file

@ -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},
)

View file

@ -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

View file

@ -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

View file

@ -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()),

57
templates/base.html Normal file
View file

@ -0,0 +1,57 @@
{% with site_title="Gooyal Accounts" %}
<!DOCTYPE html>
{% load static %}
{# {% load jalali_tags %}#}
{% load i18n %}
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>{{ site_title }}</title>
<meta name="description" content="{{ site_description }}">
<meta name="revisit-after" content="1 hour">
<META NAME="ROBOTS" CONTENT="INDEX, FOLLOW">
<meta name="keywords" content="{{ _('site_keywords') }}">
{# <link rel="icon" href="{% static "logo.png" %}" sizes="32x32" type="image/png">#}
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
crossorigin="anonymous">
</head>
<body class="font-vazir" style="direction: rtl; text-align: right">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="#">Toomar</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item active">
<a class="nav-link" href="{% url 'home' %}">home <span class="sr-only"></span></a>
</li>
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'logout' %}" >({{ request.user.phone_number }}) Logout</a>
</li>
{% endif %}
</ul>
</div>
</nav>
<div class="font-vazir container mb-3 mt-3">
{% include "include/message_frame.html" %}
{% block content %}
{% endblock %}
</div>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1"
crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
crossorigin="anonymous"></script>
</body>
</html>
{% endwith %}

View file

@ -1,12 +1,4 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
{% extends "base.html" %}
{% block content %}
{% endblock %}
</body>
</html>

View file

@ -0,0 +1,36 @@
{% load widget_tweaks %}
{# feild #}
{# form #}
{# placeholder #}
{% with field_class=field_class|default_if_none:'' %}
<div class="form-group">
{{ field.label_tag }}
{% if form.is_bound %}
{% if field.errors %}
<div class="input-group">
{% render_field field|add_class:field_class class="form-control is-invalid" placeholder=placeholder %}
</div>
{% for error in field.errors %}
<div class="invalid-feedback">
{{ error }}
</div>
{% endfor %}
{% else %}
<div class="input-group">
{% render_field field|add_class:field_class class="form-control is-valid" placeholder=placeholder %}
</div>
{% endif %}
{% else %}
<div class="input-group">
{% render_field field|add_class:field_class class="form-control" placeholder=placeholder %}
</div>
{% endif %}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text | safe }}</small>
{% endif %}
</div>
{% endwith %}

View file

@ -0,0 +1,38 @@
{% load widget_tweaks %}
{% for hidden_field in form.hidden_fields %}
{{ hidden_field }}
{% endfor %}
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% for field in form.visible_fields %}
<div class="form-group">
{{ field.label_tag }}
{% if form.is_bound %}
{% if field.errors %}
{% render_field field class="form-control is-invalid" %}
{% for error in field.errors %}
<div class="invalid-feedback">
{{ error }}
</div>
{% endfor %}
{% else %}
{% render_field field class="form-control is-valid" %}
{% endif %}
{% else %}
{% render_field field class="form-control" %}
{% endif %}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %}
</div>
{% endfor %}

View file

@ -0,0 +1,16 @@
{% load i18n %}
{% if messages %}
{% for message in messages %}
<div class="alert{% if message.tags %} alert-{{ message.tags }}{% endif %} alert-dismissible fade show"
role="alert">
{% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %} {{ _('important') }} : {% endif %}
{{ message }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
{% endfor %}
{% endif %}

View file

@ -0,0 +1,6 @@
{% extends "base_generic.html" %}
{% block content %}
success log out
{% endblock %}

View file

@ -3,7 +3,7 @@
{% block content %}
{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
<p>Your phone number and otp didn't match. Please try again.</p>
{% endif %}
{% if next %}
@ -20,13 +20,13 @@
<table>
<tr>
<td>{{ form.username.label_tag }}</td>
<td>{{ form.username }}</td>
<td>{{ form.phone_number.label_tag }}</td>
<td>{{ form.phone_number }}</td>
</tr>
<tr>
<td>{{ form.password.label_tag }}</td>
<td>{{ form.password }}</td>
<td>{{ form.otp.label_tag }}</td>
<td>{{ form.otp }}</td>
</tr>
</table>
@ -34,7 +34,5 @@
<input type="hidden" name="next" value="{{ next }}" />
</form>
{# Assumes you setup the password_reset view in your URLconf #}
<p><a href="{% url 'password_reset' %}">Lost password?</a></p>
{% endblock %}

View file

@ -0,0 +1,9 @@
{% extends "base_generic.html" %}
{% block content %}
<h2>Homepage</h2>
<p>Hello {{ user.username }}!</p>
<p>your phone number {{ user.phone_number }}!</p>
{% endblock %}