Skip to content

Commit a41cab5

Browse files
authored
Merge pull request #43 from mitodl/jkachel/6598-6538-add-auth-infrastructure
Add auth infrastructure, based on APISIX/Keycloak
2 parents 9c9dcf9 + 33360f7 commit a41cab5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+4123
-50
lines changed

.pre-commit-config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ repos:
6060
- yarn.lock
6161
- --exclude-files
6262
- ".*/generated/"
63+
- --exclude-files
64+
- "config/keycloak/tls/*"
65+
- --exclude-files
66+
- "config/keycloak/realms/default-realm.json"
6367
additional_dependencies: ["gibberish-detector"]
6468
- repo: https://github.com/astral-sh/ruff-pre-commit
6569
rev: "v0.7.2"

.secrets.baseline

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@
100100
"test_.*.py",
101101
"poetry.lock",
102102
"yarn.lock",
103-
".*/generated/"
103+
".*/generated/",
104+
"config/keycloak/tls/*"
104105
]
105106
}
106107
],

README-keycloak.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Keycloak Integration
2+
3+
The Compose file includes a Keycloak instance that you can use for authentication instead of spinning up a separate one or using one of the deployed instances. (It's enabled by default but running it won't prevent you from using a separate instance.)
4+
5+
## Default Settings
6+
7+
There are some defaults that are part of this.
8+
9+
_SSL Certificate_: There's a self-signed cert that's in `config/keycloak/tls` - if you'd rather set up your own (or you have a real cert or something to use), you can drop the PEM files in there. See the README there for info.
10+
11+
_Realm_: There's a `default-realm.json` in `config/keycloak` that will get loaded by Keycloak when it starts up, and will set up a realm for you with some users and a client so you don't have to set it up yourself. The realm it creates is called `ol-local`.
12+
13+
The users it sets up are:
14+
15+
| User | Password |
16+
| ------------------- | --------- |
17+
| `[email protected]` | `student` |
18+
| `[email protected]` | `prof` |
19+
| `[email protected]` | `admin` |
20+
21+
The client it sets up is called `apisix`. You can change the passwords and get the secret in the admin.
22+
23+
## Making it Work
24+
25+
If you don't have a Keycloak instance running locally already, you can use the pack-in one. It starts with the rest of the services and is configured to be at `http://kc.ol.local:8006` and `https://kc.ol.local:8007` by default (but you can change this in the `env` files).
26+
27+
Some setup is required to use the pack-in instance:
28+
29+
1. Set required keycloak environment values in your `.env` file:
30+
- Set a keystore password via `KEYCLOAK_SVC_KEYSTORE_PASSWORD`. This is required, but the password need not be anything special.
31+
- Set `KEYCLOAK_CLIENT_SECRET`; ask another developer for the relevant value.
32+
2. Optionally add `KEYCLOAK_SVC_HOSTNAME`, `KEYCLOAK_SVC_ADMIN`, and `KEYCLOAK_SVC_ADMIN_PASSWORD` to your `.env` file.
33+
1. `KEYCLOAK_SVC_HOSTNAME` is the hostname you want to use for the instance - the default is `kc.ol.local`.
34+
2. `KEYCLOAK_SVC_ADMIN` is the admin username. The default is `admin`.
35+
3. `KEYCLOAK_SVC_ADMIN_PASSWORD` is the admin password. The default is `admin`.
36+
3. Re-start the stack.
37+
38+
The Keycloak container should start and stay running. Once it does, you should be able to log in at `https://kc.ol.local:8007` with username and password `admin` (or the values you supplied).
39+
40+
If you'd rather use a separate Keycloak instance, ensure these settings are present in the appropriate `env` file (best is probably `backend.local.env`):
41+
42+
- `KEYCLOAK_REALM`
43+
44+
Sets the realm used by APISIX for Keycloak authentication. Defaults to `ol-local`.
45+
46+
- `KEYCLOAK_DISCOVERY_URL`
47+
48+
Sets the discovery URL for the Keycloak OIDC service. (In Keycloak admin, navigate to the realm you're using, then go to Realm Settings under Configure, and the link is under OpenID Endpoint Configuration.) This defaults to a valid value for the pack-in Keycloak instance.
49+
50+
- `KEYCLOAK_CLIENT_ID`
51+
52+
The client ID for the OIDC client for APISIX. Defaults to `apisix`.
53+
54+
- `KEYCLOAK_CLIENT_SECRET`
55+
56+
The client secret for the OIDC client. No default - you will need to get this from the Keycloak admin, even if you're using the pack-in Keycloak instance.
57+
58+
> If you're using a Keycloak instance also hosted within a Docker container on the same machine you're running the AI chatbots, you'll need to make sure it can be seen from within the `apigateway` container. This will _require_ some work on your part - generally, stuff within Composer environments can't see things outside of their own environment. There's an example of this in the `docker-compose.services.yml` file if your Keycloak instance uses a Compose environment, as we use it so that the Keycloak OIDC URLs all match externally and internally.

ai_chatbots/consumers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ async def prepare_response(self, serializer) -> ChatRequestSerializer:
5555
)
5656

5757
if user and user.username and user.username != "AnonymousUser":
58-
self.user_id = user.username
58+
self.user_id = user.global_id.replace("-", "_")
5959
elif session:
6060
if not session.session_key:
6161
session.save()

ai_chatbots/consumers_test.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
from random import randint
55
from unittest.mock import AsyncMock
6+
from uuid import uuid4
67

78
import pytest
89

@@ -18,7 +19,8 @@
1819
def agent_user():
1920
"""Return a user for the agent."""
2021
return UserFactory.build(
21-
username=f"test_user_{randint(1, 1000)}" # noqa: S311
22+
username=f"test_user_{randint(1, 1000)}", # noqa: S311
23+
global_id=f"test_user_{uuid4()!s}",
2224
)
2325

2426

@@ -166,7 +168,7 @@ async def test_syllabus_create_chatbot(
166168
mock_http_consumer_send.send_headers.assert_called_once()
167169
chatbot = syllabus_consumer.create_chatbot(serializer)
168170
assert isinstance(chatbot, SyllabusBot)
169-
assert chatbot.user_id == agent_user.username
171+
assert chatbot.user_id == agent_user.global_id.replace("-", "_")
170172
assert chatbot.temperature == 0.7
171173
assert chatbot.instructions == "Answer this question as best you can"
172174
assert chatbot.model == "gpt-3.5-turbo"

ai_chatbots/routing.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.urls import re_path
22

33
from ai_chatbots import consumers
4+
from users.consumers import UserMetaHttpConsumer
45

56
http_patterns = [
67
re_path(
@@ -13,4 +14,11 @@
1314
consumers.SyllabusBotHttpConsumer.as_asgi(),
1415
name="syllabus_agent_sse",
1516
),
17+
# This gets two routes - user_meta doesn't require auth (in the APISIX settings)
18+
# and login does.
19+
re_path(
20+
r"^http/(user_meta|login)/$",
21+
UserMetaHttpConsumer.as_asgi(),
22+
name="user_meta",
23+
),
1624
]

config/apisix/apisix.yaml

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
upstreams:
2+
- id: 1
3+
nodes:
4+
"nginx:${{NGINX_PORT}}": 1
5+
type: roundrobin
6+
7+
routes:
8+
- id: 1
9+
name: "websocket"
10+
desc: "Special handling for websocket URLs."
11+
priority: 1
12+
upstream_id: 1
13+
enable_websocket: true
14+
plugins:
15+
openid-connect:
16+
client_id: ${{KEYCLOAK_CLIENT_ID}}
17+
client_secret: ${{KEYCLOAK_CLIENT_SECRET}}
18+
discovery: ${{KEYCLOAK_DISCOVERY_URL}}
19+
realm: ${{KEYCLOAK_REALM}}
20+
scope: ${{KEYCLOAK_SCOPES}}
21+
bearer_only: false
22+
introspection_endpoint_auth_method: "client_secret_post"
23+
ssl_verify: false
24+
session:
25+
secret: ${{APISIX_SESSION_SECRET_KEY}}
26+
logout_path: "/logout"
27+
post_logout_redirect_uri: ${{APISIX_LOGOUT_URL}}
28+
unauth_action: "pass"
29+
cors:
30+
allow_origins: "**"
31+
allow_methods: "**"
32+
allow_headers: "**"
33+
allow_credential: true
34+
response-rewrite:
35+
headers:
36+
set:
37+
Referrer-Policy: "origin"
38+
uris:
39+
- "/ws/*"
40+
- id: 2
41+
name: "passauth"
42+
desc: "Wildcard route that can use auth but doesn't require it."
43+
priority: 0
44+
upstream_id: 1
45+
plugins:
46+
openid-connect:
47+
client_id: ${{KEYCLOAK_CLIENT_ID}}
48+
client_secret: ${{KEYCLOAK_CLIENT_SECRET}}
49+
discovery: ${{KEYCLOAK_DISCOVERY_URL}}
50+
realm: ${{KEYCLOAK_REALM}}
51+
scope: ${{KEYCLOAK_SCOPES}}
52+
bearer_only: false
53+
introspection_endpoint_auth_method: "client_secret_post"
54+
ssl_verify: false
55+
session:
56+
secret: ${{APISIX_SESSION_SECRET_KEY}}
57+
logout_path: "/logout"
58+
post_logout_redirect_uri: ${{APISIX_LOGOUT_URL}}
59+
unauth_action: "pass"
60+
cors:
61+
allow_origins: "**"
62+
allow_methods: "**"
63+
allow_headers: "**"
64+
allow_credential: true
65+
response-rewrite:
66+
headers:
67+
set:
68+
Referrer-Policy: "origin"
69+
uri: "*"
70+
- id: 3
71+
name: "logout-redirect"
72+
desc: "Strip trailing slash from logout redirect."
73+
priority: 10
74+
upstream_id: 1
75+
uri: "/logout/*"
76+
plugins:
77+
redirect:
78+
uri: "/logout"
79+
- id: 4
80+
name: "reqauth"
81+
desc: "Routes that require authentication."
82+
priority: 10
83+
upstream_id: 1
84+
plugins:
85+
openid-connect:
86+
client_id: ${{KEYCLOAK_CLIENT_ID}}
87+
client_secret: ${{KEYCLOAK_CLIENT_SECRET}}
88+
discovery: ${{KEYCLOAK_DISCOVERY_URL}}
89+
realm: ${{KEYCLOAK_REALM}}
90+
scope: ${{KEYCLOAK_SCOPES}}
91+
bearer_only: false
92+
introspection_endpoint_auth_method: "client_secret_post"
93+
ssl_verify: false
94+
session:
95+
secret: ${{APISIX_SESSION_SECRET_KEY}}
96+
logout_path: "/logout"
97+
post_logout_redirect_uri: ${{APISIX_LOGOUT_URL}}
98+
unauth_action: "auth"
99+
cors:
100+
allow_origins: "**"
101+
allow_methods: "**"
102+
allow_headers: "**"
103+
allow_credential: true
104+
response-rewrite:
105+
headers:
106+
set:
107+
Referrer-Policy: "origin"
108+
uris:
109+
- "/admin/login/*"
110+
- "/http/login/"
111+
#END

config/apisix/config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
apisix:
2+
enable_admin: false
3+
enable_dev_mode: false
4+
node_listen:
5+
- port: ${{APISIX_PORT}}
6+
7+
deployment:
8+
role: data_plane
9+
role_data_plane:
10+
config_provider: yaml
11+
#END

config/apisix/debug.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
basic:
18+
enable: true # Enable the basic debug mode.
19+
http_filter:
20+
enable: false # Enable HTTP filter to dynamically apply advanced debug settings.
21+
enable_header_name: X-APISIX-Dynamic-Debug # If the header is present in a request, apply the advanced debug settings.
22+
hook_conf:
23+
enable: false # Enable hook debug trace to log the target module function's input arguments or returned values.
24+
name: hook_phase # Name of module and function list.
25+
log_level: warn # Severity level for input arguments and returned values in the error log.
26+
is_print_input_args: true # Print the input arguments.
27+
is_print_return_value: true # Print the return value.
28+
29+
hook_phase: # Name of module and function list.
30+
apisix: # Required module name.
31+
- http_access_phase # Required function names.
32+
- http_header_filter_phase
33+
- http_body_filter_phase
34+
- http_log_phase
35+
#END

0 commit comments

Comments
 (0)