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:
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.