summaryrefslogtreecommitdiff
path: root/passlib/ext/django/models.py
blob: 4fb6aeaa30314f3755a74f6e0e919492a5eae5f1 (plain)
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("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
#=============================================================================