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
regstack/__init__.py
ADDED
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
|
regstack/auth/lockout.py
ADDED
|
@@ -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)
|
regstack/auth/tokens.py
ADDED
|
@@ -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()
|
regstack/cli/__init__.py
ADDED
|
File without changes
|
regstack/cli/__main__.py
ADDED
|
@@ -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()
|
regstack/cli/_runtime.py
ADDED
|
@@ -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()
|