Compare commits
21 Commits
dependabot
...
v2.6.0rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf527d9c26 | ||
|
|
b96319c7b8 | ||
|
|
04938855bb | ||
|
|
a702739d01 | ||
|
|
dcd962ad16 | ||
|
|
e42770e852 | ||
|
|
1da193f34b | ||
|
|
d0113dd2cc | ||
|
|
cf4f6ed224 | ||
|
|
de5808e998 | ||
|
|
fe433dee7b | ||
|
|
598968bc92 | ||
|
|
91e44a78c1 | ||
|
|
98ca5e972d | ||
|
|
fe06e4a34d | ||
|
|
bcf3ecc15c | ||
|
|
dda23b35cb | ||
|
|
43e33c1a12 | ||
|
|
e06bd4d176 | ||
|
|
98e9df8a23 | ||
|
|
3ac893ad50 |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,21 +1,5 @@
|
|||||||
# Change Log
|
# Change Log
|
||||||
## 2.8.0
|
## 2.6.0 (dev)
|
||||||
* Support For Django 4.0+ JSONField
|
|
||||||
* Removed jsonfield package from requirements
|
|
||||||
|
|
||||||
## 2.7.0
|
|
||||||
* Fixed #70
|
|
||||||
* Add QR Code for trusted device link
|
|
||||||
* Better formatting for trusted device start page.
|
|
||||||
## 2.6.1
|
|
||||||
* Fix: CVE-2022-42731: related to the possibility of registration replay attack.
|
|
||||||
Thanks to 'SSE (Secure Systems Engineering)'
|
|
||||||
|
|
||||||
## 2.5.1
|
|
||||||
* Fix: CVE-2022-42731: related to the possibility of registration replay attack.
|
|
||||||
Thanks to 'SSE (Secure Systems Engineering)'
|
|
||||||
|
|
||||||
## 2.6.0
|
|
||||||
* Adding Backup Recovery Codes (Recovery) as a method.
|
* Adding Backup Recovery Codes (Recovery) as a method.
|
||||||
Thanks to @Spitfireap for work, and @peterthomassen for guidance.
|
Thanks to @Spitfireap for work, and @peterthomassen for guidance.
|
||||||
* Added: `RECOVERY_ITERATION` to set the number of iteration when hashing recovery token
|
* Added: `RECOVERY_ITERATION` to set the number of iteration when hashing recovery token
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -1,7 +1,6 @@
|
|||||||
# django-mfa2
|
# django-mfa2
|
||||||
A Django app that handles MFA, it supports TOTP, U2F, FIDO2 U2F (Web Authn), Email Tokens , Trusted Devices and backup codes.
|
A Django app that handles MFA, it supports TOTP, U2F, FIDO2 U2F (Web Authn), Email Tokens , Trusted Devices and backup codes.
|
||||||
|
|
||||||
[](https://fidoalliance.org/passkeys/)
|
|
||||||
### Pip Stats
|
### Pip Stats
|
||||||
[](https://badge.fury.io/py/django-mfa2)
|
[](https://badge.fury.io/py/django-mfa2)
|
||||||
[](https://pepy.tech/project/django-mfa2)
|
[](https://pepy.tech/project/django-mfa2)
|
||||||
@@ -22,9 +21,7 @@ For FIDO2, the following are supported
|
|||||||
* **android-safetynet** (Chrome 70+, Firefox 68+)
|
* **android-safetynet** (Chrome 70+, Firefox 68+)
|
||||||
* **NFC devices using PCSC** (Not Tested, but as supported in fido2)
|
* **NFC devices using PCSC** (Not Tested, but as supported in fido2)
|
||||||
* **Soft Tokens**
|
* **Soft Tokens**
|
||||||
* ~~[krypt.co](https://krypt.co/): Login by a notification on your phone.~~
|
* [krypt.co](https://krypt.co/): Login by a notification on your phone.
|
||||||
|
|
||||||
**Update**: Dec 2022, krypt.co has been killed by Google for Passkeys.
|
|
||||||
|
|
||||||
In English :), It allows you to verify the user by security keys on PC, Laptops or Mobiles, Windows Hello (Fingerprint, PIN) on Windows 10 Build 1903+ (May 2019 Update) Touch/Face ID on Macbooks (Chrome, Safari), Touch/Face ID on iPhone and iPad and Fingerprint/Face/Iris/PIN on Android Phones.
|
In English :), It allows you to verify the user by security keys on PC, Laptops or Mobiles, Windows Hello (Fingerprint, PIN) on Windows 10 Build 1903+ (May 2019 Update) Touch/Face ID on Macbooks (Chrome, Safari), Touch/Face ID on iPhone and iPad and Fingerprint/Face/Iris/PIN on Android Phones.
|
||||||
|
|
||||||
@@ -34,8 +31,6 @@ Trusted device is a mode for the user to add a device that doesn't support secur
|
|||||||
|
|
||||||
Package tested with Django 1.8, Django 2.2 on Python 2.7 and Python 3.5+ but it was not checked with any version in between but open for issues.
|
Package tested with Django 1.8, Django 2.2 on Python 2.7 and Python 3.5+ but it was not checked with any version in between but open for issues.
|
||||||
|
|
||||||
If you just need WebAuthn and Passkeys, you can use **[django-passkeys](https://github.com/mkalioby/django-passkeys)**, which is a slim-down of this app and much easier to integrate.
|
|
||||||
|
|
||||||
Depends on
|
Depends on
|
||||||
|
|
||||||
* pyotp
|
* pyotp
|
||||||
@@ -47,12 +42,8 @@ Depends on
|
|||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
1. using pip
|
1. using pip
|
||||||
* For Django >= 4.0
|
|
||||||
|
|
||||||
`pip install django-mfa2`
|
`pip install django-mfa2`
|
||||||
* For Django < 4.0
|
|
||||||
|
|
||||||
`pip install django-mfa2 jsonfield`
|
|
||||||
2. Using Conda forge
|
2. Using Conda forge
|
||||||
|
|
||||||
`conda config --add channels conda-forge`
|
`conda config --add channels conda-forge`
|
||||||
@@ -205,8 +196,6 @@ function some_func() {
|
|||||||
* [AndreasDickow](https://github.com/AndreasDickow)
|
* [AndreasDickow](https://github.com/AndreasDickow)
|
||||||
* [mnelson4](https://github.com/mnelson4)
|
* [mnelson4](https://github.com/mnelson4)
|
||||||
* [ezrajrice](https://github.com/ezrajrice)
|
* [ezrajrice](https://github.com/ezrajrice)
|
||||||
* [Spitfireap](https://github.com/Spitfireap)
|
|
||||||
* [peterthomassen](https://github.com/peterthomassen)
|
|
||||||
|
|
||||||
|
|
||||||
# Security contact information
|
# Security contact information
|
||||||
|
|||||||
@@ -16,13 +16,12 @@ Including another URLconf
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path,re_path,include
|
from django.urls import path,re_path,include
|
||||||
from . import views,auth
|
from . import views,auth
|
||||||
from mfa import TrustedDevice
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('mfa/', include('mfa.urls')),
|
path('mfa/', include('mfa.urls')),
|
||||||
path('auth/login',auth.loginView,name="login"),
|
path('auth/login',auth.loginView,name="login"),
|
||||||
path('auth/logout',auth.logoutView,name="logout"),
|
path('auth/logout',auth.logoutView,name="logout"),
|
||||||
path('devices/add/', TrustedDevice.add,name="add_trusted_device"),
|
|
||||||
re_path('^$',views.home,name='home'),
|
re_path('^$',views.home,name='home'),
|
||||||
path('registered/',views.registered,name='registered')
|
path('registered/',views.registered,name='registered')
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -16,7 +16,7 @@ from .views import login, reset_cookie
|
|||||||
import datetime
|
import datetime
|
||||||
from .Common import get_redirect_url
|
from .Common import get_redirect_url
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.http import JsonResponse
|
|
||||||
|
|
||||||
def recheck(request):
|
def recheck(request):
|
||||||
"""Starts FIDO2 recheck"""
|
"""Starts FIDO2 recheck"""
|
||||||
@@ -49,15 +49,13 @@ def begin_registeration(request):
|
|||||||
def complete_reg(request):
|
def complete_reg(request):
|
||||||
"""Completes the registeration, called by API"""
|
"""Completes the registeration, called by API"""
|
||||||
try:
|
try:
|
||||||
if not "fido_state" in request.session:
|
|
||||||
return JsonResponse({'status': 'ERR', "message": "FIDO Status can't be found, please try again"})
|
|
||||||
data = cbor.decode(request.body)
|
data = cbor.decode(request.body)
|
||||||
|
|
||||||
client_data = CollectedClientData(data['clientDataJSON'])
|
client_data = CollectedClientData(data['clientDataJSON'])
|
||||||
att_obj = AttestationObject((data['attestationObject']))
|
att_obj = AttestationObject((data['attestationObject']))
|
||||||
server = getServer()
|
server = getServer()
|
||||||
auth_data = server.register_complete(
|
auth_data = server.register_complete(
|
||||||
request.session.pop('fido_state'),
|
request.session['fido_state'],
|
||||||
client_data,
|
client_data,
|
||||||
att_obj
|
att_obj
|
||||||
)
|
)
|
||||||
@@ -81,7 +79,7 @@ def complete_reg(request):
|
|||||||
client.captureException()
|
client.captureException()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
return JsonResponse({'status': 'ERR', "message": "Error on server, please try again later"})
|
return HttpResponse(simplejson.dumps({'status': 'ERR', "message": "Error on server, please try again later"}))
|
||||||
|
|
||||||
|
|
||||||
def start(request):
|
def start(request):
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ from django.template.context_processors import csrf
|
|||||||
from .models import *
|
from .models import *
|
||||||
import user_agents
|
import user_agents
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
def id_generator(size=6, chars=string.ascii_uppercase + string.digits):
|
def id_generator(size=6, chars=string.ascii_uppercase + string.digits):
|
||||||
x=''.join(random.choice(chars) for _ in range(size))
|
x=''.join(random.choice(chars) for _ in range(size))
|
||||||
if not User_Keys.objects.filter(properties__icontains='"key": "%s"'%x).exists(): return x
|
if not User_Keys.objects.filter(properties__shas="$.key="+x).exists(): return x
|
||||||
else: return id_generator(size,chars)
|
else: return id_generator(size,chars)
|
||||||
|
|
||||||
def getUserAgent(request):
|
def getUserAgent(request):
|
||||||
@@ -58,13 +57,12 @@ def getCookie(request):
|
|||||||
def add(request):
|
def add(request):
|
||||||
context=csrf(request)
|
context=csrf(request)
|
||||||
if request.method=="GET":
|
if request.method=="GET":
|
||||||
context.update({"username":request.GET.get('u',''),"key":request.GET.get('k','')})
|
|
||||||
return render(request,"TrustedDevices/Add.html",context)
|
return render(request,"TrustedDevices/Add.html",context)
|
||||||
else:
|
else:
|
||||||
key=request.POST["key"].replace("-","").replace(" ","").upper()
|
key=request.POST["key"].replace("-","").replace(" ","").upper()
|
||||||
context["username"] = request.POST["username"]
|
context["username"] = request.POST["username"]
|
||||||
context["key"] = request.POST["key"]
|
context["key"] = request.POST["key"]
|
||||||
trusted_keys=User_Keys.objects.filter(username=request.POST["username"],properties__icontains='"key": "%s"'%key)
|
trusted_keys=User_Keys.objects.filter(username=request.POST["username"],properties__has="$.key="+key)
|
||||||
cookie=False
|
cookie=False
|
||||||
if trusted_keys.exists():
|
if trusted_keys.exists():
|
||||||
tk=trusted_keys[0]
|
tk=trusted_keys[0]
|
||||||
@@ -99,7 +97,7 @@ def start(request):
|
|||||||
request.session["td_id"]=td.id
|
request.session["td_id"]=td.id
|
||||||
try:
|
try:
|
||||||
if td==None: td=User_Keys.objects.get(id=request.session["td_id"])
|
if td==None: td=User_Keys.objects.get(id=request.session["td_id"])
|
||||||
context={"key":td.properties["key"],"url":request.scheme+"://"+request.get_host() + reverse('add_trusted_device')}
|
context={"key":td.properties["key"]}
|
||||||
except:
|
except:
|
||||||
del request.session["td_id"]
|
del request.session["td_id"]
|
||||||
return start(request)
|
return start(request)
|
||||||
@@ -126,14 +124,12 @@ def verify(request):
|
|||||||
json= jwt.decode(request.COOKIES.get('deviceid'),settings.SECRET_KEY)
|
json= jwt.decode(request.COOKIES.get('deviceid'),settings.SECRET_KEY)
|
||||||
if json["username"].lower()== request.session['base_username'].lower():
|
if json["username"].lower()== request.session['base_username'].lower():
|
||||||
try:
|
try:
|
||||||
uk = User_Keys.objects.get(username=request.POST["username"].lower(), properties__icontains='"key": "%s"'%json["key"])
|
uk = User_Keys.objects.get(username=request.POST["username"].lower(), properties__has="$.key=" + json["key"])
|
||||||
if uk.enabled and uk.properties["status"] == "trusted":
|
if uk.enabled and uk.properties["status"] == "trusted":
|
||||||
uk.last_used=timezone.now()
|
uk.last_used=timezone.now()
|
||||||
uk.save()
|
uk.save()
|
||||||
request.session["mfa"] = {"verified": True, "method": "Trusted Device","id":uk.id}
|
request.session["mfa"] = {"verified": True, "method": "Trusted Device","id":uk.id}
|
||||||
return True
|
return True
|
||||||
except:
|
except:
|
||||||
import traceback
|
|
||||||
print(traceback.format_exc())
|
|
||||||
return False
|
return False
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -2,14 +2,7 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.db import models, migrations
|
from django.db import models, migrations
|
||||||
try:
|
import jsonfield.fields
|
||||||
from django.db.models import JSONField
|
|
||||||
except ImportError:
|
|
||||||
try:
|
|
||||||
from jsonfield.fields import JSONField
|
|
||||||
except ImportError:
|
|
||||||
raise ImportError("Can't find a JSONField implementation, please install jsonfield if django < 4.0")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def modify_json(apps, schema_editor):
|
def modify_json(apps, schema_editor):
|
||||||
@@ -31,7 +24,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='user_keys',
|
model_name='user_keys',
|
||||||
name='properties',
|
name='properties',
|
||||||
field=JSONField(null=True),
|
field=jsonfield.fields.JSONField(null=True),
|
||||||
),
|
),
|
||||||
migrations.RunPython(modify_json)
|
migrations.RunPython(modify_json)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
try:
|
|
||||||
from django.db.models import JSONField
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
try:
|
|
||||||
from jsonfield import JSONField
|
from jsonfield import JSONField
|
||||||
except ModuleNotFoundError:
|
|
||||||
raise ModuleNotFoundError("Can't find a JSONField implementation, please install jsonfield if django < 4.0")
|
|
||||||
|
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
#from jsonLookup import shasLookup, hasLookup
|
#from jsonLookup import shasLookup, hasLookup
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script src="{% static 'mfa/js/qrious.min.js' %}" type="text/javascript"></script>
|
|
||||||
<style>
|
<style>
|
||||||
#two-factor-steps {
|
#two-factor-steps {
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
@@ -14,12 +12,6 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
$(document).ready(function (){
|
|
||||||
var qr = new QRious({
|
|
||||||
element: document.getElementById('qr'),
|
|
||||||
value: "{{ url }}?u={{ request.user.username }}&k={{ key }}"
|
|
||||||
});
|
|
||||||
})
|
|
||||||
function sendEmail() {
|
function sendEmail() {
|
||||||
$("#modal-title").html("Send Link")
|
$("#modal-title").html("Send Link")
|
||||||
$("#modal-body").html("Sending Email, Please wait....");
|
$("#modal-body").html("Sending Email, Please wait....");
|
||||||
@@ -90,52 +82,21 @@
|
|||||||
{% if not_allowed %}
|
{% if not_allowed %}
|
||||||
<div class="alert alert-danger">You can't add any more devices, you need to remove previously trusted devices first.</div>
|
<div class="alert alert-danger">You can't add any more devices, you need to remove previously trusted devices first.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="color: green">Allow access from mobile phone and tables.</p><br/>
|
<p style="color: green">Allow access from mobile phone and tables.</p>
|
||||||
<br/>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<h5>Steps:</h5>
|
<h5>Steps:</h5>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>Using Camera</h5>
|
|
||||||
<ol>
|
<ol>
|
||||||
<li>Using your mobile/table, open Chrome/Firefox.</li>
|
<li>Using your mobile/table, open Chrome/Firefox.</li>
|
||||||
<li>Scan the following barcode <br/>
|
<li>Go to <b>{{ HOST }}{{ BASE_URL }}devices/add</b> <a href="javascript:void(0)" onclick="sendEmail()" title="Send to my email"><i class="fas fa-paper-plane"></i></a></li>
|
||||||
<img id="qr"/> <br/>
|
|
||||||
</li>
|
|
||||||
<li>Confirm the consent and submit form.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>Manual</h5>
|
|
||||||
<ol>
|
|
||||||
<li>Using your mobile/table, open Chrome/Firefox.</li>
|
|
||||||
<li>Go to <b>{{ url }}</b> </li>
|
|
||||||
<li>Enter your username & following 6 digits<br/>
|
<li>Enter your username & following 6 digits<br/>
|
||||||
<span style="font-size: 16px;font-weight: bold; margin-left: 50px">{{ key|slice:":3" }} - {{ key|slice:"3:" }}</span>
|
<span style="font-size: 16px;font-weight: bold; margin-left: 50px">{{ key|slice:":3" }} - {{ key|slice:"3:" }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li>Confirm the consent and submit form.</li>
|
<li>This window will ask to confirm the device.</li>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-8 offset-2">
|
|
||||||
This window will ask to confirm the device.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
</ol>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
{% include "modal.html" %}
|
{% include "modal.html" %}
|
||||||
{% include 'mfa_check.html' %}
|
{% include 'mfa_check.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,5 +6,5 @@ python-u2flib-server
|
|||||||
ua-parser
|
ua-parser
|
||||||
user-agents
|
user-agents
|
||||||
python-jose
|
python-jose
|
||||||
fido2 == 1.1.1
|
fido2 == 1.0.0
|
||||||
jsonLookup
|
jsonLookup
|
||||||
|
|||||||
9
setup.py
9
setup.py
@@ -4,7 +4,7 @@ from setuptools import find_packages, setup
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='django-mfa2',
|
name='django-mfa2',
|
||||||
version='2.8.0',
|
version='2.5.0',
|
||||||
description='Allows user to add 2FA to their accounts',
|
description='Allows user to add 2FA to their accounts',
|
||||||
long_description=open("README.md").read(),
|
long_description=open("README.md").read(),
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
@@ -17,20 +17,21 @@ setup(
|
|||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'django >= 2.0',
|
'django >= 2.0',
|
||||||
|
'jsonfield',
|
||||||
'simplejson',
|
'simplejson',
|
||||||
'pyotp',
|
'pyotp',
|
||||||
'python-u2flib-server',
|
'python-u2flib-server',
|
||||||
'ua-parser',
|
'ua-parser',
|
||||||
'user-agents',
|
'user-agents',
|
||||||
'python-jose',
|
'python-jose',
|
||||||
'fido2 == 1.1.1',
|
'fido2 == 1.0.0',
|
||||||
|
'jsonLookup'
|
||||||
],
|
],
|
||||||
python_requires=">=3.5",
|
python_requires=">=3.5",
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
zip_safe=False, # because we're including static files
|
zip_safe=False, # because we're including static files
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
#"Development Status :: 4 - Beta",
|
|
||||||
"Environment :: Web Environment",
|
"Environment :: Web Environment",
|
||||||
"Framework :: Django",
|
"Framework :: Django",
|
||||||
"Framework :: Django :: 2.0",
|
"Framework :: Django :: 2.0",
|
||||||
@@ -40,7 +41,6 @@ setup(
|
|||||||
"Framework :: Django :: 3.1",
|
"Framework :: Django :: 3.1",
|
||||||
"Framework :: Django :: 3.2",
|
"Framework :: Django :: 3.2",
|
||||||
"Framework :: Django :: 4.0",
|
"Framework :: Django :: 4.0",
|
||||||
"Framework :: Django :: 4.1",
|
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python",
|
"Programming Language :: Python",
|
||||||
@@ -51,7 +51,6 @@ setup(
|
|||||||
"Programming Language :: Python :: 3.8",
|
"Programming Language :: Python :: 3.8",
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user