Google-gebruikers aanmaken vanuit PowerShell via API

Hallo!

In dit artikel wordt de implementatie van de interactie tussen PowerShell en de Google API voor het beheren van G Suite-gebruikers beschreven.

Binnen onze organisatie gebruiken we verschillende interne en clouddiensten. In de meeste gevallen komt de autorisatie neer op Google of Active Directory, waarvoor we geen replicatie kunnen onderhouden. Bij de komst van een nieuwe medewerker moet daarom een account in deze twee systemen worden aangemaakt/geactiveerd. Om het proces te automatiseren, hebben we besloten een script te schrijven dat informatie verzamelt en naar beide diensten verzendt.

Autorisatie

Bij het opstellen van de vereisten hebben we besloten echte mensen- beheerders voor de autorisatie te gebruiken, wat de analyse van acties bij toevallige of opzettelijke massale wijzigingen vereenvoudigt.

Voor authenticatie en autorisatie gebruikt de Google API het OAuth 2.0-protocol. Gebruiksscenario's en een meer gedetailleerde beschrijving zijn hier te vinden: Using OAuth 2.0 to Access Google APIs.

Ik heb het scenario gekozen dat wordt gebruikt voor autorisatie in desktopapplicaties. Er is ook een optie om een serviceaccount te gebruiken, wat geen extra acties van de gebruiker vereist.

De afbeelding hieronder is een schematische weergave van het gekozen scenario van de Google-pagina.

Google-gebruikers aanmaken vanuit PowerShell via API

  1. Eerst sturen we de gebruiker naar de aanmeldpagina van het Google-account, waarbij we de GET-parameters opgeven:
    • applicatie-id
    • de scopes waartoe de applicatie toegang nodig heeft
    • het adres waar de gebruiker na afloop van de procedure naartoe wordt doorgestuurd
    • de manier waarop we de token gaan vernieuwen
    • de verificatiecode
    • de overdrachtsindeling van de verificatiecode

  2. Na afloop van de autorisatie wordt de gebruiker doorgestuurd naar de pagina die in het eerste verzoek is opgegeven, met een fout of de autorisatiecode, doorgegeven als GET-parameters.
  3. De applicatie (script) moet deze parameters ontvangen en, in geval van het ontvangen van de code, de volgende aanvraag voor tokenvernieuwing uitvoeren.
  4. Bij een correcte aanvraag retourneert de Google API:
    • Access-token waarmee we aanvragen kunnen doen
    • De geldigheidsduur van deze token
    • Refresh-token dat nodig is om het Access-token te vernieuwen.

Eerst moet je naar de Google API-console gaan: Credentials — Google API Console, select the required application and create an OAuth client ID in the Credentials section. You also need to specify the addresses that are allowed for redirection, which in our case will be several localhost entries with different ports (see further).

To make it easier to read the script algorithm, you can output the first steps into a separate function that will return the Access and refresh tokens for the 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

We set the Client ID and Client Secret obtained from the properties of the OAuth client ID, and the code verifier, which is a string of length between 43 and 128 characters, generated randomly from unreserved characters: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~".

This code will be sent again. It eliminates the vulnerability where an attacker could intercept the response that returned via redirect after the user has authorized.
Het verzenden van de codeverifier in de huidige aanvraag kan openlijk gebeuren (wat het zinloos maakt – dit is alleen geschikt voor systemen die SHA256 niet ondersteunen), of door een hash te maken met het SHA256-algoritme, dat gecodeerd moet worden in BASE64Url (verschilt van Base64 door twee karakters in de tabel) en het lijnbreekteken: = moet worden verwijderd.

Daarna moeten we http op de lokale machine gaan beluisteren om het antwoord te ontvangen na autorisatie, dat terugkomt via een redirect.

Administratieve taken worden uitgevoerd op een speciale server, we kunnen de kans niet uitsluiten dat meerdere beheerders tegelijkertijd het script uitvoeren, daarom kiest het willekeurig een poort voor de huidige gebruiker, maar ik heb vooraf bepaalde poorten gespecificeerd, omdat ze ook als vertrouwd in de API-console moeten worden toegevoegd.

access_type=offline betekent dat de applicatie de verlopen token zelfstandig kan vernieuwen zonder interactie van de gebruiker met de browser,
response_type=code geeft het formaat van de code aan (verwijzing naar de oude methode van autorisatie, waarbij de gebruiker de code uit de browser in het script kopieerde),
scope geeft de gebieden en toegangstypes aan. Ze moeten gescheiden worden door spaties of (overeenkomstig met URL-codering). Een lijst van toeganggebieden met types kan hier worden gezien: OAuth 2.0 Scopes voor Google API's.

Na het verkrijgen van de autorisatiecode, zal de applicatie een sluitbericht in de browser retourneren, de poort stoppen met luisteren en een POST-aanroep doen om een token te verkrijgen. We specificeren daarin de eerder opgegeven id en secret uit de API-console, het adres waarnaar de gebruiker zal worden omgeleid en grant_type in overeenstemming met de specificatie van het protocol.

Als antwoord ontvangen we de Access token, de looptijd ervan in seconden en de Refresh token, waarmee we de Access token kunnen vernieuwen.

De applicatie moet tokens op een veilige plek met een lange houdbaarheid opslaan, daarom, zolang we de verkregen toegang niet intrekken, zal de applicatie geen refresh token terugkrijgen. Aan het einde heb ik een aanvraag voor intrekking van de token toegevoegd, als de applicatie niet succesvol is beëindigd en de refresh token niet is teruggekomen, zal het de procedure opnieuw beginnen (we vonden het onveilig om tokens lokaal op de terminal op te slaan, en we willen het niet moeilijk maken met cryptografie of vaak de browser openen).

do {
  $token_result = Get-GoogleAuthToken
  $token = $token_result.access_token
  if ($token_result.refresh_token -eq $null) {
    Write-Host ("Sessie is niet vernietigd. Token intrekken...")
    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
}

Zoals je waarschijnlijk al hebt opgemerkt, wordt Invoke-WebRequest gebruikt om het token in te trekken. In tegenstelling tot Invoke-RestMethod retourneert het de verkregen gegevens niet in een gemakkelijk te gebruiken formaat en toont het de status van de aanvraag.

Vervolgens vraagt het script om de voor- en achternaam van de gebruiker in te voeren, en genereert het een gebruikersnaam + e-mail.

Queries

De volgende stappen zullen verzoeken zijn – eerst moet worden gecontroleerd of er al een gebruiker met deze gebruikersnaam bestaat, om te beslissen of er een nieuwe moet worden gemaakt of de huidige moet worden ingeschakeld.

Ik heb besloten om alle verzoeken in de vorm van één functie met een selectie te implementeren, gebruikmakend van switch:

functie 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'] + " is nu een lid van " + $query['groupkey'])
      }
    }
  }
}

In elke aanvraag moet de Authorization-header worden verzonden, die het type token en de daadwerkelijke Access-token bevat. Op dit moment is het type token altijd Bearer. Omdat we moeten controleren of het token niet is verlopen en het moeten bijwerken na een uur na uitgifte, heb ik een verzoek naar een andere functie opgegeven die de Access-token retourneert. Ditzelfde stuk code staat aan het begin van het script bij het verkrijgen van de eerste Access-token:

functie Get-GoogleToken {
  als (((Get-date).Hour -gt $token_expire.hour) -of (((Get-date).Hour -ge $token_expire.hour) -en ((Get-date).Minute -gt $token_expire.minute))) {
  Write-Host "Token Verlopen. Vernieuwen..."
    $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
    als ($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
}

Controle van aanmelding op bestaan:

functie Check_Google {
  $query = (GoogleQuery 'SearchAccount' $username)
  als ($query.users -ne $null) {
    $user = $query.users[0]
    Write-Host $user.name.fullName' - '$user.PrimaryEmail' - geschorst: '$user.Suspended
    $GAresult = $user
  }
  als ($GAresult) {
      $return = $GAresult
  } anders {$return = 'gg'}
  return $return
}

De aanvraag van email: $query vraagt het API om specifiek naar een gebruiker met dit email te zoeken, inclusief aliassen. Wildcards kunnen ook worden gebruikt: =, :, :{PREFIX}*.

Voor het ophalen van gegevens wordt de GET-aanroep gebruikt, voor het invoegen van gegevens (account aanmaken of lid toevoegen aan een groep) – POST, voor het bijwerken van bestaande gegevens – PUT, voor het verwijderen van een record (bijvoorbeeld een lid uit een groep) – DELETE.

Het script vraagt ook om een telefoonnummer (ongeldig string) en of de gebruiker in de regionale mailinglijst wordt opgenomen. Het bepaalt welke organisatorische eenheid de gebruiker moet hebben op basis van de gekozen OU in Active Directory en genereert een wachtwoord:

do {
  $phone = Read-Host "Telefoon in het formaat +7хххххххх"
} terwijl (-not $phone)
do {
    $moscow = Read-Host "In het Moskouse kantoor? (y/n) "
} terwijl (-not (($moscow -eq 'y') -of ($moscow -eq 'n')))
$orgunit = '/'
als ($OU -like "*OU=Delivery,OU=Users,OU=ROOT,DC=rocket,DC=local") {
    Write-host "Wordt aangemaakt in /Team delivery"
    $orgunit = "/Team delivery"
}
$Password = -join (48..57 + 65..90 + 97..122 | Get-Random -Count 12 | % {[char]$_})+"*Ba"

En dan begint het met manipulaties met het account:

$query = @{
  email = $email
  givenName = $firstname
  familyName = $lastname
  password = $password
  phone = $phone
  orgunit = $orgunit
}
als ($GMailExist) {
  Write-Host "Activiteit om account te wijzigen starten" -f mag
  (GoogleQuery 'UpdateAccount' $query) | fl
  Write-host "Vergeet niet de groepen voor de ingeschakelde $Username in Google te controleren."
} anders {
  Write-Host "Activiteit om account aan te maken starten" -f mag
  (GoogleQuery 'CreateAccount' $query) | fl
}
als ($moscow -eq "y"){
  Write-host "Toevoegen aan groep moscowoffice"
  $query = @{
    groupkey = 'moscowoffice@rocketguys.com'
    email = $email
  }
  (GoogleQuery 'AddMember' $query) | fl
}

De functies voor het bijwerken en aanmaken van accounts hebben een vergelijkbare syntaxis; niet alle extra velden zijn verplicht. In het gedeelte met telefoonnummers moet een array worden opgegeven die van één record met een nummer en het type kan bevatten.

Om een fout bij het toevoegen van een gebruiker aan een groep te voorkomen, kunnen we eerst controleren of deze al in de groep zit door de ledenlijst van de groep of de samenstelling van de gebruiker zelf op te vragen.

De aanvraag voor de samenstelling van groepen van een specifieke gebruiker zal niet recursief zijn en zal alleen het directe lidmaatschap tonen. Het toevoegen van een gebruiker aan de bovenliggende groep, waar al een subgroep in zit waarvan de gebruiker lid is, zal succesvol zijn.

Conclusie

We moeten de gebruiker het wachtwoord voor het nieuwe account sturen. We doen dit via SMS, en algemene informatie met instructies en inloggegevens wordt naar het persoonlijke e-mailadres gestuurd, dat door de personeelsafdeling is verstrekt, samen met het telefoonnummer. Als alternatieve optie kunnen we geld besparen en het wachtwoord in een geheime Telegram-chat sturen, wat ook als een tweede factor kan worden beschouwd (uitgezonderd MacBooks).

Dank je wel voor het lezen tot het einde. Ik kijk uit naar voorstellen voor het verbeteren van de schrijfstijl en wens je zo min mogelijk fouten te maken bij het schrijven van scripts =)

Een lijst met links die thematisch nuttig kunnen zijn of gewoon kunnen antwoorden op opkomende vragen:

Bron: habr.com

Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers 🔥 Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers | ProHoster