Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be3cf69956 | ||
|
|
98b9fce1d2 | ||
|
|
17ef0f4b1e | ||
|
|
669fef84fd | ||
|
|
25be381ca9 | ||
|
|
bdb4de3375 | ||
|
|
ab89a204bb | ||
|
|
caaa059d5b | ||
|
|
2d7b80bf5a | ||
|
|
8dba66b7b2 | ||
|
|
54db5a513b | ||
|
|
4903967c23 | ||
|
|
cb2149acf3 |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,21 @@
|
|||||||
# Change Log
|
# Change Log
|
||||||
## 2.6.0 (dev)
|
## 2.8.0
|
||||||
|
* 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
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -1,6 +1,7 @@
|
|||||||
# 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)
|
||||||
@@ -16,12 +17,14 @@ Web Authencation API (WebAuthn) is state-of-the art techology that is expected t
|
|||||||
|
|
||||||
For FIDO2, the following are supported
|
For FIDO2, the following are supported
|
||||||
* **security keys** (Firefox 60+, Chrome 67+, Edge 18+, Safari 13 on Mac OS, Chrome on Andriod, Safari on iOS 13.3+),
|
* **security keys** (Firefox 60+, Chrome 67+, Edge 18+, Safari 13 on Mac OS, Chrome on Andriod, Safari on iOS 13.3+),
|
||||||
* **Windows Hello** (Firefox 67+, Chrome 72+ , Edge) ,
|
* **Windows Hello** (Firefox 67+, Chrome 72+ , Edge),
|
||||||
* **Apple's Touch ID/Face ID** (Chrome 70+ on Mac OS X, Safari on macOS Big Sur, Safari on iOS 14.0+ ),
|
* **Apple's Touch ID/Face ID** (Chrome 70+ on Mac OS X, Safari on macOS Big Sur, Safari on iOS 14.0+ ),
|
||||||
* **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.
|
||||||
|
|
||||||
@@ -31,6 +34,8 @@ 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
|
||||||
@@ -42,8 +47,12 @@ 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`
|
||||||
@@ -196,6 +205,8 @@ 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,12 +16,13 @@ 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')
|
||||||
]
|
]
|
||||||
|
|||||||
BIN
img/Works with PassKeys-black.png
Normal file
BIN
img/Works with PassKeys-black.png
Normal file
Binary file not shown.
|
After 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,13 +49,15 @@ 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['fido_state'],
|
request.session.pop('fido_state'),
|
||||||
client_data,
|
client_data,
|
||||||
att_obj
|
att_obj
|
||||||
)
|
)
|
||||||
@@ -79,7 +81,7 @@ def complete_reg(request):
|
|||||||
client.captureException()
|
client.captureException()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
return HttpResponse(simplejson.dumps({'status': 'ERR', "message": "Error on server, please try again later"}))
|
return JsonResponse({'status': 'ERR', "message": "Error on server, please try again later"})
|
||||||
|
|
||||||
|
|
||||||
def start(request):
|
def start(request):
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ 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__shas="$.key="+x).exists(): return x
|
if not User_Keys.objects.filter(properties__icontains='"key": "%s"'%x).exists(): return x
|
||||||
else: return id_generator(size,chars)
|
else: return id_generator(size,chars)
|
||||||
|
|
||||||
def getUserAgent(request):
|
def getUserAgent(request):
|
||||||
@@ -57,12 +58,13 @@ 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__has="$.key="+key)
|
trusted_keys=User_Keys.objects.filter(username=request.POST["username"],properties__icontains='"key": "%s"'%key)
|
||||||
cookie=False
|
cookie=False
|
||||||
if trusted_keys.exists():
|
if trusted_keys.exists():
|
||||||
tk=trusted_keys[0]
|
tk=trusted_keys[0]
|
||||||
@@ -97,7 +99,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"]}
|
context={"key":td.properties["key"],"url":request.scheme+"://"+request.get_host() + reverse('add_trusted_device')}
|
||||||
except:
|
except:
|
||||||
del request.session["td_id"]
|
del request.session["td_id"]
|
||||||
return start(request)
|
return start(request)
|
||||||
@@ -124,12 +126,14 @@ 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__has="$.key=" + json["key"])
|
uk = User_Keys.objects.get(username=request.POST["username"].lower(), properties__icontains='"key": "%s"'%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,7 +2,14 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.db import models, migrations
|
from django.db import models, migrations
|
||||||
import jsonfield.fields
|
try:
|
||||||
|
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):
|
||||||
@@ -24,7 +31,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='user_keys',
|
model_name='user_keys',
|
||||||
name='properties',
|
name='properties',
|
||||||
field=jsonfield.fields.JSONField(null=True),
|
field=JSONField(null=True),
|
||||||
),
|
),
|
||||||
migrations.RunPython(modify_json)
|
migrations.RunPython(modify_json)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from jsonfield import JSONField
|
try:
|
||||||
|
from django.db.models import JSONField
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
try:
|
||||||
|
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,5 +1,7 @@
|
|||||||
{% 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;
|
||||||
@@ -12,6 +14,12 @@
|
|||||||
</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....");
|
||||||
@@ -82,21 +90,52 @@
|
|||||||
{% 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>
|
<p style="color: green">Allow access from mobile phone and tables.</p><br/>
|
||||||
<h5>Steps:</h5>
|
<br/>
|
||||||
<ol>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<h5>Steps:</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Using Camera</h5>
|
||||||
|
<ol>
|
||||||
<li>Using your mobile/table, open Chrome/Firefox.</li>
|
<li>Using your mobile/table, open Chrome/Firefox.</li>
|
||||||
<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>
|
<li>Scan the following barcode <br/>
|
||||||
|
<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>This window will ask to confirm the device.</li>
|
<li>Confirm the consent and submit form.</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 %}
|
||||||
7
setup.py
7
setup.py
@@ -4,7 +4,7 @@ from setuptools import find_packages, setup
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='django-mfa2',
|
name='django-mfa2',
|
||||||
version='2.5.0',
|
version='2.8.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,7 +17,6 @@ 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',
|
||||||
@@ -25,13 +24,13 @@ setup(
|
|||||||
'user-agents',
|
'user-agents',
|
||||||
'python-jose',
|
'python-jose',
|
||||||
'fido2 == 1.0.0',
|
'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",
|
||||||
@@ -41,6 +40,7 @@ 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,6 +51,7 @@ 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