|
| 1 | +"""MFA state for a client that cannot hold a cookie. |
| 2 | +
|
| 3 | +``MFA_API_AUTHENTICATION`` answers *who you are* from a bearer token, but |
| 4 | +whether this caller has passed a challenge -- and how recently -- is read from |
| 5 | +``request.session`` by the same code the browser flow uses. A client that |
| 6 | +discards cookies therefore arrives with a brand-new empty session on every |
| 7 | +request, and a verification completed in one can never be seen by the next. |
| 8 | +
|
| 9 | +This module closes that without inventing a second home for MFA state. The |
| 10 | +token a client holds **is** a session key: ``issue()`` mints a server-side |
| 11 | +session, hands back its key, and ``load()`` puts it on the request as |
| 12 | +``request.session`` when the client presents it in the ``X-MFA-Session`` |
| 13 | +header. Everything downstream -- django_mfa.session, the enforcement rungs, |
| 14 | +step-up freshness -- runs unchanged and unaware. |
| 15 | +
|
| 16 | +What that buys, and why it beat a signed stateless token holding |
| 17 | +``{"verified": true}``: |
| 18 | +
|
| 19 | +* **Revocation.** ``DELETE`` on the session endpoint flushes the row. A signed |
| 20 | + token is valid until it expires, and cannot be withdrawn from a device its |
| 21 | + holder has lost. |
| 22 | +* **Expiry, already solved.** ``SESSION_COOKIE_AGE`` and ``clearsessions`` |
| 23 | + apply as-is. |
| 24 | +* **One reader.** ``django_mfa/session.py`` stays the only module that touches |
| 25 | + the ``mfa`` session key, which is the rule the rest of this package is |
| 26 | + built on. |
| 27 | +
|
| 28 | +Three properties below are load-bearing rather than tidy; each says so at its |
| 29 | +own definition: the token is bound to the user it was issued to, no |
| 30 | +``Set-Cookie`` is ever sent for one, and CSRF is enforced for cookie requests |
| 31 | +only. |
| 32 | +
|
| 33 | +Not covered: the passkey endpoints. Passwordless login establishes identity, |
| 34 | +and a token cannot be issued before there is an identity to bind it to -- |
| 35 | +minting an unbound one and rotating it after the ceremony would be textbook |
| 36 | +session fixation for the window in between. Those two endpoints stay |
| 37 | +cookie-borne; see docs/rest_api.md. |
| 38 | +""" |
| 39 | + |
| 40 | +from importlib import import_module |
| 41 | + |
| 42 | +from django.conf import settings as django_settings |
| 43 | + |
| 44 | +#: The request header a client presents its token in. Deliberately not |
| 45 | +#: ``Authorization``: that is where the client's *own* credential already |
| 46 | +#: lives (a DRF token, a JWT), and this is a second, orthogonal one -- an |
| 47 | +#: answer to "has this caller passed MFA", not "who is this caller". |
| 48 | +HEADER = "X-MFA-Session" |
| 49 | +META_KEY = "HTTP_X_MFA_SESSION" |
| 50 | + |
| 51 | +#: Where issue() records the user a token belongs to, inside the session it |
| 52 | +#: mints. See load() for why a token that lacks this is refused rather than |
| 53 | +#: trusted. |
| 54 | +USER_KEY = "_mfa_api_user" |
| 55 | + |
| 56 | + |
| 57 | +class TokenSessionsUnsupported(Exception): |
| 58 | + """``SESSION_ENGINE`` cannot mint a server-side session key. |
| 59 | +
|
| 60 | + True of ``django.contrib.sessions.backends.signed_cookies``, where the |
| 61 | + "key" is the signed payload itself: it changes every time the session data |
| 62 | + changes, so a token issued before a challenge would no longer name the |
| 63 | + session that passed it. There is nothing to hand out, and pretending |
| 64 | + otherwise would give a client a token that silently stopped working at the |
| 65 | + exact moment it started to matter. |
| 66 | + """ |
| 67 | + |
| 68 | + |
| 69 | +def _store(session_key=None): |
| 70 | + return import_module(django_settings.SESSION_ENGINE).SessionStore( |
| 71 | + session_key) |
| 72 | + |
| 73 | + |
| 74 | +def token_from(request): |
| 75 | + """The token this request presented, or None. |
| 76 | +
|
| 77 | + Cheap and non-validating on purpose -- it answers "is this a token-borne |
| 78 | + request", which is the question the CSRF branch in api/views.py asks |
| 79 | + before it has a user to validate against. |
| 80 | + """ |
| 81 | + return request.META.get(META_KEY) or None |
| 82 | + |
| 83 | + |
| 84 | +def issue(user): |
| 85 | + """Mint a session for ``user`` and return ``(token, expires_in)``.""" |
| 86 | + store = _store() |
| 87 | + # str(): the default session serializer is JSON, and a UUID primary key |
| 88 | + # (or any other non-JSON-native pk a swapped AUTH_USER_MODEL might use) |
| 89 | + # would otherwise raise on save. load() compares the same way. |
| 90 | + store[USER_KEY] = str(user.pk) |
| 91 | + store.create() |
| 92 | + if store.session_key is None: |
| 93 | + raise TokenSessionsUnsupported(django_settings.SESSION_ENGINE) |
| 94 | + return store.session_key, store.get_expiry_age() |
| 95 | + |
| 96 | + |
| 97 | +def load(request, user): |
| 98 | + """The session this request's token names, or None to refuse it. |
| 99 | +
|
| 100 | + Returns None both for "no token presented" and for a token that exists but |
| 101 | + does not belong to ``user``. The caller distinguishes them by whether |
| 102 | + ``token_from()`` was truthy. |
| 103 | +
|
| 104 | + **The binding check is the security of this module.** Without it, a client |
| 105 | + could present a token issued to somebody else alongside its own identity |
| 106 | + credential and inherit that session's verified state -- a complete |
| 107 | + second-factor bypass needing only a token overheard once. It is also what |
| 108 | + keeps an ordinary browser ``sessionid`` from being replayed through this |
| 109 | + header: no browser session carries USER_KEY, so none is accepted here, and |
| 110 | + the CSRF exemption that rides on the header therefore cannot be reached |
| 111 | + with a stolen cookie. |
| 112 | + """ |
| 113 | + key = token_from(request) |
| 114 | + if key is None: |
| 115 | + return None |
| 116 | + store = _store(key) |
| 117 | + # Reading a nonexistent key yields an empty session rather than raising, |
| 118 | + # so an expired, revoked or invented token simply carries no USER_KEY and |
| 119 | + # fails the comparison below alongside a genuinely mismatched one. |
| 120 | + if store.get(USER_KEY) != str(user.pk): |
| 121 | + return None |
| 122 | + return store |
| 123 | + |
| 124 | + |
| 125 | +def persist(request): |
| 126 | + """Save a token-borne session without ever setting a cookie. |
| 127 | +
|
| 128 | + SessionMiddleware writes ``Set-Cookie`` on the way out for any session it |
| 129 | + finds modified. For a token client that cookie is at best ignored and at |
| 130 | + worst confusing -- a browser holding both would send a session cookie the |
| 131 | + server never meant it to have. Saving here and clearing ``modified`` |
| 132 | + leaves the middleware nothing to do, which is the whole trick. |
| 133 | + """ |
| 134 | + if request.session.modified: |
| 135 | + request.session.save() |
| 136 | + request.session.modified = False |
| 137 | + |
| 138 | + |
| 139 | +def revoke(request): |
| 140 | + """Destroy the token-borne session this request presented, if any. |
| 141 | +
|
| 142 | + Guarded on a token actually being in play: an unguarded flush() would log |
| 143 | + a *cookie* client out of Django entirely, which is not what "revoke my API |
| 144 | + token" can be allowed to mean. |
| 145 | + """ |
| 146 | + if token_from(request) is None: |
| 147 | + return False |
| 148 | + request.session.flush() |
| 149 | + return True |
0 commit comments