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.
The login flow follows the OpenID Connect flow. A summary:
- The user accesses the "login" view (optionally with a
next
query 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
next
query 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_url
and 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_user
andexpected_user
placeholders) - NENS_AUTH_ERROR_INVITATION_WRONG_EMAIL (accepts
actual_email
andexpected_email
placeholders)
(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.