auth2fa 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- auth2fa-0.1.0/LICENSE +21 -0
- auth2fa-0.1.0/MANIFEST.in +3 -0
- auth2fa-0.1.0/PKG-INFO +93 -0
- auth2fa-0.1.0/README.md +69 -0
- auth2fa-0.1.0/auth2fa/__init__.py +19 -0
- auth2fa-0.1.0/auth2fa/core.py +222 -0
- auth2fa-0.1.0/auth2fa/recovery.py +54 -0
- auth2fa-0.1.0/auth2fa/sql/totp_auth/create_table.sql +13 -0
- auth2fa-0.1.0/auth2fa/sql/totp_auth/delete.sql +2 -0
- auth2fa-0.1.0/auth2fa/sql/totp_auth/insert.sql +7 -0
- auth2fa-0.1.0/auth2fa/sql/totp_auth/select_by_user.sql +2 -0
- auth2fa-0.1.0/auth2fa/sql/totp_auth/update.sql +2 -0
- auth2fa-0.1.0/auth2fa/storage/__init__.py +6 -0
- auth2fa-0.1.0/auth2fa/storage/base.py +60 -0
- auth2fa-0.1.0/auth2fa/storage/json_storage.py +131 -0
- auth2fa-0.1.0/auth2fa/storage/sql_storage.py +105 -0
- auth2fa-0.1.0/auth2fa.egg-info/PKG-INFO +93 -0
- auth2fa-0.1.0/auth2fa.egg-info/SOURCES.txt +22 -0
- auth2fa-0.1.0/auth2fa.egg-info/dependency_links.txt +1 -0
- auth2fa-0.1.0/auth2fa.egg-info/requires.txt +6 -0
- auth2fa-0.1.0/auth2fa.egg-info/top_level.txt +1 -0
- auth2fa-0.1.0/pyproject.toml +39 -0
- auth2fa-0.1.0/setup.cfg +4 -0
- auth2fa-0.1.0/setup.py +18 -0
auth2fa-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 horrible-gh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
auth2fa-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auth2fa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TOTP-based Two-Factor Authentication Library for Python
|
|
5
|
+
Author-email: horrible-gh <shinjpn1@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/horrible-gh/auth2fa
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/horrible-gh/auth2fa/issues
|
|
8
|
+
Keywords: auth2fa,2fa,totp,authentication,otp,two-factor
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pyotp
|
|
19
|
+
Requires-Dist: qrcode
|
|
20
|
+
Requires-Dist: Pillow
|
|
21
|
+
Provides-Extra: sql
|
|
22
|
+
Requires-Dist: sqloader; extra == "sql"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# sqloader
|
|
26
|
+
|
|
27
|
+
A lightweight Python utility for managing SQL migrations and loading SQL from JSON or .sql files.
|
|
28
|
+
Supports common relational databases and is designed for simple, clean integration with any Python backend (e.g., FastAPI).
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```powershell
|
|
35
|
+
pip install sqloader
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- ✅ Easy database migration management
|
|
41
|
+
- ✅ Load SQL queries from `.json` or `.sql` files
|
|
42
|
+
- ✅ Supports MySQL and SQLite
|
|
43
|
+
- ✅ Clean API for integration
|
|
44
|
+
- ✅ Lightweight and dependency-minimized
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from sqloader.init import database_init
|
|
50
|
+
|
|
51
|
+
config = {
|
|
52
|
+
"type": "mysql",
|
|
53
|
+
"mysql": {
|
|
54
|
+
"host": "localhost",
|
|
55
|
+
"port": 3306,
|
|
56
|
+
"user": "root",
|
|
57
|
+
"password": "pass",
|
|
58
|
+
"database": "mydb"
|
|
59
|
+
},
|
|
60
|
+
"service": {
|
|
61
|
+
"sqloder": "res/sql/sqloader/mysql"
|
|
62
|
+
},
|
|
63
|
+
"migration": {
|
|
64
|
+
"auto_migration": True,
|
|
65
|
+
"migration_path": "res/sql/migration/mysql"
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
db, sqloader, migrator = database_init(config)
|
|
70
|
+
|
|
71
|
+
# Example usage
|
|
72
|
+
query = sqloader.load_sql("user_info", "user.get_user_by_id")
|
|
73
|
+
result = db.fetch_one(query, ['abc', 123])
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## SQL Loading Behavior
|
|
78
|
+
|
|
79
|
+
- If the value in the .json file ends with .sql, the referenced file will be loaded from the same directory.
|
|
80
|
+
- Otherwise, the value is treated as a raw SQL string.
|
|
81
|
+
|
|
82
|
+
Example JSON file user.json:
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"user": {
|
|
88
|
+
"get_user_by_id": "SELECT * FROM users WHERE id = %s",
|
|
89
|
+
"get_all_users": "user_all.sql"
|
|
90
|
+
},
|
|
91
|
+
"get_etc": "SELECT * FROM etc"
|
|
92
|
+
}
|
|
93
|
+
```
|
auth2fa-0.1.0/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# sqloader
|
|
2
|
+
|
|
3
|
+
A lightweight Python utility for managing SQL migrations and loading SQL from JSON or .sql files.
|
|
4
|
+
Supports common relational databases and is designed for simple, clean integration with any Python backend (e.g., FastAPI).
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```powershell
|
|
11
|
+
pip install sqloader
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- ✅ Easy database migration management
|
|
17
|
+
- ✅ Load SQL queries from `.json` or `.sql` files
|
|
18
|
+
- ✅ Supports MySQL and SQLite
|
|
19
|
+
- ✅ Clean API for integration
|
|
20
|
+
- ✅ Lightweight and dependency-minimized
|
|
21
|
+
|
|
22
|
+
## Quickstart
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from sqloader.init import database_init
|
|
26
|
+
|
|
27
|
+
config = {
|
|
28
|
+
"type": "mysql",
|
|
29
|
+
"mysql": {
|
|
30
|
+
"host": "localhost",
|
|
31
|
+
"port": 3306,
|
|
32
|
+
"user": "root",
|
|
33
|
+
"password": "pass",
|
|
34
|
+
"database": "mydb"
|
|
35
|
+
},
|
|
36
|
+
"service": {
|
|
37
|
+
"sqloder": "res/sql/sqloader/mysql"
|
|
38
|
+
},
|
|
39
|
+
"migration": {
|
|
40
|
+
"auto_migration": True,
|
|
41
|
+
"migration_path": "res/sql/migration/mysql"
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
db, sqloader, migrator = database_init(config)
|
|
46
|
+
|
|
47
|
+
# Example usage
|
|
48
|
+
query = sqloader.load_sql("user_info", "user.get_user_by_id")
|
|
49
|
+
result = db.fetch_one(query, ['abc', 123])
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## SQL Loading Behavior
|
|
54
|
+
|
|
55
|
+
- If the value in the .json file ends with .sql, the referenced file will be loaded from the same directory.
|
|
56
|
+
- Otherwise, the value is treated as a raw SQL string.
|
|
57
|
+
|
|
58
|
+
Example JSON file user.json:
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"user": {
|
|
64
|
+
"get_user_by_id": "SELECT * FROM users WHERE id = %s",
|
|
65
|
+
"get_all_users": "user_all.sql"
|
|
66
|
+
},
|
|
67
|
+
"get_etc": "SELECT * FROM etc"
|
|
68
|
+
}
|
|
69
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
auth2fa - TOTP-based Two-Factor Authentication Library
|
|
3
|
+
|
|
4
|
+
A flexible and easy-to-use Python library for implementing TOTP-based
|
|
5
|
+
two-factor authentication with support for multiple storage backends.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .core import TwoFactorAuth
|
|
9
|
+
from .storage.base import BaseStorage
|
|
10
|
+
from .storage.json_storage import JSONStorage
|
|
11
|
+
from .storage.sql_storage import SQLStorage
|
|
12
|
+
|
|
13
|
+
__version__ = '0.1.0'
|
|
14
|
+
__all__ = [
|
|
15
|
+
'TwoFactorAuth',
|
|
16
|
+
'BaseStorage',
|
|
17
|
+
'JSONStorage',
|
|
18
|
+
'SQLStorage'
|
|
19
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Core TOTP authentication logic for auth2fa."""
|
|
2
|
+
import base64
|
|
3
|
+
import pyotp
|
|
4
|
+
import qrcode
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional, Dict
|
|
8
|
+
from .storage.base import BaseStorage
|
|
9
|
+
from .storage.json_storage import JSONStorage
|
|
10
|
+
from .storage.sql_storage import SQLStorage
|
|
11
|
+
from .recovery import generate_recovery_codes, verify_recovery_code
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TwoFactorAuth:
|
|
15
|
+
"""Main class for TOTP-based two-factor authentication."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, issuer: str = "MyApp", storage_path: Optional[str] = None, sq=None):
|
|
18
|
+
"""
|
|
19
|
+
Initialize TwoFactorAuth.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
issuer: Name of the application/service for TOTP
|
|
23
|
+
storage_path: Path to JSON storage file (optional)
|
|
24
|
+
sq: sqloader instance for SQL storage (optional)
|
|
25
|
+
|
|
26
|
+
Initialization priority:
|
|
27
|
+
1. If sq is provided → SQLStorage
|
|
28
|
+
2. If storage_path is provided → JSONStorage with custom path
|
|
29
|
+
3. Otherwise → JSONStorage with default path ("./totp_data.json")
|
|
30
|
+
"""
|
|
31
|
+
self.issuer = issuer
|
|
32
|
+
|
|
33
|
+
# Determine storage backend based on priority
|
|
34
|
+
if sq is not None:
|
|
35
|
+
self.storage = SQLStorage(sq)
|
|
36
|
+
elif storage_path is not None:
|
|
37
|
+
self.storage = JSONStorage(storage_path)
|
|
38
|
+
else:
|
|
39
|
+
self.storage = JSONStorage("./totp_data.json")
|
|
40
|
+
|
|
41
|
+
def setup(self, user_id: str, username: str = "") -> Dict:
|
|
42
|
+
"""
|
|
43
|
+
Set up TOTP for a user.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
user_id: Unique identifier for the user
|
|
47
|
+
username: Display name for the account (defaults to empty string)
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Dictionary containing:
|
|
51
|
+
{
|
|
52
|
+
"secret": "BASE32SECRET",
|
|
53
|
+
"qr_uri": "otpauth://totp/Issuer:username?secret=...&issuer=...",
|
|
54
|
+
"qr_image": "base64 encoded PNG QR image",
|
|
55
|
+
"recovery_codes": ["A3F8K2M1", ...]
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
59
|
+
|
|
60
|
+
if self.storage.exists(user_id):
|
|
61
|
+
raise ValueError(f"TOTP already configured for user: {user_id}")
|
|
62
|
+
|
|
63
|
+
# Generate secret and recovery codes
|
|
64
|
+
secret = pyotp.random_base32()
|
|
65
|
+
recovery_codes = generate_recovery_codes(count=8, length=8)
|
|
66
|
+
|
|
67
|
+
# Generate provisioning URI for QR code
|
|
68
|
+
account_name = username if username else user_id
|
|
69
|
+
totp = pyotp.TOTP(secret)
|
|
70
|
+
qr_uri = totp.provisioning_uri(
|
|
71
|
+
name=account_name,
|
|
72
|
+
issuer_name=self.issuer
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Generate QR code image
|
|
76
|
+
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
|
77
|
+
qr.add_data(qr_uri)
|
|
78
|
+
qr.make(fit=True)
|
|
79
|
+
|
|
80
|
+
img = qr.make_image(fill_color="black", back_color="white")
|
|
81
|
+
buffer = BytesIO()
|
|
82
|
+
img.save(buffer, format='PNG')
|
|
83
|
+
qr_image_bytes = buffer.getvalue()
|
|
84
|
+
qr_image_base64 = base64.b64encode(qr_image_bytes).decode('utf-8')
|
|
85
|
+
|
|
86
|
+
# Save to storage (not enabled yet)
|
|
87
|
+
data = {
|
|
88
|
+
'secret': secret,
|
|
89
|
+
'enabled': False,
|
|
90
|
+
'recovery_codes': recovery_codes,
|
|
91
|
+
'created_at': datetime.now().isoformat()
|
|
92
|
+
}
|
|
93
|
+
self.storage.save(user_id, data)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
"secret": secret,
|
|
97
|
+
"qr_uri": qr_uri,
|
|
98
|
+
"qr_image": qr_image_base64,
|
|
99
|
+
"recovery_codes": recovery_codes
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def activate(self, user_id: str, code: str) -> bool:
|
|
103
|
+
"""
|
|
104
|
+
Activate TOTP after user verifies the code from their app.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
user_id: Unique identifier for the user
|
|
108
|
+
code: TOTP code from authenticator app
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True if activation successful, False if code is invalid
|
|
112
|
+
"""
|
|
113
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
114
|
+
|
|
115
|
+
data = self.storage.get(user_id)
|
|
116
|
+
if not data:
|
|
117
|
+
raise ValueError(f"TOTP not configured for user: {user_id}")
|
|
118
|
+
|
|
119
|
+
if data['enabled']:
|
|
120
|
+
raise ValueError(f"TOTP already activated for user: {user_id}")
|
|
121
|
+
|
|
122
|
+
# Verify the token
|
|
123
|
+
secret = data['secret']
|
|
124
|
+
totp = pyotp.TOTP(secret)
|
|
125
|
+
if totp.verify(code, valid_window=1):
|
|
126
|
+
data['enabled'] = True
|
|
127
|
+
self.storage.save(user_id, data)
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
def verify(self, user_id: str, code: str) -> bool:
|
|
133
|
+
"""
|
|
134
|
+
Verify TOTP code during login.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
user_id: Unique identifier for the user
|
|
138
|
+
code: TOTP code or recovery code
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
True if verification successful, False otherwise
|
|
142
|
+
|
|
143
|
+
Behavior:
|
|
144
|
+
- Unconfigured user → True (pass through)
|
|
145
|
+
- TOTP code verification (valid_window=1)
|
|
146
|
+
- If TOTP fails, try recovery code
|
|
147
|
+
- Recovery code used → automatically removed
|
|
148
|
+
"""
|
|
149
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
150
|
+
|
|
151
|
+
data = self.storage.get(user_id)
|
|
152
|
+
|
|
153
|
+
# Unconfigured user → pass through
|
|
154
|
+
if not data:
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
# Try TOTP code first
|
|
158
|
+
secret = data['secret']
|
|
159
|
+
totp = pyotp.TOTP(secret)
|
|
160
|
+
if totp.verify(code, valid_window=1):
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
# TOTP failed, try recovery code
|
|
164
|
+
recovery_codes = data.get('recovery_codes', [])
|
|
165
|
+
is_valid, updated_codes = verify_recovery_code(recovery_codes, code)
|
|
166
|
+
|
|
167
|
+
if is_valid:
|
|
168
|
+
# Update storage with used code removed
|
|
169
|
+
data['recovery_codes'] = updated_codes
|
|
170
|
+
self.storage.save(user_id, data)
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
def disable(self, user_id: str) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Disable and remove TOTP configuration for a user.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
user_id: Unique identifier for the user
|
|
181
|
+
"""
|
|
182
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
183
|
+
self.storage.delete(user_id)
|
|
184
|
+
|
|
185
|
+
def is_enabled(self, user_id: str) -> bool:
|
|
186
|
+
"""
|
|
187
|
+
Check if TOTP is enabled for a user.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
user_id: Unique identifier for the user
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
True if TOTP is enabled, False otherwise
|
|
194
|
+
"""
|
|
195
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
196
|
+
|
|
197
|
+
data = self.storage.get(user_id)
|
|
198
|
+
if not data:
|
|
199
|
+
return False
|
|
200
|
+
return data.get('enabled', False)
|
|
201
|
+
|
|
202
|
+
def regenerate_recovery_codes(self, user_id: str) -> list:
|
|
203
|
+
"""
|
|
204
|
+
Regenerate recovery codes for a user.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
user_id: Unique identifier for the user
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
List of new recovery codes
|
|
211
|
+
"""
|
|
212
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
213
|
+
|
|
214
|
+
data = self.storage.get(user_id)
|
|
215
|
+
if not data:
|
|
216
|
+
raise ValueError(f"TOTP not configured for user: {user_id}")
|
|
217
|
+
|
|
218
|
+
new_codes = generate_recovery_codes(count=8, length=8)
|
|
219
|
+
data['recovery_codes'] = new_codes
|
|
220
|
+
self.storage.save(user_id, data)
|
|
221
|
+
|
|
222
|
+
return new_codes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Recovery code generation and verification for auth2fa."""
|
|
2
|
+
import secrets
|
|
3
|
+
import string
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def generate_recovery_codes(count: int = 8, length: int = 8) -> List[str]:
|
|
8
|
+
"""
|
|
9
|
+
Generate a list of random recovery codes.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
count: Number of recovery codes to generate (default: 8)
|
|
13
|
+
length: Length of each recovery code (default: 8)
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
List of recovery codes in format like ['A3F8K2M1', 'B7D2N4P9', ...]
|
|
17
|
+
"""
|
|
18
|
+
codes = []
|
|
19
|
+
# Use alphanumeric characters excluding similar-looking ones (0, O, I, 1)
|
|
20
|
+
alphabet = string.ascii_uppercase + string.digits
|
|
21
|
+
alphabet = alphabet.replace('0', '').replace('O', '').replace('I', '').replace('1', '')
|
|
22
|
+
|
|
23
|
+
for _ in range(count):
|
|
24
|
+
code = ''.join(secrets.choice(alphabet) for _ in range(length))
|
|
25
|
+
codes.append(code)
|
|
26
|
+
|
|
27
|
+
return codes
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def verify_recovery_code(stored_codes: List[str], input_code: str) -> Tuple[bool, List[str]]:
|
|
31
|
+
"""
|
|
32
|
+
Verify if a recovery code is valid and return updated code list.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
stored_codes: List of valid recovery codes
|
|
36
|
+
input_code: The recovery code to verify
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Tuple of (is_valid, updated_codes):
|
|
40
|
+
- (True, codes with used code removed) if valid
|
|
41
|
+
- (False, original codes) if invalid
|
|
42
|
+
|
|
43
|
+
Recovery codes are single-use — successful verification removes the code.
|
|
44
|
+
"""
|
|
45
|
+
# Case-insensitive comparison
|
|
46
|
+
input_upper = input_code.upper()
|
|
47
|
+
codes_upper = [rc.upper() for rc in stored_codes]
|
|
48
|
+
|
|
49
|
+
if input_upper in codes_upper:
|
|
50
|
+
# Remove the used code
|
|
51
|
+
updated_codes = [rc for rc in stored_codes if rc.upper() != input_upper]
|
|
52
|
+
return (True, updated_codes)
|
|
53
|
+
else:
|
|
54
|
+
return (False, stored_codes)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- Create TOTP authentication table
|
|
2
|
+
CREATE TABLE IF NOT EXISTS totp_auth (
|
|
3
|
+
user_id VARCHAR(255) PRIMARY KEY,
|
|
4
|
+
secret VARCHAR(255) NOT NULL,
|
|
5
|
+
enabled BOOLEAN DEFAULT FALSE,
|
|
6
|
+
recovery_codes TEXT,
|
|
7
|
+
created_at DATETIME NOT NULL,
|
|
8
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
-- Create index for faster lookups
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_totp_auth_user_id ON totp_auth(user_id);
|
|
13
|
+
CREATE INDEX IF NOT EXISTS idx_totp_auth_enabled ON totp_auth(enabled);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- name: insert
|
|
2
|
+
INSERT INTO totp_auth (user_id, secret, enabled, recovery_codes, created_at)
|
|
3
|
+
VALUES (:user_id, :secret, :enabled, :recovery_codes, :created_at)
|
|
4
|
+
ON CONFLICT (user_id) DO UPDATE
|
|
5
|
+
SET secret = EXCLUDED.secret,
|
|
6
|
+
enabled = EXCLUDED.enabled,
|
|
7
|
+
recovery_codes = EXCLUDED.recovery_codes;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Base storage interface for auth2fa."""
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseStorage(ABC):
|
|
7
|
+
"""Abstract base class for TOTP storage implementations."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def save(self, user_id: str, data: dict) -> None:
|
|
11
|
+
"""
|
|
12
|
+
Save or update TOTP data for a user.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
user_id: Unique identifier for the user
|
|
16
|
+
data: Dictionary containing TOTP data with structure:
|
|
17
|
+
{
|
|
18
|
+
"secret": "BASE32SECRET",
|
|
19
|
+
"enabled": False,
|
|
20
|
+
"recovery_codes": ["A3F8K2M1", "B7D2N4P9", ...],
|
|
21
|
+
"created_at": "2026-02-11T18:00:00"
|
|
22
|
+
}
|
|
23
|
+
"""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def get(self, user_id: str) -> Optional[dict]:
|
|
28
|
+
"""
|
|
29
|
+
Retrieve TOTP data for a user.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
user_id: Unique identifier for the user
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Dictionary containing TOTP data, or None if not found
|
|
36
|
+
"""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def delete(self, user_id: str) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Delete TOTP data for a user.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
user_id: Unique identifier for the user
|
|
46
|
+
"""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def exists(self, user_id: str) -> bool:
|
|
51
|
+
"""
|
|
52
|
+
Check if TOTP is configured for a user.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
user_id: Unique identifier for the user
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True if TOTP data exists, False otherwise
|
|
59
|
+
"""
|
|
60
|
+
pass
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""JSON file-based storage implementation for auth2fa."""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from .base import BaseStorage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JSONStorage(BaseStorage):
|
|
10
|
+
"""JSON file-based storage implementation with file locking."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, storage_path: str = "./totp_data.json"):
|
|
13
|
+
"""
|
|
14
|
+
Initialize JSON storage.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
storage_path: Path to the JSON file for storing TOTP data
|
|
18
|
+
"""
|
|
19
|
+
self.file_path = Path(storage_path)
|
|
20
|
+
self.lock_path = Path(str(self.file_path) + ".lock")
|
|
21
|
+
self._ensure_file_exists()
|
|
22
|
+
|
|
23
|
+
def _ensure_file_exists(self) -> None:
|
|
24
|
+
"""Create the JSON file if it doesn't exist."""
|
|
25
|
+
if not self.file_path.exists():
|
|
26
|
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
self._write_data({})
|
|
28
|
+
|
|
29
|
+
def _acquire_lock(self, timeout: int = 5) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Acquire file lock using lock file.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
timeout: Maximum time to wait for lock in seconds
|
|
35
|
+
"""
|
|
36
|
+
start_time = time.time()
|
|
37
|
+
while self.lock_path.exists():
|
|
38
|
+
if time.time() - start_time > timeout:
|
|
39
|
+
raise TimeoutError("Failed to acquire file lock")
|
|
40
|
+
time.sleep(0.01)
|
|
41
|
+
|
|
42
|
+
# Create lock file
|
|
43
|
+
self.lock_path.touch()
|
|
44
|
+
|
|
45
|
+
def _release_lock(self) -> None:
|
|
46
|
+
"""Release file lock by removing lock file."""
|
|
47
|
+
if self.lock_path.exists():
|
|
48
|
+
self.lock_path.unlink()
|
|
49
|
+
|
|
50
|
+
def _read_data(self) -> dict:
|
|
51
|
+
"""Read all data from the JSON file."""
|
|
52
|
+
try:
|
|
53
|
+
with open(self.file_path, 'r', encoding='utf-8') as f:
|
|
54
|
+
return json.load(f)
|
|
55
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
def _write_data(self, data: dict) -> None:
|
|
59
|
+
"""Write all data to the JSON file."""
|
|
60
|
+
with open(self.file_path, 'w', encoding='utf-8') as f:
|
|
61
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
62
|
+
|
|
63
|
+
def save(self, user_id: str, data: dict) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Save or update TOTP data for a user.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
user_id: Unique identifier for the user (converted to str)
|
|
69
|
+
data: Dictionary containing TOTP data
|
|
70
|
+
"""
|
|
71
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
72
|
+
try:
|
|
73
|
+
self._acquire_lock()
|
|
74
|
+
all_data = self._read_data()
|
|
75
|
+
all_data[user_id] = data
|
|
76
|
+
self._write_data(all_data)
|
|
77
|
+
finally:
|
|
78
|
+
self._release_lock()
|
|
79
|
+
|
|
80
|
+
def get(self, user_id: str) -> Optional[dict]:
|
|
81
|
+
"""
|
|
82
|
+
Retrieve TOTP data for a user.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
user_id: Unique identifier for the user (converted to str)
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Dictionary containing TOTP data, or None if not found
|
|
89
|
+
"""
|
|
90
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
91
|
+
try:
|
|
92
|
+
self._acquire_lock()
|
|
93
|
+
all_data = self._read_data()
|
|
94
|
+
return all_data.get(user_id)
|
|
95
|
+
finally:
|
|
96
|
+
self._release_lock()
|
|
97
|
+
|
|
98
|
+
def delete(self, user_id: str) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Delete TOTP data for a user.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
user_id: Unique identifier for the user (converted to str)
|
|
104
|
+
"""
|
|
105
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
106
|
+
try:
|
|
107
|
+
self._acquire_lock()
|
|
108
|
+
all_data = self._read_data()
|
|
109
|
+
if user_id in all_data:
|
|
110
|
+
del all_data[user_id]
|
|
111
|
+
self._write_data(all_data)
|
|
112
|
+
finally:
|
|
113
|
+
self._release_lock()
|
|
114
|
+
|
|
115
|
+
def exists(self, user_id: str) -> bool:
|
|
116
|
+
"""
|
|
117
|
+
Check if TOTP is configured for a user.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
user_id: Unique identifier for the user (converted to str)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
True if TOTP data exists, False otherwise
|
|
124
|
+
"""
|
|
125
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
126
|
+
try:
|
|
127
|
+
self._acquire_lock()
|
|
128
|
+
all_data = self._read_data()
|
|
129
|
+
return user_id in all_data
|
|
130
|
+
finally:
|
|
131
|
+
self._release_lock()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""SQL database storage implementation using sqloader."""
|
|
2
|
+
import json
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from .base import BaseStorage
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SQLStorage(BaseStorage):
|
|
8
|
+
"""SQL database storage implementation using sqloader."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, sq, table_prefix: str = "totp_auth"):
|
|
11
|
+
"""
|
|
12
|
+
Initialize SQL storage.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
sq: sqloader instance (SQLite3 or MySQL)
|
|
16
|
+
table_prefix: SQL file directory name (default: totp_auth)
|
|
17
|
+
"""
|
|
18
|
+
self.db = sq
|
|
19
|
+
self.table_prefix = table_prefix
|
|
20
|
+
self._ensure_table_exists()
|
|
21
|
+
|
|
22
|
+
def _ensure_table_exists(self) -> None:
|
|
23
|
+
"""Create the TOTP table if it doesn't exist."""
|
|
24
|
+
# sqloader will execute the create_table.sql file
|
|
25
|
+
self.db.execute(f"{self.table_prefix}/create_table")
|
|
26
|
+
|
|
27
|
+
def save(self, user_id: str, data: dict) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Save or update TOTP data for a user.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
user_id: Unique identifier for the user (converted to str)
|
|
33
|
+
data: Dictionary containing TOTP data
|
|
34
|
+
"""
|
|
35
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
36
|
+
|
|
37
|
+
# Use INSERT with ON CONFLICT (UPSERT)
|
|
38
|
+
self.db.execute(
|
|
39
|
+
f"{self.table_prefix}/insert",
|
|
40
|
+
user_id=user_id,
|
|
41
|
+
secret=data.get('secret'),
|
|
42
|
+
enabled=data.get('enabled', False),
|
|
43
|
+
recovery_codes=json.dumps(data.get('recovery_codes', [])),
|
|
44
|
+
created_at=data.get('created_at')
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def get(self, user_id: str) -> Optional[dict]:
|
|
48
|
+
"""
|
|
49
|
+
Retrieve TOTP data for a user.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
user_id: Unique identifier for the user (converted to str)
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Dictionary containing TOTP data, or None if not found
|
|
56
|
+
"""
|
|
57
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
58
|
+
|
|
59
|
+
result = self.db.execute(
|
|
60
|
+
f"{self.table_prefix}/select_by_user",
|
|
61
|
+
user_id=user_id
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if not result:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
row = result[0]
|
|
68
|
+
return {
|
|
69
|
+
'secret': row.get('secret'),
|
|
70
|
+
'enabled': bool(row.get('enabled')),
|
|
71
|
+
'recovery_codes': json.loads(row.get('recovery_codes', '[]')),
|
|
72
|
+
'created_at': row.get('created_at')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def delete(self, user_id: str) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Delete TOTP data for a user.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
user_id: Unique identifier for the user (converted to str)
|
|
81
|
+
"""
|
|
82
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
83
|
+
|
|
84
|
+
self.db.execute(
|
|
85
|
+
f"{self.table_prefix}/delete",
|
|
86
|
+
user_id=user_id
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def exists(self, user_id: str) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
Check if TOTP is configured for a user.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
user_id: Unique identifier for the user (converted to str)
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True if TOTP data exists, False otherwise
|
|
98
|
+
"""
|
|
99
|
+
user_id = str(user_id) # Ensure user_id is string
|
|
100
|
+
|
|
101
|
+
result = self.db.execute(
|
|
102
|
+
f"{self.table_prefix}/select_by_user",
|
|
103
|
+
user_id=user_id
|
|
104
|
+
)
|
|
105
|
+
return len(result) > 0
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auth2fa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TOTP-based Two-Factor Authentication Library for Python
|
|
5
|
+
Author-email: horrible-gh <shinjpn1@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/horrible-gh/auth2fa
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/horrible-gh/auth2fa/issues
|
|
8
|
+
Keywords: auth2fa,2fa,totp,authentication,otp,two-factor
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pyotp
|
|
19
|
+
Requires-Dist: qrcode
|
|
20
|
+
Requires-Dist: Pillow
|
|
21
|
+
Provides-Extra: sql
|
|
22
|
+
Requires-Dist: sqloader; extra == "sql"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# sqloader
|
|
26
|
+
|
|
27
|
+
A lightweight Python utility for managing SQL migrations and loading SQL from JSON or .sql files.
|
|
28
|
+
Supports common relational databases and is designed for simple, clean integration with any Python backend (e.g., FastAPI).
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```powershell
|
|
35
|
+
pip install sqloader
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- ✅ Easy database migration management
|
|
41
|
+
- ✅ Load SQL queries from `.json` or `.sql` files
|
|
42
|
+
- ✅ Supports MySQL and SQLite
|
|
43
|
+
- ✅ Clean API for integration
|
|
44
|
+
- ✅ Lightweight and dependency-minimized
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from sqloader.init import database_init
|
|
50
|
+
|
|
51
|
+
config = {
|
|
52
|
+
"type": "mysql",
|
|
53
|
+
"mysql": {
|
|
54
|
+
"host": "localhost",
|
|
55
|
+
"port": 3306,
|
|
56
|
+
"user": "root",
|
|
57
|
+
"password": "pass",
|
|
58
|
+
"database": "mydb"
|
|
59
|
+
},
|
|
60
|
+
"service": {
|
|
61
|
+
"sqloder": "res/sql/sqloader/mysql"
|
|
62
|
+
},
|
|
63
|
+
"migration": {
|
|
64
|
+
"auto_migration": True,
|
|
65
|
+
"migration_path": "res/sql/migration/mysql"
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
db, sqloader, migrator = database_init(config)
|
|
70
|
+
|
|
71
|
+
# Example usage
|
|
72
|
+
query = sqloader.load_sql("user_info", "user.get_user_by_id")
|
|
73
|
+
result = db.fetch_one(query, ['abc', 123])
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## SQL Loading Behavior
|
|
78
|
+
|
|
79
|
+
- If the value in the .json file ends with .sql, the referenced file will be loaded from the same directory.
|
|
80
|
+
- Otherwise, the value is treated as a raw SQL string.
|
|
81
|
+
|
|
82
|
+
Example JSON file user.json:
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"user": {
|
|
88
|
+
"get_user_by_id": "SELECT * FROM users WHERE id = %s",
|
|
89
|
+
"get_all_users": "user_all.sql"
|
|
90
|
+
},
|
|
91
|
+
"get_etc": "SELECT * FROM etc"
|
|
92
|
+
}
|
|
93
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
setup.py
|
|
6
|
+
auth2fa/__init__.py
|
|
7
|
+
auth2fa/core.py
|
|
8
|
+
auth2fa/recovery.py
|
|
9
|
+
auth2fa.egg-info/PKG-INFO
|
|
10
|
+
auth2fa.egg-info/SOURCES.txt
|
|
11
|
+
auth2fa.egg-info/dependency_links.txt
|
|
12
|
+
auth2fa.egg-info/requires.txt
|
|
13
|
+
auth2fa.egg-info/top_level.txt
|
|
14
|
+
auth2fa/sql/totp_auth/create_table.sql
|
|
15
|
+
auth2fa/sql/totp_auth/delete.sql
|
|
16
|
+
auth2fa/sql/totp_auth/insert.sql
|
|
17
|
+
auth2fa/sql/totp_auth/select_by_user.sql
|
|
18
|
+
auth2fa/sql/totp_auth/update.sql
|
|
19
|
+
auth2fa/storage/__init__.py
|
|
20
|
+
auth2fa/storage/base.py
|
|
21
|
+
auth2fa/storage/json_storage.py
|
|
22
|
+
auth2fa/storage/sql_storage.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
auth2fa
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "auth2fa"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="horrible-gh", email="shinjpn1@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "TOTP-based Two-Factor Authentication Library for Python"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
]
|
|
22
|
+
keywords = [
|
|
23
|
+
"auth2fa", "2fa", "totp", "authentication", "otp", "two-factor"
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"pyotp",
|
|
27
|
+
"qrcode",
|
|
28
|
+
"Pillow"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
sql = ["sqloader"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
"Homepage" = "https://github.com/horrible-gh/auth2fa"
|
|
36
|
+
"Bug Tracker" = "https://github.com/horrible-gh/auth2fa/issues"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.package-data]
|
|
39
|
+
auth2fa = ["sql/**/*.sql"]
|
auth2fa-0.1.0/setup.cfg
ADDED
auth2fa-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="auth2fa",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
packages=find_packages(),
|
|
7
|
+
package_data={
|
|
8
|
+
"auth2fa": ["sql/**/*.sql"],
|
|
9
|
+
},
|
|
10
|
+
install_requires=[
|
|
11
|
+
"pyotp",
|
|
12
|
+
"qrcode",
|
|
13
|
+
"Pillow",
|
|
14
|
+
],
|
|
15
|
+
extras_require={
|
|
16
|
+
"sql": ["sqloader"],
|
|
17
|
+
},
|
|
18
|
+
)
|