├── requirements.txt
├── .gitmodules
├── exemple.py
├── README.md
├── main.py
└── AADInternals.py
/requirements.txt:
--------------------------------------------------------------------------------
1 | msal
2 | requests
3 | passlib
4 | xmltodict
5 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "python_wcfbin"]
2 | path = python_wcfbin
3 | url = https://github.com/tranquilit/python-wcfbin.git
4 |
--------------------------------------------------------------------------------
/exemple.py:
--------------------------------------------------------------------------------
1 | from AADInternals import AADInternals
2 |
3 | #az = AADInternals(tenant_id='00000000-0000-0000-0000-000000000000')
4 | az = AADInternals(domain='mydomain.com')
5 |
6 | #enable sync password feature
7 | print(az.set_sync_features(enable_features=['PasswordHashSync']))
8 |
9 | #create account
10 | #az.set_azureadobject('sourceanchortest',"test@mydomain.com",netBiosName='MYDOMAIN',givenName='givenName',dnsDomainName='dnsDomainName',displayName="displayName",surname='surname',commonName='commonName')
11 |
12 | #Send password (if error is "2") please wait ...
13 | #print(az.set_userpassword(hashnt="8846F7EAEE8FB117AD06BDD830B7586C",sourceanchor='sourceanchortest'))
14 |
15 | #create group with member
16 | #print(az.set_azureadobject("testgroup",usertype='Group',SecurityEnabled=True,displayName='testgroup',groupMembers=["sourceanchortest"]))
17 |
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AADInternals python
2 |
3 | Reimplementation of part of the AAdinternals (https://github.com/Gerenios/AADInternals) project.
4 |
5 | this project focuses on the synchronization of users, groups and password "azure ad"
6 |
7 | Please note that this project uses Microsoft APIs not officially documented. Microsoft may break compatibility at any time
8 |
9 |
10 | # Install Notes
11 |
12 | ```
13 | git clone https://github.com/sfonteneau/AADInternals_python
14 | cd AADInternals_python
15 | git submodule update --progress --init -- "python_wcfbin"
16 | ```
17 |
18 | Install dependency
19 | -----------------------------
20 |
21 | ```
22 | apt-get install python3-passlib python3-xmltodict python3-requests python3-msal -y
23 | ```
24 |
25 |
26 | If you are not under debian or if you do not have the packages available :
27 |
28 | ```
29 | pip3 install -r requirements.txt
30 | ```
31 |
32 | # Use main
33 |
34 | Exemple:
35 |
36 | ```
37 | python3 main.py -help
38 | python3 main.py --domain mydomain.com set_azureadobject -help
39 | python3 main.py --domain mydomain.com set_azureadobject --SourceAnchor=test00 --userPrincipalName=test00@mydomain.com
40 | python3 main.py --domain mydomain.com set_userpassword --sourceanchor=test00 --password password
41 | python3 main.py --domain mydomain.com get_syncconfiguration
42 | ```
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import argparse
3 | import inspect
4 | import os
5 | from AADInternals import AADInternals
6 |
7 | logging.getLogger("adal-python").setLevel(logging.WARN)
8 |
9 | def main():
10 | # Initialize the main parser
11 | parser = argparse.ArgumentParser(description="Utility to interact with AADInternals.")
12 |
13 | # Basic arguments to instantiate AADInternals
14 | parser.add_argument("--domain", type=str, help="Domain name")
15 | parser.add_argument("--tenant_id", type=str, help="Azure AD tenant ID")
16 | parser.add_argument("--proxies", type=dict, default={}, help="Dictionary of proxies")
17 | parser.add_argument("--use_cache", type=bool, default=True, help="Use cache")
18 | parser.add_argument("--save_to_cache", type=bool, default=True, help="Save to cache")
19 | parser.add_argument("--cache_file", type=str, default=os.path.join(os.path.dirname(os.path.realpath(__file__)), 'last_token.json'), help="Cache file")
20 |
21 | # Create a subparser for each method
22 | subparsers = parser.add_subparsers(dest="method", help="Available methods in AADInternals")
23 |
24 | # Temporarily instantiate AADInternals to access its methods
25 | temp_aad = AADInternals(tenant_id=False, domain=False)
26 |
27 | # Create a subparser with help for each method
28 | for method_name, method in inspect.getmembers(temp_aad, predicate=inspect.ismethod):
29 | method_parser = subparsers.add_parser(method_name, help=f"Help for {method_name}")
30 |
31 | # Add specific arguments for each method
32 | for param_name, param in inspect.signature(method).parameters.items():
33 | if param_name != "self": # Exclude 'self'
34 | # Add the parameter to the parser
35 | method_parser.add_argument(f"--{param_name}", help=f"Argument for {method_name}")
36 |
37 | # Parse all arguments
38 | args = parser.parse_args()
39 |
40 | # Initialize AADInternals with the base arguments
41 | aad = AADInternals(
42 | proxies=args.proxies,
43 | use_cache=args.use_cache,
44 | save_to_cache=args.save_to_cache,
45 | tenant_id=args.tenant_id,
46 | cache_file=args.cache_file,
47 | domain=args.domain
48 | )
49 |
50 | # Check that the method exists and call it with its arguments
51 | if not args.method:
52 | parser.print_help()
53 | elif hasattr(aad, args.method):
54 | method = getattr(aad, args.method)
55 |
56 | # Prepare the parameters to pass to the method
57 | method_params = {}
58 | for param_name in inspect.signature(method).parameters:
59 | if param_name in vars(args) and vars(args)[param_name] is not None:
60 | method_params[param_name] = vars(args)[param_name]
61 |
62 | # Debug output
63 | print(f"Calling method: {args.method} with parameters: {method_params}")
64 |
65 | # Call the method with relevant parameters
66 | result = method(**method_params)
67 | print(result)
68 | else:
69 | print(f"The method '{args.method}' does not exist in AADInternals.")
70 |
71 | if __name__ == "__main__":
72 | main()
73 |
--------------------------------------------------------------------------------
/AADInternals.py:
--------------------------------------------------------------------------------
1 | from hashlib import pbkdf2_hmac
2 | from passlib.hash import nthash
3 | import msal
4 | import json
5 | import sys
6 | import os
7 |
8 | if "__file__" in locals():
9 | sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)),'python_wcfbin'))
10 | sys.path.append(os.path.dirname(os.path.realpath(__file__)))
11 |
12 | from python_wcfbin.wcf.xml2records import XMLParser
13 | from python_wcfbin.wcf.records import dump_records
14 | from python_wcfbin.wcf.records import Record, print_records
15 | import io
16 | import requests
17 | import random
18 | import uuid
19 | import datetime
20 | import xmltodict
21 |
22 | aadsync_server= "adminwebservice.microsoftonline.com"
23 | aadsync_client_version="8.0"
24 | aadsync_client_build= "2.4.21.0"
25 | client_id = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
26 |
27 | class AADInternals():
28 |
29 | def __init__(self, proxies={},use_cache=True,save_to_cache=True,tenant_id=None,cache_file=os.path.join(os.path.dirname(os.path.realpath(__file__)),'last_token.json'),domain=None,verify=True):
30 | """
31 | Establish a connection with Microsoft and attempts to retrieve a token from Microsoft servers.
32 | Is initialization interactive if cache is not available : (M.F.A.)
33 |
34 | Args:
35 | proxies (dict): Specify proxies if needed.
36 | use_cache (bool): Define if the cache_file is used (last token generated if exists)
37 | save_to_cache (bool): Define if the token give is backup in cache_file
38 | tenant_id (str): tenant id azure
39 | cache_file (str): Path to the cache_file (last token generated)
40 | domain (str): domain name , use for search tenant_id if tenant_id = None
41 | verify (str or Bool) : Allows you to specify SSL certificate verification when connecting to Microsoft servers. If `verify` is a path of type `str`, it must point to a certificate that will be used for SSL verification. If `verify` is of type `bool`, setting `True` enables certificate verification with the default certificate, while `False` disables all certificate verification.
42 |
43 | Returns:
44 | None
45 |
46 | >>> az = AADInternals(tenant_id='00000000-0000-0000-0000-000000000000')
47 |
48 | """
49 | if tenant_id == False and domain == False:
50 | return None
51 |
52 | self.proxies=proxies
53 | self.verify = verify
54 | self.use_cache=use_cache
55 | self.save_to_cache=save_to_cache
56 | self.cache_file=cache_file
57 |
58 | self.requests_session_call_adsyncapi = requests.Session()
59 |
60 | if domain and (not tenant_id):
61 |
62 | data = json.loads(requests.get('https://login.microsoftonline.com/%s/.well-known/openid-configuration' % domain,proxies=proxies,verify=self.verify).content.decode('utf-8'))
63 | if data.get('error') == 'invalid_tenant':
64 | print(data['error_description'])
65 | sys.exit(1)
66 | tenant_id = data['token_endpoint'].split('https://login.microsoftonline.com/')[1].split('/')[0]
67 |
68 | if not tenant_id:
69 | print('Error, Please provide tenant_id')
70 | sys.exit(1)
71 |
72 | self.tenant_id = tenant_id
73 |
74 | if self.use_cache:
75 | self.old_token={}
76 | self.token_cache = msal.SerializableTokenCache()
77 | if os.path.isfile(self.cache_file) :
78 | with open(self.cache_file,'r') as f:
79 | data= f.read()
80 | self.token_cache.deserialize(data)
81 | self.old_token=json.loads(data)
82 | if "refresh_token" in self.old_token:
83 | self.token_cache = msal.SerializableTokenCache()
84 | else:
85 | self.token_cache = msal.TokenCache()
86 |
87 | self.app = msal.PublicClientApplication(
88 | client_id,
89 | authority=f"https://login.microsoftonline.com/{tenant_id}",
90 | proxies=self.proxies,
91 | verify=self.verify,
92 | token_cache=self.token_cache
93 | )
94 |
95 | def get_token(self,scopes=["https://graph.windows.net/.default"]):
96 | token_response = None
97 |
98 | if self.use_cache:
99 | #Add backwards compatibility
100 | if "refresh_token" in self.old_token:
101 | token_response = self.app.acquire_token_by_refresh_token(refresh_token=self.old_token['refresh_token'],scopes=["https://graph.windows.net/.default"])
102 | self.old_token={}
103 |
104 | accounts = self.app.get_accounts()
105 | for account in accounts:
106 | if account['realm'] != self.tenant_id:
107 | continue
108 | result = self.app.acquire_token_silent(scopes=scopes, account=account)
109 | if result:
110 | token_response = result
111 | break
112 |
113 | if not token_response :
114 | flow = self.app.initiate_device_flow(scopes=scopes)
115 | print(flow["message"])
116 | token_response = self.app.acquire_token_by_device_flow(flow)
117 |
118 | if self.save_to_cache:
119 | if self.token_cache.has_state_changed:
120 | with open(self.cache_file,'w') as f:
121 | f.write(self.token_cache.serialize())
122 | self.token_cache.has_state_changed = False
123 | return token_response['access_token']
124 |
125 | def call_graphapi(self, Command, select='', top=100):
126 | results = []
127 | url = f"https://graph.microsoft.com/v1.0/{Command}"
128 |
129 | if select or top:
130 | query = []
131 | if select:
132 | query.append(f"$select={select}")
133 | if top:
134 | query.append(f"$top={top}")
135 | url += "?" + "&".join(query)
136 |
137 | while url:
138 | response = requests.get(
139 | url,
140 | headers={"Authorization": f"Bearer {self.get_token(['https://graph.microsoft.com/.default'])}"},
141 | proxies=self.proxies,
142 | verify=self.verify
143 | )
144 | data = response.json()
145 |
146 | results.extend(data.get('value', []))
147 |
148 | url = data.get('@odata.nextLink')
149 |
150 | return results
151 |
152 |
153 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L8
154 | def get_syncconfiguration(self):
155 | """
156 | Gets tenant's synchronization configuration using Provisioning and Azure AD Sync API.
157 | If the user doesn't have admin rights, only a subset of information is returned.
158 |
159 | Returns:
160 | dicts: {'AllowedFeatures': 'None', 'AnchorAttribute': None, 'ApplicationVersion': None , ...}
161 |
162 | """
163 | body = '''
164 | false
165 | '''
166 | message_id = str(uuid.uuid4())
167 | command = "GetCompanyConfiguration"
168 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
169 | response = self.call_adsyncapi(envelope,command,self.tenant_id,message_id)
170 | data = self.xml_to_result(self.binarytoxml(response),command)
171 | dict_data = {"AllowedFeatures" : data["AllowedFeatures"],
172 | "AnchorAttribute" : data["DirSyncConfiguration"].get("AnchorAttribute",""),
173 | "ApplicationVersion" : data["DirSyncConfiguration"].get("ApplicationVersion",""),
174 | "ClientVersion" : data["DirSyncConfiguration"].get("ClientVersion",""),
175 | "DirSyncClientMachine" : data["DirSyncConfiguration"].get("CurrentExport",{}).get("DirSyncClientMachineName",""),
176 | "DirSyncFeatures" : int(data["DirSyncFeatures"]),
177 | "DisplayName" : data["DisplayName"],
178 | "IsDirSyncing" : data["IsDirSyncing"],
179 | "IsPasswordSyncing" : data["IsPasswordSyncing"],
180 | "IsTrackingChanges" : data["DirSyncConfiguration"].get("IsTrackingChanges",""),
181 | "MaxLinksSupportedAcrossBatchInProvision" : data["MaxLinksSupportedAcrossBatchInProvision2"],
182 | "PreventAccidentalDeletion" : data["DirSyncConfiguration"].get("PreventAccidentalDeletion",{}).get("DeletionPrevention",''),
183 | "SynchronizationInterval" : data["SynchronizationInterval"],
184 | "TenantId" : data["TenantId"],
185 | "TotalConnectorSpaceObjects" : data["DirSyncConfiguration"].get("CurrentExport",{}).get("TotalConnectorSpaceObjects",''),
186 | "TresholdCount" : data["DirSyncConfiguration"].get("PreventAccidentalDeletion",{}).get("ThresholdCount",''),
187 | "TresholdPercentage" : data["DirSyncConfiguration"].get("PreventAccidentalDeletion",{}).get("ThresholdPercentage",''),
188 | "UnifiedGroupContainer" : data["DirSyncConfiguration"].get("Writeback",{}).get("UnifiedGroupContainer",{}).get('@i:nil',''),
189 | "UserContainer" : data["DirSyncConfiguration"].get("Writeback",{}).get("UserContainer",{}).get('@i:nil',''),
190 | }
191 | return dict_data
192 |
193 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L515
194 | def update_syncfeatures(self,feature=None):
195 | body = '''
196 | %s
197 | ''' % feature
198 | message_id = str(uuid.uuid4())
199 | command = "SetCompanyDirsyncFeatures"
200 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
201 | response = self.call_adsyncapi(envelope,command,self.tenant_id,message_id)
202 | return self.binarytoxml(response)
203 |
204 | #https://github.com/Gerenios/AADInternals/blob/fd6474e840f457c32a297cadbad051cabe2a019b/ProvisioningAPI.ps1#L4582
205 | def get_companyinformation(self):
206 | body = ''''''
207 | command = "GetCompanyInformation"
208 | envelope = self.create_envelope(command,body)
209 | response = self.call_provisioningapi(envelope)
210 | return self.xml_to_result(response,command)['b:ReturnValue']
211 |
212 |
213 | #https://github.com/Gerenios/AADInternals/blob/fd6474e840f457c32a297cadbad051cabe2a019b/ProvisioningAPI.ps1#L2870
214 | def get_users(self,pagesize=500,sortdirection="Ascending",sortfield="None",searchstring=""):
215 | body = rf'''
216 | {pagesize}
217 | {searchstring}
218 | {sortdirection}
219 | {sortfield}
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 | '''
239 | command = "ListUsers"
240 | envelope = self.create_envelope(command,body)
241 | response = self.call_provisioningapi(envelope)
242 | return self.xml_to_result(response,command)['b:ReturnValue']
243 |
244 | #https://github.com/Gerenios/AADInternals/blob/fd6474e840f457c32a297cadbad051cabe2a019b/ProvisioningAPI.ps1#L3988
245 | def get_userbyobjectid(self,objectid,returndeletedusers=False):
246 | body = rf'''{objectid}
247 | {str(returndeletedusers).lower()}'''
248 | command = "GetUser"
249 | envelope = self.create_envelope(command,body)
250 | response = self.call_provisioningapi(envelope)
251 | return self.xml_to_result(response,command)['b:ReturnValue']
252 |
253 | #https://github.com/Gerenios/AADInternals/blob/fd6474e840f457c32a297cadbad051cabe2a019b/ProvisioningAPI.ps1#L6119
254 | def get_group(self,objectid):
255 | body = rf'''{objectid}'''
256 | command = "GetGroup"
257 | envelope = self.create_envelope(command,body)
258 | response = self.call_provisioningapi(envelope)
259 | return self.xml_to_result(response,command)['b:ReturnValue']
260 |
261 |
262 |
263 | #https://github.com/Gerenios/AADInternals/blob/fd6474e840f457c32a297cadbad051cabe2a019b/ProvisioningAPI.ps1#L715
264 | def get_groups(self,pagesize=500,sortdirection="Ascending",sortfield="None"):
265 | body = rf'''
266 | {pagesize}
267 |
268 | {sortdirection}
269 | {sortfield}
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 | '''
279 | command = "ListGroups"
280 | envelope = self.create_envelope(command,body)
281 | response = self.call_provisioningapi(envelope)
282 | return self.xml_to_result(response,command)['b:ReturnValue']
283 |
284 | #https://github.com/Gerenios/AADInternals/blob/fd6474e840f457c32a297cadbad051cabe2a019b/ProvisioningAPI.ps1#L4332
285 | def get_groupsmembers(self,objectid,pagesize=500,sortdirection="Ascending",sortfield="None"):
286 | body = rf'''
287 | {pagesize}
288 |
289 | {sortdirection}
290 | {sortfield}
291 | {objectid}
292 |
293 |
294 | '''
295 | command = "ListGroupMembers"
296 | envelope = self.create_envelope(command,body)
297 | response = self.call_provisioningapi(envelope)
298 | return self.xml_to_result(response,command)['b:ReturnValue']
299 |
300 |
301 |
302 | #https://github.com/microsoftgraph/entra-powershell/blob/af0c8b538d293390ce0c2fafe30b95259cdcf774/module/Entra/Microsoft.Entra/DirectoryManagement/Set-EntraDirSyncEnabled.ps1#L44
303 | def set_adsyncenabled(self,enabledirsync=True):
304 |
305 | url = f"https://graph.microsoft.com/v1.0/organization/{self.tenant_id}"
306 | token = self.get_token(['https://graph.microsoft.com/.default'])
307 | headers = {
308 | "Authorization": f"Bearer {token}",
309 | "Content-Type": "application/json"
310 | }
311 |
312 | body = {"OnPremisesSyncEnabled": enabledirsync}
313 |
314 | response = requests.patch(url, json=body, headers=headers,proxies=self.proxies,verify=self.verify)
315 |
316 | if response.status_code == 204:
317 | return
318 | else:
319 | raise Exception (response.content)
320 |
321 | #https://github.com/Gerenios/AADInternals/blob/fd6474e840f457c32a297cadbad051cabe2a019b/ProvisioningAPI.ps1#L5561
322 | def set_userlicenses(self,objectid):
323 | body = rf'''
324 | {objectid}
325 |
326 | '''
327 | command = "SetUserLicenses"
328 | envelope = self.create_envelope(command,body)
329 | response = self.call_provisioningapi(envelope)
330 | return response
331 |
332 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L784
333 | def remove_azureadoject(self,sourceanchor=None,objecttype=None):
334 | """Removes Azure AD object using Azure AD Sync API"""
335 | body = '''
336 |
337 |
338 |
339 |
340 | SourceAnchor%s
341 |
342 | %s
343 | Delete
344 |
345 |
346 |
347 | ''' % (sourceanchor,objecttype)
348 | message_id = str(uuid.uuid4())
349 | command = "ProvisionAzureADSyncObjects"
350 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
351 | response = self.call_adsyncapi(envelope,command,self.tenant_id,message_id)
352 | return self.binarytoxml(response)
353 |
354 |
355 |
356 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L1585
357 | def get_kerberosdomainsyncconfig(self):
358 | body = '''
359 | '''
360 | message_id = str(uuid.uuid4())
361 | command = "GetKerberosDomainSyncConfig"
362 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
363 | response = self.call_adsyncapi(envelope,command,self.tenant_id,message_id)
364 | return self.binarytoxml(response)
365 |
366 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L1665
367 | def get_kerberosdomain(self,domainname):
368 | body = '''
369 | %s
370 | ''' % domainname
371 | message_id = str(uuid.uuid4())
372 | command = "GetKerberosDomain"
373 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
374 | response = self.call_adsyncapi(envelope,command,self.tenant_id,message_id)
375 | return self.binarytoxml(response)
376 |
377 |
378 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L1743
379 | def get_windowscredentialssyncconfig(self):
380 | body = ''''''
381 | message_id = str(uuid.uuid4())
382 | command = "GetMonitoringTenantCertificate"
383 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
384 | response = self.call_adsyncapi(envelope,command,self.tenant_id,message_id)
385 | return self.binarytoxml(response)
386 |
387 |
388 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L1881
389 | def get_syncdeviceconfiguration(self):
390 | body = ''''''
391 | message_id = str(uuid.uuid4())
392 | command = "GetDeviceConfiguration"
393 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
394 | response = self.call_adsyncapi(envelope,command,self.tenant_id,message_id)
395 | return self.binarytoxml(response)
396 |
397 |
398 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L1972
399 | def get_synccapabilities(self):
400 | body = ''''''
401 | message_id = str(uuid.uuid4())
402 | command = "Capabilities"
403 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
404 | response = self.call_adsyncapi(envelope,command,self.tenant_id,message_id)
405 | return self.binarytoxml(response)
406 |
407 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L872
408 | def finalize_export(self,count=0):
409 | body = '''
410 | %s
411 | %s
412 | ''' % (count,count)
413 | message_id = str(uuid.uuid4())
414 | command = "FinalizeExport"
415 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
416 | response = self.call_adsyncapi(envelope,command,self.tenant_id,message_id)
417 | return self.binarytoxml(response)
418 |
419 |
420 | def set_sync_features(self,enable_features=[], disable_features=[]):
421 | feature_values = {
422 | "PasswordHashSync": 1,
423 | "PasswordWriteBack": 2,
424 | "DirectoryExtensions": 4,
425 | "DuplicateUPNResiliency": 8,
426 | "EnableSoftMatchOnUpn": 16,
427 | "DuplicateProxyAddressResiliency": 32,
428 | "EnforceCloudPasswordPolicyForPasswordSyncedUsers": 512,
429 | "UnifiedGroupWriteback": 1024,
430 | "UserWriteback": 2048,
431 | "DeviceWriteback": 4096,
432 | "SynchronizeUpnForManagedUsers": 8192,
433 | "EnableUserForcePasswordChangeOnLogon": 16384,
434 | "PassThroughAuthentication": 131072,
435 | "BlockSoftMatch": 524288,
436 | "BlockCloudObjectTakeoverThroughHardMatch": 1048576
437 | }
438 |
439 |
440 |
441 |
442 | current_features = self.get_syncconfiguration()['DirSyncFeatures']
443 |
444 | for feature in enable_features:
445 | current_features = current_features | feature_values[feature]
446 |
447 | for feature in disable_features:
448 | current_features = current_features & (0x7FFFFFFF ^ feature_values[feature])
449 |
450 |
451 | return self.update_syncfeatures(current_features)
452 |
453 |
454 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L570
455 | def set_azureadobject(self,
456 | SourceAnchor=None,
457 | userPrincipalName=None,
458 | usertype='User',
459 | operation_type="Set",
460 | accountEnabled=True,
461 | surname=None,
462 | onPremisesSamAccountName=None,
463 | onPremisesDistinguishedName=None,
464 | onPremiseSecurityIdentifier=None,
465 | netBiosName=None,
466 | lastPasswordChangeTimestamp=None,
467 | givenName=None,
468 | dnsDomainName=None,
469 | displayName=None,
470 | countryCode=None,
471 | commonName=None,
472 | cloudMastered=None,
473 | usageLocation=None,
474 | proxyAddresses=None,
475 | thumbnailPhoto=None,
476 | groupMembers=None,
477 | deviceId=None,
478 | deviceOSType=None,
479 | deviceTrustType=None,
480 | userCertificate=None,
481 | physicalDeliveryOfficeName=None,
482 | employeeId=None,
483 | deviceOSVersion=None,
484 | country=None,
485 | city=None,
486 | streetAddress=None,
487 | state=None,
488 | department=None,
489 | telephoneNumber=None,
490 | company=None,
491 | employeeType=None,
492 | facsimileTelephoneNumber=None,
493 | mail=None,
494 | mobile=None,
495 | title=None,
496 | SecurityEnabled=None,
497 | **kwargs
498 | ):
499 | """
500 | Creates or updates Azure AD object using Azure AD Sync API. Can also set cloud-only user's sourceAnchor (ImmutableId) and onPremisesSAMAccountName. SourceAnchor can only be set once!
501 | """
502 | tenant_id = self.tenant_id
503 |
504 | datakwargs = []
505 | for k in kwargs:
506 | datakwargs.append(self.Add_PropertyValue(k,Value=kwargs[k]))
507 |
508 | datakwargs = '\n'.join(datakwargs)
509 |
510 |
511 | command = "ProvisionAzureADSyncObjects"
512 | body = rf"""
513 |
514 |
515 |
516 |
517 | {self.Add_PropertyValue("SourceAnchor",Value=SourceAnchor)}
518 | {self.Add_PropertyValue("accountEnabled",Value=accountEnabled,Type="bool")}
519 | {self.Add_PropertyValue("userPrincipalName",Value=userPrincipalName)}
520 | {self.Add_PropertyValue("commonName",Value=commonName)}
521 | {self.Add_PropertyValue("deviceOSVersion",Value=deviceOSVersion)}
522 | {self.Add_PropertyValue("countryCode",Value=countryCode,Type="long")}
523 | {self.Add_PropertyValue("displayName",Value=displayName)}
524 | {self.Add_PropertyValue("dnsDomainName",Value=dnsDomainName)}
525 | {self.Add_PropertyValue("givenName",Value=givenName)}
526 | {self.Add_PropertyValue("lastPasswordChangeTimestamp",Value=lastPasswordChangeTimestamp)}
527 | {self.Add_PropertyValue("netBiosName",Value=netBiosName)}
528 | {self.Add_PropertyValue("onPremiseSecurityIdentifier",Value=onPremiseSecurityIdentifier,Type='base64')}
529 | {self.Add_PropertyValue("onPremisesDistinguishedName",Value=onPremisesDistinguishedName)}
530 | {self.Add_PropertyValue("onPremisesSamAccountName",Value=onPremisesSamAccountName)}
531 | {self.Add_PropertyValue("surname",Value=surname)}
532 | {self.Add_PropertyValue("cloudMastered",Value=cloudMastered,Type="bool")}
533 | {self.Add_PropertyValue("usageLocation",Value=usageLocation)}
534 | {self.Add_PropertyValue("ThumbnailPhoto",Value=thumbnailPhoto)}
535 | {self.Add_PropertyValue("proxyAddresses",Value=proxyAddresses,Type="ArrayOfstring")}
536 | {self.Add_PropertyValue("member",Value=groupMembers,Type="ArrayOfstring")}
537 | {self.Add_PropertyValue("deviceId",Value=deviceId,Type="base64")}
538 | {self.Add_PropertyValue("deviceTrustType",Value=deviceTrustType)}
539 | {self.Add_PropertyValue("deviceOSType",Value=deviceOSType)}
540 | {self.Add_PropertyValue("userCertificate",Value=userCertificate,Type='ArrayOfbase64')}
541 | {self.Add_PropertyValue("physicalDeliveryOfficeName",Value=physicalDeliveryOfficeName)}
542 | {self.Add_PropertyValue("department",Value=department)}
543 | {self.Add_PropertyValue("employeeId",Value=employeeId)}
544 | {self.Add_PropertyValue("streetAddress",Value=streetAddress)}
545 | {self.Add_PropertyValue("city",Value=city)}
546 | {self.Add_PropertyValue("state",Value=state)}
547 | {self.Add_PropertyValue("country",Value=country)}
548 | {self.Add_PropertyValue("telephoneNumber",Value=telephoneNumber)}
549 | {self.Add_PropertyValue("company",Value=company)}
550 | {self.Add_PropertyValue("employeeType",Value=employeeType)}
551 | {self.Add_PropertyValue("facsimileTelephoneNumber",Value=facsimileTelephoneNumber)}
552 | {self.Add_PropertyValue("mail",Value=mail)}
553 | {self.Add_PropertyValue("mobile",Value=mobile)}
554 | {self.Add_PropertyValue("title",Value=title)}
555 | {self.Add_PropertyValue("SecurityEnabled",Value=SecurityEnabled,Type="bool")}
556 | {datakwargs}
557 |
558 | {usertype}
559 | {operation_type}
560 |
561 |
562 |
563 | """
564 |
565 | message_id = str(uuid.uuid4())
566 | command = "ProvisionAzureADSyncObjects"
567 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
568 | rawresponse = self.call_adsyncapi(envelope,command,tenant_id,message_id)
569 | newresponse = self.xml_to_result(self.binarytoxml(rawresponse),command)['b:SyncObjectResults']['b:AzureADSyncObjectResult']
570 | if newresponse['b:ResultCode'] == 'Failure' or newresponse['b:ResultErrorDescription'] != {'@i:nil': 'true'}:
571 | raise Exception (newresponse['b:ResultErrorDescription'])
572 | return newresponse
573 |
574 | def xml_to_result(self,response,command):
575 | dataxml = xmltodict.parse(response)
576 | try:
577 | return dataxml["s:Envelope"]["s:Body"]["%sResponse" % command]['%sResult' % command]
578 | except KeyError:
579 | if 's:Fault' in dataxml.get("s:Envelope",{}).get("s:Body",{}):
580 | raise Exception(dataxml["s:Envelope"]["s:Body"]['s:Fault']['s:Reason']['s:Text']['#text'])
581 | else:
582 | raise Exception(dataxml)
583 |
584 | #Official api for search
585 | def search_user(self, upn_or_object_id,select=""):
586 | return self.call_graphapi(f'users/{upn_or_object_id}',select="")
587 |
588 | def list_users(self,select=''):
589 | result = []
590 | response = self.call_graphapi('users',select=select)
591 | for entry in response:
592 | result.append(entry)
593 | return result
594 |
595 | def get_dict_cloudanchor_sourceanchor(self):
596 | dict_cloudanchor_sourceanchor = {}
597 | for entry in self.get_syncobjects(False):
598 | cloudanchor = None
599 | sourceanchor = None
600 | if type(entry) == str:
601 | continue
602 | for v in entry['b:PropertyValues']['c:KeyValueOfstringanyType']:
603 | if v['c:Key'] == "CloudAnchor":
604 | cloudanchor = v["c:Value"].get('#text')
605 | if v['c:Key'] == "SourceAnchor":
606 | sourceanchor = v["c:Value"].get('#text')
607 |
608 | if (not cloudanchor) or (not sourceanchor):
609 | continue
610 | dict_cloudanchor_sourceanchor[cloudanchor] = sourceanchor
611 |
612 | return dict_cloudanchor_sourceanchor
613 |
614 | def list_groups(self,include_immutable_id=True,select=""):
615 |
616 | result = []
617 | if include_immutable_id:
618 | dict_cloudanchor_sourceanchor = self.get_dict_cloudanchor_sourceanchor()
619 | else:
620 | dict_cloudanchor_sourceanchor = {}
621 |
622 | response = self.call_graphapi('groups',select=select)
623 | for entry in response:
624 | data = entry
625 | if str('Group_' + data['id']) in dict_cloudanchor_sourceanchor:
626 | data['onPremisesImmutableId'] = dict_cloudanchor_sourceanchor[str('Group_' + data['id'])]
627 | else:
628 | if include_immutable_id :
629 | data['onPremisesImmutableId'] = None
630 | result.append(data)
631 | return result
632 |
633 | def list_devices(self,include_immutable_id=True,select=""):
634 |
635 | result = []
636 | if include_immutable_id:
637 | dict_cloudanchor_sourceanchor = self.get_dict_cloudanchor_sourceanchor()
638 | else:
639 | dict_cloudanchor_sourceanchor = {}
640 |
641 | response = self.call_graphapi('devices',select=select)
642 | for entry in response:
643 | data = entry
644 | if str('Device_' + data['id']) in dict_cloudanchor_sourceanchor:
645 | data['onPremisesImmutableId'] = dict_cloudanchor_sourceanchor[str('Device_' + data['id'])]
646 | else:
647 | if include_immutable_id :
648 | data['onPremisesImmutableId'] = None
649 | result.append(data)
650 | return result
651 |
652 |
653 |
654 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L927
655 | def get_syncobjects(self,fullsync=True,version=2):
656 | if version==2:
657 | txtvers="2"
658 | else:
659 | txtvers=""
660 | body = '''
661 | true
662 |
663 | %s
664 | ''' % (txtvers,fullsync,txtvers)
665 | message_id = str(uuid.uuid4())
666 | command = "ReadBackAzureADSyncObjects%s" % txtvers
667 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
668 | response = self.call_adsyncapi(envelope,command,self.tenant_id,message_id)
669 | dataxml = self.xml_to_result(self.binarytoxml(response),command)
670 | if dataxml.get('b:ResultObjects',{}) == None:
671 | dataxml['b:ResultObjects'] = {}
672 | return dataxml.get('b:ResultObjects',{}).get('b:AzureADSyncObject',[])
673 |
674 | #https://github.com/Gerenios/AADInternals/blob/9cc2a3673248dbfaf0dccf960481e7830a395ea8/AzureADConnectAPI.ps1#L1087
675 | def set_userpassword(self,cloudanchor=None,sourceanchor=None,userprincipalname=None,password=None,hashnt=None,changedate=None,iterations=1000,):
676 | """
677 | Sets the password of the given user using Azure AD Sync API. If the Result is 0, the change was successful.
678 | Requires that Directory Synchronization is enabled for the tenant!
679 | """
680 | tenant_id = self.tenant_id
681 | credentialdata = self.create_aadhash(hashnt=hashnt,password=password,iterations=iterations)
682 |
683 | if userprincipalname and (not cloudanchor) and (not sourceanchor):
684 | cloudanchor = 'User_' + self.search_user(userprincipalname)['objectId']
685 |
686 | if not changedate :
687 | changedate = datetime.datetime.now()
688 |
689 | if cloudanchor :
690 | cloudanchordata = "%s" % cloudanchor
691 | else:
692 | cloudanchordata = ''
693 |
694 | if sourceanchor:
695 | sourceanchordata= '%s' % sourceanchor
696 | else:
697 | sourceanchordata= ''
698 |
699 | body = r'''
700 |
701 |
702 |
703 | %s
704 | %s
705 | %s
706 | false
707 | %s
708 |
709 |
710 |
711 |
712 |
713 | ''' % ( '%sZ' % changedate.isoformat() ,cloudanchordata, credentialdata,sourceanchordata )
714 |
715 | message_id = str(uuid.uuid4())
716 | command = "ProvisionCredentials"
717 | envelope = self.create_syncenvelope(command,body,message_id,binary=True)
718 | response = self.call_adsyncapi(envelope,command,tenant_id,message_id)
719 | formatresponse = self.xml_to_result(self.binarytoxml(response),command)['b:Results']['b:SyncCredentialsChangeResult']
720 | if formatresponse['b:Result'] != '0':
721 | raise Exception(formatresponse.get('b:ExtendedErrorInformation',formatresponse))
722 | return formatresponse
723 |
724 |
725 |
726 | # https://github.com/Gerenios/AADInternals/blob/b135545d50a5a473c942139182265850f9d256c2/AzureADConnectAPI_utils.ps1#L279
727 | def create_aadhash(self,hashnt=None,iterations = 1000,password=None):
728 | if not hashnt:
729 | if not password:
730 | raise Exception('Please provide hashnt or password')
731 | hashnt = nthash.encrypt(password).upper()
732 | if len(hashnt) != 32:
733 | raise Exception('Invalid hash length!')
734 |
735 | hashbytes = bytearray(hashnt.encode('UTF-16LE'))
736 |
737 | listnb = []
738 | while not len(listnb) >= 10 :
739 | listnb.append(random.choice(list(range(0, 256))))
740 |
741 | salt = bytearray(listnb)
742 | #salt = bytearray([180 ,119 ,18 ,77 ,229 ,76 ,32 ,48 ,55 ,143])
743 |
744 | salthex = salt.hex()
745 | key = pbkdf2_hmac("sha256", hashbytes, salt, iterations, 32).hex()
746 | aadhash = "v1;PPH1_MD4,%s,%s,%s;" % (salthex,iterations,key)
747 | return aadhash
748 |
749 | #https://github.com/Gerenios/AADInternals/blob/fd6474e840f457c32a297cadbad051cabe2a019b/ProvisioningAPI_utils.ps1#L64
750 | def create_envelope(self,command,requestelements):
751 | message_id = str(uuid.uuid4())
752 | envelope = rf'''
753 |
754 |
755 | http://provisioning.microsoftonline.com/IProvisioningWebService/{command}
756 | urn:uuid:{message_id}
757 |
758 | http://www.w3.org/2005/08/addressing/anonymous
759 |
760 |
761 | Bearer {self.get_token(scopes=["https://graph.windows.net/.default"])}
762 |
763 |
764 |
765 | 50afce61-c917-435b-8c6d-60aa5a8b8aa7
766 | 1.2.183.17
767 |
768 |
769 | Version47
770 |
771 | https://provisioningapi.microsoftonline.com/provisioningwebservice.svc
772 |
773 |
774 | <{command} xmlns="http://provisioning.microsoftonline.com/">
775 |
776 | Version16
777 | {self.tenant_id}
778 |
779 | {requestelements}
780 |
781 | {command}>
782 |
783 |
784 | '''
785 | return envelope
786 |
787 |
788 | #https://github.com/Gerenios/AADInternals/blob/b135545d50a5a473c942139182265850f9d256c2/AzureADConnectAPI_utils.ps1#L77
789 | def create_syncenvelope(self,command,body,message_id,server=aadsync_server,binary=True,isinstalledondc=False,richcoexistenceenabled=False,version=1):
790 |
791 | if version == 2:
792 | applicationclient= "6eb59a73-39b2-4c23-a70f-e2e3ce8965b1"
793 | else :
794 | applicationclient = "1651564e-7ce4-4d99-88be-0a65050d8dc3"
795 |
796 | envelope = rf'''
797 |
798 | http://schemas.microsoft.com/online/aws/change/2010/01/IProvisioningWebService/{command}
799 |
800 | {applicationclient}
801 | {self.get_token(scopes=["https://graph.windows.net/.default"])}
802 | {aadsync_client_version}
803 | {aadsync_client_build}
804 | {aadsync_client_build}
805 | {isinstalledondc}
806 | 0001-01-01T00:00:00
807 | en-US
808 |
809 | 2.0
810 | {richcoexistenceenabled}
811 | {message_id}
812 |
813 | urn:uuid:{message_id}
814 |
815 | http://www.w3.org/2005/08/addressing/anonymous
816 |
817 | https://{server}/provisioningservice.svc
818 |
819 |
820 | {body}
821 |
822 | '''
823 |
824 | if binary:
825 | return self.xmltobinary(envelope)
826 | else:
827 | return envelope
828 |
829 | #https://github.com/Gerenios/AADInternals/blob/fd6474e840f457c32a297cadbad051cabe2a019b/ProvisioningAPI_utils.ps1#L127
830 | def call_provisioningapi(self,envelope):
831 | headers = {
832 | 'Content-type': 'application/soap+xml'
833 | }
834 | r = requests.post("https://provisioningapi.microsoftonline.com/provisioningwebservice.svc", headers=headers,data=envelope,proxies=self.proxies,timeout=15,verify=self.verify)
835 | return r.content
836 |
837 | #https://github.com/Gerenios/AADInternals/blob/b135545d50a5a473c942139182265850f9d256c2/AzureADConnectAPI_utils.ps1#L166
838 | def call_adsyncapi(self,envelope,command,tenant_id,message_id,server=aadsync_server):
839 | headers = {
840 | "Host":server,
841 | 'Content-type': 'application/soap+msbin1',
842 | "x-ms-aadmsods-clientversion": aadsync_client_version,
843 | "x-ms-aadmsods-dirsyncbuildnumber": aadsync_client_build,
844 | "User-Agent":"",
845 | "x-ms-aadmsods-fimbuildnumber": aadsync_client_build,
846 | "x-ms-aadmsods-tenantid" : tenant_id,
847 | "client-request-id": message_id,
848 | "x-ms-aadmsods-appid":"1651564e-7ce4-4d99-88be-0a65050d8dc3",
849 | "x-ms-aadmsods-apiaction": command
850 | }
851 | r = self.requests_session_call_adsyncapi.post("https://%s/provisioningservice.svc" % server, headers=headers,data=envelope,proxies=self.proxies,verify=self.verify)
852 |
853 | return r.content
854 |
855 |
856 | #https://github.com/Gerenios/AADInternals/blob/b135545d50a5a473c942139182265850f9d256c2/AzureADConnectAPI_utils.ps1#L228
857 | #generate by chatgpt
858 | def Add_PropertyValue(self,Key: str, Value, Type: str = "string"):
859 | if Value is not None:
860 | PropBlock = "" + Key + ""
861 | if Type == "long":
862 | PropBlock += "" + str(Value) + ""
863 | elif Type == "bool":
864 | PropBlock += "" + str(Value).lower() + ""
865 | elif Type == "base64":
866 | PropBlock += "" + Value + ""
867 | elif Type == "ArrayOfstring":
868 | PropBlock += ""
869 | for stringValue in Value:
870 | PropBlock += "" + stringValue + ""
871 | PropBlock += ""
872 | elif Type == "ArrayOfbase64":
873 | PropBlock += ""
874 | for stringValue in Value:
875 | PropBlock += "" + stringValue + ""
876 | PropBlock += ""
877 | else:
878 | if Value:
879 | PropBlock += "" + Value + ""
880 | else:
881 | PropBlock += """"""
882 | PropBlock += ""
883 | return PropBlock
884 | else:
885 | return ""
886 |
887 | def set_desktop_sso_enabled(self,enable: bool = True):
888 | tenant_id = self.tenant_id
889 | url = f"https://{tenant_id}.registration.msappproxy.net/register/EnableDesktopSsoFlag"
890 | token = self.get_token(scopes=['https://proxy.cloudwebappproxy.net/registerapp/.default'])
891 | body = f'''
892 |
893 | {token}
894 | {str(enable).lower()}
895 | '''
896 | headers = {'Content-Type': 'application/xml; charset=utf-8'}
897 | response = xmltodict.parse( requests.post(url, data=body, headers=headers,proxies=self.proxies,timeout=15,verify=self.verify).content.decode('utf-8'))
898 | if not response['DesktopSsoEnablementResult']['IsSuccessful']:
899 | raise Exception (response['DesktopSsoEnablementResult']['ErrorMessage'])
900 |
901 |
902 | def set_desktop_sso(self, domain_name: str, password: str, computer_name: str = "AZUREADSSOACC", enable: bool = True):
903 | tenant_id = self.tenant_id
904 | url = f"https://{tenant_id}.registration.msappproxy.net/register/EnableDesktopSso"
905 | token = self.get_token(scopes=['https://proxy.cloudwebappproxy.net/registerapp/.default'])
906 | body = f'''
907 |
908 | {token}
909 | {computer_name}
910 | {domain_name}
911 | {str(enable).lower()}
912 | {password}
913 | '''
914 |
915 | headers = {'Content-Type': 'application/xml; charset=utf-8'}
916 | response = xmltodict.parse( requests.post(url, data=body, headers=headers,proxies=self.proxies,timeout=15,verify=self.verify).content.decode('utf-8'))
917 | if not response['DesktopSsoEnablementResult']['IsSuccessful']:
918 | raise Exception (response['DesktopSsoEnablementResult']['ErrorMessage'])
919 |
920 |
921 |
922 | def binarytoxml(self,binaryxml):
923 | fp = io.BytesIO(binaryxml)
924 | records = Record.parse(fp)
925 | fp = io.StringIO()
926 | print_records(records,fp=fp)
927 | fp.seek(0)
928 | data = fp.read()
929 | return str(data)
930 |
931 | def xmltobinary(self,dataxml):
932 | r = XMLParser.parse(dataxml)
933 | data = dump_records(r)
934 | return data
935 |
936 |
--------------------------------------------------------------------------------