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
regstack/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from regstack.app import RegStack
2
+ from regstack.config.schema import EmailConfig, RegStackConfig, SmsConfig
3
+ from regstack.version import __version__
4
+
5
+ __all__ = ["EmailConfig", "RegStack", "RegStackConfig", "SmsConfig", "__version__"]
regstack/app.py ADDED
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ from fastapi.staticfiles import StaticFiles
7
+
8
+ from regstack.auth.clock import Clock, SystemClock
9
+ from regstack.auth.dependencies import AuthDependencies
10
+ from regstack.auth.jwt import JwtCodec
11
+ from regstack.auth.lockout import LockoutService
12
+ from regstack.auth.password import PasswordHasher
13
+ from regstack.config.schema import RegStackConfig
14
+ from regstack.db.indexes import install_indexes as _install_indexes
15
+ from regstack.db.repositories.blacklist_repo import BlacklistRepo
16
+ from regstack.db.repositories.login_attempt_repo import LoginAttemptRepo
17
+ from regstack.db.repositories.mfa_code_repo import MfaCodeRepo
18
+ from regstack.db.repositories.pending_repo import PendingRepo
19
+ from regstack.db.repositories.user_repo import UserRepo
20
+ from regstack.email.base import EmailService
21
+ from regstack.email.composer import MailComposer
22
+ from regstack.email.factory import build_email_service
23
+ from regstack.hooks.events import HookRegistry
24
+ from regstack.models.user import BaseUser
25
+ from regstack.routers import build_router
26
+ from regstack.sms.base import SmsService
27
+ from regstack.sms.factory import build_sms_service
28
+ from regstack.ui.pages import build_ui_environment, build_ui_router, default_static_dir
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Awaitable, Callable
32
+
33
+ from fastapi import APIRouter
34
+ from jinja2 import Environment
35
+ from pymongo.asynchronous.database import AsyncDatabase
36
+
37
+
38
+ class RegStack:
39
+ """Embeddable account-management module.
40
+
41
+ Hosts construct one of these per FastAPI application, then mount
42
+ ``regstack.router`` with ``app.include_router(regstack.router, prefix=...)``.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ config: RegStackConfig,
49
+ db: AsyncDatabase,
50
+ clock: Clock | None = None,
51
+ email_service: EmailService | None = None,
52
+ mail_composer: MailComposer | None = None,
53
+ sms_service: SmsService | None = None,
54
+ ) -> None:
55
+ self.config = config
56
+ self.db = db
57
+ self.clock: Clock = clock or SystemClock()
58
+ self.password_hasher = PasswordHasher()
59
+ self.jwt = JwtCodec(config, self.clock)
60
+ self.users = UserRepo(db, config.user_collection, clock=self.clock)
61
+ self.pending = PendingRepo(db, config.pending_collection)
62
+ self.blacklist = BlacklistRepo(db, config.blacklist_collection)
63
+ self.attempts = LoginAttemptRepo(db, config.login_attempt_collection)
64
+ self.mfa_codes = MfaCodeRepo(db, config.mfa_code_collection, clock=self.clock)
65
+ self.lockout = LockoutService(attempts=self.attempts, config=config, clock=self.clock)
66
+ self.email: EmailService = email_service or build_email_service(config.email)
67
+ self.sms: SmsService = sms_service or build_sms_service(config.sms)
68
+ self.mail = mail_composer or MailComposer(
69
+ email_config=config.email,
70
+ app_name=config.app_name,
71
+ )
72
+ self.hooks = HookRegistry()
73
+ self.deps = AuthDependencies(jwt=self.jwt, users=self.users, blacklist=self.blacklist)
74
+ self._template_dirs: list[Path] = list(config.extra_template_dirs)
75
+ self._ui_env: Environment | None = None
76
+ self._router: APIRouter | None = None
77
+ self._ui_router: APIRouter | None = None
78
+ self._static_files: StaticFiles | None = None
79
+
80
+ @property
81
+ def router(self) -> APIRouter:
82
+ if self._router is None:
83
+ self._router = build_router(self)
84
+ return self._router
85
+
86
+ @property
87
+ def ui_env(self) -> Environment:
88
+ if self._ui_env is None:
89
+ self._ui_env = build_ui_environment(self._template_dirs)
90
+ return self._ui_env
91
+
92
+ @property
93
+ def ui_router(self) -> APIRouter:
94
+ if self._ui_router is None:
95
+ self._ui_router = build_ui_router(self)
96
+ return self._ui_router
97
+
98
+ @property
99
+ def static_files(self) -> StaticFiles:
100
+ """Bundled CSS / JS — host mounts at ``config.static_prefix``."""
101
+ if self._static_files is None:
102
+ self._static_files = StaticFiles(directory=str(default_static_dir()))
103
+ return self._static_files
104
+
105
+ # --- Lifecycle -------------------------------------------------------
106
+
107
+ async def install_indexes(self) -> None:
108
+ await _install_indexes(self.db, self.config)
109
+
110
+ async def bootstrap_admin(self, email: str, password: str) -> BaseUser:
111
+ """Create a verified superuser if none exists for the given email."""
112
+ existing = await self.users.get_by_email(email)
113
+ if existing is not None:
114
+ if not existing.is_superuser:
115
+ assert existing.id is not None
116
+ await self.users.set_superuser(existing.id, is_superuser=True)
117
+ existing.is_superuser = True
118
+ return existing
119
+ user = BaseUser(
120
+ email=email,
121
+ hashed_password=self.password_hasher.hash(password),
122
+ is_active=True,
123
+ is_verified=True,
124
+ is_superuser=True,
125
+ )
126
+ return await self.users.create(user)
127
+
128
+ # --- Extension surface ------------------------------------------------
129
+
130
+ def set_email_backend(self, service: EmailService) -> None:
131
+ self.email = service
132
+
133
+ def set_sms_backend(self, service: SmsService) -> None:
134
+ self.sms = service
135
+
136
+ def add_template_dir(self, path: str | Path) -> None:
137
+ """Prepend a host-supplied template directory. Host templates win
138
+ over regstack defaults via Jinja2's ``ChoiceLoader`` for both the
139
+ email composer and the SSR UI pages.
140
+ """
141
+ path_obj = Path(path)
142
+ self.mail.add_template_dir(path_obj)
143
+ if path_obj not in self._template_dirs:
144
+ self._template_dirs.insert(0, path_obj)
145
+ # Force the UI environment to rebuild on next access so the new
146
+ # directory takes effect even if the env was already touched.
147
+ self._ui_env = None
148
+
149
+ def on(self, event: str, handler: Callable[..., Awaitable[None] | None]) -> None:
150
+ self.hooks.on(event, handler)
@@ -0,0 +1,21 @@
1
+ from regstack.auth.clock import Clock, FrozenClock, SystemClock
2
+ from regstack.auth.dependencies import AuthDependencies
3
+ from regstack.auth.jwt import JwtCodec, RevocationChecker, TokenPayload
4
+ from regstack.auth.lockout import LockoutDecision, LockoutService
5
+ from regstack.auth.password import PasswordHasher
6
+ from regstack.auth.tokens import generate_verification_token, hash_token
7
+
8
+ __all__ = [
9
+ "AuthDependencies",
10
+ "Clock",
11
+ "FrozenClock",
12
+ "JwtCodec",
13
+ "LockoutDecision",
14
+ "LockoutService",
15
+ "PasswordHasher",
16
+ "RevocationChecker",
17
+ "SystemClock",
18
+ "TokenPayload",
19
+ "generate_verification_token",
20
+ "hash_token",
21
+ ]
regstack/auth/clock.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from typing import Protocol
5
+
6
+
7
+ class Clock(Protocol):
8
+ def now(self) -> datetime: ...
9
+
10
+
11
+ class SystemClock:
12
+ def now(self) -> datetime:
13
+ return datetime.now(UTC)
14
+
15
+
16
+ class FrozenClock:
17
+ """Test seam — returns a fixed timestamp until ``advance`` is called."""
18
+
19
+ def __init__(self, start: datetime | None = None) -> None:
20
+ self._now = start or datetime(2025, 1, 1, tzinfo=UTC)
21
+
22
+ def now(self) -> datetime:
23
+ return self._now
24
+
25
+ def advance(self, delta: timedelta) -> None:
26
+ self._now += delta
27
+
28
+ def set(self, when: datetime) -> None:
29
+ self._now = when
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from fastapi import Depends, HTTPException, Request, status
6
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
7
+
8
+ from regstack.auth.jwt import TokenError, is_payload_bulk_revoked
9
+
10
+ if TYPE_CHECKING:
11
+ from regstack.auth.jwt import JwtCodec
12
+ from regstack.db.repositories.blacklist_repo import BlacklistRepo
13
+ from regstack.db.repositories.user_repo import UserRepo
14
+ from regstack.models.user import BaseUser
15
+
16
+ _bearer = HTTPBearer(auto_error=False)
17
+
18
+
19
+ class AuthDependencies:
20
+ """Factory for FastAPI dependencies bound to a particular RegStack instance.
21
+
22
+ A factory is used (rather than module-level dependencies) because two
23
+ embedded RegStack instances in the same process would otherwise share
24
+ state via module globals.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ jwt: JwtCodec,
31
+ users: UserRepo,
32
+ blacklist: BlacklistRepo,
33
+ ) -> None:
34
+ self._jwt = jwt
35
+ self._users = users
36
+ self._blacklist = blacklist
37
+
38
+ def current_user(self) -> object:
39
+ """Return a callable usable as a FastAPI dependency yielding the authenticated user."""
40
+
41
+ async def _dep(
42
+ request: Request,
43
+ creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
44
+ ) -> BaseUser:
45
+ user = await self._authenticate(creds)
46
+ request.state.regstack_user = user
47
+ return user
48
+
49
+ return _dep
50
+
51
+ def current_admin(self) -> object:
52
+ async def _dep(
53
+ request: Request,
54
+ creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
55
+ ) -> BaseUser:
56
+ user = await self._authenticate(creds)
57
+ if not user.is_superuser:
58
+ raise HTTPException(
59
+ status_code=status.HTTP_403_FORBIDDEN,
60
+ detail="Administrator privileges required.",
61
+ )
62
+ request.state.regstack_user = user
63
+ return user
64
+
65
+ return _dep
66
+
67
+ async def _authenticate(self, creds: HTTPAuthorizationCredentials | None):
68
+ if creds is None or creds.scheme.lower() != "bearer":
69
+ raise HTTPException(
70
+ status_code=status.HTTP_401_UNAUTHORIZED,
71
+ detail="Authentication required.",
72
+ headers={"WWW-Authenticate": "Bearer"},
73
+ )
74
+ try:
75
+ payload = self._jwt.decode(creds.credentials)
76
+ except TokenError as exc:
77
+ raise HTTPException(
78
+ status_code=status.HTTP_401_UNAUTHORIZED,
79
+ detail=f"Invalid token: {exc}",
80
+ headers={"WWW-Authenticate": "Bearer"},
81
+ ) from exc
82
+
83
+ if await self._blacklist.is_revoked(payload.jti):
84
+ raise HTTPException(
85
+ status_code=status.HTTP_401_UNAUTHORIZED,
86
+ detail="Token has been revoked.",
87
+ )
88
+
89
+ user = await self._users.get_by_id(payload.sub)
90
+ if user is None or not user.is_active:
91
+ raise HTTPException(
92
+ status_code=status.HTTP_401_UNAUTHORIZED,
93
+ detail="User no longer active.",
94
+ )
95
+
96
+ if is_payload_bulk_revoked(payload, user.tokens_invalidated_after):
97
+ raise HTTPException(
98
+ status_code=status.HTTP_401_UNAUTHORIZED,
99
+ detail="Session was invalidated; please sign in again.",
100
+ )
101
+
102
+ return user
regstack/auth/jwt.py ADDED
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta
6
+ from typing import TYPE_CHECKING, Any, Protocol
7
+
8
+ import jwt as pyjwt
9
+
10
+ from regstack.config.secrets import derive_secret
11
+
12
+ if TYPE_CHECKING:
13
+ from regstack.auth.clock import Clock
14
+ from regstack.config.schema import RegStackConfig
15
+
16
+ _DEFAULT_PURPOSE = "session"
17
+
18
+
19
+ class TokenError(Exception):
20
+ """Raised when a token cannot be decoded or is no longer valid."""
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class TokenPayload:
25
+ sub: str
26
+ jti: str
27
+ iat: datetime
28
+ exp: datetime
29
+ purpose: str
30
+
31
+ def to_claims(self, audience: str | None) -> dict[str, Any]:
32
+ # iat is emitted as a float so a within-the-same-second login after a
33
+ # bulk-revoke cutoff isn't falsely rejected. RFC 7519 NumericDate
34
+ # explicitly allows non-integer values.
35
+ claims: dict[str, Any] = {
36
+ "sub": self.sub,
37
+ "jti": self.jti,
38
+ "iat": self.iat.timestamp(),
39
+ "exp": int(self.exp.timestamp()),
40
+ "purpose": self.purpose,
41
+ }
42
+ if audience is not None:
43
+ claims["aud"] = audience
44
+ return claims
45
+
46
+
47
+ class RevocationChecker(Protocol):
48
+ """Anything that knows whether a `jti` has been revoked."""
49
+
50
+ async def is_revoked(self, jti: str) -> bool: ...
51
+
52
+
53
+ class JwtCodec:
54
+ """Encode and decode regstack's session JWTs.
55
+
56
+ Each purpose (session, verification, password_reset) signs with a key
57
+ derived from ``config.jwt_secret`` so a leak of one derived key does
58
+ not compromise the master.
59
+ """
60
+
61
+ def __init__(self, config: RegStackConfig, clock: Clock) -> None:
62
+ if not config.jwt_secret.get_secret_value():
63
+ raise ValueError(
64
+ "RegStackConfig.jwt_secret is empty. Run `regstack init` to generate one, "
65
+ "or set REGSTACK_JWT_SECRET."
66
+ )
67
+ self._config = config
68
+ self._clock = clock
69
+
70
+ def _key(self, purpose: str) -> bytes:
71
+ return derive_secret(self._config.jwt_secret.get_secret_value(), purpose)
72
+
73
+ def encode(
74
+ self,
75
+ subject: str,
76
+ *,
77
+ purpose: str = _DEFAULT_PURPOSE,
78
+ ttl_seconds: int | None = None,
79
+ ) -> tuple[str, TokenPayload]:
80
+ now = self._clock.now()
81
+ ttl = ttl_seconds if ttl_seconds is not None else self._config.jwt_ttl_seconds
82
+ exp = now + timedelta(seconds=ttl)
83
+ payload = TokenPayload(
84
+ sub=subject,
85
+ jti=secrets.token_urlsafe(16),
86
+ iat=now,
87
+ exp=exp,
88
+ purpose=purpose,
89
+ )
90
+ token = pyjwt.encode(
91
+ payload.to_claims(self._config.jwt_audience),
92
+ self._key(purpose),
93
+ algorithm=self._config.jwt_algorithm,
94
+ )
95
+ return token, payload
96
+
97
+ def decode(self, token: str, *, purpose: str = _DEFAULT_PURPOSE) -> TokenPayload:
98
+ try:
99
+ # We disable pyjwt's exp/iat checks because they use the system
100
+ # wall clock; we re-check both against the injected Clock so that
101
+ # FrozenClock-driven tests (and any future time-mocking host) get
102
+ # consistent behaviour.
103
+ claims = pyjwt.decode(
104
+ token,
105
+ self._key(purpose),
106
+ algorithms=[self._config.jwt_algorithm],
107
+ audience=self._config.jwt_audience,
108
+ options={
109
+ "require": ["sub", "exp", "iat", "jti", "purpose"],
110
+ "verify_exp": False,
111
+ "verify_iat": False,
112
+ "verify_nbf": False,
113
+ },
114
+ )
115
+ except pyjwt.PyJWTError as exc:
116
+ raise TokenError(str(exc)) from exc
117
+
118
+ if claims.get("purpose") != purpose:
119
+ raise TokenError("token purpose mismatch")
120
+
121
+ now = self._clock.now()
122
+ tz = now.tzinfo
123
+ iat = datetime.fromtimestamp(float(claims["iat"]), tz=tz)
124
+ exp = datetime.fromtimestamp(int(claims["exp"]), tz=tz)
125
+ if now >= exp:
126
+ raise TokenError("Signature has expired")
127
+
128
+ return TokenPayload(
129
+ sub=str(claims["sub"]),
130
+ jti=str(claims["jti"]),
131
+ iat=iat,
132
+ exp=exp,
133
+ purpose=str(claims["purpose"]),
134
+ )
135
+
136
+
137
+ def is_payload_bulk_revoked(payload: TokenPayload, cutoff: datetime | None) -> bool:
138
+ """Return True when the user's bulk-revocation cutoff is at-or-after the
139
+ token's ``iat``. Tokens issued strictly after the cutoff (even by
140
+ microseconds) survive; tokens issued at exactly the same instant are
141
+ treated as before-the-change for security.
142
+ """
143
+ if cutoff is None:
144
+ return False
145
+ return payload.iat <= cutoff
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import timedelta
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from regstack.auth.clock import Clock
9
+ from regstack.config.schema import RegStackConfig
10
+ from regstack.db.repositories.login_attempt_repo import LoginAttemptRepo
11
+
12
+
13
+ @dataclass(slots=True, frozen=True)
14
+ class LockoutDecision:
15
+ locked: bool
16
+ retry_after_seconds: int
17
+
18
+
19
+ class LockoutService:
20
+ """Counts failed logins per email in a sliding window. Locks the account
21
+ when the threshold is exceeded for the rest of the window.
22
+
23
+ Disabled (always returns ``locked=False``) when ``config.rate_limit_disabled``
24
+ is set — tests rely on this to avoid timing flakes.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ attempts: LoginAttemptRepo,
31
+ config: RegStackConfig,
32
+ clock: Clock,
33
+ ) -> None:
34
+ self._attempts = attempts
35
+ self._config = config
36
+ self._clock = clock
37
+
38
+ @property
39
+ def _window(self) -> timedelta:
40
+ return timedelta(seconds=self._config.login_lockout_window_seconds)
41
+
42
+ async def check(self, email: str) -> LockoutDecision:
43
+ if self._config.rate_limit_disabled:
44
+ return LockoutDecision(locked=False, retry_after_seconds=0)
45
+ count = await self._attempts.count_recent(email, window=self._window, now=self._clock.now())
46
+ if count >= self._config.login_lockout_threshold:
47
+ return LockoutDecision(
48
+ locked=True,
49
+ retry_after_seconds=self._config.login_lockout_window_seconds,
50
+ )
51
+ return LockoutDecision(locked=False, retry_after_seconds=0)
52
+
53
+ async def record_failure(self, email: str, *, ip: str | None = None) -> None:
54
+ if self._config.rate_limit_disabled:
55
+ return
56
+ await self._attempts.record_failure(email, when=self._clock.now(), ip=ip)
57
+
58
+ async def clear(self, email: str) -> None:
59
+ await self._attempts.clear(email)
regstack/auth/mfa.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from typing import TYPE_CHECKING
5
+
6
+ from regstack.auth.tokens import hash_token
7
+
8
+ if TYPE_CHECKING:
9
+ from regstack.config.schema import RegStackConfig
10
+
11
+
12
+ def generate_numeric_code(length: int) -> str:
13
+ """Cryptographically random numeric code as a zero-padded string.
14
+
15
+ Using ``secrets.randbelow`` (rather than ``random.randint``) keeps the
16
+ distribution uniform without leaking via the timing of ``randint``'s
17
+ rejection sampling — which itself uses ``getrandbits`` — but ``secrets``
18
+ is the standard answer for this in modern Python.
19
+ """
20
+ if length < 1:
21
+ raise ValueError("length must be >= 1")
22
+ upper_exclusive = 10**length
23
+ return f"{secrets.randbelow(upper_exclusive):0{length}d}"
24
+
25
+
26
+ def generate_mfa_code(config: RegStackConfig) -> tuple[str, str]:
27
+ """Return ``(raw_code, code_hash)``."""
28
+ raw = generate_numeric_code(config.sms_code_length)
29
+ return raw, hash_token(raw)
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from pwdlib import PasswordHash
4
+ from pwdlib.hashers.argon2 import Argon2Hasher
5
+
6
+
7
+ class PasswordHasher:
8
+ """Thin wrapper over ``pwdlib`` so we can swap algorithms without touching callers."""
9
+
10
+ def __init__(self) -> None:
11
+ self._hasher = PasswordHash((Argon2Hasher(),))
12
+
13
+ def hash(self, password: str) -> str:
14
+ return self._hasher.hash(password)
15
+
16
+ def verify(self, password: str, hashed: str) -> bool:
17
+ return self._hasher.verify(password, hashed)
18
+
19
+ def needs_rehash(self, hashed: str) -> bool:
20
+ return self._hasher.check_needs_rehash(hashed)
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import secrets
5
+
6
+
7
+ def generate_verification_token() -> tuple[str, str]:
8
+ """Return (raw_token_for_email, hash_for_db) for a single-use verification link.
9
+
10
+ The raw token is only ever sent in the verification email; only the
11
+ SHA-256 digest hits the database, so a database read does not yield
12
+ usable tokens.
13
+ """
14
+ raw = secrets.token_urlsafe(32)
15
+ return raw, hash_token(raw)
16
+
17
+
18
+ def hash_token(raw: str) -> str:
19
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
File without changes
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from regstack.cli.admin import create_admin as create_admin_cmd
6
+ from regstack.cli.doctor import doctor as doctor_cmd
7
+ from regstack.cli.init import init as init_cmd
8
+ from regstack.version import __version__
9
+
10
+
11
+ @click.group(help="regstack — embeddable account registration for FastAPI/MongoDB apps.")
12
+ @click.version_option(__version__, prog_name="regstack")
13
+ def cli() -> None:
14
+ pass
15
+
16
+
17
+ cli.add_command(init_cmd, name="init")
18
+ cli.add_command(create_admin_cmd)
19
+ cli.add_command(doctor_cmd)
20
+
21
+
22
+ def main() -> None:
23
+ cli(prog_name="regstack")
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
@@ -0,0 +1,39 @@
1
+ """Helpers shared between the CLI commands.
2
+
3
+ Keeps the per-command modules small and focused on their click flag wiring.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from regstack.app import RegStack
13
+ from regstack.config.schema import RegStackConfig
14
+ from regstack.db.client import make_client
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import AsyncIterator
18
+
19
+
20
+ def load_runtime_config(toml_path: Path | None = None) -> RegStackConfig:
21
+ return RegStackConfig.load(toml_path=toml_path) if toml_path else RegStackConfig.load()
22
+
23
+
24
+ @asynccontextmanager
25
+ async def open_regstack(toml_path: Path | None = None) -> AsyncIterator[RegStack]:
26
+ """Yield a fully-wired ``RegStack`` against a real Mongo connection.
27
+
28
+ Both the connection and the regstack instance are torn down on exit so
29
+ short-lived CLI invocations don't leak background tasks.
30
+ """
31
+ config = load_runtime_config(toml_path)
32
+ mongo = make_client(config)
33
+ try:
34
+ db = mongo[config.mongodb_database]
35
+ rs = RegStack(config=config, db=db)
36
+ await rs.install_indexes()
37
+ yield rs
38
+ finally:
39
+ await mongo.aclose()