login via oauth server

This commit is contained in:
mahdavi 2024-07-10 18:04:16 +03:30
parent e3c1d010df
commit 5fa4dfd780
11 changed files with 301 additions and 12 deletions

2
.gitignore vendored
View file

@ -1,7 +1,7 @@
.python-version
.idea
media/*
delme*.py
utils/delme.py
*.pyc
.env
/venv/

View file

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

View 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)),
],
),
]

View 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),
),
]

View 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),
),
]

View file

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

View file

@ -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'),
]

View file

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

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

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

View file

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