This library defines the necessary views and models to connect the AWS Cognito user pool to the local django user database.
The nens-auth-client library exposes one django application: nens_auth_client.
The django built-in apps auth, sessions and contenttypes are
also required, but they probably are already there.
To add nens_auth_client to a django project, add the following to the requirements.txt:
--extra-index-url https://packages.lizard.net ... nens-auth-client ...
Add these to the INSTALLED_APPS setting. Make sure your project's app is
listed before nens_auth_client:
INSTALLED_APPS = [
...
"nens_auth_client",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
...
]
Modify the authentication backends as follows:
AUTHENTICATION_BACKENDS = [
"nens_auth_client.backends.RemoteUserBackend",
"nens_auth_client.backends.SSOMigrationBackend",
# ^^^ only for sites with existing users (see below)
"nens_auth_client.backends.AcceptNensBackend",
# ^^^ only for sites meant for N&S users (see below)
"nens_auth_client.backends.TrustedProviderMigrationBackend",
# ^^^ only when you want to migrate users between backends (see below)
"django.contrib.auth.backends.ModelBackend",
# ^^^ only if you still need local login (e.g. admin)
]
Set the authorization server (the "issuer"):
NENS_AUTH_ISSUER = "https://cognito-idp.eu-west-1.amazonaws.com/...."
Identify your application as a unique OpenID Connect Client:
NENS_AUTH_CLIENT_ID = "..." # generate one on AWS Cognito NENS_AUTH_CLIENT_SECRET = "..." # generate one on AWS Cognito
Include the nens-auth-client urls in your application's urls.py:
from django.conf.urls import include
urlpatterns = [
...
path("accounts/", include("nens_auth_client.urls", namespace="auth")),
...
]
You must register the absolute authorize and logout-success URIs in
AWS Cognito.
If the site runs on multiple domains, they all have to be registered. Wildcards
are not possible because of security reasons.
The admin and djangorestframework login / logout views should be overridden. Otherwise these views still try to authenticate in the local (Django) database. Achieve this as follows (in urls.py):
from nens_auth_client.urls import override_admin_auth
from nens_auth_client.urls import override_rest_framework_auth
urlpatterns = [
...
*override_admin_auth(),
path("admin/", admin.site.urls), # is probably already there
...
*override_rest_framework_auth(), # only if you use rest_framework
path("api-auth/", include("rest_framework.urls")),
...
]
The override always goes before the corresponding include.
Note that if you use a non-standard path it should be given as argument to
the override, e.g. override_admin_auth("my-custom-admin-path").
The path admin/local-login is added (by the override) for emergency access.
If not done already for your project, set up a working email backend and a sender ('from') email address:
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST = ... DEFAULT_FROM_EMAIL = ...
See https://docs.djangoproject.com/en/2.2/topics/email/ for further information.
There are two types of invitees: existing users and new users. Existing users, for example, may be invited to join another organisation. They already have an account that they can use to sign in. New users, however, will have to sign up first. The following settings are required for this to work:
NENS_AUTH_REGION_NAME = "..." # AWS region code NENS_AUTH_ACCESS_KEY_ID = "..." # Access key ID of the AWS IAM user NENS_AUTH_SECRET_ACCESS_KEY = "..." # Secret access key of the AWS IAM user NENS_AUTH_USER_POOL_ID = "..." # User pool ID in AWS Cognito
The login flow follows the OpenID Connect flow. A summary:
- The user accesses the "login" view (optionally with a
nextquery parameter). - The user is redirected to the Authorization Server (AWS Cognito).
- The user logs in on the Authorization server.
- The user is redirected to the "authorize" view with an authorization code.
- The "authorize" view contains code to exchange the code for an ID Token (at the Authorization Server).
- The ID token contains a "sub" (subject) claim, which is a unique identifier of the user. A RemoteUser is looked up with a matching "external_user_id". The associated Django user is logged in. If the user does not exist, the server responds with a 403 Permission Denied, unless an invitation was included in step 1. (see First-time login section)
- The User's metadata (email, first_name, last_name) is updated from the claims in the ID token.
- The user is redirected to the 'next' URL provided in step 1.
The logout flow follows a similar flow:
- The user accesses the "logout" view (optionally with a
nextquery parameter). - The user is logged out locally and is redirected to the Authorization Server's logout view.
- The Authorization Server logs the user out.
- The user is redirected to the "logout-success" view.
- The user is redirected to the 'next' URL provided in step 1.
Optionally set defaults for the redirects after successful login/logout:
NENS_AUTH_DEFAULT_SUCCESS_URL = "/welcome/" NENS_AUTH_DEFAULT_LOGOUT_URL = "/goodbye/"
For first-time logins, there is no RemoteUser object to match the external
user ID with a local django user. In this case, users are accepted only if the
user presents a valid invitation (or when using TrustedProviderMigrationBackend, see below).
This is because there is no way to safely
match external user ids to local django users.
There are two kinds of invitations: invitations with user, and invitations
without. If the invitation has a user set, the external user id will be
connected to that user (through a RemoteUser). If the invitation has no user
set, a new User + RemoteUser will be created. The local username will equal the
Cognito username field ("cognito:username").
Additionally, an invitation contains permissions to be assigned to the user.
Permissions are assigned through a PermissionBackend, that differs per app,
because each app has its own authorization model. This project has an
example implementation in permissions.py. This is the default backend:
NENS_AUTH_PERMISSION_BACKEND = "nens_auth_client.permissions.DjangoPermissionBackend"
The default DjangoPermissionBackend expects natural keys of django's builtin
Permission objects like this:
{"user_permissions": [["add_invite", "nens_auth_client", "invite"]]}
Invitations can be accepted by users through the accept_invitation url,
which looks like this:
/accept_invitation/{secret invitation slug}/accept/?next=/admin/
If the user is logged in, the invitation is accepted and the user is redirected to (in this example) /admin/. If not, the user is first redirected to the login view (adding the invitation query parameter to do the first-time login).
The complete first-time user flow goes like this:
- https://my.site/invitations/abc123/accept/?next=/admin/
- https://my.site/login/?invitation=abc123&next=%2Finvitations%2Fabc123%2Faccept%2F%3Fnext%3D%2Fadmin%2F
- https://aws.cognito/login?...&redirect_uri=https://auth.lizard.net/authorize/
- https://my.site/authorize/
- https://my.site/invitations/abc123/accept/?next=/admin/
- https://my.site/admin/
Invitation objects can be created with and without an associated user. For invitations that have no associated user, a user will be created automatically when the invite is accepted.
Creation via the admin:
- Create an invitation. The "email" field is mandatory. Optionally provide "user", "permissions" and "created_by". The form of "permissions" depends on the permission backend. Note that the "email" is independent from the "user.email".
- Select the newly created invitation and use "(Re)send selected invitations"
in the dropdown at the top. This will send the invitation email.
Another option is to copy the
accept_urland supply that to the invited user by other means.
Programmatic creation:
- Create an Invitation object using
Invitation.objects.create. - Send the email using
invitation.send_email, or build your own logic usinginvitation.get_accept_url(request)to get the accept URL.
The invitation email can be changed by overriding the nens_auth_client/invitation.txt
and nens_auth_client/invitation.html templates. For this, your project's app
needs to be listed before nens_auth_client in the INSTALLED_APPS.
The default email subject is "Invitation" is the default subject.
Change the invitation email subject as follows:
NENS_AUTH_INVITATION_EMAIL_SUBJECT = "My-custom-subject" # this is the default
By default, an invitation is valid for 14 days. Change this as follows:
NENS_AUTH_INVITATION_EXPIRY_DAYS = 7
Invitation objects need to be cleaned periodically, or else the database table will keep growing. Use the management command clean_invitations for that, or wrap the nens_auth_client.models.clean_invitations function in a celery task and schedule it every day.
For apps with an existing user database, it may not be desirable to have every
user going through the invitation process (described above). For this we have the
SSOMigrationBackend. If the user's ID Token has "custom:from_sso": "1",
users are matched by username. On first-time login, a RemoteUser object is
created to link the external and local users permanently.
For some sites we might want to automatically create local users if they log in
from a trusted identity provider. For such sites, enable the
TrustedProviderMigrationBackend and add a NENS_AUTH_TRUSTED_PROVIDERS_NEW_USERS setting.
The setting contains the list of provider names (as configured in cognito) that we trust to
have correct email addresses.
If you want to auto-accept all users that authenticate through OAuth2, use a wildcard as follows:
NENS_AUTH_TRUSTED_PROVIDERS = ["*"] NENS_AUTH_TRUSTED_PROVIDERS_NEW_USERS = ["*"]
For (mostly-)internal sites that are intended for N&S users, sending
invitations seems unnecessary. For such sites, enable the
AcceptNensBackend in addition to the regular RemoteUserBackend. This
automatically accepts N&S users and creates a User object for them if it
doesn't exist already.
You can still invite other non-N&S users in the regular manner.
You probably don't need the SSOMigrationBackend, though, as N&S users get
accepted (and thus migrated) automatically. They can be used at the same
time, however, and the order in which they're placed doesn't matter.
Users that originally had an account in the regular cognito database might be in an organisation that now has coupled their azure AD as an external provider. If they try to log in via that external provider, they won't be allowed in as they have no connected user account.
The TrustedProviderMigrationBackend coupled with the setting
NENS_AUTH_TRUSTED_PROVIDERS solves it. The setting contains the list of
provider names (as configured in cognito) that we trust to have correct email
addresses. New users from that provider then are checked if they have an
existing account with the correct email address.
There is no check on email_verified as that turns out to be hard to
configure.
Any user that logs in can automatically be assigned permissions. This can be
implemented in the auto_assign(user, claims) method of a custom permission class,
which needs to be set on the NENS_AUTH_PERMISSION_BACKEND setting.
If your web application acts as a Resource Server in the Authorization Code
or Client Credentials Flow, then it will need to accept Bearer tokens in
http requests. nens-auth-client implements two methods for this:
Django middleware and Django REST framework authentication class.
In both cases, you need to configure the NENS_AUTH_RESOURCE_SERVER_ID setting, which
should match the one set in the AWS Cognito. It needs a trailing slash:
NENS_AUTH_RESOURCE_SERVER_ID = "..." # configure this on AWS Cognito
Option 1: middleware
The Django Middleware will log the user in without starting a session. It works
for all views. Additionaly, middleware will set the request.user.oauth2_scope
that your application may use for additional authorization logic.
Configure the middleware as follows:
MIDDLEWARE = [
...
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"nens_auth_client.middleware.AccessTokenMiddleware",
...
]
Option 2: REST framework authentication class
The REST framework authentication class will is only applicable to REST framework
views. After a token appears valid, it will set request.user and
request.auth.scope. Permission classes should use the scope for additional
authorization logic. By default (like in the built-in IsAuthenticated)
the scope is ignored, which may lead to more permissive behavior than expected.
Configure the authentication class:
REST_FRAMEWORK = {
(...)
"DEFAULT_AUTHENTICATION_CLASSES": [
"nens_auth_client.rest_framework.OAuth2TokenAuthentication",
(...)
]
}
Notes
When using a Bearer token, the external user ID ("sub" claim) must already be registered in
the app (as a RemoteUser). There is not much you can do about that because
bearer tokens typically do not include much information about the user. A user
should do a one-time login so that a RemoteUser is created. After that,
the user can be found by the "sub" claim in the access token.
For the Client Credentials Flow there isn't any user. For that, a RemoteUser
should be created manually (with external_user_id equaling the client_id.
This should be attached to some service account.
The authorize view may give several kinds of exceptions. See the relevant
docstring. These errors are unhandled by nens_auth_client, so that django's
built-in 403, 404, and 500 templates are used.
For overriding these views, see: https://docs.djangoproject.com/en/3.1/ref/views/#error-views
The error detail messages can be modified with the following settings:
- NENS_AUTH_ERROR_USER_DOES_NOT_EXIST
- NENS_AUTH_ERROR_USER_INACTIVE
- NENS_AUTH_ERROR_INVITATION_DOES_NOT_EXIST
- NENS_AUTH_ERROR_INVITATION_UNUSABLE
- NENS_AUTH_ERROR_INVITATION_EXPIRED
- NENS_AUTH_ERROR_INVITATION_WRONG_USER (accepts
actual_userandexpected_userplaceholders) - NENS_AUTH_ERROR_INVITATION_WRONG_EMAIL (accepts
actual_emailandexpected_emailplaceholders)
(Re)create & activate a virtualenv:
$ rm -rf .venv $ virtualenv .venv --python=python3 $ source .venv/bin/activate
Install package and run tests:
(virtualenv)$ pip install django==3.2 (virtualenv)$ pip install -e .[test] (virtualenv)$ pytest
For testing against an actual User Pool, configure the following environment variables:
NENS_AUTH_CLIENT_ID=...
NENS_AUTH_CLIENT_SECRET=...
NENS_AUTH_ISSUER=https://cognito-idp.{region}.amazonaws.com/{pool-id}
Note that github actions tests agains a variety of python/django versions, see
the .github/workflows/main.yml file.