Skip to content

Commit 23992ac

Browse files
authored
feat: PasswordHash field type (#452)
Implements a PasswordHash field type with multiple supported backends. Includes built-in backends for: - `passlib` - `argon2` - `pwdlib`
1 parent 27f6f7e commit 23992ac

File tree

18 files changed

+3613
-2954
lines changed

18 files changed

+3613
-2954
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ offering:
3939
- Optimized JSON types including a custom JSON type for Oracle
4040
- Integrated support for UUID6 and UUID7 using [`uuid-utils`](https://github.com/aminalaee/uuid-utils) (install with the `uuid` extra)
4141
- Integrated support for Nano ID using [`fastnanoid`](https://github.com/oliverlambson/fastnanoid) (install with the `nanoid` extra)
42+
- Custom encrypted text type with multiple backend support including [`pgcrypto`](https://www.postgresql.org/docs/current/pgcrypto.html) for PostgreSQL and the Fernet implementation from [`cryptography`](https://cryptography.io/en/latest/) for other databases
43+
- Custom password hashing type with multiple backend support including [`Argon2`](https://github.com/P-H-C/phc-winner-argon2), [`Passlib`](https://passlib.readthedocs.io/en/stable/), and [`Pwdlib`](https://pwdlib.readthedocs.io/en/stable/) with automatic salt generation
4244
- Pre-configured base classes with audit columns UUID or Big Integer primary keys and
4345
a [sentinel column](https://docs.sqlalchemy.org/en/20/core/connections.html#configuring-sentinel-columns).
4446
- Synchronous and asynchronous repositories featuring:

advanced_alchemy/alembic/templates/asyncio/script.py.mako

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
1111

1212
import sqlalchemy as sa
1313
from alembic import op
14-
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject
14+
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash
1515
from sqlalchemy import Text # noqa: F401
1616
${imports if imports else ""}
1717
if TYPE_CHECKING:

advanced_alchemy/alembic/templates/sync/script.py.mako

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
1111

1212
import sqlalchemy as sa
1313
from alembic import op
14-
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject
14+
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash
1515
from sqlalchemy import Text # noqa: F401
1616
${imports if imports else ""}
1717
if TYPE_CHECKING:

advanced_alchemy/types/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
"""SQLAlchemy custom types for use with the ORM."""
22

3-
from advanced_alchemy.types import file_object
3+
from advanced_alchemy.types import encrypted_string, file_object, password_hash
44
from advanced_alchemy.types.datetime import DateTimeUTC
55
from advanced_alchemy.types.encrypted_string import (
66
EncryptedString,
77
EncryptedText,
88
EncryptionBackend,
99
FernetBackend,
10-
PGCryptoBackend,
1110
)
1211
from advanced_alchemy.types.file_object import (
1312
FileObject,
@@ -22,6 +21,7 @@
2221
from advanced_alchemy.types.identity import BigIntIdentity
2322
from advanced_alchemy.types.json import ORA_JSONB, JsonB
2423
from advanced_alchemy.types.mutables import MutableList
24+
from advanced_alchemy.types.password_hash.base import HashedPassword, PasswordHash
2525

2626
__all__ = (
2727
"GUID",
@@ -36,13 +36,16 @@
3636
"FernetBackend",
3737
"FileObject",
3838
"FileObjectList",
39+
"HashedPassword",
3940
"JsonB",
4041
"MutableList",
41-
"PGCryptoBackend",
42+
"PasswordHash",
4243
"StorageBackend",
4344
"StorageBackendT",
4445
"StorageRegistry",
4546
"StoredObject",
47+
"encrypted_string",
4648
"file_object",
49+
"password_hash",
4750
"storages",
4851
)

advanced_alchemy/types/password_hash/__init__.py

Whitespace-only changes.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Argon2 Hashing Backend using argon2-cffi."""
2+
3+
from typing import TYPE_CHECKING, Any, Union
4+
5+
from advanced_alchemy.types.password_hash.base import HashingBackend
6+
7+
if TYPE_CHECKING:
8+
from sqlalchemy import BinaryExpression, ColumnElement
9+
10+
from argon2 import PasswordHasher as Argon2PasswordHasher # pyright: ignore
11+
from argon2.exceptions import InvalidHash, VerifyMismatchError # pyright: ignore
12+
13+
14+
class Argon2Hasher(HashingBackend):
15+
"""Hashing backend using Argon2 via the argon2-cffi library."""
16+
17+
def __init__(self, **kwargs: Any) -> None:
18+
"""Initialize Argon2Backend.
19+
20+
Args:
21+
**kwargs: Optional keyword arguments to pass to the argon2.PasswordHasher constructor.
22+
See argon2-cffi documentation for available parameters (e.g., time_cost,
23+
memory_cost, parallelism, hash_len, salt_len, type).
24+
"""
25+
self.hasher = Argon2PasswordHasher(**kwargs) # pyright: ignore
26+
27+
def hash(self, value: "Union[str, bytes]") -> str:
28+
"""Hash the password using Argon2.
29+
30+
Args:
31+
value: The plain text password (will be encoded to UTF-8 if string).
32+
33+
Returns:
34+
The Argon2 hash string.
35+
"""
36+
return self.hasher.hash(self._ensure_bytes(value))
37+
38+
def verify(self, plain: "Union[str, bytes]", hashed: str) -> bool:
39+
"""Verify a plain text password against an Argon2 hash.
40+
41+
Args:
42+
plain: The plain text password (will be encoded to UTF-8 if string).
43+
hashed: The Argon2 hash string to verify against.
44+
45+
Returns:
46+
True if the password matches the hash, False otherwise.
47+
"""
48+
try:
49+
self.hasher.verify(hashed, self._ensure_bytes(plain))
50+
51+
except (VerifyMismatchError, InvalidHash):
52+
return False
53+
except Exception: # noqa: BLE001
54+
return False
55+
return True
56+
57+
def compare_expression(self, column: "ColumnElement[str]", plain: "Union[str, bytes]") -> "BinaryExpression[bool]":
58+
"""Direct SQL comparison is not supported for Argon2.
59+
60+
Raises:
61+
NotImplementedError: Always raised.
62+
"""
63+
msg = "Argon2Hasher does not support direct SQL comparison."
64+
raise NotImplementedError(msg)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Base classes for password hashing backends."""
2+
3+
import abc
4+
from typing import Any, Union, cast
5+
6+
from sqlalchemy import BinaryExpression, ColumnElement, FunctionElement, String, TypeDecorator
7+
8+
9+
class HashingBackend(abc.ABC):
10+
"""Abstract base class for password hashing backends.
11+
12+
This class defines the interface that all password hashing backends must implement.
13+
Concrete implementations should provide the actual hashing and verification logic.
14+
"""
15+
16+
@staticmethod
17+
def _ensure_bytes(value: Union[str, bytes]) -> bytes:
18+
if isinstance(value, str):
19+
return value.encode("utf-8")
20+
return value
21+
22+
@abc.abstractmethod
23+
def hash(self, value: "Union[str, bytes]") -> "Union[str, Any]":
24+
"""Hash the given value.
25+
26+
Args:
27+
value: The plain text value to hash.
28+
29+
Returns:
30+
Either a string (the hash) or a SQLAlchemy SQL expression for DB-side hashing.
31+
"""
32+
33+
@abc.abstractmethod
34+
def verify(self, plain: "Union[str, bytes]", hashed: str) -> bool:
35+
"""Verify a plain text value against a hash.
36+
37+
Args:
38+
plain: The plain text value to verify.
39+
hashed: The hash to verify against.
40+
41+
Returns:
42+
True if the plain text matches the hash, False otherwise.
43+
"""
44+
45+
@abc.abstractmethod
46+
def compare_expression(self, column: "ColumnElement[str]", plain: "Union[str, bytes]") -> "BinaryExpression[bool]":
47+
"""Generate a SQLAlchemy expression for comparing a column with a plain text value.
48+
49+
Args:
50+
column: The SQLAlchemy column to compare.
51+
plain: The plain text value to compare against.
52+
53+
Returns:
54+
A SQLAlchemy binary expression for the comparison.
55+
"""
56+
57+
58+
class HashedPassword:
59+
"""Wrapper class for a hashed password.
60+
61+
This class holds the hash string and provides a method to verify a plain text password against it.
62+
"""
63+
64+
def __hash__(self) -> int:
65+
return hash(self.hash_string)
66+
67+
def __init__(self, hash_string: str, backend: "HashingBackend") -> None:
68+
"""Initialize a HashedPassword object.
69+
70+
Args:
71+
hash_string: The hash string.
72+
backend: The hashing backend to use for verification.
73+
"""
74+
self.hash_string = hash_string
75+
self.backend = backend
76+
77+
def verify(self, plain_password: "Union[str, bytes]") -> bool:
78+
"""Verify a plain text password against this hash.
79+
80+
Args:
81+
plain_password: The plain text password to verify.
82+
83+
Returns:
84+
True if the password matches the hash, False otherwise.
85+
"""
86+
return self.backend.verify(plain_password, self.hash_string)
87+
88+
89+
class PasswordHash(TypeDecorator[str]):
90+
"""SQLAlchemy TypeDecorator for storing hashed passwords in a database.
91+
92+
This type provides transparent hashing of password values using the specified backend.
93+
It extends :class:`sqlalchemy.types.TypeDecorator` and implements String as its underlying type.
94+
"""
95+
96+
impl = String
97+
cache_ok = True
98+
99+
def __init__(self, backend: "HashingBackend", length: int = 128) -> None:
100+
"""Initialize the PasswordHash TypeDecorator.
101+
102+
Args:
103+
backend: The hashing backend class to use
104+
length: The maximum length of the hash string. Defaults to 128.
105+
"""
106+
self.length = length
107+
super().__init__(length=length)
108+
self.backend = backend
109+
110+
@property
111+
def python_type(self) -> "type[str]":
112+
"""Returns the Python type for this type decorator.
113+
114+
Returns:
115+
The Python string type.
116+
"""
117+
return str
118+
119+
def process_bind_param(self, value: Any, dialect: Any) -> "Union[str, FunctionElement[str], None]":
120+
"""Process the value before binding it to the SQL statement.
121+
122+
This method hashes the value using the specified backend.
123+
If the backend returns a SQLAlchemy FunctionElement (for DB-side hashing),
124+
it is returned directly. Otherwise, the hashed string is returned.
125+
126+
Args:
127+
value: The value to process.
128+
dialect: The SQLAlchemy dialect.
129+
130+
Returns:
131+
The hashed string, a SQLAlchemy FunctionElement, or None.
132+
"""
133+
if value is None:
134+
return value
135+
136+
hashed_value = self.backend.hash(value)
137+
138+
# Check if the backend returned a SQL function for DB-side hashing
139+
if isinstance(hashed_value, FunctionElement):
140+
return cast("FunctionElement[str]", hashed_value)
141+
142+
# Otherwise, assume it's a string or HashedPassword object (convert to string)
143+
return str(hashed_value)
144+
145+
def process_result_value(self, value: Any, dialect: Any) -> "Union[HashedPassword, None]": # type: ignore[override]
146+
"""Process the value after retrieving it from the database.
147+
148+
This method wraps the hash string in a HashedPassword object.
149+
150+
Args:
151+
value: The value to process.
152+
dialect: The SQLAlchemy dialect.
153+
154+
Returns:
155+
A HashedPassword object or None if the input is None.
156+
"""
157+
if value is None:
158+
return value
159+
# Ensure the retrieved value is a string before passing to HashedPassword
160+
return HashedPassword(str(value), self.backend)
161+
162+
def compare_value(
163+
self, column: "ColumnElement[str]", plain_password: "Union[str, bytes]"
164+
) -> "BinaryExpression[bool]":
165+
"""Generate a SQLAlchemy expression for comparing a column with a plain text password.
166+
167+
Args:
168+
column: The SQLAlchemy column to compare.
169+
plain_password: The plain text password to compare against.
170+
171+
Returns:
172+
A SQLAlchemy binary expression for the comparison.
173+
"""
174+
return self.backend.compare_expression(column, plain_password)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Passlib Hashing Backend."""
2+
3+
from typing import TYPE_CHECKING, Any, Union
4+
5+
from passlib.context import CryptContext # pyright: ignore
6+
7+
from advanced_alchemy.types.password_hash.base import HashingBackend
8+
9+
if TYPE_CHECKING:
10+
from sqlalchemy import BinaryExpression, ColumnElement
11+
12+
13+
class PasslibHasher(HashingBackend):
14+
"""Hashing backend using Passlib.
15+
16+
Relies on the `passlib` package being installed.
17+
Install with `pip install passlib` or `uv pip install passlib`.
18+
"""
19+
20+
def __init__(self, context: CryptContext) -> None:
21+
"""Initialize PasslibBackend.
22+
23+
Args:
24+
context: The Passlib CryptContext to use for hashing and verification.
25+
"""
26+
self.context = context
27+
28+
def hash(self, value: "Union[str, bytes]") -> str:
29+
"""Hash the given value using the Passlib context.
30+
31+
Args:
32+
value: The plain text value to hash. Will be converted to string.
33+
34+
Returns:
35+
The hashed string.
36+
"""
37+
return self.context.hash(self._ensure_bytes(value))
38+
39+
def verify(self, plain: "Union[str, bytes]", hashed: str) -> bool:
40+
"""Verify a plain text value against a hash using the Passlib context.
41+
42+
Args:
43+
plain: The plain text value to verify. Will be converted to string.
44+
hashed: The hash to verify against.
45+
46+
Returns:
47+
True if the plain text matches the hash, False otherwise.
48+
"""
49+
try:
50+
return self.context.verify(self._ensure_bytes(plain), hashed)
51+
except Exception: # noqa: BLE001
52+
# Passlib can raise various errors for invalid hashes
53+
return False
54+
55+
def compare_expression(self, column: "ColumnElement[str]", plain: Any) -> "BinaryExpression[bool]":
56+
"""Direct SQL comparison is not supported for Passlib.
57+
58+
Raises:
59+
NotImplementedError: Always raised.
60+
"""
61+
msg = "PasslibHasher does not support direct SQL comparison."
62+
raise NotImplementedError(msg)

0 commit comments

Comments
 (0)