Creazione di utenti Google da PowerShell tramite API

Ciao!

In questo articolo sarà descritta l'implementazione dell'interazione tra PowerShell e Google API per eseguire operazioni sugli utenti di G Suite.

Nell'organizzazione utilizziamo diversi servizi interni e basati su cloud. Per la maggior parte, l'autenticazione in essi si riduce a Google o Active Directory, tra i quali non possiamo mantenere una replica; di conseguenza, all'uscita di un nuovo dipendente, è necessario creare/attivare un account in questi due sistemi. Per automatizzare il processo, abbiamo deciso di scrivere uno script che raccoglie informazioni e le invia a entrambi i servizi.

Autenticazione

Stendendo i requisiti, abbiamo deciso di utilizzare per l'autenticazione persone reali amministratori; ciò semplifica l'analisi delle azioni in caso di modifiche massive accidentali o intenzionali.

Per l'autenticazione e l'autorizzazione, Google API utilizza il protocollo OAuth 2.0. Gli scenari di utilizzo e una descrizione più dettagliata possono essere consultati qui: Utilizzo di OAuth 2.0 per accedere alle API di Google.

Ho scelto lo scenario utilizzato per l'autenticazione nelle applicazioni desktop. Esiste anche la possibilità di utilizzare un account di servizio, che non richiede ulteriori azioni da parte dell'utente.

L'immagine qui sotto è una rappresentazione schematica dello scenario scelto dalla pagina di Google.

Creazione di utenti Google da PowerShell tramite API

  1. Per prima cosa, inviamo l'utente alla pagina di autenticazione dell'account Google, specificando i parametri GET:
    • ID applicazione
    • aree a cui l'applicazione necessita accesso
    • indirizzo a cui l'utente verrà reindirizzato dopo il completamento della procedura
    • metodo che utilizzeremo per aggiornare il token
    • codice di verifica
    • formato di trasmissione del codice di verifica

  2. Dopo il completamento dell'autenticazione, l'utente verrà reindirizzato alla pagina specificata nella prima richiesta, con un errore o un codice di autorizzazione, trasmesso tramite i parametri GET.
  3. L'applicazione (script) dovrà acquisire questi parametri e, nel caso venga ricevuto un codice, eseguire la successiva richiesta per ottenere i token.
  4. Per una richiesta corretta, Google API restituisce:
    • Token di accesso, con il quale possiamo effettuare richieste
    • Scadenza di questo token
    • Token di rinnovo, necessario per aggiornare il token di accesso.

Per prima cosa, dobbiamo andare nella console delle API di Google: Credenziali - Console API di Google, seleziona l'applicazione desiderata e nella sezione Credenziali crea un identificatore OAuth client. Lì (o in seguito, nelle proprietà dell'identificatore creato) è necessario specificare gli indirizzi a cui è consentito il reindirizzamento. Nel nostro caso, si tratterà di diverse voci localhost con porte differenti (vedi sotto).

Per rendere più leggibile l'algoritmo dello script, possiamo estrarre i primi passaggi in una funzione separata, che restituirà i token di Access e refresh per l'applicazione:

$client_secret = 'Il nostro Client Secret'
$client_id = 'Il nostro Client ID'
function Get-GoogleAuthToken {
  if (-not [System.Net.HttpListener]::IsSupported) {
    "HttpListener non è supportato."
    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 "Avvia il 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 {
    "Impossibile avviare il listener."
    exit 1
  }
  while (($code -eq $null)) {
    $context = $listener.GetContext()
    Write-Host "Connessione accettata" -f 'mag'
    $url = $context.Request.RawUrl
    $code = $url.split('?')[1].split('=')[1].split('&')[0]
    if ($url.split('?')[1].split('=')[0] -eq 'error') {
      Write-Host "Errore!"$code -f 'red'
      $buffer = [System.Text.Encoding]::UTF8.GetBytes("Errore!"+$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("Ora puoi chiudere questa scheda del browser.")
    $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

Impostiamo il Client ID e il Client Secret ottenuti dalle proprietà dell'identificatore del client OAuth, e il code verifier è una stringa lunga da 43 a 128 caratteri, che deve essere generata casualmente dai caratteri non riservati: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~".

Successivamente, questo codice sarà trasmesso nuovamente. Esclude la vulnerabilità per cui un aggressore potrebbe intercettare la risposta, tornata tramite reindirizzamento dopo l'autorizzazione dell'utente.
Inviato il code verifier nella richiesta corrente può avvenire in chiaro (cosa che lo rende privo di senso – questo è adatto solo per sistemi che non supportano SHA256), oppure creando un hash utilizzando l'algoritmo SHA256, che deve essere codificato in BASE64Url (che differisce da Base64 in due simboli) e rimuovendo il carattere di fine riga: =.

Successivamente, dobbiamo iniziare ad ascoltare http sulla macchina locale per ricevere la risposta dopo l'autenticazione, che verrà restituita tramite redirect.

Le attività amministrative vengono eseguite su un server speciale, non possiamo escludere la possibilità che più amministratori eseguano lo script contemporaneamente, quindi verrà scelto casualmente una porta per l'utente attuale, ma ho specificato porte predefinite, poiché devono essere aggiunte come fidate nel console API.

access_type=offline significa che l'applicazione può aggiornare autonomamente il token scaduto senza interazione dell'utente con il browser,
response_type=code definisce il formato in cui verrà restituito il codice (riferimento al vecchio modo di autenticazione, quando l'utente copiavano e incollavano il codice dal browser nello script),
scope specifica le aree e il tipo di accesso. Devono essere separate da spazi o (secondo l'URL Encoding). È possibile visualizzare l'elenco delle aree di accesso con i tipi qui: Scope OAuth 2.0 per Google APIs.

Dopo aver ricevuto il codice di autorizzazione, l'applicazione restituirà al browser un messaggio di chiusura, smetterà di ascoltare la porta e invierà una richiesta POST per ricevere il token. Indichiamo in esso l'id e il secret precedentemente specificati dalla console API, l'indirizzo al quale l'utente verrà reindirizzato e grant_type secondo le specifiche del protocollo.

In risposta riceveremo un Access token, la sua durata in secondi e un Refresh token, con il quale possiamo aggiornare l'Access token.

L'applicazione deve memorizzare i token in un luogo sicuro con una lunga durata di archiviazione, quindi, finché non ritiriamo l'accesso ottenuto, all'applicazione non verrà restituito il refresh token. Alla fine ho aggiunto una richiesta per il ritiro del token, se l'applicazione non è stata completata con successo e il refresh token non è stato restituito, essa inizierà la procedura da capo (abbiamo considerato non sicuro memorizzare i token localmente sul terminale, e non vogliamo complicare le cose con la crittografia o aprire frequentemente il browser).

do {
  $token_result = Get-GoogleAuthToken
  $token = $token_result.access_token
  if ($token_result.refresh_token -eq $null) {
    Write-Host ("La session non è distrutta. Revoca il 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
}

Come avrete notato, per revocare il token si utilizza Invoke-WebRequest. A differenza di Invoke-RestMethod, non restituisce i dati ottenuti in un formato facilmente utilizzabile e mostra lo stato della richiesta.

Successivamente, lo script chiederà di inserire nome e cognome dell'utente, generando login + email.

Richieste

Le seguenti saranno le richieste – prima di tutto è necessario verificare se esiste già un utente con quel login per decidere se generare un nuovo account o attivare quello esistente.

Ho deciso di implementare tutte le richieste in un'unica funzione con selezione, utilizzando 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'] + " ora è un membro di " + $query['groupkey'])
      }
    }
  }
}

In ogni richiesta è necessario inviare l'intestazione Authorization, contenente il tipo di token e il token di accesso stesso. Attualmente, il tipo di token è sempre Bearer. Poiché dobbiamo verificare che il token non sia scaduto e aggiornarlo un'ora dopo il rilascio, ho specificato una richiesta a un'altra funzione che restituisce il token di accesso. Questo stesso pezzo di codice è all'inizio dello script quando si ottiene il primo token di accesso:

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 scaduto. Aggiornamento..."
    $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
}

Verifica dell'esistenza del login:

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

Richiesta email: $query chiederà all'API di cercare un utente con questa email, comprese le aliassi. È anche possibile utilizzare i caratteri jolly: =, :, :{PREFIX}*.

Per ottenere i dati viene utilizzato il metodo di richiesta GET, per l'inserimento dei dati (creazione di un account o aggiunta di un partecipante a un gruppo) – POST, per l'aggiornamento di dati esistenti – PUT, per l'eliminazione di un record (ad esempio, un partecipante da un gruppo) – DELETE.

Lo script chiederà anche il numero di telefono (stringa non validata) e se si desidera essere inclusi nel gruppo regionale di distribuzione. Determina quale unità organizzativa deve avere l'utente in base all'OU di Active Directory selezionata e genera una password:

do {
  $phone = Read-Host "Telefono nel formato +7хххххххх"
} while (-not $phone)
do {
    $moscow = Read-Host "Nel ufficio di Mosca? (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 "Sarà creato in /Team delivery"
    $orgunit = " / Team delivery"
}
$Password = -join (48..57 + 65..90 + 97..122 | Get-Random -Count 12 | % {[char]$_})+"*Ba"

E in seguito inizia a manipolare l'account:

$query = @{
  email = $email
  givenName = $firstname
  familyName = $lastname
  password = $password
  phone = $phone
  orgunit = $orgunit
}
if ($GMailExist) {
  Write-Host "Avviamo la modifica dell'account" -f mag
  (GoogleQuery 'UpdateAccount' $query) | fl
  write-host "Non dimenticare di controllare i gruppi dell'abilitato $Username in Google."
} else {
  Write-Host "Avviamo la creazione dell'account" -f mag
  (GoogleQuery 'CreateAccount' $query) | fl
}
if ($moscow -eq "y"){
  write-host "Aggiungendo al gruppo moscowoffice"
  $query = @{
    groupkey = 'moscowoffice@rocketguys.com'
    email = $email
  }
  (GoogleQuery 'AddMember' $query) | fl
}

Le funzioni di aggiornamento e creazione dell'account hanno una sintassi simile, non tutti i campi aggiuntivi sono obbligatori; nella sezione con i numeri di telefono è necessario fornire un array che può contenere da una registrazione con il numero e il suo tipo.

Per evitare un errore nell'aggiungere un utente a un gruppo, possiamo prima verificare se è già presente in quel gruppo, ottenendo l'elenco dei membri o la composizione dello stesso utente.

La richiesta della composizione dei gruppi di un determinato utente non sarà ricorsiva e mostrerà solo l'appartenenza diretta. L'inclusione di un utente in un gruppo genitore, in cui è già presente un gruppo figlio di cui l'utente è membro, sarà avvenuta con successo.

Conclusione

Rimane solo da inviare all'utente la password del nuovo account. Lo facciamo tramite SMS, mentre le informazioni generali con istruzioni e login vengono inviate all'indirizzo email personale fornito, insieme al numero di telefono, dal dipartimento di reclutamento. Come alternativa, si può risparmiare qualche soldo e inviare la password in una chat segreta di Telegram, che può essere anche considerato un secondo fattore (esclusi i MacBook).

Grazie per aver letto fino alla fine. Sarò felice di vedere suggerimenti per migliorare lo stile nella scrittura degli articoli e vi auguro di commettere meno errori nella scrittura degli script =)

Elenco di link che potrebbero essere tematicamente utili o semplicemente rispondere a domande sorte:

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster