Skip to content

Commit

Permalink
Merge pull request #97 from mitodl/jkachel/add-third-party-auth-support
Browse files Browse the repository at this point in the history
Adds third-party auth support to the app.
  • Loading branch information
jkachel authored Mar 27, 2024
2 parents ab2fdd8 + 8a26139 commit 5c2a9b6
Show file tree
Hide file tree
Showing 29 changed files with 1,079 additions and 72 deletions.
11 changes: 8 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,16 @@ MITOL_PAYMENT_GATEWAY_CYBERSOURCE_MERCHANT_ID=sample-setting
MITOL_PAYMENT_GATEWAY_CYBERSOURCE_MERCHANT_SECRET=sample-setting
MITOL_PAYMENT_GATEWAY_CYBERSOURCE_MERCHANT_SECRET_KEY_ID=sample-setting

KEYCLOAK_REALM=

KEYCLOAK_ADMIN_CLIENT_ID=
KEYCLOAK_ADMIN_CLIENT_SECRET=
KEYCLOAK_URL=https://keycloak.odl.local:8443
KEYCLOAK_REALM=odltest
KEYCLOAK_ADMIN_URL=
KEYCLOAK_ADMIN_SECURE=False

KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=

TRAEFIK_SECRET=
TRAEFIK_FORWARD_AUTH_ADMIN_URL=
TRAEFIK_ADMIN_PORT=8081
TRAEFIK_PORT=80
122 changes: 107 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,35 +37,47 @@ The following settings must be configured before running the app:

Sets the Django secret for the application. This just needs to be a random string.

If you're going to use the included Traefik Composer environment, also set these:

- `KEYCLOAK_ADMIN_URL`

Sets the base URL for the Keycloak instance. Do not append a trailing slash.

- `KEYCLOAK_ADMIN_CLIENT_ID`

Sets the client ID for the service account. (This should be in the `master` realm.)

- `KEYCLOAK_ADMIN_CLIENT_SECRET`

Sets the client secret for the service account.

- `KEYCLOAK_URL`
Sets the client secret for the service account in the `master` realm.

Sets the base URL for the Keycloak instance. Do not append a trailing slash.
- `KEYCLOAK_ADMIN_SECURE`

Check the Keycloak instance's certificates for validity. Defaults to True - set to False when running locally.

- `KEYCLOAK_REALM`

Sets the realm name that the application should use.

If you're going to use the included Traefik Composer environment, also set these:

Sets the realm that the forward auth should use for authentication.

- `KEYCLOAK_CLIENT_ID`

Sets the client ID for authentication within the realm.
Sets the client ID for authentication within the realm by the forward auth.

- `KEYCLOAK_CLIENT_SECRET`

Sets the client secret for authentication within the realm.
Sets the client secret for authentication within the realm by the forward auth.

- `TRAEFIK_FORWARD_AUTH_ADMIN_URL`

Sets base URI for the provider - this should be the _plain HTTP_ version of `KEYCLOAK_ADMIN_URL`.

- `TRAEFIK_PORT`

Sets the port that Traefik uses for incoming requests. (Usually, this is port 80.)

- `TRAEFIK_SECRET`
- `TRAEFIK_ADMIN_PORT`

Sets the secret Traefik will use for cookie generation (works like the Django `SECRET_KEY`).
Sets the port that Traefik's admin dashboard lives on. (8081 is a good choice.)

### Loading and Accessing Data

Expand All @@ -92,13 +104,93 @@ The app depends on an outside system to provide the authentication layer for its

#### Traefik

The base `docker-compose.yml` has labels added to support running this with Traefik on your local machine. Traefik can run outside of the UE Compose environment - it wiill pick up the configuration it needs automatically. The Compose file expects the Traefik network to be named `traefik-keycloak_default`. You will need a ForwardAuth configured to forward authentication to your Keycloak instance for auth. (The app then uses `X-Forwarded-User` to figure out who the user is.)
If you want to run the system behind a Traefik install, there is a separate Compose file that will start the app with Traefik for you. Use the `docker-compose-traefik.yml` file for this purpose. This will expose the service on port 80, and you should be able to get to the Traefik dashboard at port 8080. The Compose file is set up with the forward auth support necessary to talk to Keycloak. Make sure you've included the extra `.env` settings mentioned above.

If you don't have a Traefik instance set up, there's a `docker-compose-traefik.yml` file that you can use to set up a basic environment with a ForwardAuth that'll work for the app. Make sure the environment settings are set up properly (see above) and then bring the Compose environment up _in its own project_ - e.g., `docker-compose up -p traefik-keycloak -f docker-compose-traefik.yml`. If your project name is different, you'll have to change the base docker-compose file or create an override so that the `nginx` container sits in the right network.
The Traefik forward auth will need a client to authenticate users with. Create this client in the realm you want to use for user authentication. The steps to do this are:

1. Log into Keycloak Admin and navigate to the realm you want to use for authentication.
2. Go to Clients, then click Create client.
3. Fill out the form.
1. The `Client ID` can be any valid string - a good choice is `traefik-client`. Save this in your `.env` as `KEYCLOAK_CLIENT_ID`.
2. For local testing, it's OK to use `*` for both `Valid redirect URIs` and `Web origins`. This is not OK for anything attached to the Internet.
3. Make sure `Client authentication` is on, and `Standard flow` and `Implicit flow` are checked.
4. After you've saved the client, go to Credentials and copy out the `Client secret`. (You may need to manually cut and paste; the copy to clipboard button has never worked for me.) Set this in your `.env` as `KEYCLOAK_CLIENT_SECRET`.

Additionally, you'll need to create a service account for the application in Keycloak, as the app will need to call Keycloak directly to get user information. To do this:

1. Create a Client in the master realm that has "Client authentication" turned on, which should also allow you to turn on "Service account roles". (You can follow the same steps above for this; just make sure "Service account roles" is turned on.)
2. After saving, go to the Advanced tab and turn on "Use refresh tokens" and "Use refresh tokens for client credentials grant" under Open ID Connect Compatibility Modes. Save there (it has its own button).
3. Under Service Accounts Roles, click Assign Role and assign everything.

Then, specify the _client ID and secret_ as the `KEYCLOAK_ADMIN_CLIENT_ID` and `KEYCLOAK_ADMIN_CLIENT_SECRET` in the `.env` file.

Set `KEYCLOAK_ADMIN_URL` to the base HTTPS URL for the Keycloak instance. This does need to be the HTTPS one. For local development, you'll also want to set `KEYCLOAK_ADMIN_SECURE` to `False` so that calls to Keycloak are done without checking the SSL certificates. (This defaults to True.)

Finally, you will need a `TRAEFIK_FORWARD_AUTH_ADMIN_URL` set up in your `.env` file - this should be the same as `KEYCLOAK_ADMIN_URL` but is separated out so you can use plain HTTP for this. The forward auth handler for Traefik can't be set to ignore invalid (self-signed) SSL certificates, so it will fail to start if you're running Keycloak without a real certificate. (The Django OAuth2 code _can_ be set to be OK with invalid certs, hence the split.)

If you don't want to use the pack-in Traefik instance, you can attach the necessary tags to the `nginx` container by running the regular `docker-compose.yml` _with_ the `docker-compose-traefik-override.yml` file. You'll also likely want to create a `docker-compose-override.yml` to put the `nginx` container in the same network as your existing Traefik instance. You'll need to set up the forward auth as appropriate for your instance.

#### Others (APISIX)

The system is also set up to run using APISIX as the gateway (to an extent). Use the `docker-compose-apisix.yml` file to spin up the application with APISIX and etcd. You will need to define routes for the app and set up forward auth on your own.
The system is also set up to run using APISIX as the gateway (to an extent). Use the `docker-compose-apisix.yml` file to spin up the application with APISIX and etcd. The APISIX configuration is not ready for production use - it's only here for assist in local testing and development.

You'll need to define routes for APISIX before it will handle traffic for the appplication. Here are the steps to accomplish that:

1. In your Keycloak instance, create a new Client in the realm you are going to use for UE.
2. The `Client ID` can be any valid string - a good choice is `apisix-client`. Set this in your shell as `CLIENT_ID`.
1. For local testing, it's OK to use `*` for both `Valid redirect URIs` and `Web origins`. This is not OK for anything attached to the Internet.
2. Make sure `Client authentication` is on, and `Standard flow` and `Implicit flow` are checked.
3. After you've saved the client, go to Credentials and copy out the `Client secret`. (You may need to manually cut and paste; the copy to clipboard button has never worked for me.) Set this in your shell as `CLIENT_SECRET`.
2. Set the realm you're using in your shell as `OIDC_REALM`.
3. In your Keycloak Realm Settings, you should be able to find the OpenID Endpoint Configuration link. Copy/paste this somewhere - you'll need it later. Set this in your shell as `DISCOVERY_URL`.
4. From the `config/apisix/apisix.yml` file, get the `key` out. This should be on line 11. You can also reset it here if you wish. Set this as `API_KEY`.
5. Start the entire thing: `docker compose -f docker-compose-apisix.yml up`. This will bring up Universal Ecommerce and the APISIX instance.
6. Create an all-encompassing route for UE in APISIX. This uses the APISIX API - be sure to read this through before running it and fill out placeholders.
```bash
# Set variables - skip if you were doing this in each step above

API_KEY=<api key>
OIDC_REALM=<your Keycloak realm>
CLIENT_ID=<your client ID>
CLIENT_SECRET=<your client secret>
DISCOVERY_URL=<OpenID Endpoint Configuration link>

# Define upstream connection

curl "http://127.0.0.1:9180/apisix/admin/upstreams/2" \
-H "X-API-KEY: $API_KEY" -X PUT -d '
{
"type": "roundrobin",
"nodes": {
"nginx:8073": 1
}
}'

# Define the Universal Ecommerce wildcard route

postbody=$(cat << ROUTE_END
{
"uri": "/*",
"plugins":{
"openid-connect":{
"client_id": "${CLIENT_ID}",
"client_secret": "${CLIENT_SECRET}",
"discovery": "${DISCOVERY_URL}",
"scope": "openid profile",
"bearer_only": false,
"realm": "${OIDC_REALM}",
"introspection_endpoint_auth_method": "client_secret_post"
}
},
"upstream_id": 2
}
ROUTE_END
)

curl http://127.0.0.1:9180/apisix/admin/routes/ue -H "X-API-KEY: $API_KEY" -X PUT -d $postbody
```

You should now be able to get to the app via APISIX. There is an internal API at `http://ue.odl.local:9080/_/v0/meta/apisix_test_request/` that you can hit to see if it worked. The wildcard route above will route all UE traffic (or, more correctly, all traffic going into APISIX) through Keycloak and then into UE, so you should also be able to access the Django Admin through it if you've set your Keycloak user to be an admin.

## Code Generation

Expand Down
Empty file added authentication/__init__.py
Empty file.
8 changes: 8 additions & 0 deletions authentication/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Django Admin for authentication app"""

from django.contrib import admin

from authentication import models

admin.site.register(models.KeycloakUserToken)
admin.site.register(models.KeycloakAdminToken)
197 changes: 197 additions & 0 deletions authentication/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""API functions for authentication."""

import logging

import requests
from django.conf import settings
from django.contrib.auth import get_user_model
from oauthlib.oauth2 import (
BackendApplicationClient,
InvalidGrantError,
TokenExpiredError,
)
from requests_oauthlib import OAuth2Session

from authentication.models import KeycloakAdminToken
from unified_ecommerce.celery import app
from unified_ecommerce.exceptions import KeycloakAuthError

User = get_user_model()
log = logging.getLogger(__name__)


def keycloak_session_init(url, **kwargs): # noqa: C901
"""
Initialize a Keycloak session.
This is a helper function that will initialize a Keycloak session with the
provided URL. It will also handle refreshing the token if it has expired.
Args:
url (str): The Keycloak admin URL.
**kwargs: Additional arguments to pass to the OAuth2Session initializer.
Returns:
None, or dict of data returned.
"""

token_url = (
f"{settings.KEYCLOAK_ADMIN_URL}/auth/realms/master/"
"protocol/openid-connect/token"
)
client = BackendApplicationClient(client_id=settings.KEYCLOAK_ADMIN_CLIENT_ID)

auto_refresh_kwargs = {
"client_id": settings.KEYCLOAK_ADMIN_CLIENT_ID,
"client_secret": settings.KEYCLOAK_ADMIN_CLIENT_SECRET,
}

def update_token(token):
log.debug("Refreshing Keycloak token %s", token)
KeycloakAdminToken.objects.all().delete()
KeycloakAdminToken.objects.create(
authorization_token=token.get("access_token"),
refresh_token=token.get("refresh_token", ""),
authorization_token_expires_in=token.get("access_token_expires_in", 60),
refresh_token_expires_in=token.get("refresh_token_expires_in", 60),
)

def check_for_token():
token = KeycloakAdminToken.latest()

if not token:
token_error_msg = "No token found" # noqa: S105
raise TokenExpiredError(token_error_msg)

return token

def regenerate_token(client):
"""Regenerate the token, or raise an exception."""

try:
session = OAuth2Session(client=client)
new_token = session.fetch_token(
token_url=token_url,
client_id=settings.KEYCLOAK_ADMIN_CLIENT_ID,
client_secret=settings.KEYCLOAK_ADMIN_CLIENT_SECRET,
verify=settings.KEYCLOAK_ADMIN_SECURE,
)

log.debug("Successfully refreshed token %s", new_token)

update_token(new_token)
except InvalidGrantError:
log.exception(
(
"keycloak_session_init couldn't refresh token %s because of an"
" invalid grant error"
),
token,
)
return None
except TokenExpiredError:
log.exception(
(
"keycloak_session_init couldn't refresh token %s because of an"
" expired token error"
),
token,
)
return None
except requests.exceptions.RequestException:
log.exception(
(
"keycloak_session_init couldn't refresh token %s because of an"
" HTTP error"
),
token,
)
return None

return session

try:
token = check_for_token()

log.debug("Trying to start up a session with token %s", token.token_formatted)

session = OAuth2Session(
client=client,
token=token.token_formatted,
auto_refresh_url=token_url,
auto_refresh_kwargs=auto_refresh_kwargs,
token_updater=update_token,
)

keycloak_response = session.get(url, **kwargs).json()
except (InvalidGrantError, TokenExpiredError) as ige:
log.debug("Token error, trying to get a new token: %s", ige)

session = regenerate_token(client)

keycloak_response = session.get(url, **kwargs).json()
except requests.exceptions.RequestException:
log.exception(
(
"keycloak_session_init couldn't establish the session because of"
" an HTTP error: %s"
),
token,
)
return None

log.debug("Keycloak response: %s", keycloak_response)

if "error" in keycloak_response:
log.error("Keycloak returned an error: %s", keycloak_response["error"])
raise KeycloakAuthError(keycloak_response)

return keycloak_response


def keycloak_get_user(user: User):
"""Get a user from Keycloak."""

userinfo_url = (
f"{settings.KEYCLOAK_ADMIN_URL}/auth/admin/"
f"realms/{settings.KEYCLOAK_REALM}/users/"
)

log.debug("Trying to get user info for %s", user.username)

if user.keycloak_user_tokens.exists():
params = {"id": user.keycloak_user_tokens.first().keycloak_id}
else:
params = {"email": user.username}

userinfo = keycloak_session_init(
userinfo_url, verify=settings.KEYCLOAK_ADMIN_SECURE, params=params
)

if len(userinfo) == 0:
log.debug("Keycloak didn't return anything")
return None

return userinfo[0]


@app.task
def keycloak_update_user_account(user_id: int):
"""Update the user account using info from Keycloak asynchronously."""

user = User.objects.get(id=user_id)

keycloak_user = keycloak_get_user(user)

if keycloak_user is None:
return

user.first_name = keycloak_user["firstName"]
user.last_name = keycloak_user["lastName"]
user.email = keycloak_user["email"]
user.username = keycloak_user["id"]
user.save()

user.keycloak_user_tokens.all().delete()
user.keycloak_user_tokens.create(keycloak_id=keycloak_user["id"])
user.save()
10 changes: 10 additions & 0 deletions authentication/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""App initialization for authentication"""

from django.apps import AppConfig


class AuthenticationConfig(AppConfig):
"""Config for the authentication app"""

default_auto_field = "django.db.models.BigAutoField"
name = "authentication"
Loading

0 comments on commit 5c2a9b6

Please sign in to comment.