33import json
44from django .conf import settings
55from django .utils .http import urlencode
6- import base64
76import calendar
7+ from django .db .models import Q
88
99from django .core .management import BaseCommand , CommandError
1010from keycloak_user_export .models import UserExportToKeycloak
1414
1515class 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