1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
|
"""passlib.ext.django.models -- monkeypatch django hashing framework"""
#=============================================================================
# imports
#=============================================================================
# core
import logging; log = logging.getLogger(__name__)
from warnings import warn
# site
from django import VERSION
from django.conf import settings
# pkg
from passlib.context import CryptContext
from passlib.exc import ExpectedTypeError
from passlib.ext.django.utils import _PatchManager, hasher_to_passlib_name, \
get_passlib_hasher, get_preset_config
from passlib.utils.compat import callable, unicode, bytes
# local
__all__ = ["password_context"]
#=============================================================================
# global attrs
#=============================================================================
# the context object which this patches contrib.auth to use for password hashing.
# configuration controlled by ``settings.PASSLIB_CONFIG``.
password_context = CryptContext()
# function mapping User objects -> passlib user category.
# may be overridden via ``settings.PASSLIB_GET_CATEGORY``.
def _get_category(user):
"""default get_category() implementation"""
if user.is_superuser:
return "superuser"
elif user.is_staff:
return "staff"
else:
return None
# object used to track state of patches applied to django.
_manager = _PatchManager(log=logging.getLogger(__name__ + "._manager"))
# patch status
_patched = False
#=============================================================================
# applying & removing the patches
#=============================================================================
def _apply_patch():
"""monkeypatch django's password handling to use ``passlib_context``,
assumes the caller will configure the object.
"""
#
# setup constants
#
log.debug("preparing to monkeypatch 'django.contrib.auth' ...")
global _patched
assert not _patched, "monkeypatching already applied"
HASHERS_PATH = "django.contrib.auth.hashers"
MODELS_PATH = "django.contrib.auth.models"
USER_PATH = MODELS_PATH + ":User"
FORMS_PATH = "django.contrib.auth.forms"
#
# import UNUSUABLE_PASSWORD and is_password_usuable() helpers
# (providing stubs for older django versions)
#
if VERSION < (1,4):
has_hashers = False
if VERSION < (1,0):
UNUSABLE_PASSWORD = "!"
else:
from django.contrib.auth.models import UNUSABLE_PASSWORD
def is_password_usable(encoded):
return (encoded is not None and encoded != UNUSABLE_PASSWORD)
def is_valid_secret(secret):
return secret is not None
else:
has_hashers = True
from django.contrib.auth.hashers import UNUSABLE_PASSWORD, \
is_password_usable
def is_valid_secret(secret):
# NOTE: changed in 1.4 - empty passwords no longer valid.
return bool(secret)
#
# backport ``User.set_unusable_password()`` for Django 0.9
# (simplifies rest of the code)
#
if not hasattr(_manager.getorig(USER_PATH), "set_unusable_password"):
assert VERSION < (1,0)
@_manager.monkeypatch(USER_PATH)
def set_unusable_password(user):
user.password = UNUSABLE_PASSWORD
@_manager.monkeypatch(USER_PATH)
def has_usable_password(user):
return is_password_usable(user.password)
#
# patch ``User.set_password() & ``User.check_password()`` to use
# context & get_category (would just leave these as wrappers for hashers
# module under django 1.4, but then we couldn't pass User object into
# get_category very easily)
#
@_manager.monkeypatch(USER_PATH)
def set_password(user, password):
"passlib replacement for User.set_password()"
if is_valid_secret(password):
# NOTE: pulls _get_category from module globals
cat = _get_category(user)
user.password = password_context.encrypt(password, category=cat)
else:
user.set_unusable_password()
@_manager.monkeypatch(USER_PATH)
def check_password(user, password):
"passlib replacement for User.check_password()"
hash = user.password
if not is_valid_secret(password) or not is_password_usable(hash):
return False
# NOTE: pulls _get_category from module globals
cat = _get_category(user)
ok, new_hash = password_context.verify_and_update(password, hash,
category=cat)
if ok and new_hash is not None:
# migrate to new hash if needed.
user.password = new_hash
user.save()
return ok
#
# override check_password() with our own implementation
#
@_manager.monkeypatch(HASHERS_PATH, enable=has_hashers)
@_manager.monkeypatch(MODELS_PATH)
def check_password(password, encoded, setter=None, preferred="default"):
"passlib replacement for check_password()"
# XXX: this currently ignores "preferred" keyword, since it's purpose
# was for hash migration, and that's handled by the context.
if not is_valid_secret(password) or not is_password_usable(encoded):
return False
ok = password_context.verify(password, encoded)
if ok and setter and password_context.needs_update(encoded):
setter(password)
return ok
#
# patch the other functions defined in the ``hashers`` module, as well
# as any other known locations where they're imported within ``contrib.auth``
#
if has_hashers:
@_manager.monkeypatch(HASHERS_PATH)
@_manager.monkeypatch(MODELS_PATH)
def make_password(password, salt=None, hasher="default"):
"passlib replacement for make_password()"
if not is_valid_secret(password):
return UNUSABLE_PASSWORD
kwds = {}
if salt is not None:
kwds['salt'] = salt
if hasher != "default":
kwds['scheme'] = hasher_to_passlib_name(hasher)
return password_context.encrypt(password, **kwds)
@_manager.monkeypatch(HASHERS_PATH)
@_manager.monkeypatch(FORMS_PATH)
def get_hasher(algorithm="default"):
"passlib replacement for get_hasher()"
if algorithm == "default":
scheme = None
else:
scheme = hasher_to_passlib_name(algorithm)
handler = password_context.handler(scheme)
return get_passlib_hasher(handler)
# NOTE: custom helper that doesn't exist in django proper
# (though submitted a patch - https://code.djangoproject.com/ticket/18184)
@_manager.monkeypatch(HASHERS_PATH)
@_manager.monkeypatch(FORMS_PATH)
def identify_hasher(encoded):
"passlib helper to identify hasher from encoded password"
handler = password_context.identify(encoded, resolve=True,
required=True)
return get_passlib_hasher(handler)
_patched = True
log.debug("... finished monkeypatching django")
def _remove_patch():
"""undo the django monkeypatching done by this module.
offered as a last resort if it's ever needed.
.. warning::
This may cause problems if any other Django modules have imported
their own copies of the patched functions, though the patched
code has been designed to throw an error as soon as possible in
this case.
"""
global _patched
if _patched:
log.debug("removing django monkeypatching...")
_manager.unpatch_all(unpatch_conflicts=True)
password_context.load({})
_patched = False
log.debug("...finished removing django monkeypatching")
return True
if _manager: # pragma: no cover -- sanity check
log.warning("reverting partial monkeypatching of django...")
_manager.unpatch_all()
password_context.load({})
log.debug("...finished removing django monkeypatching")
return True
log.debug("django not monkeypatched")
return False
#=============================================================================
# main code
#=============================================================================
def _load():
global _get_category
# TODO: would like to add support for inheriting config from a preset
# (or from existing hasher state) and letting PASSLIB_CONFIG
# be an update, not a replacement.
# TODO: wrap and import any custom hashers as passlib handlers,
# so they could be used in the passlib config.
# load config from settings
_UNSET = object()
config = getattr(settings, "PASSLIB_CONFIG", _UNSET)
if config is _UNSET:
# XXX: should probably deprecate this alias
config = getattr(settings, "PASSLIB_CONTEXT", _UNSET)
if config is _UNSET:
config = "passlib-default"
if config is None:
warn("setting PASSLIB_CONFIG=None is deprecated, "
"and support will be removed in Passlib 1.8, "
"use PASSLIB_CONFIG='disabled' instead.",
DeprecationWarning)
config = "disabled"
elif not isinstance(config, (unicode, bytes, dict)):
raise ExpectedTypeError(config, "str or dict", "PASSLIB_CONFIG")
# load custom category func (if any)
get_category = getattr(settings, "PASSLIB_GET_CATEGORY", None)
if get_category and not callable(get_category):
raise ExpectedTypeError(get_category, "callable", "PASSLIB_GET_CATEGORY")
# check if we've been disabled
if config == "disabled":
if _patched: # pragma: no cover -- sanity check
log.error("didn't expect monkeypatching would be applied!")
_remove_patch()
return
# resolve any preset aliases
if isinstance(config, str) and '\n' not in config:
config = get_preset_config(config)
# setup context
_apply_patch()
password_context.load(config)
if get_category:
# NOTE: _get_category is module global which is read by
# monkeypatched functions constructed by _apply_patch()
_get_category = get_category
log.debug("passlib.ext.django loaded")
# wrap load function so we can undo any patching if something goes wrong
try:
_load()
except:
_remove_patch()
raise
#=============================================================================
# eof
#=============================================================================
|