auth2fa 0.1.0__py3-none-any.whl
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/__init__.py +19 -0
- auth2fa/core.py +222 -0
- auth2fa/recovery.py +54 -0
- auth2fa/sql/totp_auth/create_table.sql +13 -0
- auth2fa/sql/totp_auth/delete.sql +2 -0
- auth2fa/sql/totp_auth/insert.sql +7 -0
- auth2fa/sql/totp_auth/select_by_user.sql +2 -0
- auth2fa/sql/totp_auth/update.sql +2 -0
- auth2fa/storage/__init__.py +6 -0
- auth2fa/storage/base.py +60 -0
- auth2fa/storage/json_storage.py +131 -0
- auth2fa/storage/sql_storage.py +105 -0
- auth2fa-0.1.0.dist-info/METADATA +93 -0
- auth2fa-0.1.0.dist-info/RECORD +24 -0
- auth2fa-0.1.0.dist-info/WHEEL +5 -0
- auth2fa-0.1.0.dist-info/licenses/LICENSE +21 -0
- auth2fa-0.1.0.dist-info/top_level.txt +1 -0
- sqloader/__init__.py +6 -0
- sqloader/_prototype.py +101 -0
- sqloader/init.py +69 -0
- sqloader/migrator.py +95 -0
- sqloader/mysql.py +288 -0
- sqloader/sqlite3.py +230 -0
- sqloader/sqloader.py +51 -0
auth2fa/__init__.py
ADDED
|
@@ -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
|
+
]
|
auth2fa/core.py
ADDED
|
@@ -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
|
auth2fa/recovery.py
ADDED
|
@@ -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;
|
auth2fa/storage/base.py
ADDED
|
@@ -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
|