login via oauth server
This commit is contained in:
parent
e3c1d010df
commit
5fa4dfd780
11 changed files with 301 additions and 12 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,7 +1,7 @@
|
|||
.python-version
|
||||
.idea
|
||||
media/*
|
||||
delme*.py
|
||||
utils/delme.py
|
||||
*.pyc
|
||||
.env
|
||||
/venv/
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
from django import forms
|
||||
from django.contrib.auth import (authenticate, get_user_model)
|
||||
from django.contrib.auth import authenticate, get_user_model
|
||||
from django.contrib.auth.forms import UsernameField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.text import capfirst
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from .models import OAuthCode
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
class OAUTHLoginRequestForm(forms.ModelForm):
|
||||
"""
|
||||
Base class for authenticating users. Extend this to get a form that accepts
|
||||
username/password logins.
|
||||
"""
|
||||
agreement = forms.BooleanField(required=True)
|
||||
|
||||
class Meta:
|
||||
model = OAuthCode
|
||||
fields = ['agreement']
|
||||
22
apps/users/migrations/0002_oauthcode.py
Normal file
22
apps/users/migrations/0002_oauthcode.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 5.0.6 on 2024-07-10 12:48
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OAuthCode',
|
||||
fields=[
|
||||
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('code_verifier', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('code', models.CharField(blank=True, max_length=64, null=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
apps/users/migrations/0003_alter_oauthcode_code_verifier.py
Normal file
18
apps/users/migrations/0003_alter_oauthcode_code_verifier.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.0.6 on 2024-07-10 13:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0002_oauthcode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='oauthcode',
|
||||
name='code_verifier',
|
||||
field=models.CharField(blank=True, max_length=100, null=True),
|
||||
),
|
||||
]
|
||||
18
apps/users/migrations/0004_alter_oauthcode_code_verifier.py
Normal file
18
apps/users/migrations/0004_alter_oauthcode_code_verifier.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.0.6 on 2024-07-10 13:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0003_alter_oauthcode_code_verifier'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='oauthcode',
|
||||
name='code_verifier',
|
||||
field=models.CharField(blank=True, max_length=130, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -2,6 +2,7 @@ import random
|
|||
import string
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
# import service_clients
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.base_user import BaseUserManager
|
||||
|
|
@ -11,6 +12,10 @@ from django.db import models
|
|||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import uuid
|
||||
import random
|
||||
import string
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
|
||||
|
||||
|
|
@ -74,3 +79,45 @@ class User(AbstractUser):
|
|||
|
||||
def __str__(self):
|
||||
return str(self.username or self.pk)
|
||||
|
||||
|
||||
# TODO: user redis instead.
|
||||
# TODO: create a queryset
|
||||
class OAuthCode(models.Model):
|
||||
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
|
||||
code_verifier = models.CharField(max_length=130, blank=True, null=True)
|
||||
code = models.CharField(max_length=64, blank=True, null=True)
|
||||
|
||||
def generate_code_verifier(self):
|
||||
code_verifier = ''.join(
|
||||
random.choice(string.ascii_uppercase + string.digits) for _ in range(random.randint(43, 128)))
|
||||
self.code_verifier = code_verifier
|
||||
print(len(code_verifier))
|
||||
self.save()
|
||||
return code_verifier
|
||||
|
||||
def generate_code_challenge(self):
|
||||
code_challenge = hashlib.sha256(self.code_verifier.encode('utf-8')).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8').replace('=', '')
|
||||
return code_challenge
|
||||
|
||||
def generate_login_url(self):
|
||||
url = f'''{settings.BASE_OAUTH2_PROVIDER_URL}/oauth2/authorize/?response_type=code&code_challenge={self.generate_code_challenge()}&code_challenge_method=S256&client_id={settings.CLIENT_ID}&scope=wallet.wallet:get_balance+wallet.transaction:list&state={self.uuid}'''
|
||||
return url
|
||||
|
||||
def validate_code(self):
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
data = {
|
||||
"client_id": settings.CLIENT_ID,
|
||||
"client_secret": settings.CLIENT_SECRET,
|
||||
"code": self.code,
|
||||
"code_verifier": self.code_verifier,
|
||||
# "redirect_uri=http://127.0.0.1:8000/noexist/callback",
|
||||
"grant_type": "authorization_code"
|
||||
}
|
||||
|
||||
response = requests.post(f'{settings.BASE_OAUTH2_PROVIDER_URL}/oauth2/token/', data=data, headers=headers)
|
||||
|
||||
return response.json()
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
from django.urls import path
|
||||
from .views import AccountView
|
||||
from .views import AccountView, OAUTHLoginRequestView, OAUTHLoginCallbackView
|
||||
|
||||
app_name = "users"
|
||||
|
||||
urlpatterns = [
|
||||
path('login/request/', OAUTHLoginRequestView.as_view(), name='login_request'),
|
||||
path('login/callback/', OAUTHLoginCallbackView.as_view(), name='login_calback'),
|
||||
path('api/account/', AccountView.as_view(), name='account_api'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login as auth_login
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.views import LoginView
|
||||
|
|
@ -5,19 +13,23 @@ from django.shortcuts import render
|
|||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.generic import DetailView, UpdateView
|
||||
from django.views.generic import DetailView, UpdateView, CreateView
|
||||
from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope
|
||||
from rest_framework import generics, status, permissions
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
|
||||
from apps.users.models import User
|
||||
from apps.users.serializers import AccountSerializer
|
||||
from apps.gooyal_oauth2.validators import OAuth2Validator
|
||||
from apps.users.forms import OAUTHLoginRequestForm
|
||||
from apps.users.models import User, OAuthCode
|
||||
from apps.users.serializers import AccountSerializer
|
||||
from django.contrib.auth import login
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class AccountView(generics.RetrieveUpdateAPIView):
|
||||
serializer_class = AccountSerializer
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
|
|
@ -43,3 +55,68 @@ class ProfileDetailView(DetailView):
|
|||
|
||||
def get_object(self, queryset=None):
|
||||
return self.request.user
|
||||
|
||||
|
||||
class OAUTHLoginRequestView(CreateView):
|
||||
model = OAuthCode
|
||||
form_class = OAUTHLoginRequestForm
|
||||
template_name = 'users/login_request.html'
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object: OAuthCode = form.save(commit=False)
|
||||
self.object.generate_code_verifier()
|
||||
self.object.save()
|
||||
return HttpResponseRedirect(self.object.generate_login_url())
|
||||
|
||||
def form_invalid(self, form):
|
||||
for error_key in form.errors:
|
||||
for error_list in form.errors[error_key].data:
|
||||
for error in error_list:
|
||||
key = _(error_key)
|
||||
messages.error(self.request, f"{key}: {error}")
|
||||
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class OAUTHLoginCallbackView(DetailView):
|
||||
model = OAuthCode
|
||||
template_name = 'users/login_callback.html'
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
state = self.request.GET.get('state')
|
||||
code = self.request.GET.get('code')
|
||||
obj = OAuthCode.objects.get(pk=state)
|
||||
obj.code = code
|
||||
obj.save()
|
||||
return obj
|
||||
|
||||
def introspect_token(self, token):
|
||||
url = settings.OAUTH2_PROVIDER['RESOURCE_SERVER_INTROSPECTION_URL']
|
||||
credentials = settings.OAUTH2_PROVIDER['RESOURCE_SERVER_INTROSPECTION_CREDENTIALS']
|
||||
validator = OAuth2Validator()
|
||||
access_token = validator._get_token_from_authentication_server(token, url, None, credentials)
|
||||
return access_token
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
data = self.object.validate_code()
|
||||
# {'access_token': 'WhnIAfci6yIyTsh63PPqM1HXfXqKvr', 'expires_in': 36000, 'token_type': 'Bearer',
|
||||
# 'scope': 'wallet.wallet:get_balance wallet.transaction:list',
|
||||
# 'refresh_token': 'qRHFXXc3R3EiQKUq5BcSY2b9xuTm0d'}
|
||||
#
|
||||
# from oauth2_provider.models import get_access_token_model, get_application_model
|
||||
access_token = self.introspect_token(data['access_token'])
|
||||
login(self.request, access_token.user, backend='django.contrib.auth.backends.ModelBackend')
|
||||
return super().get_context_data(**kwargs)
|
||||
# AccessToken = get_access_token_model()
|
||||
# access_token = AccessToken.objects.create(
|
||||
# token=data['access_token'],
|
||||
# scope=data['scope'],
|
||||
# expires=timezone.now() + timedelta(data['scope']),
|
||||
# )
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
print(data)
|
||||
return super().get_context_data(**kwargs)
|
||||
|
|
|
|||
41
templates/users/login_callback.html
Executable file
41
templates/users/login_callback.html
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Login</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'bootstrap-5.1.3-dist/css/bootstrap.min.css' %}">
|
||||
<link href="{% static 'bootstrap-icons-1.7.2/bootstrap-icons.css' %}" rel="stylesheet">
|
||||
<script src="{% static 'jquery-3.6.0.min.js' %}"></script>
|
||||
<script src="{% static 'bootstrap-5.1.3-dist/js/bootstrap.bundle.min.js' %}"></script>
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'login/css/util.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'login/css/main.css' %}">
|
||||
<link href="{% static 'fonts.css' %}" rel="stylesheet">
|
||||
{# <link href="{% static 'main.css' %}" rel="stylesheet">#}
|
||||
<!--===============================================================================================-->
|
||||
</head>
|
||||
<body>
|
||||
<div class="limiter">
|
||||
{{ request.user }}
|
||||
<div>
|
||||
<div>
|
||||
{# <div class="text-center pb-5">#}
|
||||
{# <img src="{% static 'image/arian_saeed.png' %}" class="img-fluid">#}
|
||||
{# </div>#}
|
||||
{# <div class="card">#}
|
||||
{# <div class="card-body">#}
|
||||
<div class="text-center mt-3" dir="rtl">
|
||||
{% include "include/messages.html" %}
|
||||
<div class="" id="alert_placeholder"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
48
templates/users/login_request.html
Executable file
48
templates/users/login_request.html
Executable file
|
|
@ -0,0 +1,48 @@
|
|||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Login</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'bootstrap-5.1.3-dist/css/bootstrap.min.css' %}">
|
||||
<link href="{% static 'bootstrap-icons-1.7.2/bootstrap-icons.css' %}" rel="stylesheet">
|
||||
<script src="{% static 'jquery-3.6.0.min.js' %}"></script>
|
||||
<script src="{% static 'bootstrap-5.1.3-dist/js/bootstrap.bundle.min.js' %}"></script>
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'login/css/util.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'login/css/main.css' %}">
|
||||
<link href="{% static 'fonts.css' %}" rel="stylesheet">
|
||||
{# <link href="{% static 'main.css' %}" rel="stylesheet">#}
|
||||
<!--===============================================================================================-->
|
||||
</head>
|
||||
<body>
|
||||
<div class="limiter">
|
||||
<div>
|
||||
<div>
|
||||
{# <div class="text-center pb-5">#}
|
||||
{# <img src="{% static 'image/arian_saeed.png' %}" class="img-fluid">#}
|
||||
{# </div>#}
|
||||
{# <div class="card">#}
|
||||
{# <div class="card-body">#}
|
||||
<form method="post" action="{% url 'users:login_request' %}">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="col-12">
|
||||
<input class="w-100 btn btn-primary btn-lg" type="submit" value="login" name="save"/>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
<div class="text-center mt-3" dir="rtl">
|
||||
{% include "include/messages.html" %}
|
||||
<div class="" id="alert_placeholder"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -62,6 +62,7 @@ MIDDLEWARE = [
|
|||
# 'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'oauth2_provider.middleware.OAuth2TokenMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
|
|
@ -74,7 +75,9 @@ OAUTH2_PROVIDER_ID_TOKEN_MODEL = "gooyal_oauth2.IDToken"
|
|||
OAUTH2_PROVIDER_GRANT_MODEL = "gooyal_oauth2.Grant"
|
||||
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "gooyal_oauth2.RefreshToken"
|
||||
|
||||
|
||||
BASE_OAUTH2_PROVIDER_URL = "https://accounts.gooyal.com"
|
||||
CLIENT_ID = 'dCHm08iIoXvbRSgOI79SV6kIs0aoUzwJehtoczvJ'
|
||||
CLIENT_SECRET = 'QBGwaye4Mvr2JHAtn5lQpZhd3RfSVw4XZNrgt6P4UBuV7u6IhsJXUV5QYv3v6rQYEuzjZdKYMjDQuXPmjHeF5UI5fFx080E7FPxFfYHIYBr3ZyvuPi1u7zDRF0EQbzbH'
|
||||
OAUTH2_PROVIDER = {
|
||||
# this is the list of available scopes
|
||||
# 'SCOPES_BACKEND_CLASS': 'apps.gooyal_oauth2.scopes.Scopes',
|
||||
|
|
@ -83,9 +86,9 @@ OAUTH2_PROVIDER = {
|
|||
# 'groups': 'Access to your groups',
|
||||
# 'introspection': 'Introspect token scope'},
|
||||
'OAUTH2_VALIDATOR_CLASS': 'apps.gooyal_oauth2.validators.OAuth2Validator',
|
||||
'RESOURCE_SERVER_INTROSPECTION_URL': 'https://accounts.gooyal.com/oauth2/introspect/',
|
||||
'RESOURCE_SERVER_INTROSPECTION_URL': BASE_OAUTH2_PROVIDER_URL + '/oauth2/introspect/',
|
||||
# 'RESOURCE_SERVER_AUTH_TOKEN': '3yUqsWtwKYKHnfivFcJu', # OR this but not both:
|
||||
'RESOURCE_SERVER_INTROSPECTION_CREDENTIALS': ('dCHm08iIoXvbRSgOI79SV6kIs0aoUzwJehtoczvJ','QBGwaye4Mvr2JHAtn5lQpZhd3RfSVw4XZNrgt6P4UBuV7u6IhsJXUV5QYv3v6rQYEuzjZdKYMjDQuXPmjHeF5UI5fFx080E7FPxFfYHIYBr3ZyvuPi1u7zDRF0EQbzbH'),
|
||||
'RESOURCE_SERVER_INTROSPECTION_CREDENTIALS': (CLIENT_ID, CLIENT_SECRET),
|
||||
}
|
||||
|
||||
# GOOYAL_DYNAMIC_SCOPES
|
||||
|
|
@ -119,8 +122,7 @@ ROOT_URLCONF = 'wallet.urls'
|
|||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates']
|
||||
,
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
|
|
@ -134,6 +136,7 @@ TEMPLATES = [
|
|||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
'oauth2_provider.backends.OAuth2Backend',
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
)
|
||||
|
||||
|
|
@ -222,7 +225,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|||
|
||||
|
||||
|
||||
LOGIN_URL = '/users/login/'
|
||||
LOGIN_URL = '/users/login/request/'
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue