Tworzenie użytkowników Google z PowerShell za pośrednictwem API

Hi!

W tym artykule opisano implementację interakcji PowerShell z Google API w celu manipulowania użytkownikami G Suite.

W całej organizacji korzystamy z kilku usług wewnętrznych i chmurowych. W większości autoryzacja w nich sprowadza się do Google lub Active Directory, pomiędzy którymi nie możemy utrzymywać repliki, dlatego też, gdy odchodzi nowy pracownik, należy założyć/włączyć konto w tych dwóch systemach. Aby zautomatyzować proces, postanowiliśmy napisać skrypt, który zbiera informacje i wysyła je do obu serwisów.

autoryzacja

Przygotowując wymagania, zdecydowaliśmy się na wykorzystanie do autoryzacji prawdziwych administratorów, co upraszcza analizę działań w przypadku przypadkowych lub zamierzonych masowych zmian.

Interfejsy API Google korzystają z protokołu OAuth 2.0 do uwierzytelniania i autoryzacji. Przypadki użycia i bardziej szczegółowe opisy można znaleźć tutaj: Korzystanie z protokołu OAuth 2.0 w celu uzyskania dostępu do interfejsów API Google.

Wybrałem skrypt, który służy do autoryzacji w aplikacjach desktopowych. Istnieje również możliwość skorzystania z konta serwisowego, które nie wymaga od użytkownika zbędnych ruchów.

Poniższy obrazek to schematyczny opis wybranego scenariusza ze strony Google.

Tworzenie użytkowników Google z PowerShell za pośrednictwem API

  1. Najpierw odsyłamy użytkownika na stronę uwierzytelniania konta Google, podając 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 zaktualizujemy token
    • Kod bezpieczeństwa
    • format transmisji kodu weryfikacyjnego

  2. Po zakończeniu autoryzacji użytkownik zostanie przekierowany na stronę podaną w pierwszym żądaniu, z błędem lub kodem autoryzacyjnym przekazanym przez parametry GET
  3. Aplikacja (skrypt) będzie musiała otrzymać te parametry i po otrzymaniu kodu wykonać następujące żądanie uzyskania tokenów
  4. Jeśli żądanie jest prawidłowe, Google API zwraca:
    • Token dostępu, za pomocą którego możemy składać żądania
    • Okres ważności tego tokena
    • Odśwież token wymagany do odświeżenia tokenu dostępu.

Najpierw musisz przejść do konsoli Google API: Dane logowania — konsola Google API, wybierz żądaną aplikację i w sekcji Poświadczenia utwórz identyfikator OAuth klienta. Tam (lub później, we właściwościach utworzonego identyfikatora) musisz określić adresy, na które dozwolone jest przekierowanie. W naszym przypadku będzie to kilka wpisów localhost z różnymi portami (patrz niżej).

Aby ułatwić odczytanie algorytmu skryptu, możesz wyświetlić pierwsze kroki w osobnej funkcji, która zwróci tokeny dostępu i odświeżenia dla aplikacji:

$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

Ustawiamy Client ID i Client Secret uzyskane we właściwościach identyfikatora klienta OAuth, a weryfikatorem kodu jest ciąg od 43 do 128 znaków, który należy wygenerować losowo ze znaków niezarezerwowanych: [AZ] / [az] / [0-9 ] / „-” / „.” / „_” / „~”.

Kod ten zostanie następnie przesłany ponownie. Eliminuje lukę, w wyniku której atakujący mógłby przechwycić odpowiedź zwróconą jako przekierowanie po autoryzacji użytkownika.
Weryfikator kodu możesz wysłać w bieżącym żądaniu w postaci czystego tekstu (co czyni go bezsensownym - nadaje się tylko dla systemów, które nie obsługują SHA256) lub tworząc skrót za pomocą algorytmu SHA256, który musi być zakodowany w BASE64Url (różniący się z Base64 o dwa znaki tabeli) i usunięcie końcówek linii znakowych: =.

Następnie musimy rozpocząć nasłuchiwanie http na lokalnej maszynie, aby po autoryzacji otrzymać odpowiedź, która zostanie zwrócona jako przekierowanie.

Zadania administracyjne wykonywane są na specjalnym serwerze, nie możemy wykluczyć, że kilku administratorów uruchomi skrypt w tym samym czasie, więc losowo wybierze port dla bieżącego użytkownika, ale podałem porty predefiniowane, ponieważ należy je także dodać jako zaufane w konsoli API.

typ_dostępu=offline oznacza, że ​​aplikacja może samodzielnie zaktualizować wygasły token bez interakcji użytkownika z przeglądarką,
typ_odpowiedzi=kod ustawia format w jaki sposób zostanie zwrócony kod (nawiązanie do starej metody autoryzacji, gdy użytkownik kopiował kod z przeglądarki do skryptu),
zakres wskazuje zakres i rodzaj dostępu. Muszą być oddzielone spacjami lub%20 (zgodnie z kodowaniem URL). Listę obszarów dostępu z typami można zobaczyć tutaj: Zakresy protokołu OAuth 2.0 dla interfejsów API Google.

Po otrzymaniu kodu autoryzacyjnego aplikacja zwróci do przeglądarki komunikat o zamknięciu, przestanie nasłuchiwać na porcie i wyśle ​​żądanie POST w celu uzyskania tokena. Podajemy w nim podany wcześniej identyfikator i sekret z API konsoli, adres na który użytkownik zostanie przekierowany oraz typ_grantu zgodny ze specyfikacją protokołu.

W odpowiedzi otrzymamy token dostępu, jego okres ważności w sekundach oraz token odświeżenia, za pomocą którego będziemy mogli zaktualizować token dostępu.

Aplikacja musi przechowywać tokeny w bezpiecznym miejscu z długim terminem ważności, dlatego dopóki nie odwołamy otrzymanego dostępu, aplikacja nie zwróci tokena odświeżenia. Na koniec dodałem prośbę o unieważnienie tokena; jeśli aplikacja nie została pomyślnie zakończona i token odświeżenia nie został zwrócony, procedura rozpocznie się od nowa (uważaliśmy, że przechowywanie tokenów lokalnie na terminalu jest niebezpieczne i tego nie robimy nie chcę komplikować rzeczy z kryptografią ani często otwierać przeglądarki).

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
}

Jak już zauważyłeś, podczas unieważniania tokena używany jest Invoke-WebRequest. W przeciwieństwie do Invoke-RestMethod nie zwraca odebranych danych w użytecznym formacie i pokazuje status żądania.

Następnie skrypt poprosi o podanie imienia i nazwiska użytkownika, generując login + e-mail.

wnioski

Kolejnymi żądaniami będą - w pierwszej kolejności należy sprawdzić, czy użytkownik o tym samym loginie już istnieje, aby uzyskać decyzję o utworzeniu nowego lub włączeniu bieżącego.

Postanowiłem zaimplementować wszystkie żądania w formacie jednej funkcji z wyborem, używając przełącznika:

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 zawierający typ tokena oraz sam token dostępu. Obecnie typem tokena jest zawsze Bearer. Ponieważ musimy sprawdzić czy token nie wygasł i zaktualizować go po godzinie od momentu jego wystawienia, podałem żądanie innej funkcji zwracającej token Access. Ten sam fragment kodu znajduje się na początku skryptu podczas odbierania pierwszego tokena Access:

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 Expired. Refreshing..."
    $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' - suspended: '$user.Suspended
    $GAresult = $user
  }
  if ($GAresult) {
      $return = $GAresult
  } else {$return = 'gg'}
  return $return
}

Żądanie email:$query poprosi interfejs API o wyszukanie użytkownika o dokładnie tym adresie e-mail, łącznie z aliasami. Możesz także użyć symbolu wieloznacznego: =, :, :{PREFIX}*.

Aby uzyskać dane, użyj metody żądania GET, wprowadź dane (zakładając konto lub dodając członka do grupy) - POST, aby zaktualizować istniejące dane - PUT, aby usunąć rekord (np. członka z grupy) - USUWAĆ.

Skrypt poprosi również o numer telefonu (niezweryfikowany ciąg znaków) i o włączenie do regionalnej grupy dystrybucyjnej. Decyduje jaką jednostkę organizacyjną powinien posiadać użytkownik na podstawie wybranej jednostki organizacyjnej Active Directory i podaje hasło:

do {
  $phone = Read-Host "Телефон в формате +7хххххххх"
} while (-not $phone)
do {
    $moscow = Read-Host "В Московский офис? (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 "Будет создана в /Team delivery"
    $orgunit = "/Team delivery"
}
$Password =  -join ( 48..57 + 65..90 + 97..122 | Get-Random -Count 12 | % {[char]$_})+"*Ba"

A potem zaczyna manipulować kontem:

$query = @{
  email = $email
  givenName = $firstname
  familyName = $lastname
  password = $password
  phone = $phone
  orgunit = $orgunit
}
if ($GMailExist) {
  Write-Host "Запускаем изменение аккаунта" -f mag
  (GoogleQuery 'UpdateAccount' $query) | fl
  write-host "Не забудь проверить группы у включенного $Username в Google."
} else {
  Write-Host "Запускаем создание аккаунта" -f mag
  (GoogleQuery 'CreateAccount' $query) | fl
}
if ($moscow -eq "y"){
  write-host "Добавляем в группу moscowoffice"
  $query = @{
    groupkey = '[email protected]'
    email = $email
  }
  (GoogleQuery 'AddMember' $query) | fl
}

Funkcje aktualizacji i założenia konta mają podobną składnię, nie wszystkie dodatkowe pola są wymagane, w sekcji z numerami telefonów należy określić tablicę, która może zawierać maksymalnie jeden rekord z numerem i jego typem.

Aby nie otrzymać błędu przy dodawaniu użytkownika do grupy, możemy w pierwszej kolejności sprawdzić, czy jest on już członkiem tej grupy, pozyskując listę członków grupy lub skład od samego użytkownika.

Zapytanie o członkostwo określonego użytkownika w grupie nie będzie rekurencyjne i wyświetli jedynie bezpośrednie członkostwo. Dołączenie użytkownika do grupy nadrzędnej, która ma już grupę podrzędną, której członkiem jest ten użytkownik, zakończy się sukcesem.

wniosek

Pozostaje tylko wysłać użytkownikowi hasło do nowego konta. Robimy to za pomocą wiadomości SMS, a ogólne informacje wraz z instrukcją i loginem wysyłamy na prywatny adres e-mail, który wraz z numerem telefonu przekazał nam dział rekrutacji. Alternatywnie możesz zaoszczędzić pieniądze i wysłać hasło na tajny czat telegramowy, co można również uznać za drugi czynnik (MacBooki będą wyjątkiem).

Dziękuję za przeczytanie do końca. Chętnie zobaczę sugestie dotyczące poprawy stylu pisania artykułów i życzę wychwytywania mniejszej liczby błędów podczas pisania skryptów =)

Lista linków, które mogą być przydatne tematycznie lub po prostu odpowiedzieć na pytania:

Źródło: www.habr.com

Dodaj komentarz