Skip to content

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

Bases: Model

A single named permission (e.g. "articles.publish").

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:

pip install "djanorm[auth-argon2]"

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.