regstack 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.
- regstack/__init__.py +5 -0
- regstack/app.py +150 -0
- regstack/auth/__init__.py +21 -0
- regstack/auth/clock.py +29 -0
- regstack/auth/dependencies.py +102 -0
- regstack/auth/jwt.py +145 -0
- regstack/auth/lockout.py +59 -0
- regstack/auth/mfa.py +29 -0
- regstack/auth/password.py +20 -0
- regstack/auth/tokens.py +19 -0
- regstack/cli/__init__.py +0 -0
- regstack/cli/__main__.py +27 -0
- regstack/cli/_runtime.py +39 -0
- regstack/cli/admin.py +45 -0
- regstack/cli/doctor.py +186 -0
- regstack/cli/init.py +236 -0
- regstack/config/__init__.py +4 -0
- regstack/config/loader.py +114 -0
- regstack/config/schema.py +148 -0
- regstack/config/secrets.py +22 -0
- regstack/db/__init__.py +17 -0
- regstack/db/client.py +26 -0
- regstack/db/indexes.py +70 -0
- regstack/db/repositories/__init__.py +0 -0
- regstack/db/repositories/blacklist_repo.py +28 -0
- regstack/db/repositories/login_attempt_repo.py +27 -0
- regstack/db/repositories/mfa_code_repo.py +99 -0
- regstack/db/repositories/pending_repo.py +76 -0
- regstack/db/repositories/user_repo.py +169 -0
- regstack/email/__init__.py +12 -0
- regstack/email/base.py +23 -0
- regstack/email/composer.py +142 -0
- regstack/email/console.py +28 -0
- regstack/email/factory.py +23 -0
- regstack/email/ses.py +47 -0
- regstack/email/smtp.py +46 -0
- regstack/email/templates/email_change.html +15 -0
- regstack/email/templates/email_change.subject.txt +1 -0
- regstack/email/templates/email_change.txt +7 -0
- regstack/email/templates/password_reset.html +15 -0
- regstack/email/templates/password_reset.subject.txt +1 -0
- regstack/email/templates/password_reset.txt +7 -0
- regstack/email/templates/sms_login_mfa.txt +1 -0
- regstack/email/templates/sms_phone_setup.txt +1 -0
- regstack/email/templates/verification.html +15 -0
- regstack/email/templates/verification.subject.txt +1 -0
- regstack/email/templates/verification.txt +7 -0
- regstack/hooks/__init__.py +3 -0
- regstack/hooks/events.py +59 -0
- regstack/models/__init__.py +15 -0
- regstack/models/_objectid.py +30 -0
- regstack/models/login_attempt.py +31 -0
- regstack/models/mfa_code.py +40 -0
- regstack/models/pending_registration.py +38 -0
- regstack/models/user.py +104 -0
- regstack/routers/__init__.py +37 -0
- regstack/routers/_schemas.py +34 -0
- regstack/routers/account.py +274 -0
- regstack/routers/admin.py +187 -0
- regstack/routers/login.py +223 -0
- regstack/routers/logout.py +39 -0
- regstack/routers/password.py +114 -0
- regstack/routers/phone.py +242 -0
- regstack/routers/register.py +99 -0
- regstack/routers/verify.py +116 -0
- regstack/sms/__init__.py +5 -0
- regstack/sms/base.py +24 -0
- regstack/sms/factory.py +23 -0
- regstack/sms/null.py +26 -0
- regstack/sms/sns.py +42 -0
- regstack/sms/twilio.py +49 -0
- regstack/ui/__init__.py +3 -0
- regstack/ui/pages.py +148 -0
- regstack/ui/static/css/core.css +204 -0
- regstack/ui/static/css/theme.css +43 -0
- regstack/ui/static/js/regstack.js +411 -0
- regstack/ui/templates/auth/email_change_confirm.html +10 -0
- regstack/ui/templates/auth/forgot.html +14 -0
- regstack/ui/templates/auth/login.html +24 -0
- regstack/ui/templates/auth/me.html +110 -0
- regstack/ui/templates/auth/mfa_confirm.html +14 -0
- regstack/ui/templates/auth/register.html +23 -0
- regstack/ui/templates/auth/reset.html +13 -0
- regstack/ui/templates/auth/verify.html +10 -0
- regstack/ui/templates/base.html +46 -0
- regstack/version.py +1 -0
- regstack-0.1.0.dist-info/METADATA +209 -0
- regstack-0.1.0.dist-info/RECORD +92 -0
- regstack-0.1.0.dist-info/WHEEL +4 -0
- regstack-0.1.0.dist-info/entry_points.txt +2 -0
- regstack-0.1.0.dist-info/licenses/LICENSE +202 -0
- regstack-0.1.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from regstack.email.base import EmailMessage, EmailService
|
|
2
|
+
from regstack.email.composer import MailComposer
|
|
3
|
+
from regstack.email.console import ConsoleEmailService
|
|
4
|
+
from regstack.email.factory import build_email_service
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ConsoleEmailService",
|
|
8
|
+
"EmailMessage",
|
|
9
|
+
"EmailService",
|
|
10
|
+
"MailComposer",
|
|
11
|
+
"build_email_service",
|
|
12
|
+
]
|
regstack/email/base.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class EmailMessage:
|
|
9
|
+
to: str
|
|
10
|
+
subject: str
|
|
11
|
+
html: str
|
|
12
|
+
text: str
|
|
13
|
+
from_address: str
|
|
14
|
+
from_name: str
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def from_header(self) -> str:
|
|
18
|
+
return f"{self.from_name} <{self.from_address}>"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EmailService(ABC):
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def send(self, message: EmailMessage) -> None: ...
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib import resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PackageLoader, select_autoescape
|
|
8
|
+
|
|
9
|
+
from regstack.email.base import EmailMessage
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from regstack.config.schema import EmailConfig
|
|
13
|
+
from regstack.models.user import BaseUser
|
|
14
|
+
|
|
15
|
+
_DEFAULT_PACKAGE = "regstack.email"
|
|
16
|
+
_DEFAULT_TEMPLATE_DIR = "templates"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MailComposer:
|
|
20
|
+
"""Builds rendered ``EmailMessage`` instances from Jinja2 templates.
|
|
21
|
+
|
|
22
|
+
Hosts override the default templates by registering their own template
|
|
23
|
+
directory via ``RegStack.add_template_dir``; the underlying
|
|
24
|
+
``ChoiceLoader`` resolves the host directory first.
|
|
25
|
+
|
|
26
|
+
Each email kind has three template files:
|
|
27
|
+
``<name>.subject.txt`` — single line, whitespace-stripped
|
|
28
|
+
``<name>.html`` — rich body
|
|
29
|
+
``<name>.txt`` — plain-text fallback
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
email_config: EmailConfig,
|
|
36
|
+
app_name: str,
|
|
37
|
+
host_template_dirs: list[Path] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._email_config = email_config
|
|
40
|
+
self._app_name = app_name
|
|
41
|
+
self._host_dirs: list[Path] = list(host_template_dirs or [])
|
|
42
|
+
self._env = self._build_env()
|
|
43
|
+
|
|
44
|
+
def add_template_dir(self, path: Path) -> None:
|
|
45
|
+
self._host_dirs.insert(0, path)
|
|
46
|
+
self._env = self._build_env()
|
|
47
|
+
|
|
48
|
+
def _build_env(self) -> Environment:
|
|
49
|
+
loaders = [FileSystemLoader(str(p)) for p in self._host_dirs]
|
|
50
|
+
loaders.append(PackageLoader(_DEFAULT_PACKAGE, _DEFAULT_TEMPLATE_DIR))
|
|
51
|
+
return Environment(
|
|
52
|
+
loader=ChoiceLoader(loaders),
|
|
53
|
+
autoescape=select_autoescape(["html"]),
|
|
54
|
+
trim_blocks=True,
|
|
55
|
+
lstrip_blocks=True,
|
|
56
|
+
keep_trailing_newline=True,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _render(self, template_name: str, context: dict[str, object]) -> str:
|
|
60
|
+
return self._env.get_template(template_name).render(context)
|
|
61
|
+
|
|
62
|
+
def _compose(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
kind: str,
|
|
66
|
+
to: str,
|
|
67
|
+
context: dict[str, object],
|
|
68
|
+
) -> EmailMessage:
|
|
69
|
+
subject = self._render(f"{kind}.subject.txt", context).strip()
|
|
70
|
+
html = self._render(f"{kind}.html", context)
|
|
71
|
+
text = self._render(f"{kind}.txt", context)
|
|
72
|
+
return EmailMessage(
|
|
73
|
+
to=to,
|
|
74
|
+
subject=subject,
|
|
75
|
+
html=html,
|
|
76
|
+
text=text,
|
|
77
|
+
from_address=self._email_config.from_address,
|
|
78
|
+
from_name=self._email_config.from_name,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# --- Public renderers -------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def verification(self, *, to: str, full_name: str | None, url: str) -> EmailMessage:
|
|
84
|
+
return self._compose(
|
|
85
|
+
kind="verification",
|
|
86
|
+
to=to,
|
|
87
|
+
context={
|
|
88
|
+
"app_name": self._app_name,
|
|
89
|
+
"full_name": full_name or "",
|
|
90
|
+
"url": url,
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def password_reset(
|
|
95
|
+
self, *, to: str, full_name: str | None, url: str, ttl_minutes: int
|
|
96
|
+
) -> EmailMessage:
|
|
97
|
+
return self._compose(
|
|
98
|
+
kind="password_reset",
|
|
99
|
+
to=to,
|
|
100
|
+
context={
|
|
101
|
+
"app_name": self._app_name,
|
|
102
|
+
"full_name": full_name or "",
|
|
103
|
+
"url": url,
|
|
104
|
+
"ttl_minutes": ttl_minutes,
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def email_change(
|
|
109
|
+
self, *, to: str, full_name: str | None, url: str, ttl_minutes: int
|
|
110
|
+
) -> EmailMessage:
|
|
111
|
+
return self._compose(
|
|
112
|
+
kind="email_change",
|
|
113
|
+
to=to,
|
|
114
|
+
context={
|
|
115
|
+
"app_name": self._app_name,
|
|
116
|
+
"full_name": full_name or "",
|
|
117
|
+
"url": url,
|
|
118
|
+
"ttl_minutes": ttl_minutes,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# SMS bodies live here too — same Jinja loader stack so hosts can
|
|
123
|
+
# override the wording by dropping ``sms_<kind>.txt`` into their
|
|
124
|
+
# template directory.
|
|
125
|
+
def sms_body(self, *, kind: str, **context: object) -> str:
|
|
126
|
+
full = {"app_name": self._app_name, **context}
|
|
127
|
+
return self._render(f"sms_{kind}.txt", full).strip()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def default_template_dir() -> Path:
|
|
131
|
+
"""Filesystem path of the bundled defaults — useful for tooling that wants
|
|
132
|
+
to copy and customise rather than override per-template.
|
|
133
|
+
"""
|
|
134
|
+
return Path(str(resources.files(_DEFAULT_PACKAGE).joinpath(_DEFAULT_TEMPLATE_DIR)))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
__all__ = ["MailComposer", "default_template_dir"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def for_user(user: BaseUser) -> dict[str, object]:
|
|
141
|
+
"""Tiny convenience used by routers building template contexts."""
|
|
142
|
+
return {"email": user.email, "full_name": user.full_name}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from regstack.email.base import EmailMessage, EmailService
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger("regstack.email.console")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConsoleEmailService(EmailService):
|
|
11
|
+
"""Logs the email payload instead of sending it. Used in dev and tests.
|
|
12
|
+
|
|
13
|
+
Captured messages are also kept in ``self.outbox`` so tests can assert on
|
|
14
|
+
rendered content without scraping logs.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.outbox: list[EmailMessage] = []
|
|
19
|
+
|
|
20
|
+
async def send(self, message: EmailMessage) -> None:
|
|
21
|
+
self.outbox.append(message)
|
|
22
|
+
log.info(
|
|
23
|
+
"[regstack/console-email] To: %s | From: %s | Subject: %s",
|
|
24
|
+
message.to,
|
|
25
|
+
message.from_header,
|
|
26
|
+
message.subject,
|
|
27
|
+
)
|
|
28
|
+
log.debug("[regstack/console-email] text body:\n%s", message.text)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from regstack.email.base import EmailService
|
|
6
|
+
from regstack.email.console import ConsoleEmailService
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from regstack.config.schema import EmailConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_email_service(config: EmailConfig) -> EmailService:
|
|
13
|
+
if config.backend == "console":
|
|
14
|
+
return ConsoleEmailService()
|
|
15
|
+
if config.backend == "smtp":
|
|
16
|
+
from regstack.email.smtp import SmtpEmailService
|
|
17
|
+
|
|
18
|
+
return SmtpEmailService(config)
|
|
19
|
+
if config.backend == "ses":
|
|
20
|
+
from regstack.email.ses import SesEmailService
|
|
21
|
+
|
|
22
|
+
return SesEmailService(config)
|
|
23
|
+
raise ValueError(f"Unknown email backend: {config.backend!r}")
|
regstack/email/ses.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from regstack.email.base import EmailMessage, EmailService
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from regstack.config.schema import EmailConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SesEmailService(EmailService):
|
|
12
|
+
"""Sends mail via AWS SES. Requires the optional ``ses`` extra
|
|
13
|
+
(``pip install regstack[ses]``) which pulls in ``aioboto3``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config: EmailConfig) -> None:
|
|
17
|
+
try:
|
|
18
|
+
import aioboto3 # noqa: F401 (import-time check)
|
|
19
|
+
except ImportError as exc:
|
|
20
|
+
raise RuntimeError(
|
|
21
|
+
"The SES email backend requires the 'ses' extra. "
|
|
22
|
+
"Install with `pip install regstack[ses]` or `uv sync --extra ses`."
|
|
23
|
+
) from exc
|
|
24
|
+
self._config = config
|
|
25
|
+
# Defer client construction to send() so each call gets a fresh
|
|
26
|
+
# short-lived session. SES clients are cheap to instantiate.
|
|
27
|
+
|
|
28
|
+
async def send(self, message: EmailMessage) -> None:
|
|
29
|
+
import aioboto3
|
|
30
|
+
|
|
31
|
+
session_kwargs = {}
|
|
32
|
+
if self._config.ses_profile:
|
|
33
|
+
session_kwargs["profile_name"] = self._config.ses_profile
|
|
34
|
+
session = aioboto3.Session(**session_kwargs)
|
|
35
|
+
|
|
36
|
+
async with session.client("ses", region_name=self._config.ses_region) as client:
|
|
37
|
+
await client.send_email(
|
|
38
|
+
Source=message.from_header,
|
|
39
|
+
Destination={"ToAddresses": [message.to]},
|
|
40
|
+
Message={
|
|
41
|
+
"Subject": {"Data": message.subject, "Charset": "UTF-8"},
|
|
42
|
+
"Body": {
|
|
43
|
+
"Text": {"Data": message.text, "Charset": "UTF-8"},
|
|
44
|
+
"Html": {"Data": message.html, "Charset": "UTF-8"},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
)
|
regstack/email/smtp.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from email.message import EmailMessage as MimeMessage
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import aiosmtplib
|
|
7
|
+
|
|
8
|
+
from regstack.email.base import EmailMessage, EmailService
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from regstack.config.schema import EmailConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SmtpEmailService(EmailService):
|
|
15
|
+
"""Sends mail via aiosmtplib. STARTTLS is enabled by default; set
|
|
16
|
+
``smtp_starttls=False`` for an SMTP-over-SSL server (port 465 et al.)
|
|
17
|
+
or for plaintext local relays.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: EmailConfig) -> None:
|
|
21
|
+
if not config.smtp_host:
|
|
22
|
+
raise ValueError("EmailConfig.smtp_host is required for the SMTP backend.")
|
|
23
|
+
self._config = config
|
|
24
|
+
|
|
25
|
+
async def send(self, message: EmailMessage) -> None:
|
|
26
|
+
mime = MimeMessage()
|
|
27
|
+
mime["From"] = message.from_header
|
|
28
|
+
mime["To"] = message.to
|
|
29
|
+
mime["Subject"] = message.subject
|
|
30
|
+
mime.set_content(message.text)
|
|
31
|
+
mime.add_alternative(message.html, subtype="html")
|
|
32
|
+
|
|
33
|
+
username = self._config.smtp_username or None
|
|
34
|
+
password = (
|
|
35
|
+
self._config.smtp_password.get_secret_value()
|
|
36
|
+
if self._config.smtp_password is not None
|
|
37
|
+
else None
|
|
38
|
+
)
|
|
39
|
+
await aiosmtplib.send(
|
|
40
|
+
mime,
|
|
41
|
+
hostname=self._config.smtp_host,
|
|
42
|
+
port=self._config.smtp_port,
|
|
43
|
+
start_tls=self._config.smtp_starttls,
|
|
44
|
+
username=username,
|
|
45
|
+
password=password,
|
|
46
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>{{ app_name }} — confirm new email</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #222; line-height: 1.5;">
|
|
8
|
+
<p>Hi{% if full_name %} {{ full_name }}{% endif %},</p>
|
|
9
|
+
<p>Someone — hopefully you — asked to change the email address on your <strong>{{ app_name }}</strong> account to this one. Click the link below to confirm. The link is valid for {{ ttl_minutes }} minutes.</p>
|
|
10
|
+
<p><a href="{{ url }}" style="display:inline-block;padding:10px 20px;background:#222;color:#fff;text-decoration:none;border-radius:4px;">Confirm new email</a></p>
|
|
11
|
+
<p>If the button doesn't work, paste this URL into your browser:</p>
|
|
12
|
+
<p style="word-break: break-all;"><a href="{{ url }}">{{ url }}</a></p>
|
|
13
|
+
<p style="color:#666;font-size: 12px;">If you didn't request this change, ignore this email — your address won't change.</p>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Confirm your new {{ app_name }} email address
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Hi{% if full_name %} {{ full_name }}{% endif %},
|
|
2
|
+
|
|
3
|
+
Someone — hopefully you — asked to change the email address on your {{ app_name }} account to this one. Use the link below to confirm. It is valid for {{ ttl_minutes }} minutes.
|
|
4
|
+
|
|
5
|
+
{{ url }}
|
|
6
|
+
|
|
7
|
+
If you didn't request this change, ignore this email — your address won't change.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>{{ app_name }} — reset your password</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #222; line-height: 1.5;">
|
|
8
|
+
<p>Hi{% if full_name %} {{ full_name }}{% endif %},</p>
|
|
9
|
+
<p>We received a request to reset the password for your <strong>{{ app_name }}</strong> account. The link below is valid for {{ ttl_minutes }} minutes.</p>
|
|
10
|
+
<p><a href="{{ url }}" style="display:inline-block;padding:10px 20px;background:#222;color:#fff;text-decoration:none;border-radius:4px;">Choose a new password</a></p>
|
|
11
|
+
<p>If the button doesn't work, paste this URL into your browser:</p>
|
|
12
|
+
<p style="word-break: break-all;"><a href="{{ url }}">{{ url }}</a></p>
|
|
13
|
+
<p style="color:#666;font-size: 12px;">If you didn't request a reset, you can ignore this email — your password won't change.</p>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Reset your {{ app_name }} password
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Hi{% if full_name %} {{ full_name }}{% endif %},
|
|
2
|
+
|
|
3
|
+
We received a request to reset the password for your {{ app_name }} account. The link below is valid for {{ ttl_minutes }} minutes:
|
|
4
|
+
|
|
5
|
+
{{ url }}
|
|
6
|
+
|
|
7
|
+
If you didn't request a reset, you can ignore this email — your password won't change.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{{ app_name }} sign-in code: {{ code }}. Expires in {{ ttl_minutes }} minutes. If you didn't try to sign in, ignore this message.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{{ app_name }} verification code: {{ code }}. It expires in {{ ttl_minutes }} minutes.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>{{ app_name }} — confirm your email</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #222; line-height: 1.5;">
|
|
8
|
+
<p>Hi{% if full_name %} {{ full_name }}{% endif %},</p>
|
|
9
|
+
<p>Thanks for signing up to <strong>{{ app_name }}</strong>. Please confirm your email address by clicking the link below.</p>
|
|
10
|
+
<p><a href="{{ url }}" style="display:inline-block;padding:10px 20px;background:#222;color:#fff;text-decoration:none;border-radius:4px;">Confirm my account</a></p>
|
|
11
|
+
<p>If the button doesn't work, paste this URL into your browser:</p>
|
|
12
|
+
<p style="word-break: break-all;"><a href="{{ url }}">{{ url }}</a></p>
|
|
13
|
+
<p style="color:#666;font-size: 12px;">If you didn't create an account, you can safely ignore this email — it will expire automatically.</p>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Confirm your {{ app_name }} account
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Hi{% if full_name %} {{ full_name }}{% endif %},
|
|
2
|
+
|
|
3
|
+
Thanks for signing up to {{ app_name }}. Please confirm your email address by visiting the link below:
|
|
4
|
+
|
|
5
|
+
{{ url }}
|
|
6
|
+
|
|
7
|
+
If you didn't create an account, you can safely ignore this email — the link will expire automatically.
|
regstack/hooks/events.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("regstack.hooks")
|
|
11
|
+
|
|
12
|
+
Handler = Callable[..., Awaitable[None] | None]
|
|
13
|
+
|
|
14
|
+
# Known event names. Hosts may also subscribe to custom events at their own risk.
|
|
15
|
+
KNOWN_EVENTS = {
|
|
16
|
+
"user_registered",
|
|
17
|
+
"user_logged_in",
|
|
18
|
+
"user_logged_out",
|
|
19
|
+
"user_verified",
|
|
20
|
+
"password_reset_requested",
|
|
21
|
+
"password_reset_completed",
|
|
22
|
+
"password_changed",
|
|
23
|
+
"user_deleted",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HookRegistry:
|
|
28
|
+
"""Tiny event bus. Handlers are awaited concurrently; exceptions are logged
|
|
29
|
+
and swallowed so a misbehaving host hook cannot break a primary auth flow.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._handlers: dict[str, list[Handler]] = defaultdict(list)
|
|
34
|
+
|
|
35
|
+
def on(self, event: str, handler: Handler) -> None:
|
|
36
|
+
self._handlers[event].append(handler)
|
|
37
|
+
|
|
38
|
+
async def fire(self, event: str, /, **kwargs: Any) -> None:
|
|
39
|
+
handlers = self._handlers.get(event, ())
|
|
40
|
+
if not handlers:
|
|
41
|
+
return
|
|
42
|
+
coros = []
|
|
43
|
+
for handler in handlers:
|
|
44
|
+
try:
|
|
45
|
+
result = handler(**kwargs)
|
|
46
|
+
except Exception:
|
|
47
|
+
log.exception("regstack hook %r raised synchronously", event)
|
|
48
|
+
continue
|
|
49
|
+
if inspect.isawaitable(result):
|
|
50
|
+
coros.append(_swallow(event, result))
|
|
51
|
+
if coros:
|
|
52
|
+
await asyncio.gather(*coros)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _swallow(event: str, awaitable: Awaitable[None]) -> None:
|
|
56
|
+
try:
|
|
57
|
+
await awaitable
|
|
58
|
+
except Exception:
|
|
59
|
+
log.exception("regstack hook %r raised in awaitable", event)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from regstack.models.login_attempt import LoginAttempt
|
|
2
|
+
from regstack.models.mfa_code import MfaCode, MfaKind
|
|
3
|
+
from regstack.models.pending_registration import PendingRegistration
|
|
4
|
+
from regstack.models.user import BaseUser, UserCreate, UserPublic, UserUpdate
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"BaseUser",
|
|
8
|
+
"LoginAttempt",
|
|
9
|
+
"MfaCode",
|
|
10
|
+
"MfaKind",
|
|
11
|
+
"PendingRegistration",
|
|
12
|
+
"UserCreate",
|
|
13
|
+
"UserPublic",
|
|
14
|
+
"UserUpdate",
|
|
15
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
from bson import ObjectId
|
|
6
|
+
from pydantic import GetCoreSchemaHandler
|
|
7
|
+
from pydantic_core import CoreSchema, core_schema
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _ObjectIdValidator:
|
|
11
|
+
"""Pydantic v2 type that accepts an ``ObjectId`` or its 24-char hex string and serialises as str."""
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def __get_pydantic_core_schema__(
|
|
15
|
+
cls, source_type: Any, handler: GetCoreSchemaHandler
|
|
16
|
+
) -> CoreSchema:
|
|
17
|
+
def validate(value: Any) -> str:
|
|
18
|
+
if isinstance(value, ObjectId):
|
|
19
|
+
return str(value)
|
|
20
|
+
if isinstance(value, str) and ObjectId.is_valid(value):
|
|
21
|
+
return value
|
|
22
|
+
raise ValueError(f"Not a valid ObjectId: {value!r}")
|
|
23
|
+
|
|
24
|
+
return core_schema.no_info_plain_validator_function(
|
|
25
|
+
validate,
|
|
26
|
+
serialization=core_schema.plain_serializer_function_ser_schema(str),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ObjectIdStr = Annotated[str, _ObjectIdValidator]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
|
7
|
+
|
|
8
|
+
from regstack.models._objectid import ObjectIdStr
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _utcnow() -> datetime:
|
|
12
|
+
return datetime.now(UTC)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LoginAttempt(BaseModel):
|
|
16
|
+
"""One row per failed login attempt. TTL index on ``when`` reaps rows
|
|
17
|
+
once they fall outside the lockout window.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
21
|
+
|
|
22
|
+
id: ObjectIdStr | None = Field(default=None, alias="_id")
|
|
23
|
+
email: EmailStr
|
|
24
|
+
when: datetime = Field(default_factory=_utcnow)
|
|
25
|
+
ip: str | None = None
|
|
26
|
+
|
|
27
|
+
def to_mongo(self) -> dict[str, Any]:
|
|
28
|
+
data = self.model_dump(by_alias=True, exclude_none=True)
|
|
29
|
+
if data.get("_id") is None:
|
|
30
|
+
data.pop("_id", None)
|
|
31
|
+
return data
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from regstack.models._objectid import ObjectIdStr
|
|
9
|
+
|
|
10
|
+
MfaKind = Literal["phone_setup", "login_mfa"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _utcnow() -> datetime:
|
|
14
|
+
return datetime.now(UTC)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MfaCode(BaseModel):
|
|
18
|
+
"""One-time SMS code awaiting verification.
|
|
19
|
+
|
|
20
|
+
Only the SHA-256 hash of the code lives in the DB — a database read does
|
|
21
|
+
not yield usable codes. Codes are unique per ``(user_id, kind)`` so
|
|
22
|
+
re-issuing a code automatically invalidates the previous one.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
26
|
+
|
|
27
|
+
id: ObjectIdStr | None = Field(default=None, alias="_id")
|
|
28
|
+
user_id: str
|
|
29
|
+
kind: MfaKind
|
|
30
|
+
code_hash: str
|
|
31
|
+
expires_at: datetime
|
|
32
|
+
attempts: int = 0
|
|
33
|
+
max_attempts: int = 5
|
|
34
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
35
|
+
|
|
36
|
+
def to_mongo(self) -> dict[str, Any]:
|
|
37
|
+
data = self.model_dump(by_alias=True, exclude_none=True)
|
|
38
|
+
if data.get("_id") is None:
|
|
39
|
+
data.pop("_id", None)
|
|
40
|
+
return data
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
|
7
|
+
|
|
8
|
+
from regstack.models._objectid import ObjectIdStr
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _utcnow() -> datetime:
|
|
12
|
+
return datetime.now(UTC)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PendingRegistration(BaseModel):
|
|
16
|
+
"""Pre-verification user record. Lives in `pending_registrations` until
|
|
17
|
+
the user clicks the verification link (which moves them to `users`) or
|
|
18
|
+
`expires_at` passes (TTL index reaps).
|
|
19
|
+
|
|
20
|
+
Only the SHA-256 hash of the verification token is stored — the raw
|
|
21
|
+
token only exists in the email body and the user's clipboard.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
25
|
+
|
|
26
|
+
id: ObjectIdStr | None = Field(default=None, alias="_id")
|
|
27
|
+
email: EmailStr
|
|
28
|
+
hashed_password: str
|
|
29
|
+
full_name: str | None = None
|
|
30
|
+
token_hash: str
|
|
31
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
32
|
+
expires_at: datetime
|
|
33
|
+
|
|
34
|
+
def to_mongo(self) -> dict[str, Any]:
|
|
35
|
+
data = self.model_dump(by_alias=True, exclude_none=True)
|
|
36
|
+
if data.get("_id") is None:
|
|
37
|
+
data.pop("_id", None)
|
|
38
|
+
return data
|