login
*****

.. highlight:: python

https://gitlab.com/kb/login

- :doc:`dev-ember-auth`
- :doc:`sys-azure`

Management Commands
===================

create-user-system-generated
----------------------------

Create the ``SYSTEM_GENERATED`` user:

.. code-block:: bash

  django-admin create-user-system-generated

.. tip:: If you prefer to auto-create the ``SYSTEM_GENERATED`` user in code,
         then just call ``get_system_generated_user`` directly e.g.

::

  from login.util import get_system_generated_user
  system_generated_user = get_system_generated_user()

list-of-users
-------------

To create a CSV file containing the ``username`` and full name of all the active
users on your site:

.. code-block:: bash

  django-admin.py list-of-users out.csv

.. _app_login_openid_connect:

OpenID Connect
==============

.. note:: If you don't want to use OpenID Connect, then just set the following
          in ``settings/base.py`` (``USE_OPENID_CONNECT = False``).

Register your application in the :doc:`sys-azure` Portal.

Add the following to ``requirements/base.txt``::

  mozilla-django-oidc==

.. tip:: See :doc:`dev-requirements` for the current version...

Add the following to ``project/urls.py``::

  re_path(r"^oidc/", view=include("mozilla_django_oidc.urls")),

Add the following to ``settings/base.py``::

  MIDDLEWARE = (
      # ...
      "mozilla_django_oidc.middleware.SessionRefresh",
      "reversion.middleware.RevisionMiddleware",
  )

  THIRD_PARTY_APPS = (
      "mozilla_django_oidc",

.. note:: Probably best not to use ``SessionRefresh`` middleware on example
          apps (it will log you out every 15 minutes).

::

  # 17/01/2020, We are getting a redirect loop when we use this 'LOGIN_URL'
  # LOGIN_URL = reverse_lazy("oidc_authentication_init")
  LOGIN_URL = reverse_lazy("login")

  # https://mozilla-django-oidc.readthedocs.io/
  USE_OPENID_CONNECT = get_env_variable_bool("USE_OPENID_CONNECT")
  AUTHENTICATION_BACKENDS = ("login.service.KBSoftwareOIDCAuthenticationBackend",)
  LOGIN_REDIRECT_URL_FAILURE = reverse_lazy("login")
  OIDC_CREATE_USER = False
  OIDC_OP_AUTHORIZATION_ENDPOINT = get_env_variable(
      "OIDC_OP_AUTHORIZATION_ENDPOINT"
  )
  OIDC_OP_JWKS_ENDPOINT = get_env_variable("OIDC_OP_JWKS_ENDPOINT")
  OIDC_OP_TOKEN_ENDPOINT = get_env_variable("OIDC_OP_TOKEN_ENDPOINT")
  OIDC_OP_USER_ENDPOINT = "NOT_USED_BY_KB_LOGIN_SERVICE"
  OIDC_RP_CLIENT_ID = get_env_variable("OIDC_RP_CLIENT_ID")
  OIDC_RP_CLIENT_SECRET = get_env_variable("OIDC_RP_CLIENT_SECRET")
  OIDC_RP_SIGN_ALGO = get_env_variable("OIDC_RP_SIGN_ALGO")
  OIDC_USE_NONCE = get_env_variable_bool("OIDC_USE_NONCE")

.. warning:: Double check your settings files to make sure you don't have other
             ``AUTHENTICATION_BACKENDS`` configured
             i.e. The only ``AUTHENTICATION_BACKENDS`` in your project should
             be the one in the section shown above.

.. warning:: Don't forget to set ``LOGIN_REDIRECT_URL_FAILURE`` for Django
             projects which are the API for an Ember app.
             If you do forget, the default URL is ``/`` which will not exist
             for a Django *backend* project.

.. tip:: You may prefer to set ``LOGIN_REDIRECT_URL_FAILURE`` to
         ``reverse_lazy("project.home")``.

Add the following to ``settings/local.py``::

  KB_TEST_EMAIL_FOR_OIDC = get_env_variable("KB_TEST_EMAIL_FOR_OIDC")
  KB_TEST_EMAIL_USERNAME = get_env_variable("KB_TEST_EMAIL_USERNAME")

  OPEN_ID_CONNECT_EMBER_REDIRECT_URI = "http://localhost:4200"

.. note:: The ``KB_TEST_EMAIL_``  settings are used by the
          ``demo_data_login_oidc`` management command.

.. note:: The ``OPEN_ID_CONNECT_EMBER_REDIRECT_URI`` needs to match your
          ``ember`` apps host name and port.

Add the following to ``settings/production.py``::

  OPEN_ID_CONNECT_EMBER_REDIRECT_URI = get_env_variable("HOST_NAME")

.. note:: Using ``HOST_NAME`` will work as long as the Django site is on the
          same host as the Ember site.

Add the following to ``.gitlab.ci``::

  test:
    script:
    - export KB_TEST_EMAIL_FOR_OIDC="patrick@kbsoftware.co.uk"
    - export KB_TEST_EMAIL_USERNAME="admin"
    - export OIDC_CREATE_USER=False
    - export OIDC_OP_AUTHORIZATION_ENDPOINT="http://localhost:1235/"
    - export OIDC_OP_JWKS_ENDPOINT="http://localhost:1236/"
    - export OIDC_OP_TOKEN_ENDPOINT="http://localhost:1237/"
    - export OIDC_OP_USER_ENDPOINT="http://localhost:1238/"
    - export OIDC_RP_CLIENT_ID="my-oidc-client-id"
    - export OIDC_RP_CLIENT_SECRET="my-oidc-client-secret"
    - export OIDC_RP_SIGN_ALGO="RS256"
    - export OIDC_USE_NONCE=False
    - export USE_OPENID_CONNECT=True

Add the following to your environment e.g. ``.env.fish``::

  set -x USE_OPENID_CONNECT "True"
  set -x KB_TEST_EMAIL_USERNAME "admin"

Set-up your ``.private`` file using the information from :doc:`sys-azure`.

How does it work?
-----------------

.. tip:: For Ember ``oidc`` auth, see :doc:`dev-ember-auth`.

Start by clicking on the link:

.. image:: ./misc/2022-02-07-oidc-login-verify-its-you.png

To browse to ``/oidc/authenticate/``::

  # site-packages/mozilla_django_oidc/
  url(r'^authenticate/$',
      OIDCAuthenticationRequestView.as_view(),
      name='oidc_authentication_init'),

``OIDCAuthenticationRequestView`` will return a redirect URL to the third
party authentication provider using the ``OIDC_OP_AUTHORIZATION_ENDPOINT``
e.g.

https://login.microsoftonline.com/3ab1a2b2/oauth2/v2.0/authorize?response_type=code&scope=openid+email&client_id=386e3c7&redirect_uri=https://www.hatherleigh.info/oidc/callback/&state=YdIhnM3

The browser will make an HTTP request to the URL of the third party
authentication provider, passing a ``client_id`` and ``redirect_uri``
(the ``redirect_uri`` includes a randomly generated ``state``)

The third part authentication provider will authenticate the user before
calling the ``redirect_uri``::

  # site-packages/mozilla_django_oidc/
  url(r'^callback/$',
      OIDCAuthenticationCallbackView.as_view(),
      name='oidc_authentication_callback'),

The ``OIDCAuthenticationCallbackView`` will use our
``login.service.KBSoftwareOIDCAuthenticationBackend`` to log the user in
before returning a redirect to the ``LOGIN_REDIRECT_URL``.

The browser will redirect to the ``LOGIN_REDIRECT_URL``.

.. tip:: For Ember ``oidc`` auth, see :doc:`dev-ember-auth`.

Diagostics
----------

.. warning:: Ask the user which app they are trying to log into.
             This will make sure we check the correct log files!!

Check the log file e.g::

  [07/Feb/2023 17:11:04] DEBUG [mozilla_django_oidc.auth:340]
  Login failed: No user with email patrick.kimber@hatherleigh.onmicrosoft.com found, and OIDC_CREATE_USER is False

OIDC Login will only work if one of your users has a matching email address.

If you have trouble logging a user in, then you can display the email address
by adding a ``print`` statement to::

  venv/lib/python3.10/site-packages/mozilla_django_oidc/auth.py

e.g::

  def get_or_create_user(self, access_token, id_token, payload):
      user_info = self.get_userinfo(access_token, id_token, payload)
      email = user_info.get("email")
      print(email)

Check the user ``is_active``:

  If the user is no longer active, then this may be the reason for a login fail.

Testing / Debug
---------------

If your ``AUTHENTICATION_BACKENDS`` are set to use OIDC, then tests using the
``ModelBackend`` for authentication will fail.  To fix this, add the following
to the beginning of each test::

  @pytest.mark.django_db
  def test_create(client, settings):
      settings.AUTHENTICATION_BACKENDS = [
          "django.contrib.auth.backends.ModelBackend"
      ]

.. note:: This fix will also allow our ``perm_check`` fixture to work.

When *testing* (**on your laptop**) you can use the ``demo_data_login_oidc``
management command to update the email address for the ``staff`` user:

1. Use ``.env.fish`` to set the ``KB_TEST_EMAIL_FOR_OIDC``
   and ``KB_TEST_EMAIL_USERNAME`` environment variables.
   This will need to match the email address of the user in the Azure portal.
2. Run the ``django-admin.py demo_data_login_oidc`` management command to update
   the email address for the ``KB_TEST_EMAIL_USERNAME`` user to match the
   ``KB_TEST_EMAIL_FOR_OIDC`` environment variable:

Deployment
----------

Using the information from your ``.private`` file
(see above and :doc:`sys-azure` for more information),
update the Salt ``pillar`` file for your site e.g:

.. code-block:: yaml

  sites:
    my_site:
      package: bpm
      profile: django
      env:
        use_openid_connect: True
        oidc_op_authorization_endpoint: "https://login.microsoftonline.com/fcee251/oauth2/v2.0/authorize"
        oidc_op_jwks_endpoint: "https://login.microsoftonline.com/common/discovery/v2.0/keys"
        oidc_op_token_endpoint: "https://login.microsoftonline.com/fcee251/oauth2/v2.0/token"
        oidc_rp_client_id: "36ad9"
        oidc_rp_client_secret: "aead6"
        oidc_rp_sign_algo: "RS256"
        oidc_use_nonce: False

Password
========

Brute Force
-----------

`django-axes`_ will lock out repeated attempts from the same IP address.

To configure:

Add ``django-axes`` to ``requirements/base.txt``

Add ``axes`` to ``THIRD_PARTY_APPS`` in ``settings/base.py``::

  THIRD_PARTY_APPS = (
      'axes',

Configure in ``settings/base.py``::

  from datetime import timedelta
  AXES_COOLOFF_TIME = timedelta(minutes=15)
  AXES_FAILURE_LIMIT = 5
  AXES_LOCKOUT_TEMPLATE = 'login/axes_lockout_template.html',
  AXES_PASSWORD_FORM_FIELD = 'password1'

.. note:: ``AXES_COOLOFF_TIME`` configures a 15 minute cooling off period before
          the next login attempt can be made.

.. note:: The ``axes_lockout_template.html`` is in the ``login`` app.

To administer Axes, the ``admin`` app has a list of *Access attempts* and
*Access logs* at ``/admin/axes/``.

Reset all lockouts and access records::

  django-admin.py axes_reset

Clear lockout/records for an ip address::

  django-admin.py axes_reset ip

Reset
-----

When a user (or non-user) attemps to reset their password, the ``Notify`` users
are emailed.  For logic, see:
https://gitlab.com/kb/login/blob/master/login/forms.py

To use Google ReCaptcha on the password reset form,
use the ``RECAPTCHA_PRIVATE_KEY`` settings.
For details, see :doc:`dev-captcha`.

I think this is a potential risk for a DOS attack.  If we get a DOS attack
then we could use the ``PasswordResetAudit`` model to limit the number of
notification emails we send:
https://gitlab.com/kb/login/blob/master/login/models.py

To see an audit of password reset attempts, browse to
``/accounts/password/reset/audit/report/``

Validation
----------

.. tip:: Unit tests for this feature are in the ``login`` app.

To add password validators, just add them to this list in ``settings/base.py``.

We have these validators working on a live project::

  AUTH_PASSWORD_VALIDATORS = [
      {
         'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
      },
      {
          'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
          'OPTIONS': {
              'min_length': 8,
          }
      },
  ]

For documentation, see `Enabling password validation`_...

Register
========

If you want to allow users to register on your site, add the following to
``urls.py``::

  from login.views import RegisterCreateView

  url(regex=r'^accounts/register/$',
      view=RegisterCreateView.as_view(),
      name='register'
      ),

Staff
=====

If you want a member of staff to be able to update user names and passwords for
other users:

Create a couple of views in ``views.py``.  This will allow you to set the
success URL for your project::

  from login.views import (
      UpdateUserNameView,
      UpdateUserPasswordView,
  )

  class MyUpdateUserNameView(UpdateUserNameView):

      def get_success_url(self):
          return reverse('example.test')

  class MyUpdateUserPasswordView(UpdateUserPasswordView):

      def get_success_url(self):
          return reverse('example.test')

Add the views to ``urls.py``::

  from .views import (
      MyUpdateUserNameView,
      MyUpdateUserPasswordView,
  )

  url(regex=r'^accounts/user/(?P<pk>\d+)/username/$',
      view=MyUpdateUserNameView.as_view(),
      name='update_user_name',
      ),
  url(regex=r'^accounts/user/(?P<pk>\d+)/password/$',
      view=MyUpdateUserPasswordView.as_view(),
      name='update_user_password',
      ),

You can use these views in your project as follows:

.. code-block:: html

  <td>
    <a href="{% url 'update_user_name' u.pk %}">
      <i class="fa fa-edit"></i>
      {{ u.username }}
    </a>
  </td>
  <td>
    <a href="{% url 'update_user_password' u.pk %}">
      <i class="fa fa-edit"></i>
      ********
    </a>
  </td>

Templates
=========

If you want to override the ``login`` templates with your own versions...

Update ``settings/base.py`` to search the ``project/templates/`` folder before
the app folders::

  from pathlib import Path

  BASE_DIR = Path(__file__).resolve().parent.parent

  TEMPLATES = [
      {
          "BACKEND": "django.template.backends.django.DjangoTemplates",
          "DIRS": [BASE_DIR / "project" / "templates"],
          "APP_DIRS": True,
          "OPTIONS": {

e.g::

  project/templates/login/password_reset.html
  project/templates/login/login.html

For more details, see
https://docs.djangoproject.com/en/3.2/howto/overriding-templates/


.. _`django-axes`: https://django-axes.readthedocs.io/en/latest/configuration.html
.. _`Enabling password validation`: https://docs.djangoproject.com/en/2.0/topics/auth/passwords/#enabling-password-validation