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 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,2 @@
1
+ -- name: delete
2
+ DELETE FROM totp_auth WHERE user_id = :user_id;
@@ -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,2 @@
1
+ -- name: select_by_user
2
+ SELECT * FROM totp_auth WHERE user_id = :user_id;
@@ -0,0 +1,2 @@
1
+ -- name: update
2
+ UPDATE totp_auth SET enabled = :enabled, recovery_codes = :recovery_codes WHERE user_id = :user_id;
@@ -0,0 +1,6 @@
1
+ """Storage modules for auth2fa."""
2
+ from .base import BaseStorage
3
+ from .json_storage import JSONStorage
4
+ from .sql_storage import SQLStorage
5
+
6
+ __all__ = ['BaseStorage', 'JSONStorage', 'SQLStorage']
@@ -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