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

Salut!

Cet article décrira la mise en œuvre de l'interaction PowerShell avec l'API Google pour manipuler les utilisateurs de G Suite.

Nous utilisons plusieurs services internes et cloud dans toute l'organisation. Pour la plupart, l'autorisation revient à Google ou à Active Directory, entre lesquels nous ne pouvons pas maintenir de réplique ; par conséquent, lorsqu'un nouvel employé part, vous devez créer/activer un compte dans ces deux systèmes. Pour automatiser le processus, nous avons décidé d'écrire un script qui collecte des informations et les envoie aux deux services.

AUTORISATION

Lors de l'élaboration des exigences, nous avons décidé de faire appel à de véritables administrateurs humains pour l'autorisation, ce qui simplifie l'analyse des actions en cas de changements massifs accidentels ou intentionnels.

Les API Google utilisent le protocole OAuth 2.0 pour l'authentification et l'autorisation. Des cas d'utilisation et des descriptions plus détaillées peuvent être trouvés ici : Utiliser OAuth 2.0 pour accéder aux API Google.

J'ai choisi le script utilisé pour l'autorisation dans les applications de bureau. Il existe également la possibilité d'utiliser un compte de service, qui ne nécessite pas de mouvements inutiles de la part de l'utilisateur.

L'image ci-dessous est une description schématique du scénario sélectionné sur la page Google.

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

  1. Tout d'abord, nous renvoyons l'utilisateur vers la page d'authentification du compte Google, en spécifiant les paramètres GET :
    • ID d'application
    • zones auxquelles l’application doit accéder
    • l'adresse vers laquelle l'utilisateur sera redirigé après avoir terminé la procédure
    • la façon dont nous mettrons à jour le jeton
    • Code de sécurité
    • format de transmission du code de vérification

  2. Une fois l'autorisation terminée, l'utilisateur sera redirigé vers la page spécifiée dans la première requête, avec une erreur ou un code d'autorisation transmis par les paramètres GET.
  3. L'application (script) devra recevoir ces paramètres et, si elle reçoit le code, faire la requête suivante pour obtenir des tokens
  4. Si la requête est correcte, l'API Google renvoie :
    • Jeton d'accès avec lequel nous pouvons faire des demandes
    • La durée de validité de ce token
    • Jeton d’actualisation requis pour actualiser le jeton d’accès.

Vous devez d'abord accéder à la console API Google : Identifiants – Console API Google, sélectionnez l'application souhaitée et dans la section Identifiants créez un identifiant client OAuth. Là (ou plus tard, dans les propriétés de l'identifiant créé), vous devez spécifier les adresses vers lesquelles la redirection est autorisée. Dans notre cas, il s'agira de plusieurs entrées localhost avec des ports différents (voir ci-dessous).

Pour faciliter la lecture de l'algorithme du script, vous pouvez afficher les premières étapes dans une fonction distincte qui renverra les jetons d'accès et d'actualisation pour l'application :

$client_secret = 'Our Client Secret'
$client_id = 'Our Client ID'
function Get-GoogleAuthToken {
  if (-not [System.Net.HttpListener]::IsSupported) {
    "HttpListener is not supported."
    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 "Start browser..."
  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 {
    "Unable to start listener."
    exit 1
  }
  while (($code -eq $null)) {
    $context = $listener.GetContext()
    Write-Host "Connection accepted" -f 'mag'
    $url = $context.Request.RawUrl
    $code = $url.split('?')[1].split('=')[1].split('&')[0]
    if ($url.split('?')[1].split('=')[0] -eq 'error') {
      Write-Host "Error!"$code -f 'red'
      $buffer = [System.Text.Encoding]::UTF8.GetBytes("Error!"+$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("Now you can close this browser tab.")
    $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 l'ID client et le secret client obtenus dans les propriétés de l'identifiant client OAuth, et le vérificateur de code est une chaîne de 43 à 128 caractères qui doivent être générés aléatoirement à partir de caractères non réservés : [AZ] / [az] / [0-9 ] / "-" / "." / "_" / "~".

Ce code sera ensuite à nouveau transmis. Il élimine la vulnérabilité dans laquelle un attaquant pourrait intercepter une réponse renvoyée sous forme de redirection après l'autorisation de l'utilisateur.
Vous pouvez envoyer un vérificateur de code dans la requête actuelle en texte clair (ce qui le rend dénué de sens - cela ne convient que pour les systèmes qui ne prennent pas en charge SHA256), ou en créant un hachage à l'aide de l'algorithme SHA256, qui doit être codé en BASE64Url (différent de Base64 par deux caractères de table) et en supprimant les fins de ligne de caractères : =.

Ensuite, nous devons commencer à écouter http sur la machine locale afin de recevoir une réponse après autorisation, qui sera renvoyée sous forme de 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 en même temps, il sélectionnera donc aléatoirement un port pour l'utilisateur actuel, mais j'ai spécifié des ports prédéfinis car ils doivent également être ajoutés comme étant approuvés dans la console API.

access_type=hors ligne signifie que l'application peut mettre à jour elle-même un token expiré sans interaction de l'utilisateur avec le navigateur,
type_réponse=code définit le format de retour du code (une référence à l'ancienne méthode d'autorisation, lorsque l'utilisateur copiait le code du navigateur dans le script),
portée indique l’étendue et le type d’accès. Ils doivent être séparés par des espaces ou %20 (selon l'encodage de l'URL). Une liste des zones d'accès avec leurs types peut être consultée ici : Portées OAuth 2.0 pour les API Google.

Après avoir reçu le code d'autorisation, l'application renverra un message de fermeture au navigateur, cessera d'écouter sur le port et enverra une requête POST pour obtenir le token. Nous y indiquons l'identifiant et le secret précédemment spécifiés de l'API de la console, l'adresse vers laquelle l'utilisateur sera redirigé et le type d'octroi conformément à la spécification du protocole.

En réponse, nous recevrons un jeton d'accès, sa période de validité en secondes et un jeton d'actualisation, avec lequel nous pourrons mettre à jour le jeton d'accès.

L'application doit stocker les jetons dans un endroit sécurisé avec une longue durée de conservation, donc jusqu'à ce que nous révoquions l'accès reçu, l'application ne renverra pas le jeton d'actualisation. À la fin, j'ai ajouté une demande de révocation du jeton ; si l'application n'a pas été complétée avec succès et que le jeton d'actualisation n'a pas été renvoyé, la procédure recommencera (nous avons considéré qu'il était dangereux de stocker les jetons localement sur le terminal, et nous ne le faisons pas). Je ne veux pas compliquer les choses avec la cryptographie ou ouvrir fréquemment le navigateur).

do {
  $token_result = Get-GoogleAuthToken
  $token = $token_result.access_token
  if ($token_result.refresh_token -eq $null) {
    Write-Host ("Session is not destroyed. Revoking 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é, lors de la révocation d'un jeton, Invoke-WebRequest est utilisé. Contrairement à Invoke-RestMethod, il ne renvoie pas les données reçues dans un format utilisable et affiche l'état de la demande.

Ensuite, le script vous demande de saisir le prénom et le nom de l’utilisateur, générant un login + email.

demandes

Les prochaines requêtes seront - tout d'abord, vous devez vérifier si un utilisateur avec le même login existe déjà afin d'obtenir une décision sur la création d'un nouveau ou l'activation de l'actuel.

J'ai décidé d'implémenter toutes les requêtes sous la forme d'une 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'])
      }
    }
  }
}

Dans chaque requête, vous devez envoyer 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. Parce que nous devons vérifier que le jeton n'a pas expiré et le mettre à jour une heure après son émission, j'ai spécifié une demande pour une autre fonction qui renvoie un jeton d'accès. Le même morceau de code se trouve au début du script lors de la réception 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 Expired. Refreshing..."
    $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 du login :

function Check_Google {
  $query = (GoogleQuery 'SearchAccount' $username)
  if ($query.users -ne $null) {
    $user = $query.users[0]
    Write-Host $user.name.fullName' - '$user.PrimaryEmail' - suspended: '$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 exactement cette adresse e-mail, y compris les alias. Vous pouvez également utiliser un caractère générique : =, :, :{PRÉFIXE}*.

Pour obtenir des données, utilisez la méthode de requête GET, pour insérer des données (création d'un compte ou ajout d'un membre à un groupe) - POST, pour mettre à jour des données existantes - PUT, pour supprimer un enregistrement (par exemple, un membre d'un groupe) - SUPPRIMER.

Le script demandera également un numéro de téléphone (une chaîne non validée) et l'inclusion dans un groupe de distribution régional. Il décide quelle unité organisationnelle l'utilisateur doit avoir en fonction de l'unité d'organisation Active Directory sélectionnée et propose un mot de passe :

do {
  $phone = Read-Host "Телефон в формате +7хххххххх"
} while (-not $phone)
do {
    $moscow = Read-Host "В Московский офис? (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 "Будет создана в /Team delivery"
    $orgunit = "/Team delivery"
}
$Password =  -join ( 48..57 + 65..90 + 97..122 | Get-Random -Count 12 | % {[char]$_})+"*Ba"

Et puis il commence à manipuler le compte :

$query = @{
  email = $email
  givenName = $firstname
  familyName = $lastname
  password = $password
  phone = $phone
  orgunit = $orgunit
}
if ($GMailExist) {
  Write-Host "Запускаем изменение аккаунта" -f mag
  (GoogleQuery 'UpdateAccount' $query) | fl
  write-host "Не забудь проверить группы у включенного $Username в Google."
} else {
  Write-Host "Запускаем создание аккаунта" -f mag
  (GoogleQuery 'CreateAccount' $query) | fl
}
if ($moscow -eq "y"){
  write-host "Добавляем в группу moscowoffice"
  $query = @{
    groupkey = '[email protected]'
    email = $email
  }
  (GoogleQuery 'AddMember' $query) | fl
}

Les fonctions de mise à jour et de création d'un compte ont une syntaxe similaire ; tous les champs supplémentaires ne sont pas obligatoires ; dans la section avec les numéros de téléphone, vous devez spécifier un tableau pouvant contenir jusqu'à un enregistrement avec le numéro et son type.

Afin de ne pas recevoir d'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 une liste des membres du groupe ou une composition auprès de l'utilisateur lui-même.

L'interrogation de l'appartenance au groupe d'un utilisateur spécifique ne sera pas récursive et affichera uniquement l'appartenance directe. L’inclusion d’un utilisateur dans un groupe parent qui possède déjà un groupe enfant dont l’utilisateur est membre réussira.

Conclusion

Il ne reste plus qu'à envoyer à l'utilisateur le mot de passe du nouveau compte. Nous le faisons par SMS et envoyons des informations générales avec des instructions et nous nous connectons à une adresse e-mail personnelle, qui, avec un numéro de téléphone, a été fournie par le service de recrutement. Comme alternative, vous pouvez économiser de l'argent et envoyer votre mot de passe à un chat par télégramme secret, ce qui peut également être considéré comme le deuxième facteur (les MacBook seront une exception).

Merci d'avoir lu jusqu'au bout. Je serai heureux de voir des suggestions pour améliorer le style d'écriture des articles et je vous souhaite de détecter moins d'erreurs lors de la rédaction de scripts =)

Liste de liens qui peuvent être thématiquement utiles ou simplement répondre à des questions :

Source: habr.com

Ajouter un commentaire