Ciao!
In questo articolo verrà descritta l'implementazione dell'interazione tra PowerShell e l'API di Google per la gestione degli utenti di G Suite.
Nell'organizzazione utilizziamo diversi servizi interni e cloud. Per la maggior parte, l'autenticazione in essi si riduce a Google o Active Directory, tra i quali non possiamo mantenere una replica, quindi, quando entra 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
Stabilendo i requisiti, abbiamo deciso di utilizzare persone reale come amministratori per l'autenticazione, il che semplifica l'analisi delle azioni in caso di modifiche di massa accidentali o intenzionali.
Per l'autenticazione e l'autorizzazione, l'API di Google utilizza il protocollo OAuth 2.0. Gli scenari di utilizzo e una descrizione più dettagliata possono essere visualizzati qui: .
Ho scelto lo scenario utilizzato per l'autenticazione nelle applicazioni desktop. C'è anche la possibilità di utilizzare un account di servizio, che non richiede ulteriori azioni da parte dell'utente.
L'immagine sottostante è una descrizione schematica dello scenario selezionato dalla pagina Google.

- Iniziamo reindirizzando l'utente alla pagina di autenticazione dell'account Google, specificando i parametri GET:
- identificatore dell'applicazione
- domini ai quali l'applicazione richiede accesso
- indirizzo al quale l'utente sarà reindirizzato dopo aver completato la procedura
- metodo con cui aggiorneremo il token
- codice di verifica
- formato di trasmissione del codice di verifica
- Dopo aver completato l'autorizzazione, l'utente sarà reindirizzato alla pagina specificata nella prima richiesta, con l'errore o il codice di autorizzazione passati come parametri GET.
- L'applicazione (script) dovrà acquisire questi parametri e, in caso di ricezione del codice, eseguire la richiesta successiva per ottenere i token.
- In caso di richiesta corretta, Google API restituisce:
- Access token, con il quale possiamo effettuare richieste
- Durata di validità di questo token
- Refresh token, necessario per aggiornare l'Access token.
Per prima cosa, dobbiamo andare nella console Google API: , selezionare l'applicazione desiderata e nella sezione Credenziali creare un identificatore OAuth client. Lì (o successivamente, nelle proprietà dell'identificatore creato) è necessario specificare gli indirizzi a cui è consentito il reindirizzamento. Nel nostro caso, ci saranno diverse voci localhost con porte diverse (vedi dopo).
Per una lettura più facile dell'algoritmo dello script, è possibile estrarre i primi passi in una funzione separata, che restituirà i token di Accesso e di 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 nelle proprietà dell'identificatore client OAuth e il code verifier: è una stringa lunga da 43 a 128 caratteri, che deve essere generata casualmente da caratteri non riservati: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~".
Successivamente, questo codice sarà trasmesso nuovamente. Esclude la vulnerabilità in cui un attaccante può intercettare la risposta, tornata tramite un reindirizzamento dopo l'autenticazione dell'utente.
Puoi inviare il code verifier nella richiesta corrente in chiaro (il che lo rende inutile - è adatto solo per sistemi che non supportano SHA256), oppure creando un hash utilizzando l'algoritmo SHA256, che deve essere codificato in BASE64Url (diverso da Base64 in due caratteri) e rimuovere il carattere di fine riga: =.
Dobbiamo poi iniziare ad ascoltare http sulla macchina locale per ricevere la risposta dopo l'autenticazione, che verrà restituita tramite un reindirizzamento.
I compiti amministrativi vengono eseguiti su un server speciale. Non possiamo escludere la possibilità che più amministratori avviino contemporaneamente lo script, quindi esso sceglierà a caso una porta per l'utente attuale. Tuttavia, ho specificato in anticipo delle porte, in quanto devono essere aggiunte come fidate nella console API.
access_type=offline significa che l'applicazione può aggiornare il token scaduto autonomamente senza interazione dell'utente con il browser.
response_type=code specifica il formato in cui verrà restituito il codice (riferimento al vecchio metodo di autenticazione, quando l'utente copiava e incollava il codice dal browser nello script).
scope indica le aree e i tipi di accesso. Devono essere separati da spazi o (in conformità con l'URL Encoding). Puoi vedere qui l'elenco delle aree di accesso con i tipi: .
Dopo aver ricevuto il codice di autorizzazione, l'applicazione restituirà un messaggio di chiusura nel browser, smetterà di ascoltare la porta e invierà una richiesta POST per ottenere il token. Includiamo in essa l'id e il secret precedentemente definiti dalla console API, l'indirizzo a cui verrà reindirizzato l'utente e grant_type secondo le specifiche del protocollo.
In risposta, otterremo un token di accesso, la sua durata in secondi e un token di aggiornamento, con il quale possiamo aggiornare il token di accesso.
L'applicazione deve memorizzare i token in un luogo sicuro con un lungo periodo di conservazione, quindi, finché non revocati, non verrà restituito un token di aggiornamento all'applicazione. Alla fine, ho aggiunto una richiesta per revocare il token se l'applicazione è stata terminata in modo non corretto e il token di aggiornamento non è stato restituito; il processo ripartirà da capo (abbiamo ritenuto insicuro memorizzare i token localmente sul terminale e non vogliamo complicare 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 ("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
}
Come già sapete, per la revoca del token si utilizza Invoke-WebRequest. A differenza di Invoke-RestMethod, questo non restituisce i dati ottenuti in un formato utilizzabile e mostra lo stato della richiesta.
Il successivo script chiederà di inserire nome e cognome dell'utente, generando login + email.
Richieste
Le prossime saranno le richieste: prima di tutto è necessario verificare se esiste già un utente con questo login per decidere se formare un nuovo account o includere quello attuale.
Ho deciso di implementare tutte le richieste in un'unica funzione con selezione, utilizzando switch:
funzione 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 è presente 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
}La richiesta email: $query chiederà all'API di cercare un utente con questa email, inclusi eventuali alias. È possibile utilizzare anche un carattere jolly: =, :, :{PREFIX}*.
Per ottenere i dati si utilizza il metodo di richiesta GET, per inserire dati (creare un account o aggiungere un partecipante a un gruppo) si utilizza POST, per aggiornare dati esistenti si utilizza PUT, e per eliminare un record (ad esempio, un partecipante da un gruppo) si utilizza DELETE.
Lo script chiederà anche il numero di telefono (stringa non convalidata) e se si desidera essere inclusi nel gruppo regionale di distribuzione. Decide 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 "In ufficio a 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 poi inizia a manovrare l'account:
$query = @{
email = $email
givenName = $firstname
familyName = $lastname
password = $password
phone = $phone
orgunit = $orgunit
}
if ($GMailExist) {
Write-Host "Eseguiamo la modifica dell'account" -f mag
(GoogleQuery 'UpdateAccount' $query) | fl
write-host "Non dimenticare di controllare i gruppi per l'utente $Username in Google."
} else {
Write-Host "Eseguiamo 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 dei numeri di telefono è necessario specificare un array che può contenere una o più voci con il numero e il suo tipo.
Per evitare errori nell'aggiunta di un utente a un gruppo, possiamo prima verificare se è già membro di quel gruppo, ottenendo l'elenco dei membri del gruppo o la composizione dello stesso utente.
La richiesta della composizione dei gruppi di un determinato utente non sarà ricorsiva e mostrerà solo l'adesione immediata. L'inclusione di un utente in un gruppo genitore, di cui fa già parte un gruppo figlio dove è presente l'utente, avrà successo.
Conclusione
Resta da inviare all'utente la password per il nuovo account. Lo facciamo via SMS, mentre le informazioni generali con le istruzioni e il login vengono inviate all'indirizzo email personale fornito dal reparto selezione. Come alternativa, è possibile risparmiare denaro e inviare la password in una chat segreta di Telegram, che può essere considerata anche un secondo fattore (le eccezioni saranno i MacBook).
Grazie per aver letto fino alla fine. Sarò lieto di vedere suggerimenti per migliorare lo stile di 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
