login

https://gitlab.com/kb/login

Management Commands

login_usernames

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

django-admin.py login_usernames out.csv

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 Microsoft Azure Portal.

Add the following to requirements/base.txt:

mozilla-django-oidc==

Tip

See 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 Microsoft Azure.

How does it work?

Tip

For Ember oidc auth, see Ember Authentication.

Start by clicking on the link:

_images/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 Ember Authentication.

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 Microsoft Azure for more information), update the Salt pillar file for your site e.g:

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 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:

<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/