Skip to content

Passwords & Tokens

Next to permissions passwords & tokens are essential to restrict the access of users. For such patterns there is a primititive named PasswordField.

PasswordField

We have a field named PasswordField. It contains an interfacing parameter for a password hasher named derive_fn, which mangles a user provided password.

import edgy
import secrets
from contextlib import suppress

hasher = Hasher()


class User(edgy.Model):
    pw: str = edgy.PasswordField(null=False, derive_fn=hasher.derive)
    token: str = edgy.PasswordField(null=False, default=secrets.token_hex)
    ...


# we can check if the pw matches by providing a tuple
with suppress(Exception):
    # oops, doesn't work
    obj = await User.query.create(pw=("foobar", "notfoobar"))
obj = await User.query.create(pw=("foobar", "foobar"))
# now let's check the pw
hasher.compare_pw(obj.pw, "foobar")
# now let's compare the token safely
secrets.compare_digest(obj.token, "<token>")

As also seen in the example when not providing a derive_fn the password is not mangled. This is quite useful for tokens.

By default the PasswordFields are secret and are hidden when using exclude_secrets. You can overwrite the behavior by providing an explicit secret parameter.

Passwords

Integration

Despite edgy has no inbuilt password hasher it provides an easy to use interface for the integration of thirdparty libraries doing password hashing.

Two good libraries are passlib (general including argon2) and argon2id-cffi (only argon2 family).

Validation during creation

A common pattern is to check if the user is able to provide the password 2 times. This can be automatized via providing a tuple of two string elements. If they match the check is assumed to be successful and the password processing is continued otherwise an exception is raised.

Sometimes the password should be checked again before mangling. This can be done via the <fieldname>_original attribute. Despite it is a field it is excluded from serialization and has no column, so the password stays secure. This field is added by default when providing the derive_fn parameter but can be explicitly set via keep_original. There is one limitation: after a successful load, insert or update, this includes a successful save, the pseudo-field is blanked with None. This means a different flow has to be used:

Realistic example with password retry
import edgy
from contextlib import suppress

from passlib.context import CryptContext
from pydantic import ValidationError

pwd_context = CryptContext(
    # Replace this list with the hash(es) you wish to support.
    schemes=["pbkdf2_sha256"],
    deprecated="auto",
)


class User(edgy.Model):
    name: str = edgy.CharField(max_length=255)
    # derive_fn implies the original password is saved in <field>_original
    pw: str = edgy.PasswordField(null=False, derive_fn=pwd_context.hash)
    ...

    @model_validator(mode="after")
    def other_validation_check(self) -> str:
        if getattr(self, "pw_original", None) is not None and self.pw_original == "":
            raise ValueError("must not be empty")
        return self


def validate_password_strength(password: str) -> None:
    if len(password) < 10:
        raise ValueError()


async def create_user(pw1, pw2):
    user = User(name="edgy", pw=(pw1, pw2))

    try:
        # operate on the non-hashed original
        validate_password_strength(user.pw_original)
        return True, await model.save()
    except Exception:
        # ooops something went wrong, we want to show the user his password again, unhashed
        model.__dict__["pw"] = model.pw_original
        return False, model


with suppress(ValidationError):
    # model validator fails
    edgy.run_sync(create_user("", ""))

# pw strength fails
success, halfinitialized_model = edgy.run_sync(create_user("foobar", "foobar"))

# works
user = edgy.run_sync(
    create_user(halfinitialized_model.pw + "12345678", halfinitialized_model.pw + "12345678")
)

# comparing
pwd_context.verify("pw", user.pw)

Tokens

The PasswordField is despite its name quite handy for tokens. The default for secret removes the token automatically from queries with exclude_secrets set and by providing a callable default it is easy to autogenerate tokens.

import edgy
import secrets
from contextlib import suppress


class Computer(edgy.Model):
    token: str = edgy.PasswordField(null=False, default=secrets.token_hex)
    ...


obj = await Computer.query.create()
# now let's compare the token safely
secrets.compare_digest(obj.token, "<token>")

However tokens should be checked by using a cryptographic safe compare method. Otherwise it is possible to open a side-channel attack by comparing timings.