Skip to content

Commit 8ec03ae

Browse files
4249: remove code from user migration script for importing credentials (#4253)
* Working, hold * Added more documentation * code review comments * Fix some more * much simpler Nathan idea
1 parent a172e66 commit 8ec03ae

File tree

1 file changed

+65
-51
lines changed

1 file changed

+65
-51
lines changed

keycloak_user_export/management/commands/migrate_users_to_keycloak.py

Lines changed: 65 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import json
44
from django.conf import settings
55
from django.utils.http import urlencode
6-
import base64
76
import calendar
7+
from django.db.models import Q
88

99
from django.core.management import BaseCommand, CommandError
1010
from keycloak_user_export.models import UserExportToKeycloak
@@ -14,17 +14,31 @@
1414

1515
class Command(BaseCommand):
1616
"""
17-
Creates Keycloak user records for all Django user records which have a password set
18-
and no associated social-auth record for the "ol-oidc" provider. The Keycloak user
19-
record is populated with the Django user's first_name, last_name, email, and password.
17+
Creates Keycloak user records for all Django user records which have no associated
18+
social-auth record for the "ol-oidc" provider. The Keycloak user
19+
record is populated with the Django user's first_name, last_name, and email.
20+
21+
Optionally, the "--filter-provider-name" argument can be defined (string) when running this script.
22+
If defined, Keycoak user records will be created only for Django user records which have no associated
23+
social-auth record for the "ol-oidc" provider, and have a social-auth record with a provider
24+
equal to the argument value.
25+
26+
Optionally, the "--keycloak-group-path" argument can be defined (string) when running this script
27+
which will add all created Keycloak users to the Keycloak group path defined as the
28+
argument value. For example, "--keycloak-group-path=/imported/open-discussions/touchstone".
29+
If the argument is defined, the Keycloak group path must exist prior to executing this script.
30+
31+
Optionally, the "--batch-size" argument can be defined (int) when running this script.
32+
The value of this argument controls how many Keycloak user records should be created
33+
with each API request made to Keycloak. The default is 25.
2034
2135
Keycloak users are created in the realm defined by the `KEYCLOAK_REALM_NAME`
2236
environment variable.
2337
2438
The `KEYCLOAK_BASE_URL` environment variable must be defined and equal to the
2539
base URL of the Keycloak instance.
2640
27-
This command assumes Django users are defined with the default Django User model (first_name, last_name, email, password).
41+
This command assumes Django users are defined with the default Django User model (first_name, last_name, email).
2842
2943
A UserExportToKeycloak record is created for each successfully exported user. If a UserExportToKeycloak
3044
already exists for a user, no duplicate UserExportToKeycloak record will be created.
@@ -34,8 +48,8 @@ class Command(BaseCommand):
3448
"""
3549

3650
help = """
37-
Creates Keycloak user records for all Django user records which have a password set
38-
and no associated social-auth record for the "ol-oidc" provider.
51+
Creates Keycloak user records for all Django user records which have
52+
no associated social-auth record for the "ol-oidc" provider.
3953
Keycloak users are created in the realm defined by the `KEYCLOAK_REALM_NAME`
4054
environment variable.
4155
The `KEYCLOAK_BASE_URL` environment variable must be defined and equal to the
@@ -47,60 +61,73 @@ def add_arguments(self, parser):
4761

4862
# pylint: disable=expression-not-assigned
4963
parser.add_argument(
50-
"email",
51-
help="Email address of a Keycloak realm admin user.",
64+
"username",
65+
help="Username of a Keycloak realm admin user.",
5266
)
5367
parser.add_argument(
5468
"password",
5569
help="Password of a Keycloak realm admin user.",
5670
)
5771
parser.add_argument(
58-
"client_id",
72+
"client-id",
5973
help="Client ID for the Keycloak Admin-CLI client.",
6074
)
6175
parser.add_argument(
62-
"client_secret",
76+
"client-secret",
6377
help="Client secret for the Keycloak Admin-CLI client.",
6478
)
6579
parser.add_argument(
66-
"--batchsize",
80+
"--batch-size",
6781
nargs="?",
6882
default=25,
6983
type=int,
7084
help="(Optional) How many users to export to Keycloak at a time.",
7185
)
86+
parser.add_argument(
87+
"--keycloak-group-path",
88+
nargs="?",
89+
default="",
90+
type=str,
91+
help="(Optional) The Keycloak group's path users should will added to.",
92+
)
93+
parser.add_argument(
94+
"--filter-provider-name",
95+
nargs="?",
96+
default=None,
97+
type=str,
98+
help="(Optional) Only create Keycloak users for Django user records associated with a specific social-auth provider.",
99+
)
72100

73101
def _get_access_token(
74-
self, client_id: str, email: str, password: str, client_secret: str
102+
self, client_id: str, username: str, password: str, client_secret: str
75103
):
76104
"""
77105
Creates a new access token for a Keycloak realm administrator for use with the admin-cli client.
78106
79107
Args:
80108
client_id (str): The client_ID associated with Keycloak's admin-cli client.
81-
email (str): The email address of a Keycloak realm administrator user.
82-
password (str): The password associated with the email address for a Keycloak realm administrator user.
109+
username (str): The username of a Keycloak realm administrator user.
110+
password (str): The password associated with the username for a Keycloak realm administrator user.
83111
client_secret (str): The client secret associated with Keycloak's admin-cli client.
84112
85113
Returns:
86-
A new access_token for the administrator user for use with the Keycloak admin-cli client.
114+
A new access_token (string) for the administrator user for use with the Keycloak admin-cli client.
87115
"""
88116
url = f"{settings.KEYCLOAK_BASE_URL}/realms/{settings.KEYCLOAK_REALM_NAME}/protocol/openid-connect/token"
89-
payload = f"{urlencode({'client_id': client_id})}&{urlencode({'username': email})}&{urlencode({'password': password})}&grant_type=password&{urlencode({'client_secret': client_secret})}&{urlencode({'scope': 'email openid'})}"
117+
payload = f"{urlencode(dict(client_id=client_id, username=username, password=password, grant_type='password', client_secret=client_secret, scope='email openid'))}"
90118
headers = {"Content-Type": "application/x-www-form-urlencoded"}
91119

92120
response = requests.request("POST", url, headers=headers, data=payload)
93121
return response.json()["access_token"]
94122

95-
def _generate_keycloak_user_payload(self, user):
123+
def _generate_keycloak_user_payload(self, user, keycloak_group_path):
96124
"""
97125
Returns a dictionary formatted as the expected user representation for the
98126
Keycloak partialImport Admin REST API endpoint.
99-
The dictionary will include credential data if the user has a password
100-
defined.
101127
102128
Args:
103129
user (models.User): A Django User model record.
130+
keycloak_group_path (str): The Keycloak group path which newly created users should be added to.
104131
105132
Returns:
106133
dict: user representation for use with the Keycloak partialImport Admin REST API endpoint.
@@ -119,27 +146,8 @@ def _generate_keycloak_user_payload(self, user):
119146
"requiredActions": [],
120147
"realmRoles": ["default-roles-master"],
121148
"notBefore": 0,
122-
"groups": [],
149+
"groups": [keycloak_group_path],
123150
}
124-
# If the user has a password defined, we will create a credential for them in Keycloak.
125-
# This allows the user to
126-
if user.password and user.has_usable_password():
127-
_, iterations, salt, hash = user.password.split("$", 3)
128-
base64_salt = base64.b64encode(salt.encode())
129-
user_keycloak_payload["credentials"].append(
130-
{
131-
"secretData": json.dumps(
132-
{"value": hash, "salt": base64_salt.decode()}
133-
),
134-
"type": "password",
135-
"credentialData": json.dumps(
136-
{
137-
"hashIterations": iterations,
138-
"algorithm": "pbkdf2-sha256",
139-
}
140-
),
141-
}
142-
)
143151
return user_keycloak_payload
144152

145153
def _verify_environment_variables_configured(self):
@@ -165,31 +173,37 @@ def handle(self, *args, **kwargs):
165173
self._verify_environment_variables_configured()
166174

167175
keycloak_partial_import_url = f"{settings.KEYCLOAK_BASE_URL}/admin/realms/{settings.KEYCLOAK_REALM_NAME}/partialImport"
168-
176+
unsynced_users_social_auth_query = Q()
177+
if kwargs["filter_provider_name"] is not None:
178+
unsynced_users_social_auth_query &= Q(
179+
social_auth__provider=kwargs["filter_provider_name"]
180+
)
169181
unsynced_users = (
170-
User.objects.only("email", "password")
182+
User.objects.only("email")
171183
.exclude(social_auth__provider="ol-oidc")
172184
.exclude(userexporttokeycloak__isnull=False)
185+
.filter(unsynced_users_social_auth_query)
173186
.select_related("userexporttokeycloak")
174187
.prefetch_related("social_auth")
175188
)
176-
177189
access_token = self._get_access_token(
178-
kwargs["client_id"],
179-
kwargs["email"],
190+
kwargs["client-id"],
191+
kwargs["username"],
180192
kwargs["password"],
181-
kwargs["client_secret"],
193+
kwargs["client-secret"],
182194
)
183195

184196
unsynced_users_keycloak_payload_array = []
185197

186198
# Process batches of the users who must be exported.
187-
batch_size = kwargs["batchsize"]
199+
batch_size = kwargs["batch_size"]
188200
for i in range(0, len(unsynced_users), batch_size):
189201
batch = unsynced_users[i : i + batch_size]
190202
for user in batch:
191203
unsynced_users_keycloak_payload_array.append(
192-
self._generate_keycloak_user_payload(user)
204+
self._generate_keycloak_user_payload(
205+
user, kwargs["keycloak_group_path"]
206+
)
193207
)
194208
headers = {
195209
"Content-Type": "application/json",
@@ -208,10 +222,10 @@ def handle(self, *args, **kwargs):
208222
# If Keycloak responds with a 401, refresh the access_token and retry once.
209223
if response.status_code == 401:
210224
access_token = self._get_access_token(
211-
kwargs["client_id"],
212-
kwargs["email"],
225+
kwargs["client-id"],
226+
kwargs["username"],
213227
kwargs["password"],
214-
kwargs["client_secret"],
228+
kwargs["client-secret"],
215229
)
216230
headers = {
217231
"Content-Type": "application/json",

0 commit comments

Comments
 (0)