Skip to content

Commit b1f9de4

Browse files
committed
Add model-driven identity contract (configurable login/recovery, email-optional accounts)
1 parent 850d17d commit b1f9de4

11 files changed

Lines changed: 699 additions & 79 deletions

File tree

crudauth/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ async def me(user: Principal = Depends(auth.current_user())):
3939
)
4040
from .crud_auth import CRUDAuth
4141
from .hooks import AuthHooks, HookContext
42+
from .identity import IdentityConfig
43+
from .models.mixin import AuthUserMixin, make_auth_identity
4244
from .oauth import OAuthCredentials
4345
from .principal import Principal
4446
from .provisioning import NewUserContext, NewUserFields
@@ -62,6 +64,9 @@ async def me(user: Principal = Depends(auth.current_user())):
6264
"EmailChannel",
6365
"AuthHooks",
6466
"HookContext",
67+
"IdentityConfig",
68+
"AuthUserMixin",
69+
"make_auth_identity",
6570
"NewUserContext",
6671
"NewUserFields",
6772
"Transport",

crudauth/crud_auth.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ async def me(user: Principal = Depends(auth.current_user())):
3939
UnauthorizedException,
4040
)
4141
from .hooks import AuthHooks
42+
from .identity import IdentityConfig
4243
from .oauth import OAuthAccountService, OAuthProviderFactory
4344
from .oauth.router import build_oauth_router
4445
from .principal import Principal
@@ -99,6 +100,7 @@ def __init__(
99100
SECRET_KEY: str,
100101
transports: Sequence[Transport] | None = None,
101102
column_map: dict[str, str] | None = None,
103+
identity: IdentityConfig | None = None,
102104
oauth: dict[str, Any] | None = None,
103105
email: Any = None,
104106
channels: list[DeliveryChannel] | None = None,
@@ -201,7 +203,11 @@ def __init__(
201203
if not SECRET_KEY:
202204
raise ValueError("SECRET_KEY is required")
203205
self.session = session
204-
self.repo = UserRepository(user_model, column_map, register_extra_fields)
206+
self.identity = identity or IdentityConfig()
207+
self.repo = UserRepository(
208+
user_model, column_map, register_extra_fields, login_fields=self.identity.login
209+
)
210+
self._validate_identity(oauth=oauth, email=email)
205211
self.new_user_fields = new_user_fields
206212
self._new_user_defaults = self.repo.filter_provisioning_data(new_user_defaults or {})
207213
self.hooks = hooks or AuthHooks()
@@ -235,7 +241,7 @@ def __init__(
235241

236242
self._email_service: EmailFlowService | None = None
237243
self._email_token_store: AbstractSessionStorage[Any] | None = None
238-
if email is not None or channels:
244+
if self.identity.recovery is not None and (email is not None or channels):
239245
self._build_email(email, channels, algorithm)
240246

241247
self._oauth_router: APIRouter | None = None
@@ -276,6 +282,34 @@ def _build_lockout(
276282
fail_open=False,
277283
)
278284

285+
def _validate_identity(self, *, oauth: dict[str, Any] | None, email: Any) -> None:
286+
"""Check the identity contract against the model, fail-closed at construction.
287+
288+
The model owns the shape; this asserts the config agrees with it, so a
289+
login field that isn't a unique column, a non-unique recovery field, OAuth
290+
without an email login, or an email flow on a model with no email column
291+
all raise here rather than splitting into a silent second source of truth.
292+
"""
293+
for login_field in self.identity.login:
294+
if not self.repo.is_unique_column(login_field):
295+
raise ValueError(
296+
f"identity.login field '{login_field}' is not a unique column on the user "
297+
"model; every login field must be a single-field unique column."
298+
)
299+
recovery = self.identity.recovery
300+
if recovery is not None and not self.repo.is_unique_column(recovery):
301+
raise ValueError(
302+
f"identity.recovery field '{recovery}' is not a unique column on the user "
303+
"model; the recovery field must be a single-field unique column."
304+
)
305+
if oauth and "email" not in self.identity.login:
306+
raise ValueError(
307+
"OAuth requires 'email' in identity.login - OAuth links and creates accounts "
308+
"by email, so an email-less contract cannot enable OAuth."
309+
)
310+
if email is not None and not self.repo.has("email"):
311+
raise ValueError("email=EmailConfig(...) requires an 'email' column on the user model.")
312+
279313
def _warn_on_register_extra_fields(self, extra: set[str] | None) -> None:
280314
"""Warn when ``register_extra_fields`` tries to opt in a privileged field.
281315
@@ -518,6 +552,11 @@ async def admin(_: Principal = Depends(auth.current_user(superuser=True))):
518552
...
519553
```
520554
"""
555+
if verified and not self.repo.has("email"):
556+
raise ValueError(
557+
"current_user(verified=True) gates on email_verified, which requires an "
558+
"email column on the user model. (PR-1 placeholder; recovery_verified is PR 2.)"
559+
)
521560
selected = self._select_transports(transport)
522561
required_scopes = list(scopes or [])
523562

crudauth/identity.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""The identity contract: the login/recovery intent the model's columns can't carry.
2+
3+
The model owns the *shape* (which columns exist, nullable, unique);
4+
[IdentityConfig][crudauth.identity.IdentityConfig] owns the *intent* a schema
5+
can't express, and [CRUDAuth][crudauth.crud_auth.CRUDAuth] validates the two
6+
against each other at construction, so a config that contradicts the model fails
7+
loudly at startup instead of splitting into a silent second source of truth.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from dataclasses import dataclass, field
13+
14+
__all__ = ["IdentityConfig"]
15+
16+
17+
@dataclass(frozen=True)
18+
class IdentityConfig:
19+
"""How an account's identity behaves, validated against the model at construction.
20+
21+
Attributes:
22+
login: Logical fields a login identifier is matched against, in order;
23+
first match wins. Each must be a unique column on the model (asserted
24+
at construction, which is what makes first-match-wins safe).
25+
recovery: The single field verify/reset/change is delivered against, or
26+
``None`` to disable recovery (the recovery endpoints aren't mounted).
27+
Must be a unique column when set.
28+
29+
Example:
30+
```python
31+
# username login, phone recovery
32+
CRUDAuth(..., identity=IdentityConfig(login=["username"], recovery="phone"))
33+
34+
# anonymous: username login, no recovery at all
35+
CRUDAuth(..., identity=IdentityConfig(login=["username"], recovery=None))
36+
```
37+
"""
38+
39+
login: list[str] = field(default_factory=lambda: ["email", "username"])
40+
recovery: str | None = "email"

crudauth/models/mixin.py

Lines changed: 121 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,144 @@
1-
"""The [AuthUserMixin][crudauth.models.mixin.AuthUserMixin] - inherit it and get every column crudauth needs.
2-
3-
Your own columns coexist freely. If you have an existing table with different
4-
column names, don't rename your schema - map the contract via
5-
``column_map=`` on [CRUDAuth][crudauth.crud_auth.CRUDAuth] instead.
1+
"""The auth user-model columns, emitted by [make_auth_identity]
2+
[crudauth.models.mixin.make_auth_identity].
3+
4+
Inherit [AuthUserMixin][crudauth.models.mixin.AuthUserMixin] (the default output)
5+
and get every column crudauth needs. Your own columns coexist freely. For a
6+
different account shape (username-only login, phone recovery, no recovery at all)
7+
call the factory yourself. If you have an existing table with different column
8+
names, don't rename your schema - map the contract via ``column_map=`` on
9+
[CRUDAuth][crudauth.crud_auth.CRUDAuth] instead.
610
"""
711

812
from __future__ import annotations
913

1014
from datetime import datetime, timezone
15+
from typing import Any, Iterable
1116

1217
from sqlalchemy import DateTime, String
1318
from sqlalchemy.orm import Mapped, mapped_column
1419

15-
__all__ = ["AuthUserMixin"]
20+
__all__ = ["AuthUserMixin", "make_auth_identity"]
1621

1722

1823
def _utcnow() -> datetime:
1924
return datetime.now(timezone.utc)
2025

2126

22-
class AuthUserMixin:
23-
"""Declarative mixin supplying the full set of columns crudauth relies on.
24-
25-
Note:
26-
``token_version`` is a monotonic credential epoch: bearer tokens embed it
27-
as the ``ver`` claim and a password reset bumps it, so a reset rejects
28-
every outstanding bearer token. See
29-
[BearerTransport][crudauth.transports.bearer.transport.BearerTransport].
27+
def make_auth_identity(
28+
*,
29+
identifiers: Iterable[str] = ("email", "username"),
30+
recovery: str | None = "email",
31+
oauth: bool = True,
32+
) -> type[Any]:
33+
"""Build a declarative mixin carrying crudauth's user columns for one account shape.
34+
35+
The model is the source of truth for shape: this emits the columns, and
36+
[CRUDAuth][crudauth.crud_auth.CRUDAuth] reads them back at construction, so the
37+
two can't disagree. ``username``, ``hashed_password``, the status flags,
38+
``token_version``, and the timestamps are always emitted; ``email`` and the
39+
oauth-linkage columns are conditional.
40+
41+
Args:
42+
identifiers: Logical fields a user may log in with. Each becomes a
43+
``NOT NULL`` unique column. ``email`` here makes the email column
44+
required.
45+
recovery: The single field recovery (verify/reset/change) is delivered
46+
against, or ``None`` for an account shape with no recovery. ``"email"``
47+
(and not an identifier) makes the email column nullable but unique.
48+
oauth: Emit the oauth-linkage columns (``oauth_provider`` / ``google_id`` /
49+
``github_id`` / timestamps). Required for OAuth login.
50+
51+
Returns:
52+
A declarative mixin class. Inherit it on your model alongside ``Base``.
3053
3154
Example:
3255
```python
56+
# the default - email + username login, email recovery (today's shape)
3357
class User(Base, AuthUserMixin):
3458
__tablename__ = "users"
35-
full_name: Mapped[str | None] = mapped_column(default=None)
59+
60+
# username-only login, phone recovery
61+
Identity = make_auth_identity(identifiers=["username"], recovery="phone")
62+
class User(Base, Identity):
63+
__tablename__ = "users"
64+
phone: Mapped[str | None] = mapped_column(unique=True, default=None)
3665
```
3766
"""
38-
39-
# --- core identity -------------------------------------------------------
40-
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
41-
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
42-
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
43-
hashed_password: Mapped[str] = mapped_column(String(255))
44-
45-
# --- status flags --------------------------------------------------------
46-
is_active: Mapped[bool] = mapped_column(default=True)
47-
is_superuser: Mapped[bool] = mapped_column(default=False)
48-
email_verified: Mapped[bool] = mapped_column(default=False)
49-
50-
token_version: Mapped[int] = mapped_column(default=0)
51-
52-
# --- oauth linkage -------------------------------------------------------
53-
oauth_provider: Mapped[str | None] = mapped_column(String(32), default=None)
54-
google_id: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, default=None)
55-
github_id: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, default=None)
56-
oauth_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
57-
oauth_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
58-
59-
# --- timestamps ----------------------------------------------------------
60-
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
61-
updated_at: Mapped[datetime | None] = mapped_column(
62-
DateTime(timezone=True), default=None, onupdate=_utcnow
67+
ns: dict[str, Any] = {}
68+
ann: dict[str, Any] = {}
69+
70+
def add(name: str, annotation: Any, column: Any) -> None:
71+
ann[name] = annotation
72+
ns[name] = column
73+
74+
identifier_set = set(identifiers)
75+
email_is_identifier = "email" in identifier_set
76+
email_is_recovery = recovery == "email"
77+
78+
add("id", Mapped[int], mapped_column(primary_key=True, autoincrement=True))
79+
if email_is_identifier:
80+
add("email", Mapped[str], mapped_column(String(320), unique=True, index=True))
81+
elif email_is_recovery:
82+
add(
83+
"email",
84+
Mapped[str | None],
85+
mapped_column(String(320), unique=True, index=True, default=None),
86+
)
87+
add("username", Mapped[str], mapped_column(String(64), unique=True, index=True))
88+
add("hashed_password", Mapped[str], mapped_column(String(255)))
89+
90+
add("is_active", Mapped[bool], mapped_column(default=True))
91+
add("is_superuser", Mapped[bool], mapped_column(default=False))
92+
add("email_verified", Mapped[bool], mapped_column(default=False))
93+
add("token_version", Mapped[int], mapped_column(default=0))
94+
95+
if oauth:
96+
add("oauth_provider", Mapped[str | None], mapped_column(String(32), default=None))
97+
add(
98+
"google_id",
99+
Mapped[str | None],
100+
mapped_column(String(64), unique=True, index=True, default=None),
101+
)
102+
add(
103+
"github_id",
104+
Mapped[str | None],
105+
mapped_column(String(64), unique=True, index=True, default=None),
106+
)
107+
add(
108+
"oauth_created_at",
109+
Mapped[datetime | None],
110+
mapped_column(DateTime(timezone=True), default=None),
111+
)
112+
add(
113+
"oauth_updated_at",
114+
Mapped[datetime | None],
115+
mapped_column(DateTime(timezone=True), default=None),
116+
)
117+
118+
add("created_at", Mapped[datetime], mapped_column(DateTime(timezone=True), default=_utcnow))
119+
add(
120+
"updated_at",
121+
Mapped[datetime | None],
122+
mapped_column(DateTime(timezone=True), default=None, onupdate=_utcnow),
63123
)
124+
125+
ns["__annotations__"] = ann
126+
return type("AuthUserMixin", (), ns)
127+
128+
129+
AuthUserMixin = make_auth_identity()
130+
"""Default identity columns: email + username login, email recovery, oauth enabled.
131+
132+
Inherit this for the standard account shape (it is exactly
133+
``make_auth_identity()``):
134+
135+
```python
136+
class User(Base, AuthUserMixin):
137+
__tablename__ = "users"
138+
full_name: Mapped[str | None] = mapped_column(default=None)
139+
```
140+
141+
``token_version`` is a monotonic credential epoch: bearer tokens embed it as the
142+
``ver`` claim and a password reset bumps it, so a reset rejects every outstanding
143+
bearer token.
144+
"""

crudauth/provisioning.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,13 @@ class NewUserContext:
6363

6464
@property
6565
def suggested_name(self) -> str:
66-
"""A display name to default to: the OAuth name if present, else the
67-
email local-part."""
66+
"""A display name to default to: the OAuth name, else the email local-part,
67+
else the username - so it stays useful across email and email-less shapes."""
6868
if self.oauth is not None and self.oauth.name:
6969
return self.oauth.name
70-
return self.email.split("@")[0]
70+
if self.email:
71+
return self.email.split("@")[0]
72+
return self.username
7173

7274

7375
NewUserFields = Callable[

0 commit comments

Comments
 (0)