Metadata-Version: 2.5
Name: django-authlib
Version: 0.22.0
Summary: Authentication utils for Django
Project-URL: Homepage, http://github.com/feincms/django-authlib/
Author-email: Matthias Kestenholz <mk@feinheit.ch>
License: MIT
License-File: LICENSE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Requires-Python: >=3.9
Requires-Dist: requests-oauthlib
Provides-Extra: tests
Requires-Dist: coverage; extra == 'tests'
Requires-Dist: requests-mock; extra == 'tests'
Description-Content-Type: text/x-rst

================================================
django-authlib - Authentication utils for Django
================================================

.. image:: https://github.com/feincms/django-authlib/actions/workflows/tests.yml/badge.svg
    :target: https://github.com/feincms/django-authlib/
    :alt: CI Status

.. image:: https://readthedocs.org/projects/django-authlib/badge/?version=latest
    :target: https://django-authlib.readthedocs.io/en/latest/?badge=latest
    :alt: Documentation Status

authlib is a collection of authentication utilities for implementing
passwordless authentication. This is achieved by either sending
cryptographically signed links by email, or by fetching the email
address from third party providers such as Google, Facebook and Twitter.
After all, what's the point in additionally requiring a password for
authentication when the password can be easily resetted on most websites
when an attacker has access to the email address?


Goals
=====

- Stay small, simple and extensible.
- Offer tools and utilities instead of imposing a framework on you.


Usage
=====

- Install ``django-authlib`` using pip into your virtualenv.
- Add ``authlib.backends.EmailBackend`` to ``AUTHENTICATION_BACKENDS``.
- Adding ``authlib`` to ``INSTALLED_APPS`` is optional and only useful
  if you want to use the bundled translation files. There are no
  required database tables or anything of the sort.
- Have a user model which has a email field named ``email`` as username.
  For convenience a base user model and manager are available in the
  ``authlib.base_user`` module, ``BaseUser`` and ``BaseUserManager``.
  The ``BaseUserManager`` is automatically available as ``objects`` when
  you extend the ``BaseUser``.
- Use the bundled views or write your own. The bundled views give
  feedback using ``django.contrib.messages``, so you may want to check
  that those messages are visible to the user.

The Google, Microsoft, Facebook and Twitter OAuth clients require the following
settings:

- ``GOOGLE_CLIENT_ID``
- ``GOOGLE_CLIENT_SECRET``
- ``MICROSOFT_CLIENT_ID``
- ``MICROSOFT_CLIENT_SECRET``
- ``FACEBOOK_CLIENT_ID``
- ``FACEBOOK_CLIENT_SECRET``
- ``TWITTER_CLIENT_ID``
- ``TWITTER_CLIENT_SECRET``

Microsoft logins accept work, school and personal accounts from any tenant by
default. Set ``MICROSOFT_TENANT_ID`` to your directory (tenant) ID, which is
shown on the app registration's overview page in the Azure portal, to only
accept accounts of your organization; the authorization and token endpoints are
derived from it. Single-tenant app registrations require this setting.

The email address of a Microsoft login is taken from the ``preferred_username``
claim; for work and school accounts, that is the user principal name, whose
domain has to be verified in the user's tenant. Entra ID doesn't verify the
``email`` claim, so it is only used when ``preferred_username`` isn't an email
address and the ``xms_edov`` (email domain owner verified) claim says it can be
trusted. Add ``email`` and ``xms_edov`` as optional ID token claims to the app
registration if you need that fallback.

Note that you have to configure the Twitter app to allow email access,
this is not enabled by default.

.. note::
    If you want to use OAuth2 providers in development mode (without HTTPS) you
    could add the following lines to your ``settings.py``:

    .. code-block:: python

        if DEBUG:
            # NEVER set this variable in production environments!
            os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"

    This is required because of the strictness of
    `oauthlib <https://pypi.org/project/oauthlib/>`__ which only wants HTTPS
    URLs (and rightly so).


Use of bundled views
====================

The following URL patterns are an example for using the bundled views.
For now you'll have to dig into the code (it's not much, at the time of
writing ``django-authlib``'s Python code is less than 500 lines):

.. code-block:: python

    from django.urls import path
    from authlib import views
    from authlib.facebook import FacebookOAuth2Client
    from authlib.google import GoogleOAuth2Client
    from authlib.microsoft import MicrosoftOAuth2Client
    from authlib.twitter import TwitterOAuthClient

    urlpatterns = [
        path("login/", views.login, name="login"),
        path(
            "oauth/facebook/",
            views.oauth2,
            {"client_class": FacebookOAuth2Client},
            name="accounts_oauth_facebook",
        ),
        path(
            "oauth/google/",
            views.oauth2,
            {"client_class": GoogleOAuth2Client},
            name="accounts_oauth_google",
        ),
        path(
            "oauth/microsoft/",
            views.oauth2,
            {"client_class": MicrosoftOAuth2Client},
            name="accounts_oauth_microsoft",
        ),
        path(
            "oauth/twitter/",
            views.oauth2,
            {"client_class": TwitterOAuthClient},
            name="accounts_oauth_twitter",
        ),
        path("email/", views.email_registration, name="email_registration"),
        path(
            "email/<str:code>/",
            views.email_registration,
            name="email_registration_confirm",
        ),
        path("logout/", views.logout, name="logout"),
    ]

``views.oauth2`` also accepts ``email_login`` and ``post_login_response``
arguments which replace the functions of the same name in ``authlib.views``,
e.g. to remember in the session that a user logged in through an OAuth
provider:

.. code-block:: python

    def sso_email_login(request, **kwargs):
        user, created = views.email_login(request, **kwargs)
        if user:
            request.session["is_sso"] = True
        return user, created

    path(
        "oauth/google/",
        views.oauth2,
        {"client_class": GoogleOAuth2Client, "email_login": sso_email_login},
        name="accounts_oauth_google",
    )

``views.login`` accepts ``post_login_response`` too, and
``views.email_registration`` accepts both.

``views.oauth2`` passes the rest of the data it got from the provider to
``email_login`` as ``user_data``. All bundled clients provide a ``full_name``
there (when the provider has one), which allows persisting the name on a user
model which has a field for it (``authlib.little_auth.User`` hasn't):

.. code-block:: python

    def email_login_with_name(request, *, email, user_data=None):
        user, created = views.email_login(request, email=email)
        if user and not user.full_name and user_data and user_data.get("full_name"):
            user.full_name = user_data["full_name"]
            user.save(update_fields=["full_name"])
        return user, created

``views.email_registration`` calls ``email_login`` without ``user_data``, since
a magic link only carries the email address.


Admin OAuth2
============

The ``authlib.admin_oauth`` app allows using Google or Microsoft OAuth2 to allow all
users with the same email domain to authenticate for Django's
administration interface. You have to use authlib's authentication
backend (``EmailBackend``) for this.

Installation is as follows:

- Follow the steps in the "Usage" section above.
- Add ``authlib.admin_oauth`` to your ``INSTALLED_APPS`` before
  ``django.contrib.admin``, so that our login template is picked up.
- Add ``GOOGLE_CLIENT_ID`` and ``GOOGLE_CLIENT_SECRET`` to your settings
  as described above.
- Add a ``ADMIN_OAUTH_PATTERNS`` setting. The first item is the domain,
  the second the email address of a staff account. If no matching staff
  account exists, authentication fails:

.. code-block:: python

    ADMIN_OAUTH_PATTERNS = [
        (r"@example\.com$", "admin@example.com"),
    ]

- Add an entry to your URLconf:

.. code-block:: python

    urlpatterns = [
        path("", include("authlib.admin_oauth.urls")),
        # ...
    ]

- Add ``https://yourdomain.com/admin/__oauth__/`` as a valid redirect
  URI in your Google developers console.

Please note that the ``authlib.admin_oauth.urls`` module assumes that the admin
site is registered at ``/admin/``. If this is not the case you can integrate
the view yourself under a different URL.

It is also allowed to use a callable instead of the email address in the
``ADMIN_OAUTH_PATTERNS`` setting; the callable is passed the result of matching
the regex. If a resulting email address does not exist, authentication (of
course) fails:

.. code-block:: python

    ADMIN_OAUTH_PATTERNS = [
        (r"^.*@example\.org$", lambda match: match[0]),
    ]

Note the ``^.*`` in the pattern above: the callable is passed the match, not
the email address, so ``match[0]`` is only the part of the address which the
pattern matched. ``(r"@example\.org$", lambda match: match[0])`` returns
``"@example.org"`` and therefore never authenticates anyone. Use
``match.string`` if you'd rather not anchor the pattern. A system check
(``authlib.E004``) runs the callables in ``ADMIN_OAUTH_PATTERNS`` against an
example address generated from their pattern and complains if what comes back
isn't an email address, so this class of mistake fails ``manage.py check``
instead of only failing logins.

Failing logins are logged to the ``authlib.admin_oauth`` logger together with
the addresses the patterns produced; the message shown in the browser only
mentions the visitor's own address, since anyone with an account at the OAuth
provider can reach the view.

If a pattern succeeds but no matching user with staff access is found
processing continues with the next pattern. This means that you can
authenticate users with their individual accounts (if they have one) and
fall back to an account for everyone having a Google email address on
your domain:

.. code-block:: python

    ADMIN_OAUTH_PATTERNS = [
        (r"^.*@example\.org$", lambda match: match[0]),
        (r"@example\.com$", "admin@example.com"),
    ]

You could also remove the fallback line; in this case users can only
authenticate if they have a personal staff account.

Staff accounts can also be created on the fly. Set
``ADMIN_OAUTH_CREATE_USER_CALLBACK`` to the dotted path of a callable; it is
called with the request and an address produced by ``ADMIN_OAUTH_PATTERNS``
when no user with that address exists, and may (but doesn't have to) create
one. ``"authlib.admin_oauth.views.create_superuser"`` creates superusers. The
callable also receives the data the OAuth provider returned as a ``user_data``
keyword argument, e.g. to persist the name. Callables which don't accept
``user_data`` are still called without it, but that's deprecated: the system
check ``authlib.W004`` and a ``DeprecationWarning`` at call time point them
out.

.. code-block:: python

    def create_staff_user(request, email, *, user_data):
        User.objects.create(
            email=email,
            full_name=user_data.get("full_name") or "",
            is_staff=True,
        )

``user_data`` describes the visitor, whose address (``user_data["email"]``)
isn't necessarily the address the callable is asked to create an account for.

Disabling passwords in the admin
================================

Single sign-on with an identity provider which enforces MFA is only worth
something if a password can't be used instead: as long as Django still accepts
one, the MFA is optional in practice. ``disable_passwords`` closes the admin
site's login form and its password change page:

.. code-block:: python

    from django.contrib import admin
    from django.urls import include, path

    from authlib.admin_oauth.passwords import disable_passwords

    disable_passwords(admin.site)

    urlpatterns = [
        path("", include("authlib.admin_oauth.urls")),
        path("admin/", admin.site.urls),
        # ...
    ]

The login page then only shows the single sign-on buttons: no username and
password inputs are rendered at all, and a POST with credentials is refused
before ``authenticate()`` is called, so no password ever reaches an
authentication backend. The password change page only explains itself.

Call ``disable_passwords`` before the admin site's URLs are built -- at the top
of the ROOT_URLCONF module as above, or in an ``admin.py``, which is imported
while the apps are loading. The ``authlib.E012`` system check complains if the
call came too late for the password change page.

Locally you probably have no OAuth credentials, so skip the call in
development:

.. code-block:: python

    if not settings.DEBUG:
        disable_passwords(admin.site)

The templates come from ``authlib.admin_oauth``; pass ``login_template`` and/or
``password_change_template`` to use your own (``authlib.E011`` tells you if a
template cannot be loaded).

Only the admin site is affected. Everything else which sets passwords keeps
working: ``django.contrib.auth``'s password change and password reset views,
the user admin's "change password" form, ``manage.py changepassword``. The
first two matter more than they look, since any active staff session gets into
the admin whether or not the admin's login form created it -- so ``manage.py
check`` warns (``authlib.W003``) when they appear in the URLconf. If the
non-staff users of your site do need passwords, silence the check:

.. code-block:: python

    SILENCED_SYSTEM_CHECKS = ["authlib.W003"]

Little Auth
===========

The ``authlib.little_auth`` app contains a basic user model with email
as username that can be used if you do not want to write your own user
model but still profit from authlib's authentication support.

Usage is as follows:

- Add ``authlib.little_auth`` to your ``INSTALLED_APPS``
- Set ``AUTH_USER_MODEL = "little_auth.User"``
- Optionally also follow any of the steps above.

Email Registration
==================

For email registration to work, two templates are needed:

* ``registration/email_registration_email.txt``
* ``registration/email_registration.html``


A starting point would be:

``email_registration_email.txt``:

.. code-block:: text


    Subject (1st line)

    Body (3rd line onwards)
    {{ url }}
    ...


``email_registration.html``:

.. code-block:: html


    {% if messages %}
    <ul class="messages">
        {% for message in messages %}
        <li{% if message.tags %} class="{{ message.tags }}"{% endif %}>
            {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}Important: {% endif %}
            {{ message }}
        </li>
        {% endfor %}
    </ul>
    {% endif %}

    {% if form.errors and not form.non_field_errors %}
    <p class="errornote">
        {% if form.errors.items|length == 1 %}
        {% translate "Please correct the error below." %}
        {% else %}
        {% translate "Please correct the errors below." %}
        {% endif %}
    </p>
    {% endif %}

    {% if form.non_field_errors %}
    {% for error in form.non_field_errors %}
    <p class="errornote">
        {{ error }}
    </p>
    {% endfor %}
    {% endif %}

    <form action='{% url "email_registration" %}' method="post" >
        {% csrf_token %}
        <table>
            {{ form }}
        </table>
        <input type="submit" value="login">
    </form>

The above template is inspired from:

* `Messages Django documentation <https://docs.djangoproject.com/en/dev/ref/contrib/messages/#displaying-messages>`_
* `Django login template <https://github.com/django/django/blob/67d0c4644acfd7707be4a31e8976f865509b09ac/django/contrib/admin/templates/admin/login.html#L21-L44>`_

More details are documented in `the relevant module <https://github.com/feincms/django-authlib/blob/main/authlib/email.py>`_.

Magic links are signed, not stored: they work any number of times, from any
device and browser, until they expire. Anyone who gets hold of a link before
then -- because it was forwarded, or ended up in a log -- can log in with it
too. The links expire after three hours by default; pass a lower ``max_age``
(in seconds) to the confirmation URL's ``views.email_registration`` to shorten
that window:

.. code-block:: python

    path(
        "email/<str:code>/",
        views.email_registration,
        {"max_age": 15 * 60},
        name="email_registration_confirm",
    ),

Making links single-use or binding them to the browser which requested them
would close that window, but both have costs which made us leave them out:

- Corporate mail gateways and virus scanners routinely open every link in an
  email before the recipient does. A link which is consumed on first use is
  gone before the user ever clicks it.
- Requesting a link on one device and opening the email on another is common,
  and many mobile mail apps open links in their own browser with a separate
  cookie jar. A link bound to the requesting browser with a cookie fails in
  all those cases, with an error users can't make sense of. It doesn't help
  against a compromised mailbox either, since whoever reads the mail can
  request a link themselves.

If browser binding is worth that to you, the building blocks exist: generate a
random value, pass it to ``form.send_mail(payload=...)`` so that it's signed
into the link, and set it as a cookie on the response. In your own
confirmation view, ``authlib.email.decode()`` returns the payload alongside the
email address; refuse the login unless it matches the cookie
(``secrets.compare_digest``).


Roles
=====

``authlib.roles`` provides a lightweight role-based permission system for
staff users. Instead of assigning individual Django permissions to each
staff member, you define named roles in ``AUTHLIB_ROLES`` and attach a
permission-checking callback to each role.

``RoleField`` is a ``CharField`` that stores the role on the user model and
hooks into Django's permission system. ``authlib.little_auth.User`` already
includes a ``role = RoleField()`` field, so if you use *Little Auth* you only
need to configure the setting.

Setup
-----

Add ``authlib.backends.RolePermissionsBackend`` to
``AUTHENTICATION_BACKENDS``, **before** any backend that checks
database-level permissions (such as ``ModelBackend`` or ``EmailBackend``):

.. code-block:: python

    AUTHENTICATION_BACKENDS = [
        "authlib.backends.RolePermissionsBackend",
        "authlib.backends.EmailBackend",   # or any other auth backend
    ]

``RolePermissionsBackend`` routes ``has_perm()`` calls to the role callback
injected by ``RoleField``.  It also implements ``get_all_permissions()`` by
iterating every permission in the database through the callback, which is
what drives the Django admin's per-app sidebar visibility.

It answers permission checks and nothing else -- it authenticates nobody, so
it has to be accompanied by a backend which does. Add
``django.contrib.auth.backends.ModelBackend`` if your project has
username/password logins. (Until it was renamed this class extended
``ModelBackend``, so password logins used to run through it, which nothing
told you about.)

``RolePermissionsBackend`` must come first for two reasons: it avoids an
unnecessary database query when the role callback already has an answer, and
it ensures that ``deny`` patterns cannot be bypassed by a database-level
permission grant that would otherwise short-circuit the check.

Configuration
-------------

Add ``AUTHLIB_ROLES`` to your settings. Each key is a role identifier; each
value is a dict with at minimum a ``"title"`` (used as the human-readable
choice label) and optionally a ``"callback"`` function:

.. code-block:: python

    from functools import partial
    from django.utils.translation import gettext_lazy as _
    from authlib.roles import allow_deny_globs

    AUTHLIB_ROLES = {
        "default": {
            "title": _("Default"),
            # No callback → no extra permissions beyond Django's own checks
        },
        "readonly": {
            "title": _("Read-only"),
            # Grant all view permissions, nothing else
            "callback": partial(allow_deny_globs, allow={"*.view_*"}),
        },
        "editor": {
            "title": _("Editor"),
            # Grant everything except user/auth management
            "callback": partial(
                allow_deny_globs,
                allow={"*"},
                deny={"auth.*", "little_auth.*", "admin_sso.*"},
            ),
        },
        "support": {
            "title": _("Support"),
            # Grant all permissions
            "callback": partial(allow_deny_globs, allow={"*"}),
        },
    }

The callback receives three keyword arguments: ``user``, ``perm``, and
``obj``.  It should return ``True`` to grant the permission, raise
``django.core.exceptions.PermissionDenied`` to explicitly deny it, or return
a falsy value to let Django's normal permission checks continue.

When only one role is defined the ``RoleField`` renders as a hidden input in
forms, so you can add the field to existing models without cluttering the UI.

``allow_deny_globs``
--------------------

``authlib.roles.allow_deny_globs`` is a ready-made callback that matches the
permission string (``"app_label.codename"``) against two lists of
``fnmatch``-style glob patterns:

- ``deny`` – patterns checked first; a match raises ``PermissionDenied``.
- ``allow`` – patterns checked second; a match grants the permission.

Use ``functools.partial`` to bind the pattern lists, as shown above.

Because permissions are matched by glob rather than enumerated explicitly,
roles automatically cover new models as they are added.  For example, an
``"editor"`` role with ``allow={"cms.*"}`` will grant access to every new
CMS plugin model without any manual permission assignment.

Adding ``RoleField`` to a custom user model
-------------------------------------------

If you are not using *Little Auth*, add the field to your own user model:

.. code-block:: python

    from authlib.roles import RoleField

    class MyUser(AbstractBaseUser, ...):
        role = RoleField()

Then run ``makemigrations``.  The field's ``deconstruct`` method omits the
choices from the migration so that adding or renaming roles does not require
a new migration.
