├── .editorconfig ├── .gitignore ├── README.md ├── async_django_user ├── aiohttp.py ├── asyncpg.py ├── base_backend.py ├── databases.py ├── hashers.py ├── starlette.py ├── user.py └── utils.py ├── examples ├── aiohttp_app.py ├── cfg.py ├── django_app.py ├── index.html ├── requirements.txt └── starlette_app.py ├── pyproject.toml └── setup.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | __pycache__/ 3 | /*.egg-info 4 | /dist 5 | /build 6 | /.cache 7 | /.pytest_cache 8 | /.tests.sqlite 9 | /.venv 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Async Django User 2 | ================= 3 | 4 | Using [django][] user with async frameworks like [aiohttp][], [starlette][] etc. 5 | 6 | pip install async-django-session async-django-user 7 | 8 | tl;dr 9 | ----- 10 | Take a look at registration / authorization examples for 11 | [aiohttp + databases][aiohttp example] 12 | or [starlette + asyncpg][starlette example]. 13 | 14 | API 15 | --- 16 | 17 | ### Backends 18 | 19 | There's two ways of communicating to database available: 20 | 21 | - through [databases][] - which is compatible with most of major RDBMS: 22 | ```python 23 | database = databases.Database(DB_URI) 24 | await database.connect() 25 | backend = async_django_user.databases.Backend(database, SECRET_KEY) 26 | ``` 27 | - or directly through [asyncpg][] (PostgreSQL only): 28 | ```python 29 | pool = await asyncpg.create_pool(DB_URI) 30 | backend = async_django_user.asyncpg.Backend(pool, SECRET_KEY) 31 | ``` 32 | 33 | ### User 34 | 35 | To fetch an user from db by its id stored in [django session] there's 36 | `backend.get_user_from_session` method: 37 | ```python 38 | user = backend.get_user_from_session(session) 39 | ``` 40 | It's lazy so the user data won't be actually fetched until you call its 41 | `load` method. It caches the result, so it's inexpensive to call it multiple 42 | times: 43 | ```python 44 | await user.load() 45 | ``` 46 | 47 | User provides dict interface to it's data (eg `user["username"]`) and a few 48 | methods: 49 | - `await user.authenticate(username, password)` - checks credentials and populates 50 | the user from database if they're valid 51 | - `user.login()` - sets session variables logging the user in 52 | - `user.logout()` - clears the session data 53 | - `await user.set_password(password)` - sets a new password for the user 54 | - `await user.save([fields])` - saves the whole user or a particular set of its 55 | fields 56 | - `await register()` - saves a new user into db 57 | 58 | Frameworks integration 59 | ---------------------- 60 | There's built-in middlewares for a few async frameworks to automatically load 61 | user of the current request. Take a look at [examples][] folder for: 62 | - [aiohttp example][] with [databases backend][] 63 | - [starlette example][] with [asyncpg backend][] 64 | 65 | 66 | Running examples 67 | ---------------- 68 | Running the [examples][] you can see different frameworks using the same session 69 | and user data. 70 | 71 | Install the requirements: 72 | 73 | cd examples 74 | pip install -r requirements.txt 75 | 76 | Create database and tables: 77 | 78 | createdb async_django_session 79 | python django_app.py migrate 80 | 81 | Create a user: 82 | 83 | python django_app.py createsuperuser 84 | 85 | Run [aiohttp example][] which uses [databases backend][]: 86 | 87 | python aiohttp_app.py 88 | 89 | Run [starlette example][] which uses [asyncpg backend][]: 90 | 91 | python starlette_app.py 92 | 93 | Run [django example][]: 94 | 95 | python django_app.py runserver 96 | 97 | [aiohttp]: https://github.com/aio-libs/aiohttp 98 | [starlette]: https://github.com/encode/starlette 99 | [asyncpg]: https://github.com/MagicStack/asyncpg 100 | [databases]: https://github.com/encode/databases 101 | [django]: https://github.com/django/django 102 | [examples]: https://github.com/imbolc/async-django-user/tree/master/examples 103 | [django example]: https://github.com/imbolc/async-django-user/tree/master/examples/django_app.py 104 | [starlette example]: https://github.com/imbolc/async-django-user/tree/master/examples/starlette_app.py 105 | [aiohttp example]: https://github.com/imbolc/async-django-user/tree/master/examples/aiohttp_app.py 106 | [asyncpg backend]: https://github.com/imbolc/async-django-user/tree/master/async-django-user/asyncpg.py 107 | [databases backend]: https://github.com/imbolc/async-django-user/tree/master/async-django-user/databases.py 108 | [aiohttp middleware]: https://github.com/imbolc/async-django-user/tree/master/async-django-user/aiohttp.py 109 | [starlette middleware]: https://github.com/imbolc/async-django-user/tree/master/async-django-user/starlette.py 110 | -------------------------------------------------------------------------------- /async_django_user/aiohttp.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | 4 | def middleware(backend): 5 | @web.middleware 6 | async def django_user(request, handler): 7 | session = await request.get_session() 8 | user = backend.get_user_from_session(session) 9 | request.get_user = user.load 10 | response = await handler(request) 11 | return response 12 | 13 | return django_user 14 | -------------------------------------------------------------------------------- /async_django_user/asyncpg.py: -------------------------------------------------------------------------------- 1 | from .base_backend import BaseBackend 2 | 3 | 4 | class Backend(BaseBackend): 5 | def __init__(self, pool, *args, **kwargs): 6 | self.pool = pool 7 | super().__init__(*args, **kwargs) 8 | 9 | async def find_one(self, **filters): 10 | sql, params = select_sql(self.users_table, filters) 11 | async with self.pool.acquire() as con: 12 | return await con.fetchrow(sql, *params) 13 | 14 | async def update_by_id(self, id, **changes): 15 | sql, params = update_by_id_sql(self.users_table, id, changes) 16 | async with self.pool.acquire() as con: 17 | return await con.execute(sql, *params) 18 | 19 | async def insert(self, **fields): 20 | sql, params = insert_sql(self.users_table, fields) 21 | async with self.pool.acquire() as con: 22 | return await con.fetchval(sql, *params) 23 | 24 | 25 | def select_sql(table: str, filters: dict) -> (str, list): 26 | """ 27 | >>> select_sql('tbl', {'foo': 1, 'bar': 2}) 28 | ('SELECT * FROM tbl WHERE foo=$1 AND bar=$2', [1, 2]) 29 | """ 30 | where = " AND ".join(f"{k}=${i}" for i, k in enumerate(filters.keys(), 1)) 31 | sql = f"SELECT * FROM {table} WHERE {where}" 32 | return sql, list(filters.values()) 33 | 34 | 35 | def update_by_id_sql(table: str, id: int, changes: dict) -> (str, list): 36 | """ 37 | >>> update_by_id_sql('tbl', 1, {'foo': 2, 'bar': 3}) 38 | ('UPDATE tbl SET foo=$2, bar=$3 WHERE id=$1', [1, 2, 3]) 39 | """ 40 | sets = ", ".join(f"{k}=${i}" for i, k in enumerate(changes.keys(), 2)) 41 | sql = f"UPDATE {table} SET {sets} WHERE id=$1" 42 | return sql, [id] + list(changes.values()) 43 | 44 | 45 | def insert_sql(table: str, fields: dict) -> (str, list): 46 | """ 47 | >>> insert_sql('tbl', {'foo': 'bar', 'id': 1}) 48 | ('INSERT INTO tbl (foo, id) VALUES ($1, $2) RETURNING id', ['bar', 1]) 49 | """ 50 | keys = ", ".join(fields.keys()) 51 | placeholders = ", ".join(f"${i}" for i in range(1, len(fields) + 1)) 52 | sql = f"INSERT INTO {table} ({keys}) VALUES ({placeholders}) RETURNING id" 53 | return sql, list(fields.values()) 54 | -------------------------------------------------------------------------------- /async_django_user/base_backend.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha1 2 | 3 | from .user import User 4 | from . import hashers 5 | 6 | 7 | class BaseBackend: 8 | def __init__( 9 | self, 10 | secret, 11 | *, 12 | password_hashers=[ 13 | hashers.PBKDF2PasswordHasher, 14 | hashers.PBKDF2SHA1PasswordHasher, 15 | hashers.Argon2PasswordHasher, 16 | hashers.BCryptSHA256PasswordHasher, 17 | ], 18 | session_backend_key="_auth_user_backend", 19 | session_backend_val="django.contrib.auth.backends.ModelBackend", 20 | session_hash_key="_auth_user_hash", 21 | session_hash_salt="django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash", # noqa 22 | session_id_key="_auth_user_id", 23 | username_field="username", 24 | users_table="auth_user", 25 | ): 26 | self.secret = secret 27 | self.session_backend_key = session_backend_key 28 | self.session_backend_val = session_backend_val 29 | self.session_hash_key = session_hash_key 30 | self.session_id_key = session_id_key 31 | self.salted_secret = sha1( 32 | (session_hash_salt + secret).encode("utf-8") 33 | ).digest() 34 | self.hashers = [cls() for cls in password_hashers] 35 | self.hashers_by_algorithm = {h.algorithm: h for h in self.hashers} 36 | self.username_field = username_field 37 | self.users_table = users_table 38 | 39 | def get_user_from_session(self, session): 40 | return User.get_from_session(self, session) 41 | 42 | def get_hasher(self, algorithm="default"): 43 | if hasattr(algorithm, "algorithm"): 44 | return algorithm 45 | elif algorithm == "default": 46 | return self.hashers[0] 47 | else: 48 | return self.hashers_by_algorithm[algorithm] 49 | 50 | def identify_hasher(self, encoded): 51 | algorithm = encoded.split("$", 1)[0] 52 | return self.get_hasher(algorithm) 53 | 54 | def check_password(self, raw, encoded) -> bool: 55 | hasher = self.identify_hasher(encoded) 56 | return hasher.verify(raw, encoded) 57 | -------------------------------------------------------------------------------- /async_django_user/databases.py: -------------------------------------------------------------------------------- 1 | from .base_backend import BaseBackend 2 | 3 | 4 | class Backend(BaseBackend): 5 | def __init__(self, db, *args, **kwargs): 6 | self.db = db 7 | super().__init__(*args, **kwargs) 8 | 9 | def find_one(self, **filters): 10 | sql = select_sql(self.users_table, filters) 11 | return self.db.fetch_one(sql, filters) 12 | 13 | async def update_by_id(self, id, **changes): 14 | return await self.db.execute( 15 | *update_by_id_sql(self.users_table, id, changes) 16 | ) 17 | 18 | def insert(self, **fields): 19 | sql = insert_sql(self.users_table, fields) 20 | return self.db.fetch_one(sql, fields) 21 | 22 | 23 | def select_sql(table: str, filters: dict) -> str: 24 | """ 25 | >>> select_sql('tbl', {'foo': 1, 'bar': 2}) 26 | 'SELECT * FROM tbl WHERE foo=:foo AND bar=:bar' 27 | """ 28 | where = " AND ".join(f"{k}=:{k}" for k in filters.keys()) 29 | sql = f"SELECT * FROM {table} WHERE {where}" 30 | return sql 31 | 32 | 33 | def update_by_id_sql(table: str, id: int, changes: dict) -> (str, list): 34 | """ 35 | >>> update_by_id_sql('tbl', 1, {'foo': 2, 'bar': 3}) 36 | ('UPDATE tbl SET foo=:foo, bar=:bar WHERE id=:id', {'id': 1, 'foo': 2, 'bar': 3}) 37 | """ # noqa 38 | sets = ", ".join(f"{k}=:{k}" for k in changes.keys()) 39 | sql = f"UPDATE {table} SET {sets} WHERE id=:id" 40 | values = {"id": id} 41 | values.update(changes) 42 | return sql, values 43 | 44 | 45 | def insert_sql(table: str, fields: dict) -> str: 46 | """ 47 | >>> insert_sql('tbl', {'foo': 1, 'bar': 2}) 48 | 'INSERT INTO tbl (foo, bar) VALUES (:foo, :bar) RETURNING id' 49 | """ 50 | keys = ", ".join(fields.keys()) 51 | vals = ", ".join(f":{k}" for k in fields.keys()) 52 | return f"INSERT INTO {table} ({keys}) VALUES ({vals}) RETURNING id" 53 | -------------------------------------------------------------------------------- /async_django_user/hashers.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import hashlib 3 | import importlib 4 | import warnings 5 | from .utils import get_random_string 6 | import base64 7 | import binascii 8 | 9 | 10 | def pbkdf2(password, salt, iterations, dklen=0, digest=None): 11 | """Return the hash of password using pbkdf2.""" 12 | if digest is None: 13 | digest = hashlib.sha256 14 | dklen = dklen or None 15 | password = password.encode("utf-8") 16 | salt = salt.encode("utf-8") 17 | return hashlib.pbkdf2_hmac( 18 | digest().name, password, salt, iterations, dklen 19 | ) 20 | 21 | 22 | def constant_time_compare(val1, val2): 23 | """Return True if the two strings are equal, False otherwise.""" 24 | return secrets.compare_digest(val1, val2) 25 | 26 | 27 | def mask_hash(hash, show=6, char="*"): 28 | """ 29 | Return the given hash, with only the first ``show`` number shown. The 30 | rest are masked with ``char`` for security reasons. 31 | """ 32 | masked = hash[:show] 33 | masked += char * len(hash[show:]) 34 | return masked 35 | 36 | 37 | class BasePasswordHasher: 38 | """ 39 | Abstract base class for password hashers 40 | 41 | When creating your own hasher, you need to override algorithm, 42 | verify(), encode() and safe_summary(). 43 | 44 | PasswordHasher objects are immutable. 45 | """ 46 | 47 | algorithm = None 48 | library = None 49 | 50 | def _load_library(self): 51 | if self.library is not None: 52 | if isinstance(self.library, (tuple, list)): 53 | name, mod_path = self.library 54 | else: 55 | mod_path = self.library 56 | try: 57 | module = importlib.import_module(mod_path) 58 | except ImportError as e: 59 | raise ValueError( 60 | "Couldn't load %r algorithm library: %s" 61 | % (self.__class__.__name__, e) 62 | ) 63 | return module 64 | raise ValueError( 65 | "Hasher %r doesn't specify a library attribute" 66 | % self.__class__.__name__ 67 | ) 68 | 69 | def salt(self): 70 | """Generate a cryptographically secure nonce salt in ASCII.""" 71 | return get_random_string() 72 | 73 | def verify(self, password, encoded): 74 | """Check if the given password is correct.""" 75 | raise NotImplementedError( 76 | "subclasses of BasePasswordHasher must provide a verify() method" 77 | ) 78 | 79 | def encode(self, password, salt): 80 | """ 81 | Create an encoded database value. 82 | 83 | The result is normally formatted as "algorithm$salt$hash" and 84 | must be fewer than 128 characters. 85 | """ 86 | raise NotImplementedError( 87 | "subclasses of BasePasswordHasher must provide an encode() method" 88 | ) 89 | 90 | def safe_summary(self, encoded): 91 | """ 92 | Return a summary of safe values. 93 | 94 | The result is a dictionary and will be used where the password field 95 | must be displayed to construct a safe representation of the password. 96 | """ 97 | raise NotImplementedError( 98 | "subclasses of BasePasswordHasher must provide a safe_summary() method" # noqa 99 | ) 100 | 101 | def must_update(self, encoded): 102 | return False 103 | 104 | def harden_runtime(self, password, encoded): 105 | """ 106 | Bridge the runtime gap between the work factor supplied in `encoded` 107 | and the work factor suggested by this hasher. 108 | 109 | Taking PBKDF2 as an example, if `encoded` contains 20000 iterations and 110 | `self.iterations` is 30000, this method should run password through 111 | another 10000 iterations of PBKDF2. Similar approaches should exist 112 | for any hasher that has a work factor. If not, this method should be 113 | defined as a no-op to silence the warning. 114 | """ 115 | warnings.warn( 116 | "subclasses of BasePasswordHasher should provide a harden_runtime() method" # noqa 117 | ) 118 | 119 | 120 | class PBKDF2PasswordHasher(BasePasswordHasher): 121 | """ 122 | Secure password hashing using the PBKDF2 algorithm (recommended) 123 | 124 | Configured to use PBKDF2 + HMAC + SHA256. 125 | The result is a 64 byte binary string. Iterations may be changed 126 | safely but you must rename the algorithm if you change SHA256. 127 | """ 128 | 129 | algorithm = "pbkdf2_sha256" 130 | iterations = 216000 131 | digest = hashlib.sha256 132 | 133 | def encode(self, password, salt, iterations=None): 134 | assert password is not None 135 | assert salt and "$" not in salt 136 | iterations = iterations or self.iterations 137 | hash = pbkdf2(password, salt, iterations, digest=self.digest) 138 | hash = base64.b64encode(hash).decode("ascii").strip() 139 | return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash) 140 | 141 | def verify(self, password, encoded): 142 | algorithm, iterations, salt, hash = encoded.split("$", 3) 143 | assert algorithm == self.algorithm 144 | encoded_2 = self.encode(password, salt, int(iterations)) 145 | return constant_time_compare(encoded, encoded_2) 146 | 147 | def safe_summary(self, encoded): 148 | algorithm, iterations, salt, hash = encoded.split("$", 3) 149 | assert algorithm == self.algorithm 150 | return { 151 | "algorithm": algorithm, 152 | "iterations": iterations, 153 | "salt": mask_hash(salt), 154 | "hash": mask_hash(hash), 155 | } 156 | 157 | def must_update(self, encoded): 158 | algorithm, iterations, salt, hash = encoded.split("$", 3) 159 | return int(iterations) != self.iterations 160 | 161 | def harden_runtime(self, password, encoded): 162 | algorithm, iterations, salt, hash = encoded.split("$", 3) 163 | extra_iterations = self.iterations - int(iterations) 164 | if extra_iterations > 0: 165 | self.encode(password, salt, extra_iterations) 166 | 167 | 168 | class PBKDF2SHA1PasswordHasher(PBKDF2PasswordHasher): 169 | """ 170 | Alternate PBKDF2 hasher which uses SHA1, the default PRF 171 | recommended by PKCS #5. This is compatible with other 172 | implementations of PBKDF2, such as openssl's 173 | PKCS5_PBKDF2_HMAC_SHA1(). 174 | """ 175 | 176 | algorithm = "pbkdf2_sha1" 177 | digest = hashlib.sha1 178 | 179 | 180 | class Argon2PasswordHasher(BasePasswordHasher): 181 | """ 182 | Secure password hashing using the argon2 algorithm. 183 | 184 | This is the winner of the Password Hashing Competition 2013-2015 185 | (https://password-hashing.net). It requires the argon2-cffi library which 186 | depends on native C code and might cause portability issues. 187 | """ 188 | 189 | algorithm = "argon2" 190 | library = "argon2" 191 | 192 | time_cost = 2 193 | memory_cost = 512 194 | parallelism = 2 195 | 196 | def encode(self, password, salt): 197 | argon2 = self._load_library() 198 | data = argon2.low_level.hash_secret( 199 | password.encode(), 200 | salt.encode(), 201 | time_cost=self.time_cost, 202 | memory_cost=self.memory_cost, 203 | parallelism=self.parallelism, 204 | hash_len=argon2.DEFAULT_HASH_LENGTH, 205 | type=argon2.low_level.Type.I, 206 | ) 207 | return self.algorithm + data.decode("ascii") 208 | 209 | def verify(self, password, encoded): 210 | argon2 = self._load_library() 211 | algorithm, rest = encoded.split("$", 1) 212 | assert algorithm == self.algorithm 213 | try: 214 | return argon2.low_level.verify_secret( 215 | ("$" + rest).encode("ascii"), 216 | password.encode(), 217 | type=argon2.low_level.Type.I, 218 | ) 219 | except argon2.exceptions.VerificationError: 220 | return False 221 | 222 | def safe_summary(self, encoded): 223 | ( 224 | algorithm, 225 | variety, 226 | version, 227 | time_cost, 228 | memory_cost, 229 | parallelism, 230 | salt, 231 | data, 232 | ) = self._decode(encoded) 233 | assert algorithm == self.algorithm 234 | return { 235 | "algorithm": algorithm, 236 | "variety": variety, 237 | "version": version, 238 | "memory cost": memory_cost, 239 | "time cost": time_cost, 240 | "parallelism": parallelism, 241 | "salt": mask_hash(salt), 242 | "hash": mask_hash(data), 243 | } 244 | 245 | def must_update(self, encoded): 246 | ( 247 | algorithm, 248 | variety, 249 | version, 250 | time_cost, 251 | memory_cost, 252 | parallelism, 253 | salt, 254 | data, 255 | ) = self._decode(encoded) 256 | assert algorithm == self.algorithm 257 | argon2 = self._load_library() 258 | return ( 259 | argon2.low_level.ARGON2_VERSION != version 260 | or self.time_cost != time_cost 261 | or self.memory_cost != memory_cost 262 | or self.parallelism != parallelism 263 | ) 264 | 265 | def harden_runtime(self, password, encoded): 266 | # The runtime for Argon2 is too complicated to implement a sensible 267 | # hardening algorithm. 268 | pass 269 | 270 | def _decode(self, encoded): 271 | """ 272 | Split an encoded hash and return: ( 273 | algorithm, variety, version, time_cost, memory_cost, 274 | parallelism, salt, data, 275 | ). 276 | """ 277 | bits = encoded.split("$") 278 | if len(bits) == 5: 279 | # Argon2 < 1.3 280 | algorithm, variety, raw_params, salt, data = bits 281 | version = 0x10 282 | else: 283 | assert len(bits) == 6 284 | algorithm, variety, raw_version, raw_params, salt, data = bits 285 | assert raw_version.startswith("v=") 286 | version = int(raw_version[len("v=") :]) # noqa 287 | params = dict(bit.split("=", 1) for bit in raw_params.split(",")) 288 | assert len(params) == 3 and all(x in params for x in ("t", "m", "p")) 289 | time_cost = int(params["t"]) 290 | memory_cost = int(params["m"]) 291 | parallelism = int(params["p"]) 292 | return ( 293 | algorithm, 294 | variety, 295 | version, 296 | time_cost, 297 | memory_cost, 298 | parallelism, 299 | salt, 300 | data, 301 | ) 302 | 303 | 304 | class BCryptSHA256PasswordHasher(BasePasswordHasher): 305 | """ 306 | Secure password hashing using the bcrypt algorithm (recommended) 307 | 308 | This is considered by many to be the most secure algorithm but you 309 | must first install the bcrypt library. Please be warned that 310 | this library depends on native C code and might cause portability 311 | issues. 312 | """ 313 | 314 | algorithm = "bcrypt_sha256" 315 | digest = hashlib.sha256 316 | library = ("bcrypt", "bcrypt") 317 | rounds = 12 318 | 319 | def salt(self): 320 | bcrypt = self._load_library() 321 | return bcrypt.gensalt(self.rounds) 322 | 323 | def encode(self, password, salt): 324 | bcrypt = self._load_library() 325 | password = password.encode() 326 | # Hash the password prior to using bcrypt to prevent password 327 | # truncation as described in #20138. 328 | if self.digest is not None: 329 | # Use binascii.hexlify() because a hex encoded bytestring is str. 330 | password = binascii.hexlify(self.digest(password).digest()) 331 | 332 | data = bcrypt.hashpw(password, salt) 333 | return "%s$%s" % (self.algorithm, data.decode("ascii")) 334 | 335 | def verify(self, password, encoded): 336 | algorithm, data = encoded.split("$", 1) 337 | assert algorithm == self.algorithm 338 | encoded_2 = self.encode(password, data.encode("ascii")) 339 | return constant_time_compare(encoded, encoded_2) 340 | 341 | def safe_summary(self, encoded): 342 | algorithm, empty, algostr, work_factor, data = encoded.split("$", 4) 343 | assert algorithm == self.algorithm 344 | salt, checksum = data[:22], data[22:] 345 | return { 346 | "algorithm": algorithm, 347 | "work factor": work_factor, 348 | "salt": mask_hash(salt), 349 | "checksum": mask_hash(checksum), 350 | } 351 | 352 | def must_update(self, encoded): 353 | algorithm, empty, algostr, rounds, data = encoded.split("$", 4) 354 | return int(rounds) != self.rounds 355 | 356 | def harden_runtime(self, password, encoded): 357 | _, data = encoded.split("$", 1) 358 | salt = data[:29] # Length of the salt in bcrypt. 359 | rounds = data.split("$")[2] 360 | # work factor is logarithmic, adding one doubles the load. 361 | diff = 2 ** (self.rounds - int(rounds)) - 1 362 | while diff > 0: 363 | self.encode(password, salt.encode("ascii")) 364 | diff -= 1 365 | 366 | 367 | class BCryptPasswordHasher(BCryptSHA256PasswordHasher): 368 | """ 369 | Secure password hashing using the bcrypt algorithm 370 | 371 | This is considered by many to be the most secure algorithm but you 372 | must first install the bcrypt library. Please be warned that 373 | this library depends on native C code and might cause portability 374 | issues. 375 | 376 | This hasher does not first hash the password which means it is subject to 377 | bcrypt's 72 bytes password truncation. Most use cases should prefer the 378 | BCryptSHA256PasswordHasher. 379 | """ 380 | 381 | algorithm = "bcrypt" 382 | digest = None 383 | 384 | 385 | class SHA1PasswordHasher(BasePasswordHasher): 386 | """ 387 | The SHA1 password hashing algorithm (not recommended) 388 | """ 389 | 390 | algorithm = "sha1" 391 | 392 | def encode(self, password, salt): 393 | assert password is not None 394 | assert salt and "$" not in salt 395 | hash = hashlib.sha1((salt + password).encode()).hexdigest() 396 | return "%s$%s$%s" % (self.algorithm, salt, hash) 397 | 398 | def verify(self, password, encoded): 399 | algorithm, salt, hash = encoded.split("$", 2) 400 | assert algorithm == self.algorithm 401 | encoded_2 = self.encode(password, salt) 402 | return constant_time_compare(encoded, encoded_2) 403 | 404 | def safe_summary(self, encoded): 405 | algorithm, salt, hash = encoded.split("$", 2) 406 | assert algorithm == self.algorithm 407 | return { 408 | "algorithm": algorithm, 409 | "salt": mask_hash(salt, show=2), 410 | "hash": mask_hash(hash), 411 | } 412 | 413 | def harden_runtime(self, password, encoded): 414 | pass 415 | 416 | 417 | class MD5PasswordHasher(BasePasswordHasher): 418 | """ 419 | The Salted MD5 password hashing algorithm (not recommended) 420 | """ 421 | 422 | algorithm = "md5" 423 | 424 | def encode(self, password, salt): 425 | assert password is not None 426 | assert salt and "$" not in salt 427 | hash = hashlib.md5((salt + password).encode()).hexdigest() 428 | return "%s$%s$%s" % (self.algorithm, salt, hash) 429 | 430 | def verify(self, password, encoded): 431 | algorithm, salt, hash = encoded.split("$", 2) 432 | assert algorithm == self.algorithm 433 | encoded_2 = self.encode(password, salt) 434 | return constant_time_compare(encoded, encoded_2) 435 | 436 | def safe_summary(self, encoded): 437 | algorithm, salt, hash = encoded.split("$", 2) 438 | assert algorithm == self.algorithm 439 | return { 440 | "algorithm": algorithm, 441 | "salt": mask_hash(salt, show=2), 442 | "hash": mask_hash(hash), 443 | } 444 | 445 | def harden_runtime(self, password, encoded): 446 | pass 447 | 448 | 449 | class UnsaltedSHA1PasswordHasher(BasePasswordHasher): 450 | """ 451 | Very insecure algorithm that you should *never* use; store SHA1 hashes 452 | with an empty salt. 453 | 454 | This class is implemented because Django used to accept such password 455 | hashes. Some older Django installs still have these values lingering 456 | around so we need to handle and upgrade them properly. 457 | """ 458 | 459 | algorithm = "unsalted_sha1" 460 | 461 | def salt(self): 462 | return "" 463 | 464 | def encode(self, password, salt): 465 | assert salt == "" 466 | hash = hashlib.sha1(password.encode()).hexdigest() 467 | return "sha1$$%s" % hash 468 | 469 | def verify(self, password, encoded): 470 | encoded_2 = self.encode(password, "") 471 | return constant_time_compare(encoded, encoded_2) 472 | 473 | def safe_summary(self, encoded): 474 | assert encoded.startswith("sha1$$") 475 | hash = encoded[6:] 476 | return {"algorithm": self.algorithm, "hash": mask_hash(hash)} 477 | 478 | def harden_runtime(self, password, encoded): 479 | pass 480 | 481 | 482 | class UnsaltedMD5PasswordHasher(BasePasswordHasher): 483 | """ 484 | Incredibly insecure algorithm that you should *never* use; stores unsalted 485 | MD5 hashes without the algorithm prefix, also accepts MD5 hashes with an 486 | empty salt. 487 | 488 | This class is implemented because Django used to store passwords this way 489 | and to accept such password hashes. Some older Django installs still have 490 | these values lingering around so we need to handle and upgrade them 491 | properly. 492 | """ 493 | 494 | algorithm = "unsalted_md5" 495 | 496 | def salt(self): 497 | return "" 498 | 499 | def encode(self, password, salt): 500 | assert salt == "" 501 | return hashlib.md5(password.encode()).hexdigest() 502 | 503 | def verify(self, password, encoded): 504 | if len(encoded) == 37 and encoded.startswith("md5$$"): 505 | encoded = encoded[5:] 506 | encoded_2 = self.encode(password, "") 507 | return constant_time_compare(encoded, encoded_2) 508 | 509 | def safe_summary(self, encoded): 510 | return { 511 | "algorithm": self.algorithm, 512 | "hash": mask_hash(encoded, show=3), 513 | } 514 | 515 | def harden_runtime(self, password, encoded): 516 | pass 517 | 518 | 519 | class CryptPasswordHasher(BasePasswordHasher): 520 | """ 521 | Password hashing using UNIX crypt (not recommended) 522 | 523 | The crypt module is not supported on all platforms. 524 | """ 525 | 526 | algorithm = "crypt" 527 | library = "crypt" 528 | 529 | def salt(self): 530 | return get_random_string(2) 531 | 532 | def encode(self, password, salt): 533 | crypt = self._load_library() 534 | assert len(salt) == 2 535 | data = crypt.crypt(password, salt) 536 | assert ( 537 | data is not None 538 | ) # A platform like OpenBSD with a dummy crypt module. 539 | # we don't need to store the salt, but Django used to do this 540 | return "%s$%s$%s" % (self.algorithm, "", data) 541 | 542 | def verify(self, password, encoded): 543 | crypt = self._load_library() 544 | algorithm, salt, data = encoded.split("$", 2) 545 | assert algorithm == self.algorithm 546 | return constant_time_compare(data, crypt.crypt(password, data)) 547 | 548 | def safe_summary(self, encoded): 549 | algorithm, salt, data = encoded.split("$", 2) 550 | assert algorithm == self.algorithm 551 | return { 552 | "algorithm": algorithm, 553 | "salt": salt, 554 | "hash": mask_hash(data, show=3), 555 | } 556 | 557 | def harden_runtime(self, password, encoded): 558 | pass 559 | -------------------------------------------------------------------------------- /async_django_user/starlette.py: -------------------------------------------------------------------------------- 1 | def middleware(app, backend): 2 | @app.middleware("http") 3 | async def django_user(request, call_next): 4 | session = await request.state.get_session() 5 | user = backend.get_user_from_session(session) 6 | request.state.get_user = user.load 7 | response = await call_next(request) 8 | return response 9 | -------------------------------------------------------------------------------- /async_django_user/user.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | from hashlib import sha1 3 | import logging 4 | 5 | from .utils import now_utc 6 | 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | class User(dict): 12 | _loaded = False 13 | session = {} 14 | 15 | def __init__(self, backend): 16 | self.backend = backend 17 | 18 | @classmethod 19 | def get_by_id(cls, backend, id): 20 | user = cls(backend) 21 | user["id"] = id 22 | return user 23 | 24 | @classmethod 25 | def get_from_session(cls, backend, session): 26 | user = cls(backend) 27 | user.session = session 28 | if backend.session_id_key in session: 29 | user["id"] = int(session[backend.session_id_key]) 30 | return user 31 | 32 | async def load(self): 33 | if not self._loaded: 34 | await self.reload() 35 | return self 36 | 37 | async def reload(self): 38 | log.debug("Load user from db") 39 | self._loaded = True 40 | id = self.get("id") 41 | if not id: 42 | log.debug("It's a new user without an id") 43 | return 44 | row = await self.backend.find_one(id=id) 45 | if not row: 46 | log.debug("User not found in db") 47 | return 48 | self.clear() 49 | self.update(**dict(row)) 50 | return self 51 | 52 | def save(self, fields: list = None): 53 | assert "id" in self 54 | data = {k: self[k] for k in fields} if fields else self 55 | return self.backend.update_by_id(self["id"], **data) 56 | 57 | async def create(self): 58 | """It doesn't catch any exception the database layer can throw""" 59 | assert self.backend.username_field in self 60 | assert "password" in self 61 | self.setdefault("date_joined", now_utc()) 62 | self.setdefault("is_superuser", False) 63 | self.setdefault("is_staff", False) 64 | self.setdefault("is_active", True) 65 | self["id"] = await self.backend.insert(**self) 66 | 67 | async def authenticate(self, username: str, password: str): 68 | """ 69 | It checks credentials and populates the user if they're valid. 70 | It doesn't check if the user is active. 71 | """ 72 | self._loaded = True 73 | data = await self.backend.find_one( 74 | **{self.backend.username_field: username}) 75 | if not data: 76 | log.debug("User not found in db") 77 | return 78 | log.debug("User found: %s", data["id"]) 79 | if not self.backend.check_password(password, data["password"]): 80 | log.debug("Wrong password") 81 | return 82 | self.clear() 83 | self.update(**dict(data)) 84 | await self.backend.update_by_id(self["id"], last_login=now_utc()) 85 | return self 86 | 87 | def login(self): 88 | """Saves the user's id in the session""" 89 | backend = self.backend 90 | self.session[backend.session_id_key] = self["id"] 91 | self.session[backend.session_backend_key] = backend.session_backend_val 92 | self.session[backend.session_hash_key] = self._get_session_hash( 93 | self["password"] 94 | ) 95 | 96 | def logout(self): 97 | self.clear() 98 | self.session.pop(self.backend.session_backend_key, None) 99 | self.session.pop(self.backend.session_hash_key, None) 100 | self.session.pop(self.backend.session_id_key, None) 101 | 102 | def _get_session_hash(self, password): 103 | return hmac.new( 104 | self.backend.salted_secret, 105 | msg=password.encode("utf-8"), 106 | digestmod=sha1, 107 | ).hexdigest() 108 | 109 | def set_password(self, raw_password): 110 | self["password"] = self.make_password(raw_password) 111 | 112 | def make_password(self, password, salt=None, hasher="default"): 113 | hasher = self.backend.get_hasher(hasher) 114 | salt = salt or hasher.salt() 115 | return hasher.encode(password, salt) 116 | -------------------------------------------------------------------------------- /async_django_user/utils.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import string 3 | from datetime import datetime, timezone 4 | 5 | 6 | def get_random_string( 7 | length=12, allowed_chars=string.ascii_letters + string.digits 8 | ): 9 | return "".join(secrets.choice(allowed_chars) for i in range(length)) 10 | 11 | 12 | def now_utc(): 13 | return datetime.utcnow().replace(tzinfo=timezone.utc) 14 | -------------------------------------------------------------------------------- /examples/aiohttp_app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | sys.path.insert(0, "..") # noqa 4 | 5 | from functools import partial 6 | 7 | from aiohttp import web 8 | from databases import Database 9 | import async_django_session.aiohttp 10 | import async_django_session.databases 11 | import async_django_user.databases 12 | import async_django_user.aiohttp 13 | import ujson 14 | 15 | import cfg 16 | 17 | db = Database(cfg.DB_URI) 18 | jsonify = partial(web.json_response, dumps=ujson.dumps) 19 | 20 | 21 | async def on_startup(app): 22 | await db.connect() 23 | 24 | 25 | async def on_cleanup(app): 26 | await db.disconnect() 27 | 28 | 29 | async def index(request): 30 | with open("index.html") as f: 31 | return web.Response(text=f.read(), content_type="text/html") 32 | 33 | 34 | async def me(request): 35 | user = await request.get_user() 36 | session = await request.get_session() 37 | return jsonify({"user": user, "session": session}) 38 | 39 | 40 | async def login(request): 41 | user = await request.get_user() 42 | credentials = await request.json() 43 | if not await user.authenticate(**credentials): 44 | return jsonify({"message": "Bad username or password"}, status=400) 45 | if not user["is_active"]: 46 | return jsonify({"message": "User isn't active"}, status=400) 47 | user.login() 48 | return web.json_response({}) 49 | 50 | 51 | async def logout(request): 52 | user = await request.get_user() 53 | user.logout() 54 | return jsonify({}) 55 | 56 | 57 | async def register(request): 58 | data = await request.json() 59 | user = await request.get_user() 60 | if user: 61 | return jsonify({"message": "Log out first"}, status=400) 62 | user.update( 63 | { 64 | "email": "", 65 | "username": data["username"], 66 | "first_name": "", 67 | "last_name": "", 68 | } 69 | ) 70 | user.set_password(data["password"]) 71 | try: 72 | await user.create() 73 | except Exception: 74 | return jsonify( 75 | {"message": "The username is already in use"}, status=400 76 | ) 77 | user.login() 78 | return jsonify({}) 79 | 80 | 81 | async def change_password(request): 82 | data = await request.json() 83 | user = await request.get_user() 84 | if not user: 85 | return jsonify({"message": "Log in first"}, status=400) 86 | user.set_password(data["password"]) 87 | await user.save(["password"]) 88 | user.logout() 89 | return jsonify({}) 90 | 91 | 92 | user_middleware = async_django_user.aiohttp.middleware( 93 | async_django_user.databases.Backend(db, cfg.SECRET_KEY) 94 | ) 95 | session_middleware = async_django_session.aiohttp.middleware( 96 | async_django_session.databases.Backend(db, cfg.SECRET_KEY) 97 | ) 98 | 99 | app = web.Application(middlewares=[session_middleware, user_middleware]) 100 | app.add_routes( 101 | [ 102 | web.get("/", index), 103 | web.get("/api/me", me), 104 | web.post("/api/login", login), 105 | web.post("/api/logout", logout), 106 | web.post("/api/register", register), 107 | web.post("/api/change-password", change_password), 108 | ] 109 | ) 110 | app.on_startup.append(on_startup) 111 | app.on_cleanup.append(on_cleanup) 112 | 113 | 114 | if __name__ == "__main__": 115 | web.run_app(app, port=cfg.PORT) 116 | -------------------------------------------------------------------------------- /examples/cfg.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | SECRET_KEY = "foobar" 3 | DB_NAME = "async_django_session" 4 | DB_URI = "postgresql:///" + DB_NAME 5 | PORT = 8000 6 | -------------------------------------------------------------------------------- /examples/django_app.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.http import JsonResponse 3 | from django.urls import path 4 | from django.contrib import admin 5 | import django 6 | 7 | import cfg 8 | 9 | settings.configure( 10 | DEBUG=cfg.DEBUG, 11 | SECRET_KEY=cfg.SECRET_KEY, 12 | DATABASES={ 13 | "default": { 14 | "ENGINE": "django.db.backends.postgresql", 15 | "NAME": cfg.DB_NAME, 16 | } 17 | }, 18 | ROOT_URLCONF=__name__, 19 | MIDDLEWARE=[ 20 | "django.contrib.sessions.middleware.SessionMiddleware", 21 | "django.contrib.auth.middleware.AuthenticationMiddleware", 22 | "django.contrib.messages.middleware.MessageMiddleware", 23 | ], 24 | INSTALLED_APPS=[ 25 | "django.contrib.admin", 26 | "django.contrib.auth", 27 | "django.contrib.contenttypes", 28 | "django.contrib.sessions", 29 | "django.contrib.messages", 30 | "django.contrib.staticfiles", 31 | ], 32 | TEMPLATES=[ 33 | { 34 | "BACKEND": "django.template.backends.django.DjangoTemplates", 35 | "DIRS": [], 36 | "APP_DIRS": True, 37 | "OPTIONS": { 38 | "context_processors": [ 39 | # "django.template.context_processors.debug", 40 | # "django.template.context_processors.request", 41 | "django.contrib.auth.context_processors.auth", 42 | "django.contrib.messages.context_processors.messages", 43 | ] 44 | }, 45 | } 46 | ], 47 | STATIC_URL="/static/", 48 | ) 49 | 50 | 51 | def index(request): 52 | session = request.session 53 | framework = "django" 54 | session[framework] = request.session.get(framework, 0) + 1 55 | return JsonResponse( 56 | { 57 | "framework": framework, 58 | "session": dict(request.session), 59 | "user": str(request.user), 60 | } 61 | ) 62 | 63 | 64 | django.setup() 65 | urlpatterns = [path("admin/", admin.site.urls), path("", index)] 66 | 67 | 68 | if __name__ == "__main__": 69 | import sys 70 | from django.core.management import execute_from_command_line 71 | 72 | execute_from_command_line(sys.argv) 73 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
7 | 8 |
9 | 10 | 11 | 12 |
13 | 14 |

user

15 |

 16 | 
 17 | 

session

18 |

 19 | 
 20 | 
102 | 


--------------------------------------------------------------------------------
/examples/requirements.txt:
--------------------------------------------------------------------------------
1 | aiohttp
2 | asyncpg
3 | django
4 | psycopg2-binary
5 | starlette
6 | ujson
7 | uvicorn
8 | 


--------------------------------------------------------------------------------
/examples/starlette_app.py:
--------------------------------------------------------------------------------
  1 | import sys
  2 | 
  3 | sys.path.insert(0, "..")  # noqa
  4 | 
  5 | from starlette.applications import Starlette
  6 | from starlette.responses import UJSONResponse, HTMLResponse
  7 | import async_django_session.asyncpg
  8 | import async_django_session.starlette
  9 | import async_django_user.asyncpg
 10 | import async_django_user.starlette
 11 | import asyncpg
 12 | import uvicorn
 13 | 
 14 | import cfg
 15 | 
 16 | 
 17 | app = Starlette()
 18 | app.debug = True
 19 | 
 20 | 
 21 | class DB:
 22 |     async def connect(self):
 23 |         global acquire
 24 |         self.pool = await asyncpg.create_pool(cfg.DB_URI)
 25 |         self.acquire = self.pool.acquire
 26 | 
 27 | 
 28 | db = DB()
 29 | 
 30 | 
 31 | @app.on_event("startup")
 32 | async def startup():
 33 |     await db.connect()
 34 | 
 35 | 
 36 | @app.on_event("shutdown")
 37 | async def shutdown():
 38 |     await db.pool.release()
 39 | 
 40 | 
 41 | async_django_user.starlette.middleware(
 42 |     app, async_django_user.asyncpg.Backend(db, cfg.SECRET_KEY)
 43 | )
 44 | async_django_session.starlette.middleware(
 45 |     app, async_django_session.asyncpg.Backend(db, cfg.SECRET_KEY)
 46 | )
 47 | 
 48 | 
 49 | @app.route("/")
 50 | async def index(request):
 51 |     with open("index.html") as f:
 52 |         return HTMLResponse(f.read())
 53 | 
 54 | 
 55 | @app.route("/api/me")
 56 | async def me(request):
 57 |     user = await request.state.get_user()
 58 |     session = await request.state.get_session()
 59 |     return UJSONResponse({"user": user, "session": session})
 60 | 
 61 | 
 62 | @app.route("/api/login", methods=["post"])
 63 | async def login(request):
 64 |     user = await request.state.get_user()
 65 |     credentials = await request.json()
 66 |     if not await user.authenticate(**credentials):
 67 |         return UJSONResponse({"message": "Bad username or password"}, 400)
 68 |     if not user["is_active"]:
 69 |         return UJSONResponse({"message": "User isn't active"}, 400)
 70 |     user.login()
 71 |     return UJSONResponse({})
 72 | 
 73 | 
 74 | @app.route("/api/logout", methods=["post"])
 75 | async def logout(request):
 76 |     user = await request.state.get_user()
 77 |     user.logout()
 78 |     return UJSONResponse({})
 79 | 
 80 | 
 81 | @app.route("/api/register", methods=["post"])
 82 | async def register(request):
 83 |     data = await request.json()
 84 |     user = await request.state.get_user()
 85 |     if user:
 86 |         return UJSONResponse({"message": "Log out first"}, 400)
 87 |     user.update(
 88 |         {
 89 |             "email": "",
 90 |             "username": data["username"],
 91 |             "first_name": "",
 92 |             "last_name": "",
 93 |         }
 94 |     )
 95 |     user.set_password(data["password"])
 96 |     try:
 97 |         await user.create()
 98 |     except asyncpg.exceptions.UniqueViolationError:
 99 |         return UJSONResponse(
100 |             {"message": "The username is already in use"}, 400
101 |         )
102 |     user.login()
103 |     return UJSONResponse({})
104 | 
105 | 
106 | @app.route("/api/change-password", methods=["post"])
107 | async def change_password(request):
108 |     data = await request.json()
109 |     user = await request.state.get_user()
110 |     if not user:
111 |         return UJSONResponse({"message": "Log in first"}, 400)
112 |     user.set_password(data["password"])
113 |     await user.save(["password"])
114 |     user.logout()
115 |     return UJSONResponse({})
116 | 
117 | 
118 | if __name__ == "__main__":
119 |     import logging
120 | 
121 |     logging.basicConfig(level=0)
122 |     uvicorn.run(app, host="127.0.0.1", port=cfg.PORT, debug=cfg.DEBUG)
123 | 


--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
 1 | [tool.black]
 2 | line-length = 79
 3 | exclude = '''
 4 | /(
 5 |     \.git
 6 | )/
 7 | '''
 8 | 
 9 | [tool.isort]
10 | skip = '\.git/**'
11 | 


--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
 1 | #!/usr/bin/env python
 2 | import os
 3 | import sys
 4 | import setuptools
 5 | 
 6 | 
 7 | if sys.argv[-1] == "publish":
 8 |     os.system("python setup.py bdist_wheel")
 9 |     os.system("python -m twine upload dist/*")
10 |     sys.exit(0)
11 | 
12 | 
13 | setuptools.setup(
14 |     name="async_django_user",
15 |     version="0.2.0",
16 |     description="Django user for async frameworks",
17 |     long_description=open("./README.md").read(),
18 |     long_description_content_type="text/markdown",
19 |     url="https://github.com/imbolc/async-django-user",
20 |     packages=["async_django_user"],
21 |     author="Imbolc",
22 |     author_email="imbolc@imbolc.name",
23 |     license="MIT",
24 |     classifiers=[
25 |         "Development Status :: 4 - Beta",
26 |         "License :: OSI Approved :: MIT License",
27 |         "Programming Language :: Python :: 3",
28 |         "Operating System :: OS Independent",
29 |     ],
30 |     include_package_data=True,
31 | )
32 | 


--------------------------------------------------------------------------------