├── 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 | 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 | --------------------------------------------------------------------------------