Cześć!
W artykule opisano implementację interakcji PowerShell z Google API w celu zarządzania użytkownikami G Suite.
W naszej organizacji korzystamy z kilku wewnętrznych i chmurowych usług. W większości przypadków autoryzacja w nich opiera się na Google lub Active Directory, pomiędzy którymi nie możemy utrzymywać replikacji. W związku z tym, przy przyjęciu nowego pracownika, należy utworzyć/włączyć konto w tych dwóch systemach. Aby zautomatyzować ten proces, postanowiliśmy napisać skrypt, który zbiera informacje i przesyła je do obu serwisów.
Autoryzacja
Opracowując wymagania, postanowiliśmy użyć do autoryzacji rzeczywistych administratorów, co ułatwia analizowanie działań w przypadku przypadkowych lub zamierzonych masowych zmian.
Do uwierzytelniania i autoryzacji Google API wykorzystuje protokół OAuth 2.0. Scenariusze wykorzystania oraz bardziej szczegółowy opis można zobaczyć tutaj: .
Wybrałem scenariusz, który jest używany podczas autoryzacji w aplikacjach desktopowych. Istnieje również opcja użycia konta serwisowego, które nie wymaga dodatkowych działań ze strony użytkownika.
Poniższy obrazek to schematyczny opis wybranego scenariusza ze strony Google.

- Najpierw przekazujemy użytkownika na stronę uwierzytelnienia w koncie Google, podając jako parametry GET:
- identyfikator aplikacji
- obszary, do których aplikacja potrzebuje dostępu
- adres, na który użytkownik zostanie przekierowany po zakończeniu procedury
- sposób, w jaki będziemy aktualizować token
- kod weryfikacyjny
- format przekazania kodu weryfikacyjnego
- Po zakończeniu autoryzacji użytkownik zostanie przekierowany na wskazaną w pierwszym zapytaniu stronę, z błędem lub kodem autoryzacji przekazanym jako parametry GET.
- Aplikacja (skrypt) będzie musiała pobrać te parametry i, w przypadku otrzymania kodu, wysłać kolejne zapytanie o tokeny.
- Po poprawnym żądaniu Google API zwraca:
- Token dostępu, za pomocą którego możemy wysyłać zapytania
- Czas trwania tego tokena
- Token odświeżający, potrzebny do aktualizacji tokena dostępu.
Najpierw należy przejść do konsoli Google API: , wybierz odpowiednią aplikację i w sekcji Credentials utwórz identyfikator klienta OAuth. Tam również (lub później, w właściwościach utworzonego identyfikatora) musisz podać adresy, na które dozwolone jest przekierowanie. W naszym przypadku będą to różne wpisy localhost z różnymi portami (patrz niżej).
Aby ułatwić odczyt algorytmu skryptu, możemy wydzielić pierwsze kroki do osobnej funkcji, która zwróci tokeny Access i refresh dla aplikacji:
$client_secret = 'Nasz Sekret Klienta'
$client_id = 'Nasz ID Klienta'
function Get-GoogleAuthToken {
if (-not [System.Net.HttpListener]::IsSupported) {
"HttpListener nie jest obsługiwany."
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 "Uruchom przeglądarkę..."
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 {
"Nie można uruchomić nasłuchu."
exit 1
}
while (($code -eq $null)) {
$context = $listener.GetContext()
Write-Host "Połączenie przyjęte" -f 'mag'
$url = $context.Request.RawUrl
$code = $url.split('?')[1].split('=')[1].split('&')[0]
if ($url.split('?')[1].split('=')[0] -eq 'error') {
Write-Host "Błąd!"$code -f 'red'
$buffer = [System.Text.Encoding]::UTF8.GetBytes("Błąd!"+$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("Możesz teraz zamknąć tę kartę przeglądarki.")
$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
Ustawiamy Client ID i Client Secret uzyskane w właściwościach identyfikatora klienta OAuth, a code verifier to ciąg długości od 43 do 128 znaków, który musi być losowo generowany z niezarezerwowanych znaków: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~".
Następnie ten kod będzie przekazywany ponownie. Wyklucza to lukę, w której napastnik mógłby przechwycić odpowiedź zwróconą przez przekierowanie po autoryzacji użytkownika.
Można wysłać code verifier w bieżącym żądaniu w formie jawnej (co czyni go bezsensownym – nadaje się to tylko dla systemów, które nie obsługują SHA256), lub tworząc hash z algorytmem SHA256, który należy zakodować w BASE64Url (różni się od Base64 dwoma znakami z tabeli) i usunąć znak końca linii: =.
Następnie musimy rozpocząć nasłuchiwanie http na lokalnej maszynie, aby uzyskać odpowiedź po autoryzacji, która zostanie zwrócona jako przekierowanie.
Zadania administracyjne są wykonywane na specjalnym serwerze, nie możemy wykluczyć możliwości, że kilku administratorów jednocześnie uruchomi skrypt, więc on losowo wybierze port dla bieżącego użytkownika, ale określiłem wcześniej zdefiniowane porty, ponieważ muszą być one również dodane jako zaufane w konsoli API.
access_type=offline oznacza, że aplikacja może samodzielnie aktualizować przestarzały token bez interakcji użytkownika z przeglądarką,
response_type=code określa format, w jakim kod zostanie zwrócony (nawiązanie do starego sposobu autoryzacji, gdy użytkownik kopiował kod z przeglądarki do skryptu),
scope określa obszary i typ dostępu. Powinny być one oddzielane spacjami lub (zgodnie z URL Encoding). Listę obszarów dostępu z typami można zobaczyć tutaj: .
Po otrzymaniu kodu autoryzacji, aplikacja zwróci w przeglądarkę komunikat o zakończeniu, przestanie nasłuchiwać port i wyśle żądanie POST w celu uzyskania tokena. Podajemy w nim wcześniej określone id i secret z konsoli API, adres, na który użytkownik zostanie przekierowany oraz grant_type zgodnie ze specyfikacją protokołu.
W odpowiedzi otrzymamy token dostępu, jego czas ważności w sekundach i token odświeżający, za pomocą którego możemy zaktualizować token dostępu.
Aplikacja powinna przechowywać tokeny w bezpiecznym miejscu z długim okresem przechowywania, dlatego, dopóki nie odwołamy otrzymanego dostępu, aplikacja nie otrzyma tokena odświeżającego. Na końcu dodałem żądanie odwołania tokena, jeśli aplikacja zakończyła się niepowodzeniem i token odświeżający nie został zwrócony, rozpocznie procedurę od nowa (uznaliśmy to za niebezpieczne przechowywanie tokenów lokalnie na terminalu, a skomplikowanie kryptografią lub częste otwieranie przeglądarki nie jest pożądane).
do {
$token_result = Get-GoogleAuthToken
$token = $token_result.access_token
if ($token_result.refresh_token -eq $null) {
Write-Host ("Sesja nie została zniszczona. Anulowanie tokena...")
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 już zauważyłeś, do anulowania tokena używa się Invoke-WebRequest. W odróżnieniu od Invoke-RestMethod, nie zwraca on danych w łatwym do użycia formacie i pokazuje status zapytania.
Następnie skrypt będzie prosić o wprowadzenie imienia i nazwiska użytkownika, generując login + e-mail.
Zapytania
Kolejne będą zapytania – najpierw należy sprawdzić, czy użytkownik z takim loginem już istnieje, aby podjąć decyzję o utworzeniu nowego lub włączeniu obecnego.
Postanowiłem zrealizować wszystkie zapytania w formie jednej funkcji z wyborem, używając 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'])
}
}
}
}W każdym żądaniu należy wysłać nagłówek Authorization, który zawiera typ tokena oraz sam token dostępu. Obecnie typ tokena to zawsze Bearer. Ponieważ musimy sprawdzić, czy token nie jest przestarzały i zaktualizować go po upływie godziny od jego wydania, wkazałem żądanie do innej funkcji, która zwraca token dostępu. Ten sam fragment kodu znajduje się na początku skryptu podczas uzyskiwania pierwszego tokena dostępu:
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 wygasł. Odświeżanie..."
$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
}Sprawdzanie istnienia loginu:
function Check_Google {
$query = (GoogleQuery 'SearchAccount' $username)
if ($query.users -ne $null) {
$user = $query.users[0]
Write-Host $user.name.fullName' - '$user.PrimaryEmail' - zawieszony: '$user.Suspended
$GAresult = $user
}
if ($GAresult) {
$return = $GAresult
} else {$return = 'gg'}
return $return
}Zapytanie email:$query poprosi API o wyszukanie użytkownika z dokładnie takim emailem, w tym również znajdą się aliasy. Można także używać znaku wieloznacznego: =, :, :{PREFIX}*.
Do pobierania danych używa się metody GET, do dodawania danych (utworzenie konta lub dodanie uczestnika do grupy) – POST, do aktualizacji istniejących danych – PUT, do usunięcia rekordu (na przykład, uczestnika z grupy) – DELETE.
Skrypt też zapyta o numer telefonu (nieweryfikowany) oraz o przynależność do regionalnej grupy mailingowej. Ostatecznie ustala, która jednostka organizacyjna powinna być przypisana do użytkownika na podstawie wybranej OU Active Directory i tworzy hasło:
do {
$phone = Read-Host "Telefon w formacie +7xxx xxx xxx"
} while (-not $phone)
do {
$moscow = Read-Host "Do biura w Moskwie? (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 "Zostanie utworzone w /Team delivery"
$orgunit = "/Team delivery"
}
$Password = -join (48..57 + 65..90 + 97..122 | Get-Random -Count 12 | % {[char]$_})+"*Ba"
A następnie zaczyna manipulacje z kontem:
$query = @{
email = $email
givenName = $firstname
familyName = $lastname
password = $password
phone = $phone
orgunit = $orgunit
}
if ($GMailExist) {
Write-Host "Rozpoczynamy zmianę konta" -f mag
(GoogleQuery 'UpdateAccount' $query) | fl
write-host "Nie zapomnij sprawdzić grup dołączonego $Username w Google."
} else {
Write-Host "Rozpoczynamy tworzenie konta" -f mag
(GoogleQuery 'CreateAccount' $query) | fl
}
if ($moscow -eq "y") {
write-host "Dodawanie do grupy moscowoffice"
$query = @{
groupkey = 'moscowoffice@rocketguys.com'
email = $email
}
(GoogleQuery 'AddMember' $query) | fl
}
Funkcje aktualizacji i tworzenia konta mają podobną składnię, nie wszystkie dodatkowe pola są obowiązkowe. W sekcji dotyczącej numerów telefonów należy podać tablicę, która może zawierać od jednego wpisu z numerem i jego typem.
Aby uniknąć błędu podczas dodawania użytkownika do grupy, możemy najpierw sprawdzić, czy już w niej jest, uzyskując listę członków grupy lub skład samego użytkownika.
Zapytanie o skład grupy określonego użytkownika nie będzie rekurencyjne i pokaże tylko bezpośrednie członkostwo. Włączenie użytkownika do grupy nadrzędnej, w której już znajduje się grupa podrzędna, której członkiem jest użytkownik, zakończy się sukcesem.
Podsumowanie
Pozostaje wysłać użytkownikowi hasło do nowego konta. Robimy to za pośrednictwem SMS-a, a ogólne informacje z instrukcją i loginem wysyłamy na prywatny e-mail, który, razem z numerem telefonu, podał dział rekrutacji. Alternatywą może być oszczędzenie pieniędzy i wysłanie hasła w tajnym czacie na Telegramie, co można również uznać za drugi czynnik (wyjątkiem będą MacBooki).
Dziękuję, że przeczytałeś do końca. Będę wdzięczny za propozycje poprawy stylu pisania artykułów i życzę ci, abyś złapał jak najmniej błędów przy pisaniu skryptów =)
Lista linków, które mogą być tematycznie przydatne lub po prostu odpowiedzieć na pojawiające się pytania:
Źródło: habr.com
