Ahoj!
V tomto článku bude popsána realizace interakce PowerShell s Google API pro manipulaci s G Suite uživateli.
V organizaci používáme několik vnitřních a cloudových služeb. Většinou se autentizace v nich omezuje na Google nebo Active Directory, mezi kterými nemůžeme udržovat replikaci, a proto při nástupu nového zaměstnance je třeba vytvořit/aktivovat účet v těchto dvou systémech. Pro zjednodušení procesu jsme se rozhodli napsat skript, který shromažďuje informace a odesílá je do obou služeb.
Autentizace
Při sestavování požadavků jsme se rozhodli používat pro autentizaci skutečné lidi-administrátory, což usnadňuje analýzu akcí při náhodných nebo úmyslných hromadných změnách.
Pro autentizaci a autorizaci Google API používá protokol OAuth 2.0. Scénáře použití a podrobnější popis lze najít zde: .
Vybral jsem scénář, který se používá při autentizaci v desktopových aplikacích. Existuje také možnost použít servisní účet, který nevyžaduje žádné další kroky od uživatele.
Obrázek níže je schématické znázornění vybraného scénáře ze stránky Google.

- Nejprve uživatele přesměrujeme na stránku autentizace účtu Google, přičemž udáváme GET parametry:
- identifikátor aplikace
- oblasti, ke kterým je aplikaci třeba mít přístup
- adresa, na kterou bude uživatel přesměrován po dokončení procesu
- způsob, jakým budeme aktualizovat token
- kód ověření
- formát přenosu kódu ověření
- Po dokončení autentizace bude uživatel přesměrován na stránku uvedenou v prvním požadavku s chybou nebo kódem autorizace předanými jako GET parametry
- Aplikace (skript) bude muset tyto parametry získat a v případě obdržení kódu provést další požadavek na získání tokenů
- Při správném požadavku Google API vrací:
- Access token, se kterým můžeme provádět požadavky
- Doba platnosti tohoto tokenu
- Refresh token, potřebný pro aktualizaci Access tokenu.
Nejprve je třeba navštívit konzolu Google API: , vybrat požadovanou aplikaci a v části Credentials vytvořit identifikátor klienta OAuth. Tam také (nebo později, v vlastnostech vytvořeného identifikátoru) je třeba uvést adresy, na které je povoleno přesměrování. V našem případě to budou několikrát localhost s různými porty (viz dále).
Aby bylo pohodlnější číst algoritmus skriptu, můžete první kroky vypsat do samostatné funkce, která vrátí Access a refresh tokeny pro aplikaci:
$client_secret = 'Náš klientský tajný klíč'
$client_id = 'Náš klientský ID'
function Get-GoogleAuthToken {
if (-not [System.Net.HttpListener]::IsSupported) {
"HttpListener není podporován."
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 "Spuštění prohlížeče..."
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 {
"Nedaří se spustit posluchač."
exit 1
}
while (($code -eq $null)) {
$context = $listener.GetContext()
Write-Host "Připojení přijato" -f 'mag'
$url = $context.Request.RawUrl
$code = $url.split('?')[1].split('=')[1].split('&')[0]
if ($url.split('?')[1].split('=')[0] -eq 'error') {
Write-Host "Chyba!"$code -f 'red'
$buffer = [System.Text.Encoding]::UTF8.GetBytes("Chyba!"+$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("Nyní můžete zavřít tuto záložku prohlížeče.")
$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
Nastavíme Client ID a Client Secret, získané v vlastnostech identifikátoru klienta OAuth, a code verifier – což je řetězec o délce od 43 do 128 znaků, který musí být generován náhodně z nezarezervovaných znaků: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~".
Tento kód bude následně předán znovu. Vylučuje to zranitelnost, při které by útočník mohl zachytit odpověď, která se vrátila přesměrováním po autorizaci uživatele.
Odeslat code verifier v aktuálním požadavku lze v otevřené podobě (což z něj dělá zbytečnost – to vyhovuje pouze systémům, které nepodporují SHA256), nebo vytvořením hash podle algoritmu SHA256, který je třeba zakódovat do BASE64Url (liší se od Base64 dvěma znaky tabulky) a odstranit koncový znak: =.
Dále potřebujeme začít naslouchat http na lokálním počítači, abychom získali odpověď po autorizaci, která se vrátí přesměrováním.
Administrativní úkoly se provádějí na speciálním serveru, nemůžeme vyloučit možnost, že několik administrátorů spustí skript současně, takže náhodně zvolí port pro aktuálního uživatele, ale uvedl jsem předem určené porty, protože je také potřebujeme přidat jako důvěryhodné v API konzoli.
access_type=offline znamená, že aplikace může sama obnovit vypršený token bez interakce uživatele s prohlížečem,
response_type=code určuje formát, jakým se vrátí kód (odkaz na starý způsob autorizace, kdy uživatel kopíroval kód z prohlížeče do skriptu),
scope uvádí oblasti a typ přístupu. Ty by měly být odděleny mezerami nebo (v souladu s URL Encoding). Seznam oblastí přístupu s typy lze vidět zde: .
Po obdržení kódu autorizace aplikace vrátí do prohlížeče zprávu o zavření, přestane naslouchat portu a odešle POST požadavek pro získání tokenu. Uvedeme v něm dříve definované id a secret z API konzole, adresu, na kterou bude uživatel přesměrován, a grant_type v souladu se specifikací protokolu.
V odpovědi obdržíme Access token, jeho dobu platnosti ve vteřinách a Refresh token, pomocí něhož můžeme obnovit Access token.
Aplikace by měla uchovávat tokeny na bezpečném místě s dlouhou dobou platnosti, proto, dokud neodvoláme získaný přístup, aplikaci se nevrátí refresh token. Na závěr jsem přidal požadavek na odvolání tokenu, pokud aplikace nebyla úspěšně dokončena a refresh token se nevrátil, začne proceduru znovu (považovali jsme za nebezpečné uchovávat tokeny lokálně na terminálu, a nechtěli jsme komplikovat kryptografií nebo často otvírat prohlížeč).
do {
$token_result = Get-GoogleAuthToken
$token = $token_result.access_token
if ($token_result.refresh_token -eq $null) {
Write-Host ("Seance není zničena. Odvolání tokenu...")
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
}
Jak jste si již všimli, při odvolání tokenu se používá Invoke-WebRequest. Na rozdíl od Invoke-RestMethod nevrací získaná data v použitelné podobě a zobrazuje status požadavku.
Dále skript požádá o zadání jména a příjmení uživatele, čímž se vygeneruje přihlašovací jméno + email.
Požadavky
Další budou požadavky – v první řadě je třeba zkontrolovat, zda již existuje uživatel s takovým přihlašovacím jménem, aby bylo možné rozhodnout, zda vytvořit nové nebo použít stávající.
Rozhodl jsem se realizovat všechny požadavky ve formátu jedné funkce s výběrem, pomocí switch:
funkce 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'] + " nyní je členem " + $query['groupkey'])
}
}
}
}Každý požadavek musí obsahovat hlavičku Authorization, která zahrnuje typ tokenu a samotný Access token. V současné době je typ tokenu vždy Bearer. Jelikož potřebujeme kontrolovat, zda token není vypršený, a obnovit jej po hodině od jeho udělení, uvedl jsem požadavek na jinou funkci, která vrací Access token. Tento úryvek kódu je také na začátku skriptu, když získáváme první Access token:
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 je prošlý. Obnovuji..."
$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
}Kontrola existence uživatelského jména:
function Check_Google {
$query = (GoogleQuery 'SearchAccount' $username)
if ($query.users -ne $null) {
$user = $query.users[0]
Write-Host $user.name.fullName' - '$user.PrimaryEmail' - pozastaven: '$user.Suspended
$GAresult = $user
}
if ($GAresult) {
$return = $GAresult
} else {$return = 'gg'}
return $return
}Žádost o email: $query požádá API o vyhledání uživatele s tímto emailem, včetně aliasů. Také lze použít zástupných znaků: =, :, :{PREFIX}*.
K získání dat se používá metoda GET, pro vkládání dat (vytvoření účtu nebo přidání člena do skupiny) – POST, pro aktualizaci existujících dat – PUT, pro odstranění záznamu (například člena ze skupiny) – DELETE.
Skript také požádá o telefonní číslo (nevalidovaný řetězec) a o zařazení do regionálního skupinového mailu. Rozhoduje, která organizační jednotka má být uživateli na základě vybrané OU v Active Directory a vymýšlí heslo:
do {
$phone = Read-Host "Telefon ve formátu +7xxxxxxx"
} while (-not $phone)
do {
$moscow = Read-Host "V moskevské kanceláři? (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 "Bude vytvořeno v /Team delivery"
$orgunit = "/Team delivery"
}
$Password = -join (48..57 + 65..90 + 97..122 | Get-Random -Count 12 | % {[char]$_})+"*Ba"
A poté začíná manipulace s účtem:
$query = @{
email = $email
givenName = $firstname
familyName = $lastname
password = $password
phone = $phone
orgunit = $orgunit
}
if ($GMailExist) {
Write-Host "Spouštíme změnu účtu" -f mag
(GoogleQuery 'UpdateAccount' $query) | fl
write-host "Nezapomeňte zkontrolovat skupiny uživatelě $Username v Googlu."
} else {
Write-Host "Spouštíme vytvoření účtu" -f mag
(GoogleQuery 'CreateAccount' $query) | fl
}
if ($moscow -eq "y"){
write-host "Přidáváme do skupiny moscowoffice"
$query = @{
groupkey = 'moscowoffice@rocketguys.com'
email = $email
}
(GoogleQuery 'AddMember' $query) | fl
}
Funkce aktualizace a vytváření účtu mají podobnou syntaxi, ne všechna další pole jsou povinná, v sekci s telefonními čísly je třeba uvést pole, které může obsahovat od jedné položky s číslem a jeho typem.
Abychom se vyhnuli chybě při přidávání uživatele do skupiny, můžeme nejprve zkontrolovat, zda již v této skupině není, získáním seznamu členů skupiny nebo složení uživatele samotného.
Dotaz na složení skupin určitého uživatele nebude rekurzivní a zobrazí pouze přímé členství. Začlenění uživatele do nadřazené skupiny, ve které již existuje podskupina, jejíž členem je uživatel, bude úspěšné.
Závěr
Zbývá odeslat uživateli heslo pro nový účet. Děláme to prostřednictvím SMS, a celkové informace s instrukcemi a uživatelským jménem zasíláme na osobní e-mail, který, spolu s telefonním číslem, poskytl personální oddělení. Jako alternativní možnost lze ušetřit peníze a zaslat heslo do tajného chatu na Telegramu, což lze také považovat za druhý faktor (v případě MacBooků budou výjimky).
Děkuji, že jste dočetli až do konce. Budu rád za návrhy na zlepšení stylu psaní článků a přeji vám, abyste při psaní skriptů udělali co nejméně chyb =)
Seznam odkazů, které mohou být tematicky užitečné nebo prostě odpovědět na vzniklé otázky:
Zdroj: habr.com
