dorm.contrib.auth¶
Optional User / Group / Permission models, framework-agnostic.
Provides only the data model + password-hashing helpers — login
views, sessions, permission decorators and middleware are the
framework's job.
Install with INSTALLED_APPS = ["dorm.contrib.auth", ...]. No extra
dependency: password hashing uses stdlib hashlib.pbkdf2_hmac.
Quick start¶
from dorm.contrib.auth.models import User, Group, Permission
user = User.objects.create_user(email="alice@example.com", password="hunter2")
user.check_password("hunter2") # True
user.set_password("new-password")
user.save()
# Permissions: explicit per user, or via Group membership.
publish = Permission.objects.create(name="Publish", codename="articles.publish")
editors = Group.objects.create(name="editors")
editors.permissions.add(publish)
user.groups.add(editors)
user.has_perm("articles.publish") # True
Models¶
dorm.contrib.auth.models.User
¶
Bases: Model
Application user.
The password column stores a salted PBKDF2 hash via
:func:dorm.contrib.auth.make_password. Never write the plain
string into user.password directly — use
:meth:set_password so the value gets hashed.
Fields mirror Django's AbstractUser shape so the
migrate-from-django tooling can map them 1:1.
set_password(raw_password: str | None) -> None
¶
Hash raw_password and store it. Pass None to mark the
account password-unusable (SSO-only / invitation flows).
has_perm(codename: str) -> bool
¶
True if the user has the named permission either directly
or via a group. Superusers short-circuit to True.
has_perm only checks model-level permissions; object-
level rules are your application's responsibility (e.g. "may
publish article 42 because they wrote it"). Use this for the
broad "may publish any article" gate.
ahas_perm(codename: str) -> bool
async
¶
Async counterpart of :meth:has_perm.
dorm.contrib.auth.models.Group
¶
Bases: Model
A named bundle of permissions. Users grant permissions transitively by joining a group.
dorm.contrib.auth.models.Permission
¶
dorm.contrib.auth.models.UserManager
¶
Bases: Manager
Helpers for creating users with a hashed password.
The plain Manager.create(...) writes whatever you pass — use
:meth:create_user instead so the password lands hashed.
Password hashing¶
Stdlib PBKDF2-SHA256 by default, format
pbkdf2_sha256$<iterations>$<salt>$<hash> — same shape Django
emits, so passwords migrate cleanly between the two ORMs.
dorm.contrib.auth.password.make_password(password: str | None, *, salt: str | None = None, iterations: int | None = None) -> str
¶
Encode password into the storable format.
None produces an unusable hash (starts with !) — the user
can't log in via password until set_password is called. Use
this for SSO-only accounts or invitation flows.
dorm.contrib.auth.password.check_password(password: str, encoded: str) -> bool
¶
Verify password against the encoded stored value.
Constant-time comparison via :func:hmac.compare_digest so a
timing-side-channel can't leak the stored hash bytes.
Unusable hashes (!-prefix) always return False. Non-string
inputs (None, int from a malformed JSON payload, …) are
rejected as a failed verification rather than crashing — this
is a security boundary, callers should never reach a
TypeError here.
dorm.contrib.auth.password.is_password_usable(encoded: str | None) -> bool
¶
True for hashes produced by :func:make_password with a
real password; False for the unusable sentinel set_password
(None) produces.
Argon2id (3.1+, opt-in)¶
Install the optional extra:
Then use make_password_argon2 in place of make_password. The
output is prefixed with argon2$ so check_password dispatches
by algorithm tag — both PBKDF2 and Argon2 hashes verify through
the same call:
from dorm.contrib.auth.password import (
make_password_argon2, check_password,
)
h = make_password_argon2("secret-pw") # "argon2$$argon2id$..."
assert check_password("secret-pw", h)
Argon2id is the current state of the art for password hashing (memory-hard, resistant to GPU / ASIC bruteforce). PBKDF2 stays the default because it ships in stdlib without a C extension.
Reset / verification tokens¶
Stateless HMAC-signed tokens for password-reset / email-verification
flows. The signature binds to the user's last_login / password
/ email, so a single use of the token (which changes the password)
invalidates every outstanding URL.
from dorm.contrib.auth.tokens import default_token_generator
token = default_token_generator.make_token(user)
# … embed in reset email URL …
if default_token_generator.check_token(user, posted_token):
user.set_password(new_password)
user.save() # Salt rolls — token invalidates automatically.
dorm.contrib.auth.tokens.PasswordResetTokenGenerator
¶
Stateless reset / verification tokens.
Construct one instance per use case and reuse it: thread-safe,
no state of its own. Different timeout values give different
expiry policies (15-min email-verification vs. 24h
password-reset vs. 7d "remember-this-device") without sharing
the secret across multiple namespaces.
make_token(user: Any) -> str
¶
Mint a reset token for user. Embed verbatim in a URL — no further escaping needed (the value is already URL-safe-base64).
check_token(user: Any, token: str | None) -> bool
¶
Verify token binds to user's current state and is
within the configured timeout. Constant-time
comparison; never raises on malformed input — returns
False.
dorm.contrib.auth.tokens.default_token_generator = PasswordResetTokenGenerator()
module-attribute
¶
dorm.contrib.auth.tokens.generate_short_lived_token(*, prefix: str = 'tok_') -> str
¶
Stateful equivalent: a random URL-safe token that the caller stores in a one-shot table (e.g. an email-verification table keyed by hashed token + expiry). Use when stateless HMAC isn't a fit (e.g. when you need a one-row revoke list).
Meta.permissions sync¶
Custom permissions declared on a model via Meta.permissions
materialise into auth_permission rows when you call
:func:sync_permissions. Default verbs (add_x, change_x,
delete_x, view_x) get auto-emitted per concrete model.
Idempotent — safe to call every deploy.
class Article(dorm.Model):
class Meta:
permissions = [
("articles.publish", "Can publish articles"),
("articles.archive", "Can archive articles"),
]
# Run once after migrate (or wire into a deploy hook):
from dorm.contrib.auth.management import sync_permissions
sync_permissions() # → ints, count of new rows created
dorm.contrib.auth.management.sync_permissions(*, registry: dict | None = None) -> int
¶
Walk every model in registry (defaults to the global model
registry) and ensure a Permission row exists for every
default verb plus every entry in Meta.permissions.
Returns the number of new Permission rows created.
Idempotent: existing rows are left untouched. Stale permissions (codename no longer declared) are NOT removed — that's a separate cleanup step the operator runs manually, since revoking a permission may break user assignments.