user login via otp
This commit is contained in:
parent
6e6f83fad0
commit
7041dfe511
13 changed files with 432 additions and 22 deletions
137
apps/users/backends.py
Normal file
137
apps/users/backends.py
Normal 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
84
apps/users/forms.py
Normal 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},
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
57
templates/base.html
Normal 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 %}
|
||||
|
|
@ -1,12 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
36
templates/include/field_frame.html
Normal file
36
templates/include/field_frame.html
Normal 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 %}
|
||||
38
templates/include/form_frame.html
Normal file
38
templates/include/form_frame.html
Normal 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 %}
|
||||
16
templates/include/message_frame.html
Normal file
16
templates/include/message_frame.html
Normal 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">×</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
6
templates/registration/logged_out.html
Normal file
6
templates/registration/logged_out.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{% extends "base_generic.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
success log out
|
||||
{% endblock %}
|
||||
|
|
@ -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 %}
|
||||
9
templates/registration/profile.html
Normal file
9
templates/registration/profile.html
Normal 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 %}
|
||||
Loading…
Add table
Reference in a new issue