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.
Files changed (92) hide show
  1. regstack/__init__.py +5 -0
  2. regstack/app.py +150 -0
  3. regstack/auth/__init__.py +21 -0
  4. regstack/auth/clock.py +29 -0
  5. regstack/auth/dependencies.py +102 -0
  6. regstack/auth/jwt.py +145 -0
  7. regstack/auth/lockout.py +59 -0
  8. regstack/auth/mfa.py +29 -0
  9. regstack/auth/password.py +20 -0
  10. regstack/auth/tokens.py +19 -0
  11. regstack/cli/__init__.py +0 -0
  12. regstack/cli/__main__.py +27 -0
  13. regstack/cli/_runtime.py +39 -0
  14. regstack/cli/admin.py +45 -0
  15. regstack/cli/doctor.py +186 -0
  16. regstack/cli/init.py +236 -0
  17. regstack/config/__init__.py +4 -0
  18. regstack/config/loader.py +114 -0
  19. regstack/config/schema.py +148 -0
  20. regstack/config/secrets.py +22 -0
  21. regstack/db/__init__.py +17 -0
  22. regstack/db/client.py +26 -0
  23. regstack/db/indexes.py +70 -0
  24. regstack/db/repositories/__init__.py +0 -0
  25. regstack/db/repositories/blacklist_repo.py +28 -0
  26. regstack/db/repositories/login_attempt_repo.py +27 -0
  27. regstack/db/repositories/mfa_code_repo.py +99 -0
  28. regstack/db/repositories/pending_repo.py +76 -0
  29. regstack/db/repositories/user_repo.py +169 -0
  30. regstack/email/__init__.py +12 -0
  31. regstack/email/base.py +23 -0
  32. regstack/email/composer.py +142 -0
  33. regstack/email/console.py +28 -0
  34. regstack/email/factory.py +23 -0
  35. regstack/email/ses.py +47 -0
  36. regstack/email/smtp.py +46 -0
  37. regstack/email/templates/email_change.html +15 -0
  38. regstack/email/templates/email_change.subject.txt +1 -0
  39. regstack/email/templates/email_change.txt +7 -0
  40. regstack/email/templates/password_reset.html +15 -0
  41. regstack/email/templates/password_reset.subject.txt +1 -0
  42. regstack/email/templates/password_reset.txt +7 -0
  43. regstack/email/templates/sms_login_mfa.txt +1 -0
  44. regstack/email/templates/sms_phone_setup.txt +1 -0
  45. regstack/email/templates/verification.html +15 -0
  46. regstack/email/templates/verification.subject.txt +1 -0
  47. regstack/email/templates/verification.txt +7 -0
  48. regstack/hooks/__init__.py +3 -0
  49. regstack/hooks/events.py +59 -0
  50. regstack/models/__init__.py +15 -0
  51. regstack/models/_objectid.py +30 -0
  52. regstack/models/login_attempt.py +31 -0
  53. regstack/models/mfa_code.py +40 -0
  54. regstack/models/pending_registration.py +38 -0
  55. regstack/models/user.py +104 -0
  56. regstack/routers/__init__.py +37 -0
  57. regstack/routers/_schemas.py +34 -0
  58. regstack/routers/account.py +274 -0
  59. regstack/routers/admin.py +187 -0
  60. regstack/routers/login.py +223 -0
  61. regstack/routers/logout.py +39 -0
  62. regstack/routers/password.py +114 -0
  63. regstack/routers/phone.py +242 -0
  64. regstack/routers/register.py +99 -0
  65. regstack/routers/verify.py +116 -0
  66. regstack/sms/__init__.py +5 -0
  67. regstack/sms/base.py +24 -0
  68. regstack/sms/factory.py +23 -0
  69. regstack/sms/null.py +26 -0
  70. regstack/sms/sns.py +42 -0
  71. regstack/sms/twilio.py +49 -0
  72. regstack/ui/__init__.py +3 -0
  73. regstack/ui/pages.py +148 -0
  74. regstack/ui/static/css/core.css +204 -0
  75. regstack/ui/static/css/theme.css +43 -0
  76. regstack/ui/static/js/regstack.js +411 -0
  77. regstack/ui/templates/auth/email_change_confirm.html +10 -0
  78. regstack/ui/templates/auth/forgot.html +14 -0
  79. regstack/ui/templates/auth/login.html +24 -0
  80. regstack/ui/templates/auth/me.html +110 -0
  81. regstack/ui/templates/auth/mfa_confirm.html +14 -0
  82. regstack/ui/templates/auth/register.html +23 -0
  83. regstack/ui/templates/auth/reset.html +13 -0
  84. regstack/ui/templates/auth/verify.html +10 -0
  85. regstack/ui/templates/base.html +46 -0
  86. regstack/version.py +1 -0
  87. regstack-0.1.0.dist-info/METADATA +209 -0
  88. regstack-0.1.0.dist-info/RECORD +92 -0
  89. regstack-0.1.0.dist-info/WHEEL +4 -0
  90. regstack-0.1.0.dist-info/entry_points.txt +2 -0
  91. regstack-0.1.0.dist-info/licenses/LICENSE +202 -0
  92. 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.
@@ -0,0 +1,3 @@
1
+ from regstack.hooks.events import HookRegistry
2
+
3
+ __all__ = ["HookRegistry"]
@@ -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