auth-kit-fastapi 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.
- auth_kit_fastapi/__init__.py +27 -0
- auth_kit_fastapi/schemas/__init__.py +84 -0
- auth_kit_fastapi/schemas/auth.py +145 -0
- auth_kit_fastapi/schemas/passkey.py +129 -0
- auth_kit_fastapi/schemas/two_factor.py +73 -0
- auth_kit_fastapi/services/__init__.py +17 -0
- auth_kit_fastapi/services/email_service.py +468 -0
- auth_kit_fastapi/services/passkey_service.py +461 -0
- auth_kit_fastapi/services/two_factor_service.py +422 -0
- auth_kit_fastapi/services/user_service.py +407 -0
- auth_kit_fastapi-0.1.0.dist-info/METADATA +226 -0
- auth_kit_fastapi-0.1.0.dist-info/RECORD +20 -0
- auth_kit_fastapi-0.1.0.dist-info/WHEEL +5 -0
- auth_kit_fastapi-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/conftest.py +198 -0
- tests/test_auth_endpoints.py +350 -0
- tests/test_passkey_endpoints.py +282 -0
- tests/test_services.py +449 -0
- tests/test_two_factor_endpoints.py +377 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email service for authentication
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import logging
|
|
8
|
+
from email.mime.text import MIMEText
|
|
9
|
+
from email.mime.multipart import MIMEMultipart
|
|
10
|
+
import smtplib
|
|
11
|
+
import ssl
|
|
12
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
13
|
+
|
|
14
|
+
from ..core.security import (
|
|
15
|
+
generate_email_verification_token,
|
|
16
|
+
generate_password_reset_token
|
|
17
|
+
)
|
|
18
|
+
from ..core.events import auth_events
|
|
19
|
+
from ..config import AuthConfig
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EmailService:
|
|
25
|
+
"""
|
|
26
|
+
Service for sending authentication-related emails
|
|
27
|
+
|
|
28
|
+
Supports:
|
|
29
|
+
- Email verification
|
|
30
|
+
- Password reset
|
|
31
|
+
- 2FA notifications
|
|
32
|
+
- Security alerts
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: AuthConfig):
|
|
36
|
+
"""
|
|
37
|
+
Initialize email service
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
config: Authentication configuration
|
|
41
|
+
"""
|
|
42
|
+
self.config = config
|
|
43
|
+
|
|
44
|
+
# Initialize template environment if templates are available
|
|
45
|
+
if config.email_template_dir:
|
|
46
|
+
self.template_env = Environment(
|
|
47
|
+
loader=FileSystemLoader(config.email_template_dir),
|
|
48
|
+
autoescape=select_autoescape(['html', 'xml'])
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
self.template_env = None
|
|
52
|
+
|
|
53
|
+
def _create_smtp_connection(self) -> smtplib.SMTP:
|
|
54
|
+
"""
|
|
55
|
+
Create SMTP connection
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
SMTP connection
|
|
59
|
+
"""
|
|
60
|
+
# Create SSL context
|
|
61
|
+
context = ssl.create_default_context()
|
|
62
|
+
|
|
63
|
+
# Create connection based on SSL usage
|
|
64
|
+
if self.config.smtp_ssl:
|
|
65
|
+
smtp = smtplib.SMTP_SSL(
|
|
66
|
+
self.config.smtp_host,
|
|
67
|
+
self.config.smtp_port,
|
|
68
|
+
context=context
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
smtp = smtplib.SMTP(
|
|
72
|
+
self.config.smtp_host,
|
|
73
|
+
self.config.smtp_port
|
|
74
|
+
)
|
|
75
|
+
if self.config.smtp_tls:
|
|
76
|
+
smtp.starttls(context=context)
|
|
77
|
+
|
|
78
|
+
# Authenticate if credentials provided
|
|
79
|
+
if self.config.smtp_username and self.config.smtp_password:
|
|
80
|
+
smtp.login(
|
|
81
|
+
self.config.smtp_username,
|
|
82
|
+
self.config.smtp_password
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return smtp
|
|
86
|
+
|
|
87
|
+
def _render_template(
|
|
88
|
+
self,
|
|
89
|
+
template_name: str,
|
|
90
|
+
context: Dict[str, Any]
|
|
91
|
+
) -> tuple[str, str]:
|
|
92
|
+
"""
|
|
93
|
+
Render email template
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
template_name: Template name (without extension)
|
|
97
|
+
context: Template context
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Tuple of (html_content, text_content)
|
|
101
|
+
"""
|
|
102
|
+
if not self.template_env:
|
|
103
|
+
# Return simple text if no templates
|
|
104
|
+
return None, self._get_default_text(template_name, context)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
# Try to render HTML template
|
|
108
|
+
html_template = self.template_env.get_template(f"{template_name}.html")
|
|
109
|
+
html_content = html_template.render(**context)
|
|
110
|
+
except Exception:
|
|
111
|
+
html_content = None
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
# Try to render text template
|
|
115
|
+
text_template = self.template_env.get_template(f"{template_name}.txt")
|
|
116
|
+
text_content = text_template.render(**context)
|
|
117
|
+
except Exception:
|
|
118
|
+
text_content = self._get_default_text(template_name, context)
|
|
119
|
+
|
|
120
|
+
return html_content, text_content
|
|
121
|
+
|
|
122
|
+
def _get_default_text(
|
|
123
|
+
self,
|
|
124
|
+
template_name: str,
|
|
125
|
+
context: Dict[str, Any]
|
|
126
|
+
) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Get default text content for email
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
template_name: Template name
|
|
132
|
+
context: Template context
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Default text content
|
|
136
|
+
"""
|
|
137
|
+
defaults = {
|
|
138
|
+
"verify_email": f"Please verify your email by clicking: {context.get('verify_url', '')}",
|
|
139
|
+
"reset_password": f"Reset your password by clicking: {context.get('reset_url', '')}",
|
|
140
|
+
"2fa_enabled": "Two-factor authentication has been enabled on your account.",
|
|
141
|
+
"2fa_disabled": "Two-factor authentication has been disabled on your account.",
|
|
142
|
+
"passkey_added": f"A new passkey '{context.get('passkey_name', 'Unknown')}' has been added to your account.",
|
|
143
|
+
"security_alert": f"Security alert: {context.get('alert_message', 'Unknown activity detected')}",
|
|
144
|
+
"welcome": f"Welcome to {self.config.app_name}!"
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return defaults.get(template_name, "Email notification from " + self.config.app_name)
|
|
148
|
+
|
|
149
|
+
async def send_email(
|
|
150
|
+
self,
|
|
151
|
+
to_email: str,
|
|
152
|
+
subject: str,
|
|
153
|
+
template_name: str,
|
|
154
|
+
context: Dict[str, Any],
|
|
155
|
+
cc: Optional[list[str]] = None,
|
|
156
|
+
bcc: Optional[list[str]] = None
|
|
157
|
+
) -> bool:
|
|
158
|
+
"""
|
|
159
|
+
Send email using template
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
to_email: Recipient email
|
|
163
|
+
subject: Email subject
|
|
164
|
+
template_name: Template name
|
|
165
|
+
context: Template context
|
|
166
|
+
cc: CC recipients
|
|
167
|
+
bcc: BCC recipients
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if sent successfully
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
# Add common context
|
|
174
|
+
context.update({
|
|
175
|
+
"app_name": self.config.app_name,
|
|
176
|
+
"app_url": self.config.app_url,
|
|
177
|
+
"support_email": self.config.support_email,
|
|
178
|
+
"year": datetime.utcnow().year
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
# Render template
|
|
182
|
+
html_content, text_content = self._render_template(template_name, context)
|
|
183
|
+
|
|
184
|
+
# Create message
|
|
185
|
+
msg = MIMEMultipart('alternative')
|
|
186
|
+
msg['Subject'] = subject
|
|
187
|
+
msg['From'] = self.config.email_from
|
|
188
|
+
msg['To'] = to_email
|
|
189
|
+
|
|
190
|
+
if cc:
|
|
191
|
+
msg['Cc'] = ', '.join(cc)
|
|
192
|
+
if bcc:
|
|
193
|
+
msg['Bcc'] = ', '.join(bcc)
|
|
194
|
+
|
|
195
|
+
# Add text part
|
|
196
|
+
text_part = MIMEText(text_content, 'plain')
|
|
197
|
+
msg.attach(text_part)
|
|
198
|
+
|
|
199
|
+
# Add HTML part if available
|
|
200
|
+
if html_content:
|
|
201
|
+
html_part = MIMEText(html_content, 'html')
|
|
202
|
+
msg.attach(html_part)
|
|
203
|
+
|
|
204
|
+
# Send email
|
|
205
|
+
with self._create_smtp_connection() as smtp:
|
|
206
|
+
recipients = [to_email]
|
|
207
|
+
if cc:
|
|
208
|
+
recipients.extend(cc)
|
|
209
|
+
if bcc:
|
|
210
|
+
recipients.extend(bcc)
|
|
211
|
+
|
|
212
|
+
smtp.send_message(msg, to_addrs=recipients)
|
|
213
|
+
|
|
214
|
+
logger.info(f"Email sent successfully to {to_email}")
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.error(f"Failed to send email to {to_email}: {e}")
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
async def send_verification_email(
|
|
222
|
+
self,
|
|
223
|
+
user_email: str,
|
|
224
|
+
user_name: Optional[str] = None
|
|
225
|
+
) -> bool:
|
|
226
|
+
"""
|
|
227
|
+
Send email verification
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
user_email: User email
|
|
231
|
+
user_name: User's name
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
True if sent successfully
|
|
235
|
+
"""
|
|
236
|
+
# Generate verification token
|
|
237
|
+
token = generate_email_verification_token(
|
|
238
|
+
user_email,
|
|
239
|
+
self.config.jwt_secret
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Build verification URL
|
|
243
|
+
verify_url = f"{self.config.app_url}/auth/verify-email/{token}"
|
|
244
|
+
|
|
245
|
+
# Send email
|
|
246
|
+
success = await self.send_email(
|
|
247
|
+
to_email=user_email,
|
|
248
|
+
subject=f"Verify your email for {self.config.app_name}",
|
|
249
|
+
template_name="verify_email",
|
|
250
|
+
context={
|
|
251
|
+
"user_name": user_name or user_email,
|
|
252
|
+
"verify_url": verify_url,
|
|
253
|
+
"expires_in": "24 hours"
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if success:
|
|
258
|
+
await auth_events.emit("email_verification_sent", {
|
|
259
|
+
"email": user_email
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
return success
|
|
263
|
+
|
|
264
|
+
async def send_password_reset_email(
|
|
265
|
+
self,
|
|
266
|
+
user_email: str,
|
|
267
|
+
user_name: Optional[str] = None
|
|
268
|
+
) -> bool:
|
|
269
|
+
"""
|
|
270
|
+
Send password reset email
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
user_email: User email
|
|
274
|
+
user_name: User's name
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
True if sent successfully
|
|
278
|
+
"""
|
|
279
|
+
# Generate reset token
|
|
280
|
+
token = generate_password_reset_token(
|
|
281
|
+
user_email,
|
|
282
|
+
self.config.jwt_secret
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Build reset URL
|
|
286
|
+
reset_url = f"{self.config.app_url}/auth/reset-password/{token}"
|
|
287
|
+
|
|
288
|
+
# Send email
|
|
289
|
+
success = await self.send_email(
|
|
290
|
+
to_email=user_email,
|
|
291
|
+
subject=f"Reset your password for {self.config.app_name}",
|
|
292
|
+
template_name="reset_password",
|
|
293
|
+
context={
|
|
294
|
+
"user_name": user_name or user_email,
|
|
295
|
+
"reset_url": reset_url,
|
|
296
|
+
"expires_in": "1 hour"
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if success:
|
|
301
|
+
await auth_events.emit("password_reset_email_sent", {
|
|
302
|
+
"email": user_email
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
return success
|
|
306
|
+
|
|
307
|
+
async def send_2fa_enabled_email(
|
|
308
|
+
self,
|
|
309
|
+
user_email: str,
|
|
310
|
+
user_name: Optional[str] = None,
|
|
311
|
+
device_info: Optional[Dict[str, str]] = None
|
|
312
|
+
) -> bool:
|
|
313
|
+
"""
|
|
314
|
+
Send 2FA enabled notification
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
user_email: User email
|
|
318
|
+
user_name: User's name
|
|
319
|
+
device_info: Device information
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
True if sent successfully
|
|
323
|
+
"""
|
|
324
|
+
return await self.send_email(
|
|
325
|
+
to_email=user_email,
|
|
326
|
+
subject=f"Two-factor authentication enabled on {self.config.app_name}",
|
|
327
|
+
template_name="2fa_enabled",
|
|
328
|
+
context={
|
|
329
|
+
"user_name": user_name or user_email,
|
|
330
|
+
"enabled_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
|
|
331
|
+
"device_info": device_info
|
|
332
|
+
}
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
async def send_2fa_disabled_email(
|
|
336
|
+
self,
|
|
337
|
+
user_email: str,
|
|
338
|
+
user_name: Optional[str] = None,
|
|
339
|
+
device_info: Optional[Dict[str, str]] = None
|
|
340
|
+
) -> bool:
|
|
341
|
+
"""
|
|
342
|
+
Send 2FA disabled notification
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
user_email: User email
|
|
346
|
+
user_name: User's name
|
|
347
|
+
device_info: Device information
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
True if sent successfully
|
|
351
|
+
"""
|
|
352
|
+
return await self.send_email(
|
|
353
|
+
to_email=user_email,
|
|
354
|
+
subject=f"Two-factor authentication disabled on {self.config.app_name}",
|
|
355
|
+
template_name="2fa_disabled",
|
|
356
|
+
context={
|
|
357
|
+
"user_name": user_name or user_email,
|
|
358
|
+
"disabled_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
|
|
359
|
+
"device_info": device_info
|
|
360
|
+
}
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
async def send_passkey_added_email(
|
|
364
|
+
self,
|
|
365
|
+
user_email: str,
|
|
366
|
+
passkey_name: str,
|
|
367
|
+
user_name: Optional[str] = None,
|
|
368
|
+
device_info: Optional[Dict[str, str]] = None
|
|
369
|
+
) -> bool:
|
|
370
|
+
"""
|
|
371
|
+
Send passkey added notification
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
user_email: User email
|
|
375
|
+
passkey_name: Passkey name
|
|
376
|
+
user_name: User's name
|
|
377
|
+
device_info: Device information
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
True if sent successfully
|
|
381
|
+
"""
|
|
382
|
+
return await self.send_email(
|
|
383
|
+
to_email=user_email,
|
|
384
|
+
subject=f"New passkey added to your {self.config.app_name} account",
|
|
385
|
+
template_name="passkey_added",
|
|
386
|
+
context={
|
|
387
|
+
"user_name": user_name or user_email,
|
|
388
|
+
"passkey_name": passkey_name,
|
|
389
|
+
"added_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
|
|
390
|
+
"device_info": device_info
|
|
391
|
+
}
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
async def send_security_alert_email(
|
|
395
|
+
self,
|
|
396
|
+
user_email: str,
|
|
397
|
+
alert_type: str,
|
|
398
|
+
alert_message: str,
|
|
399
|
+
user_name: Optional[str] = None,
|
|
400
|
+
device_info: Optional[Dict[str, str]] = None,
|
|
401
|
+
action_url: Optional[str] = None
|
|
402
|
+
) -> bool:
|
|
403
|
+
"""
|
|
404
|
+
Send security alert email
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
user_email: User email
|
|
408
|
+
alert_type: Type of alert
|
|
409
|
+
alert_message: Alert message
|
|
410
|
+
user_name: User's name
|
|
411
|
+
device_info: Device information
|
|
412
|
+
action_url: URL for user action
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
True if sent successfully
|
|
416
|
+
"""
|
|
417
|
+
return await self.send_email(
|
|
418
|
+
to_email=user_email,
|
|
419
|
+
subject=f"Security alert for your {self.config.app_name} account",
|
|
420
|
+
template_name="security_alert",
|
|
421
|
+
context={
|
|
422
|
+
"user_name": user_name or user_email,
|
|
423
|
+
"alert_type": alert_type,
|
|
424
|
+
"alert_message": alert_message,
|
|
425
|
+
"alert_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
|
|
426
|
+
"device_info": device_info,
|
|
427
|
+
"action_url": action_url or f"{self.config.app_url}/account/security"
|
|
428
|
+
}
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
async def send_welcome_email(
|
|
432
|
+
self,
|
|
433
|
+
user_email: str,
|
|
434
|
+
user_name: Optional[str] = None,
|
|
435
|
+
verification_required: bool = False
|
|
436
|
+
) -> bool:
|
|
437
|
+
"""
|
|
438
|
+
Send welcome email to new user
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
user_email: User email
|
|
442
|
+
user_name: User's name
|
|
443
|
+
verification_required: Whether email verification is required
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
True if sent successfully
|
|
447
|
+
"""
|
|
448
|
+
context = {
|
|
449
|
+
"user_name": user_name or user_email,
|
|
450
|
+
"getting_started_url": f"{self.config.app_url}/getting-started",
|
|
451
|
+
"help_url": f"{self.config.app_url}/help",
|
|
452
|
+
"verification_required": verification_required
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if verification_required:
|
|
456
|
+
# Include verification token
|
|
457
|
+
token = generate_email_verification_token(
|
|
458
|
+
user_email,
|
|
459
|
+
self.config.jwt_secret
|
|
460
|
+
)
|
|
461
|
+
context["verify_url"] = f"{self.config.app_url}/auth/verify-email/{token}"
|
|
462
|
+
|
|
463
|
+
return await self.send_email(
|
|
464
|
+
to_email=user_email,
|
|
465
|
+
subject=f"Welcome to {self.config.app_name}!",
|
|
466
|
+
template_name="welcome",
|
|
467
|
+
context=context
|
|
468
|
+
)
|