Създаване на потребители на Google от PowerShell чрез API

Привет!

Тази статия ще опише внедряването на взаимодействието на PowerShell с API на Google за манипулиране на потребителите на G Suite.

Използваме няколко вътрешни и облачни услуги в цялата организация. В по-голямата си част авторизацията в тях се свежда до Google или Active Directory, между които не можем да поддържаме реплика, съответно при напускане на нов служител трябва да създадете/активирате акаунт в тези две системи. За да автоматизираме процеса, решихме да напишем скрипт, който събира информация и я изпраща на двете услуги.

Упълномощаване

При изготвянето на изискванията решихме да използваме истински човешки администратори за оторизация; това опростява анализа на действията в случай на случайни или умишлени масивни промени.

API на Google използват протокола OAuth 2.0 за удостоверяване и оторизация. Случаи на употреба и по-подробни описания можете да намерите тук: Използване на OAuth 2.0 за достъп до API на Google.

Избрах скрипта, който се използва за оторизация в десктоп приложения. Има и опция за използване на сервизен акаунт, който не изисква излишни движения от потребителя.

Картината по-долу е схематично описание на избрания сценарий от страницата на Google.

Създаване на потребители на Google от PowerShell чрез API

  1. Първо изпращаме потребителя на страницата за удостоверяване на акаунт в Google, като посочваме GET параметри:
    • id на приложението
    • области, до които приложението се нуждае от достъп
    • адресът, към който потребителят ще бъде пренасочен след приключване на процедурата
    • начина, по който ще актуализираме токена
    • Код за сигурност
    • формат за предаване на код за потвърждение

  2. След приключване на оторизацията, потребителят ще бъде пренасочен към страницата, посочена в първата заявка, с грешка или код за оторизация, предаван от GET параметри
  3. Приложението (скриптът) ще трябва да получи тези параметри и, ако получи кода, да направи следната заявка за получаване на токени
  4. Ако заявката е правилна, API на Google връща:
    • Токен за достъп, с който можем да правим заявки
    • Срокът на валидност на този токен
    • За опресняване на маркера за достъп е необходим токен за опресняване.

Първо трябва да отидете на Google API конзолата: Идентификационни данни - Google API Console, изберете желаното приложение и в раздела Идентификационни данни създайте клиентски OAuth идентификатор. Там (или по-късно в свойствата на създадения идентификатор) трябва да посочите адресите, към които е разрешено пренасочване. В нашия случай това ще бъдат няколко записа на localhost с различни портове (вижте по-долу).

За да направите четенето на алгоритъма на скрипта по-удобно, можете да покажете първите стъпки в отделна функция, която ще върне маркерите за достъп и опресняване за приложението:

$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

Ние задаваме ИД на клиента и тайната на клиента, получени в свойствата на идентификатора на клиента на OAuth, а верификаторът на кода е низ от 43 до 128 знака, който трябва да бъде генериран на случаен принцип от нерезервирани знаци: [AZ] / [az] / [0-9 ] / "-" / "." / "_" / "~".

След това този код ще бъде предаден отново. Той елиминира уязвимостта, при която нападател може да прихване отговор, върнат като пренасочване след оторизация на потребителя.
Можете да изпратите верификатор на код в текущата заявка в ясен текст (което го прави безсмислен - това е подходящо само за системи, които не поддържат SHA256), или чрез създаване на хеш с помощта на алгоритъма SHA256, който трябва да бъде кодиран в BASE64Url (различен от Base64 с два таблични знака) и премахване на окончанията на редовете със знаци: =.

След това трябва да започнем да слушаме http на локалната машина, за да получим отговор след оторизация, който ще бъде върнат като пренасочване.

Административните задачи се изпълняват на специален сървър, не можем да изключим възможността няколко администратора да стартират скрипта едновременно, така че произволно да избере порт за текущия потребител, но посочих предварително дефинирани портове, т.к. те също трябва да бъдат добавени като надеждни в API конзолата.

access_type=офлайн означава, че приложението може да актуализира изтекъл токен самостоятелно, без взаимодействие на потребителя с браузъра,
тип_отговор=код задава формата на това как ще бъде върнат кодът (препратка към стария метод за оторизация, когато потребителят копира кода от браузъра в скрипта),
обхват указва обхвата и вида на достъпа. Те трябва да бъдат разделени с интервали или %20 (според URL кодирането). Списък на зоните за достъп с типове можете да видите тук: Обхват на OAuth 2.0 за API на Google.

След като получи кода за оторизация, приложението ще върне съобщение за затваряне на браузъра, ще спре да слуша порта и ще изпрати POST заявка за получаване на токена. В него посочваме предварително зададения id и secret от API на конзолата, адреса, към който ще бъде пренасочен потребителят и grant_type в съответствие със спецификацията на протокола.

В отговор ще получим Access token, неговия период на валидност в секунди и Refresh token, с който можем да актуализираме Access token.

Приложението трябва да съхранява токени на сигурно място с дълъг срок на годност, така че докато не отменим получения достъп, приложението няма да върне токена за опресняване. В края добавих заявка за отмяна на токена; ако приложението не е завършено успешно и токенът за опресняване не е върнат, процедурата ще започне отново (смятахме, че е опасно да съхраняваме токени локално на терминала и не не искам да усложнявам нещата с криптография или да отварям браузъра често).

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, той не връща получените данни в използваем формат и показва статуса на заявката.

След това скриптът ви моли да въведете името и фамилията на потребителя, генерирайки вход + имейл.

искания

Следващите заявки ще бъдат - първо трябва да проверите дали вече съществува потребител със същото влизане, за да получите решение за създаване на нов или активиране на текущия.

Реших да внедря всички заявки във формата на една функция с селекция, използвайки 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'])
      }
    }
  }
}

Във всяка заявка трябва да изпратите заглавка за оторизация, съдържаща типа токен и самия токен за достъп. Понастоящем типът токен винаги е Носител. защото трябва да проверим дали токенът не е изтекъл и да го актуализираме след един час от момента на издаването му, посочих заявка за друга функция, която връща токен за достъп. Същата част от кода е в началото на скрипта при получаване на първия маркер за достъп:

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 да потърси потребител с точно този имейл, включително псевдоними. Можете също да използвате заместващ знак: =, :, :{PREFIX}*.

За получаване на данни използвайте метода GET заявка, за вмъкване на данни (създаване на акаунт или добавяне на член към група) - POST, за актуализиране на съществуващи данни - PUT, за изтриване на запис (например член от група) - ИЗТРИЙ.

Скриптът също ще поиска телефонен номер (невалидиран низ) и за включване в регионална група за разпространение. Той решава коя организационна единица трябва да има потребителят въз основа на избраното 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
}

Функциите за актуализиране и създаване на акаунт имат подобен синтаксис, не са необходими всички допълнителни полета, в секцията с телефонни номера трябва да посочите масив, който може да съдържа до един запис с номера и неговия тип.

За да не получим грешка при добавяне на потребител към група, можем първо да проверим дали той вече е член на тази група, като получим списък с членове на групата или състав от самия потребител.

Запитването за групово членство на конкретен потребител няма да бъде рекурсивно и ще показва само директно членство. Включването на потребител в родителска група, която вече има дъщерна група, в която потребителят е член, ще успее.

Заключение

Всичко, което остава, е да изпратите на потребителя паролата за новия акаунт. Ние правим това чрез SMS и изпращаме обща информация с инструкции и вход на личен имейл, който заедно с телефонен номер е предоставен от отдела за подбор на персонал. Като алтернатива можете да спестите пари и да изпратите паролата си в таен телеграм чат, което също може да се счита за втори фактор (MacBooks ще бъде изключение).

Благодаря ви, че прочетохте до края. Ще се радвам да видя предложения за подобряване на стила на писане на статии и ви пожелавам да улавяте по-малко грешки при писане на скриптове =)

Списък с връзки, които могат да бъдат тематично полезни или просто да отговарят на въпроси:

Източник: www.habr.com

Добавяне на нов коментар