Стварэнне Google карыстальнікаў з PowerShell праз API

Прывітанне!

У гэтым артыкуле будзе апісана рэалізацыя ўзаемадзеяння PowerShell з Google API для правядзення маніпуляцый з карыстальнікамі G Suite.

У арганізацыі мы выкарыстоўваем некалькі ўнутраных і хмарных сэрвісаў. Па большай частцы аўтарызацыя ў іх зводзіцца да Google або Active Directory, паміж якімі мы не можам падтрымліваць рэпліку, адпаведна, пры выхадзе новага супрацоўніка трэба стварыць/уключыць акаўнт у гэтых дзвюх сістэмах. Для аўтаматызацыі працэсу мы вырашылі напісаць скрыпт, які збірае інфармацыю і адпраўляе ў абодва сэрвісу.

Аўтарызацыя

Складаючы патрабаванні, мы вырашылі выкарыстаць для аўтарызацыі рэальных людзей-адміністратараў, гэта спрашчае разбор дзеянняў пры выпадковых ці наўмысных масіўных зменах.

Для аўтэнтыфікацыі і аўтарызацыі Google API выкарыстоўваюць пратакол OAuth 2.0. Сцэнары выкарыстання і больш падрабязнае апісанне можна паглядзець тут: Using OAuth 2.0 to Access Google APIs.

Я абраў сцэнар, які выкарыстоўваецца пры аўтарызацыі ў desktop-прыкладаннях. Таксама ёсць варыянт выкарыстоўваць сэрвісны рахунак, які не патрабуе лішніх рухаў ад карыстальніка.

Малюнак ніжэй - гэта схематычнае апісанне абранага сцэнара са старонкі Google.

Стварэнне Google карыстальнікаў з PowerShell праз API

  1. Спачатку мы адпраўляем карыстальніка на старонку аўтэнтыфікацыі ў рахунак Google, паказваючы GET параметрамі:
    • ідэнтыфікатар прыкладання
    • вобласці, да якіх неабходны доступ з дадаткам
    • адрас, на які карыстач будзе перанакіраваны пасля завяршэння працэдуры
    • спосаб, якім мы будзем абнаўляць токен
    • код праверкі
    • фармат перадачы кода праверкі

  2. Пасля завяршэння аўтарызацыі, карыстальнік будзе перанакіраваны на ўказаную ў першым запыце старонку, з памылкай або кодам аўтарызацыі, перададзенымі GET параметрамі
  3. Дадатку (скрыпту) трэба будзе атрымаць гэтыя параметры і, у выпадку атрымання кода, выканаць наступны запыт на атрыманне токенаў
  4. Пры карэктным запыце Google API вяртае:
    • Access токен, з якім мы можам рабіць запыты
    • Тэрмін дзеяння гэтага токена
    • Refresh токен, неабходны для абнаўлення Access токена.

Спачатку трэба схадзіць у кансоль Google API: Credentials - Google API Console, Выбраць патрэбнае прыкладанне і ў раздзеле Credentials стварыць ідэнтыфікатар OAuth кліента. Там жа (ці пазней, ва ўласцівасцях створанага ідэнтыфікатара) трэба ўказаць адрасы, на якія дазволена перанакіраванне. У нашым выпадку гэта будзе некалькі запісаў localhost з рознымі партамі (гл. далей).

Каб было зручней чытаць алгарытм скрыпту, можна вывесці першыя крокі ў асобную функцыю, якая верне Access і refresh токены для прыкладання:

$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

Мы задаем Client ID і Client Secret, атрыманыя ва ўласцівасцях ідэнтыфікатара кліента OAuth, і code verifier - гэта радок даўжынёй ад 43 да 128 сімвалаў, які павінен быць згенераваны выпадковым чынам з незарэзерваваных сімвалаў: [AZ] / [az] / [0-9 ] / "-" / "." / "_" / "~".

Далей гэты код будзе перададзены паўторна. Ён выключае ўразлівасць, пры якой зламыснік можа перахапіць адказ, які вярнуўся рэдырэктам пасля аўтарызацыі карыстальніка.
Адправіць code verifier у бягучым запыце можна ў адкрытым выглядзе (што робіць яго бессэнсоўным - гэта падыходзіць толькі для сістэм, якія не падтрымліваюць SHA256), або стварыўшы хэш па алгарытме SHA256, які трэба закадаваць у BASE64Url (адрозніваецца ад Base64 двума сімваламі табліцы) і выдаліць сімвал заканчэння радка: =.

Далей нам трэба пачаць праслухоўваць http на лакальнай машыне, каб атрымаць адказ пасля аўтарызацыі, які вернецца рэдырэктам.

Адміністрацыйныя задачы выконваюцца на спецыяльным серверы, мы не можам выключаць верагоднасць таго, што некалькі адміністратараў адначасова запусцяць скрыпт, таму ён наўздагад абярэ порт для бягучага карыстальніка, але я паказаў загадзя вызначаныя парты, т.я. іх трэба таксама дадаць як давераныя ў кансолі API.

access_type=offline азначае, што прыкладанне можа абнаўляць скончаны токен самастойна без узаемадзеяння карыстальніка з браўзэрам,
тып_адказу = код задае фармат таго, як вернецца код (адсылка да старога спосабу аўтарызацыі, калі карыстач копипастил код з браўзэра ў скрыпт),
сфера паказвае вобласці і тып доступу. Яны павінны падзяляцца прабеламі ці %20 (у адпаведнасці з URL Encoding). Спіс абласцей доступу з тыпамі можна ўбачыць тут: OAuth 2.0 Scopes for Google APIs.

Пасля атрымання кода аўтарызацыі, прыкладанне верне ў браўзэр паведамленне аб закрыцці, спыніць слухаць порт і адправіць POST запыт для атрымання токена. Мы паказваем у ім зададзеныя раней id і secret з API кансолі, адрас, на які будзе перанакіраваны карыстач і grant_type у адпаведнасці спецыфікацыі пратаколу.

У адказ мы атрымаем Access токен, яго час дзеяння ў секундах і Refresh токен, з дапамогай якога мы можам абнавіць Access токен.

Прыкладанне павінна захоўваць токены ў бяспечным месцы з працяглым тэрмінам захоўвання, таму, пакуль мы не адклічам атрыманы доступ, з дадаткам не вернецца refresh токен. У канцы я дадаў запыт на водгук токена, калі прыкладанне было завершана не паспяхова і refresh токен не вярнуўся, яно пачне працэдуру зноўку (мы палічылі небяспечным захоўваць токены лакальна на тэрмінале, а ўскладняць крыптаграфіяй ці часта адчыняць браўзэр не жадаецца).

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
}

Як вы ўжо заўважылі, пры водгуку токена выкарыстоўваецца Invoke-WebRequest. У адрозненні ад Invoke-RestMethod, ён не вяртае атрыманыя дадзеныя ў зручным для выкарыстання фармаце і паказвае статут запыту.

Далей скрыпт прасіць увесці імя і прозвішча карыстальніка, генеруючы лагін + email.

запыты

Наступнымі будуць запыты – у першую чаргу трэба праверыць ці існуе ўжо карыстач з такім лагінам для атрымання рашэння аб фармаванні новага або ўключэнні бягучага.

Я вырашыў рэалізаваць усе запыты ў фармаце адной функцыі з выбаркай, выкарыстоўваючы 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'])
      }
    }
  }
}

У кожным запыце трэба адпраўляць загаловак Authorization, які змяшчае тып токена і сам Access токен. На бягучы момант тып токена заўсёды Bearer. Т.к. нам трэба правяраць што токен не пратэрмінаваны і абнавіць яго па заканчэнні гадзіны з моманту выдачы, я ўказаў запыт на іншую функцыю, якая вяртае Access токен. Гэты ж кавалачак кода ёсць у пачатку скрыпту пры атрыманні першага 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
}

Праверка лагіна на існаванне:

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
}

Запыт email:$query папросіць API пашукаць карыстальніка менавіта з такім email, у тым ліку будуць знойдзеныя аліясы. Таксама можна выкарыстоўваць wildcard: =, :, :{PREFIX}*.

Для атрымання дадзеных выкарыстоўваецца метад запыту GET, для ўстаўкі дадзеных (стварэнне акаўнта або даданне ўдзельніка ў групу) - POST, для абнаўлення існуючых дадзеных - PUT, для выдалення запісу (напрыклад, удзельніка з групы) - DELETE.

Скрыпт таксама спытае нумар тэлефона (невалідаваны радок) і аб уключэнні ў рэгіянальную групу рассылання. Ён вырашае, якая арганізацыйная адзінка павінна быць у карыстальніка на аснове абранай OU Active Directory і прыдумае пароль:

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"

І далей пачынае маніпуляцыі з акаўнтам:

$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
}

Функцыі абнаўлення і стварэння акаўнта маюць аналагічны сінтаксіс, не ўсе дадатковыя палі абавязковыя, у раздзеле з нумарамі тэлефонаў трэба паказаць масіў, які можа змяшчаць ад аднаго запісу з нумарам і яго тыпам.

Каб не атрымаць памылку пры даданні карыстача ў групу, папярэдне мы можам праверыць, ці складаецца ён ужо ў гэтай групе, атрымаўшы спіс чальцоў групы або склад у самога карыстача.

Запыт складу груп вызначанага карыстальніка будзе не рэкурсіўным і пакажа толькі непасрэднае сяброўства. Уключэнне карыстальніка ў бацькоўскую групу, у якой ужо складаецца даччыная група, удзельнікам якой з'яўляецца карыстач, будзе паспяховым.

Заключэнне

Засталося адправіць карыстачу пароль ад новага акаўнта. Мы робім гэта праз СМС, а агульную інфармацыю з інструкцыяй і лагінам адпраўляем на асабістую пошту, якую, разам з нумарам тэлефона, падаў аддзел падбору персанала. Як альтэрнатыўны варыянт, можна зэканоміць грошыкі і адправіць пароль у сакрэтны чат тэлеграма, што таксама можна лічыць другім фактарам (выключэннем будуць макбукі).

Дзякуй, што прачыталі да канца. Буду рады ўбачыць прапановы па паляпшэнні стылю напісання артыкулаў і жадаю вам злавіць паменш памылак пры напісанні скрыптоў =)

Спіс спасылак, якія могуць быць тэматычна карысныя або проста адказаць на пытанні:

Крыніца: habr.com

Дадаць каментар