Création d'utilisateurs Google à partir de PowerShell via l'API

Bonjour !

Cet article décrira l'implémentation de l'interaction entre PowerShell et l'API Google pour manipuler des utilisateurs de G Suite.

Dans notre organisation, nous utilisons plusieurs services internes et cloud. La plupart du temps, l'authentification se réduit à Google ou Active Directory, entre lesquels nous ne pouvons pas maintenir de réplica ; par conséquent, lors de l'arrivée d'un nouvel employé, il faut créer/activer un compte dans ces deux systÚmes. Pour automatiser ce processus, nous avons décidé d'écrire un script qui collecte des informations et les envoie aux deux services.

Autorisation

En établissant les exigences, nous avons décidé d'utiliser de vraies personnes administrateurs pour l'authentification, ce qui simplifie l'analyse des actions lors de modifications massives accidentelles ou intentionnelles.

Pour l'authentification et l'autorisation, l'API Google utilise le protocole OAuth 2.0. Les scĂ©narios d'utilisation et une description plus dĂ©taillĂ©e peuvent ĂȘtre consultĂ©s ici : Utiliser OAuth 2.0 pour accĂ©der aux API Google.

J'ai choisi le scénario utilisé lors de l'authentification dans les applications de bureau. Il existe également une option pour utiliser un compte de service, qui ne nécessite pas d'efforts supplémentaires de la part de l'utilisateur.

L'image ci-dessous est une représentation schématique du scénario choisi à partir de la page Google.

Création d'utilisateurs Google à partir de PowerShell via l'API

  1. Tout d'abord, nous redirigeons l'utilisateur vers la page d'authentification de son compte Google, en spécifiant les paramÚtres GET :
    • l'identifiant de l'application
    • les domaines auxquels l'application nĂ©cessite l'accĂšs
    • l'adresse vers laquelle l'utilisateur sera redirigĂ© aprĂšs avoir terminĂ© la procĂ©dure
    • la mĂ©thode par laquelle nous mettrons Ă  jour le token
    • le code de vĂ©rification
    • le format de transmission du code de vĂ©rification

  2. AprÚs l'achÚvement de l'authentification, l'utilisateur sera redirigé vers la page indiquée dans la premiÚre demande, avec une erreur ou un code d'autorisation transmis dans les paramÚtres GET.
  3. L'application (script) devra récupérer ces paramÚtres et, en cas de réception du code, effectuer la demande suivante pour obtenir des tokens.
  4. Pour une demande correcte, l'API Google renvoie :
    • Un token d'accĂšs, avec lequel nous pouvons effectuer des requĂȘtes.
    • La durĂ©e de validitĂ© de ce token.
    • Un token de rafraĂźchissement, nĂ©cessaire pour renouveler le token d'accĂšs.

Tout d'abord, il faut se rendre dans la console Google API : Identifiants — Console Google API, choisissez l'application souhaitĂ©e et dans la section Credentials, crĂ©ez un identifiant OAuth client. Vous devez Ă©galement indiquer les adresses vers lesquelles la redirection est autorisĂ©e (cela peut ĂȘtre fait plus tard dans les propriĂ©tĂ©s de l'identifiant créé). Dans notre cas, cela comprendra plusieurs enregistrements localhost avec diffĂ©rents ports (voir ci-dessous).

Pour faciliter la lecture de l'algorithme du script, il est possible de placer les premiÚres étapes dans une fonction distincte qui renverra les tokens d'accÚs et de rafraßchissement pour l'application :

$client_secret = 'Notre Client Secret'
$client_id = 'Notre Client ID'
function Get-GoogleAuthToken {
  if (-not [System.Net.HttpListener]::IsSupported) {
    "HttpListener n'est pas supporté."
    exit 1
  }
  $codeverifier = -join ((65..90) + (97..122) + (48..57) + 45 + 46 + 95 + 126 |Get-Random -Count 60| % {[char]$_})
  $hasher = new-object System.Security.Cryptography.SHA256Managed
  $hashByteArray = $hasher.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($codeverifier))
  $base64 = ((([System.Convert]::ToBase64String($hashByteArray)).replace('=','')).replace('+','-')).replace('\/','_')
  $ports = @(10600,15084,39700,42847,65387,32079)
  $port = $ports[(get-random -Minimum 0 -maximum 5)]
  Write-Host "Démarrer le navigateur..."
  Start-Process "https://accounts.google.com/o/oauth2/v2/auth?code_challenge_method=S256&code_challenge=$base64&access_type=offline&client_id=$client_id&redirect_uri=http://localhost:$port&response_type=code&scope=https://www.googleapis.com/auth/admin.directory.user https://www.googleapis.com/auth/admin.directory.group"
  $listener = New-Object System.Net.HttpListener
  $listener.Prefixes.Add("http://localhost:"+$port+'\/')
  try {$listener.Start()} catch {
    "Impossible de démarrer l'écouteur."
    exit 1
  }
  while (($code -eq $null)) {
    $context = $listener.GetContext()
    Write-Host "Connexion acceptée" -f 'mag'
    $url = $context.Request.RawUrl
    $code = $url.split('?')[1].split('=')[1].split('&')[0]
    if ($url.split('?')[1].split('=')[0] -eq 'error') {
      Write-Host "Erreur!"$code -f 'red'
      $buffer = [System.Text.Encoding]::UTF8.GetBytes("Erreur!"+$code)
      $context.Response.ContentLength64 = $buffer.Length
      $context.Response.OutputStream.Write($buffer, 0, $buffer.Length)
      $context.Response.OutputStream.Close()
      $listener.Stop()
      exit 1
    }
    $buffer = [System.Text.Encoding]::UTF8.GetBytes("Vous pouvez maintenant fermer cet onglet de navigateur.")
    $context.Response.ContentLength64 = $buffer.Length
    $context.Response.OutputStream.Write($buffer, 0, $buffer.Length)
    $context.Response.OutputStream.Close()
    $listener.Stop()
  }
  Return Invoke-RestMethod -Method Post -Uri "https://www.googleapis.com/oauth2/v4/token" -Body @{
    code = $code
    client_id = $client_id
    client_secret = $client_secret
    redirect_uri = 'http://localhost:'+$port
    grant_type = 'authorization_code'
    code_verifier   = $codeverifier
  }
  $code = $null

Nous définissons le Client ID et le Client Secret, obtenus dans les propriétés de l'identifiant client OAuth, et le code de vérification qui est une chaßne de 43 à 128 caractÚres, générée aléatoirement à partir de caractÚres non réservés : [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~".

Ce code sera ensuite renvoyé. Il élimine la vulnérabilité par laquelle un attaquant pourrait intercepter la réponse retournée par redirection aprÚs l'autorisation de l'utilisateur.
Il est possible d'envoyer le code de vĂ©rification dans la requĂȘte actuelle en clair (ce qui le rend inutile – cela ne convient que pour les systĂšmes qui ne prennent pas en charge SHA256) ou en crĂ©ant un hachage selon l'algorithme SHA256, qui doit ĂȘtre encodĂ© en BASE64Url (diffĂ©rent de Base64 par deux symboles) et en supprimant le caractĂšre de fin de ligne : =.

Nous devons ensuite commencer à écouter http sur la machine locale pour recevoir la réponse aprÚs authentification, qui sera renvoyée par redirection.

Les tĂąches administratives sont effectuĂ©es sur un serveur spĂ©cial, nous ne pouvons pas exclure la possibilitĂ© que plusieurs administrateurs exĂ©cutent le script simultanĂ©ment, il choisira donc par dĂ©faut un port pour l'utilisateur actuel, mais j'ai spĂ©cifiĂ© des ports prĂ©dĂ©finis, car ils doivent Ă©galement ĂȘtre ajoutĂ©s comme approuvĂ©s dans la console API.

access_type=offline signifie que l'application peut actualiser un token expiré de maniÚre autonome sans interaction de l'utilisateur avec le navigateur,
response_type=code indique le format de retour du code (rĂ©fĂ©rence Ă  l'ancien mode d'authentification oĂč l'utilisateur copiĂ©-collait le code du navigateur dans le script),
scope indique les zones et le type d'accĂšs. Ils doivent ĂȘtre sĂ©parĂ©s par des espaces ou (conformĂ©ment Ă  l'encodage URL). La liste des zones d'accĂšs avec les types peut ĂȘtre vue ici : OAuth 2.0 Scopes for Google APIs.

AprĂšs avoir obtenu le code d'autorisation, l'application renverra au navigateur un message de fermeture, arrĂȘtera d'Ă©couter le port et enverra une requĂȘte POST pour obtenir un token. Nous y spĂ©cifions les id et secret prĂ©cĂ©demment dĂ©finis de la console API, l'adresse vers laquelle l'utilisateur sera redirigĂ© et grant_type conformĂ©ment Ă  la spĂ©cification du protocole.

En réponse, nous recevrons un token d'accÚs, sa durée de vie en secondes et un token de rafraßchissement, à l'aide duquel nous pouvons actualiser le token d'accÚs.

L'application doit stocker les tokens dans un endroit sĂ»r avec une longue durĂ©e de stockage, donc tant que nous n'avons pas rĂ©voquĂ© l'accĂšs obtenu, l'application ne recevra pas de token de rafraĂźchissement. À la fin, j'ai ajoutĂ© une requĂȘte pour rĂ©voquer le token, si l'application a Ă©tĂ© arrĂȘtĂ©e de maniĂšre non rĂ©ussie et que le token de rafraĂźchissement n'est pas revenu, elle recommencera la procĂ©dure (nous avons considĂ©rĂ© qu'il Ă©tait dangereux de stocker les tokens localement sur le terminal, et compliquĂ© de recourir Ă  la cryptographie ou d'ouvrir frĂ©quemment le navigateur).

do {
  $token_result = Get-GoogleAuthToken
  $token = $token_result.access_token
  if ($token_result.refresh_token -eq $null) {
    Write-Host ("La session n'est pas détruite. Révocation du token...")
    Invoke-WebRequest -Uri ("https://accounts.google.com/o/oauth2/revoke?token="+$token)
  }
} while ($token_result.refresh_token -eq $null)
$refresh_token = $token_result.refresh_token
$minute = ([int]("{0:mm}" -f ([timespan]::fromseconds($token_result.expires_in))))+((Get-date).Minute)-2
if ($minute -lt 0) {$minute += 60}
elseif ($minute -gt 59) {$minute -=60}
$token_expire = @{
  hour = ([int]("{0:hh}" -f ([timespan]::fromseconds($token_result.expires_in))))+((Get-date).Hour)
  minute = $minute
}

Comme vous l'avez déjà remarqué, la révocation du token utilise Invoke-WebRequest. Contrairement à Invoke-RestMethod, il ne renvoie pas les données obtenues dans un format facile à utiliser et montre le statut de la demande.

Ensuite, le script demandera le prénom et le nom de l'utilisateur, générant un identifiant + email.

RequĂȘtes

Les prochaines Ă©tapes consisteront Ă  effectuer des requĂȘtes – il est d'abord nĂ©cessaire de vĂ©rifier si un utilisateur avec cet identifiant existe dĂ©jĂ  pour dĂ©cider de crĂ©er un nouveau compte ou d'utiliser l'actuel.

J'ai dĂ©cidĂ© de rĂ©aliser toutes les requĂȘtes sous la forme d'une seule fonction avec une sĂ©lection, en utilisant switch:

function GoogleQuery {
  param (
    $type,
    $query
  )
  switch ($type) {
    "SearchAccount" {
      Return Invoke-RestMethod -Method Get -Uri "https://www.googleapis.com/admin/directory/v1/users" -Headers @{Authorization = "Bearer " + (Get-GoogleToken)} -Body @{
        domain = 'rocketguys.com'
        query  = "email:$query"
      }
    }
    "UpdateAccount" {
      $body = @{
        name  = @{
          givenName = $query['givenName']
          familyName = $query['familyName']
        }
        suspended = 'false'
        password = $query['password']
        changePasswordAtNextLogin = 'true'
        phones = @(@{
          primary = 'true'
          value = $query['phone']
          type = "mobile"
        })
        orgUnitPath = $query['orgunit']
      }
      Return Invoke-RestMethod -Method Put -Uri ("https://www.googleapis.com/admin/directory/v1/users/" + $query['email']) -Headers @{Authorization = "Bearer " + (Get-GoogleToken)} -Body (ConvertTo-Json $body) -ContentType 'application/json; charset=utf-8'
    }
    
    "CreateAccount" {
      $body = @{
        primaryEmail = $query['email']
        name  = @{
          givenName = $query['givenName']
          familyName = $query['familyName']
        }
        suspended = 'false'
        password = $query['password']
        changePasswordAtNextLogin = 'true'
        phones = @(@{
          primary = 'true'
          value = $query['phone']
          type = "mobile"
        })
        orgUnitPath = $query['orgunit']
      }
      Return Invoke-RestMethod -Method Post -Uri "https://www.googleapis.com/admin/directory/v1/users" -Headers @{Authorization = "Bearer " + (Get-GoogleToken)} -Body (ConvertTo-Json $body) -ContentType 'application/json; charset=utf-8'
    }
    "AddMember" {
      $body = @{
        userKey = $query['email']
      }
      $ifrequest = Invoke-RestMethod -Method Get -Uri "https://www.googleapis.com/admin/directory/v1/groups" -Headers @{Authorization = "Bearer " + (Get-GoogleToken)} -Body $body
      $array = @()
      foreach ($group in $ifrequest.groups) {$array += $group.email}
      if ($array -notcontains $query['groupkey']) {
        $body = @{
          email = $query['email']
          role = "MEMBER"
        }
        Return Invoke-RestMethod -Method Post -Uri ("https://www.googleapis.com/admin/directory/v1/groups/" + $query['groupkey'] + "/members") -Headers @{Authorization = "Bearer " + (Get-GoogleToken)} -Body (ConvertTo-Json $body) -ContentType 'application/json; charset=utf-8'
      } else {
        Return ($query['email'] + " now is a member of " + $query['groupkey'])
      }
    }
  }
}

Chaque requĂȘte doit inclure un en-tĂȘte Authorization, contenant le type de jeton et le jeton d'accĂšs lui-mĂȘme. Actuellement, le type de jeton est toujours Bearer. Étant donnĂ© que nous devons vĂ©rifier que le jeton n'est pas expirĂ© et le renouveler aprĂšs une heure depuis sa dĂ©livrance, j'ai indiquĂ© une requĂȘte vers une autre fonction, qui renvoie le jeton d'accĂšs. Ce mĂȘme code se trouve au dĂ©but du script lors de la rĂ©cupĂ©ration du premier jeton d'accĂšs :

function Get-GoogleToken {
  if (((Get-date).Hour -gt $token_expire.hour) -or (((Get-date).Hour -ge $token_expire.hour) -and ((Get-date).Minute -gt $token_expire.minute))) {
  Write-Host "Token Expiré. Rénouvellement..."
    $request = (Invoke-RestMethod -Method Post -Uri "https://www.googleapis.com/oauth2/v4/token" -ContentType 'application/x-www-form-urlencoded' -Body @{
      client_id = $client_id
      client_secret = $client_secret
      refresh_token = $refresh_token
      grant_type = 'refresh_token'
    })
    $token = $request.access_token
    $minute = ([int]("{0:mm}" -f ([timespan]::fromseconds($request.expires_in))))+((Get-date).Minute)-2
    if ($minute -lt 0) {$minute += 60}
    elseif ($minute -gt 59) {$minute -= 60}
    $script:token_expire = @{
      hour = ([int]("{0:hh}" -f ([timespan]::fromseconds($request.expires_in))))+((Get-date).Hour)
      minute = $minute
    }
  }
  return $token
}

Vérification de l'existence de l'identifiant :

function Check_Google {
  $query = (GoogleQuery 'SearchAccount' $username)
  if ($query.users -ne $null) {
    $user = $query.users[0]
    Write-Host $user.name.fullName' - '$user.PrimaryEmail' - suspendu : '$user.Suspended
    $GAresult = $user
  }
  if ($GAresult) {
      $return = $GAresult
  } else {$return = 'gg'}
  return $return
}

La requĂȘte email : $query demandera Ă  l'API de rechercher un utilisateur avec cet email, y compris les alias. Vous pouvez Ă©galement utiliser un wildcard : =, :, :{PREFIX}*.

Pour rĂ©cupĂ©rer des donnĂ©es, la mĂ©thode de requĂȘte GET est utilisĂ©e ; pour insĂ©rer des donnĂ©es (crĂ©er un compte ou ajouter un membre Ă  un groupe) – POST ; pour mettre Ă  jour des donnĂ©es existantes – PUT ; pour supprimer un enregistrement (par exemple, un membre d'un groupe) – DELETE.

Le script demandera Ă©galement un numĂ©ro de tĂ©lĂ©phone (chaĂźne non validĂ©e) et s'il faut ĂȘtre inclus dans le groupe rĂ©gional de diffusion. Il dĂ©termine quelle unitĂ© organisationnelle doit ĂȘtre attribuĂ©e Ă  l'utilisateur en fonction de l'OU Active Directory sĂ©lectionnĂ©e et gĂ©nĂ©rera un mot de passe :

do {
  $phone = Read-Host "Téléphone au format +7xxxxxxxx"
} while (-not $phone)
do {
    $moscow = Read-Host "Dans le bureau de Moscou ? (y/n) "
} while (-not (($moscow -eq 'y') -or ($moscow -eq 'n')))
$orgunit = ' / '
if ($OU -like "*OU=Delivery,OU=Users,OU=ROOT,DC=rocket,DC=local") {
    Write-host "Sera créé dans /Team delivery"
    $orgunit = " /Team delivery"
}
$Password =  -join ( 48..57 + 65..90 + 97..122 | Get-Random -Count 12 | % {[char]$_})+"*Ba"

Et ensuite commence les manipulations avec le compte :

$query = @{
  email = $email
  givenName = $firstname
  familyName = $lastname
  password = $password
  phone = $phone
  orgunit = $orgunit
}
if ($GMailExist) {
  Write-Host "Lancement de la modification du compte" -f mag
  (GoogleQuery 'UpdateAccount' $query) | fl
  write-host "N'oubliez pas de vérifier les groupes pour $Username dans Google."
} else {
  Write-Host "Lancement de la création du compte" -f mag
  (GoogleQuery 'CreateAccount' $query) | fl
}
if ($moscow -eq "y"){
  write-host "Ajout au groupe moscowoffice"
  $query = @{
    groupkey = 'moscowoffice@rocketguys.com'
    email = $email
  }
  (GoogleQuery 'AddMember' $query) | fl
}

Les fonctionnalités de mise à jour et de création de compte ont une syntaxe similaire, toutes les informations supplémentaires ne sont pas obligatoires. Dans la section des numéros de téléphone, il faut indiquer un tableau pouvant contenir une ou plusieurs entrées avec le numéro et son type.

Pour éviter une erreur lors de l'ajout d'un utilisateur à un groupe, nous pouvons d'abord vérifier s'il est déjà membre de ce groupe en obtenant la liste des membres du groupe ou la composition de l'utilisateur.

La demande de composition des groupes d'un utilisateur donné ne sera pas récursive et affichera uniquement l'appartenance directe. Inclure un utilisateur dans un groupe parent auquel appartient déjà un groupe enfant dont il fait partie sera un succÚs.

Conclusion

Il ne reste plus qu'Ă  envoyer Ă  l'utilisateur le mot de passe de son nouveau compte. Nous le faisons par SMS, et nous envoyons les informations gĂ©nĂ©rales avec le login Ă  l'adresse email personnelle fournie, ainsi que le numĂ©ro de tĂ©lĂ©phone, par le service de recrutement. En option, pour Ă©conomiser un peu, nous pouvons envoyer le mot de passe dans un chat secret Telegram, ce qui peut Ă©galement ĂȘtre considĂ©rĂ© comme un deuxiĂšme facteur (les MacBooks constitueront une exception).

Merci d'avoir lu jusqu'à la fin. Je serais ravi de recevoir des suggestions pour améliorer le style des articles et je vous souhaite de rencontrer moins d'erreurs lors de la rédaction de scripts =)

Voici une liste de liens qui peuvent ĂȘtre thĂ©matiquement utiles ou simplement rĂ©pondre Ă  certaines questions :

Source : habr.com

Acheter un hĂ©bergement fiable pour les sites avec protection DDoS, serveurs VPS VDS đŸ”„ Acheter un hĂ©bergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster