Створення користувачів Google з PowerShell через API

Привіт!

У цій статті буде описано реалізацію взаємодії PowerShell з Google API для проведення маніпуляцій з користувачами G Suite.

В організації ми використовуємо декілька внутрішніх та хмарних сервісів. Здебільшого авторизація в них зводиться до Google або Active Directory, між якими ми не можемо підтримувати репліку, відповідно при виході нового співробітника потрібно створити/включити обліковий запис у цих двох системах. Для автоматизації процесу ми вирішили написати скрипт, який збирає інформацію та відправляє в обидва сервіси.

Авторизація

Складаючи вимоги, ми вирішили використати для авторизації реальних людей-адміністраторів, це спрощує розбір дій за випадкових чи навмисних масивних змін.

Для аутентифікації та авторизації Google API використовують протокол OAuth 2.0. Сценарії використання та детальніший опис можна подивитися тут: Використання OAuth 2.0 для доступу до API Google.

Я вибрав сценарій, який використовується при авторизації в додатках. Також є варіант використовувати сервісний обліковий запис, що не вимагає зайвих рухів від користувача.

Зображення нижче – це схематичний опис вибраного сценарію зі сторінки Google.

Створення користувачів Google з PowerShell через API

  1. Спочатку ми надсилаємо користувача на сторінку аутентифікації в обліковий запис Google, вказуючи GET параметрами:
    • ідентифікатор програми
    • області, до яких необхідний доступ до програми
    • адресу, на яку користувач буде перенаправлено після завершення процедури
    • спосіб, яким ми оновлюватимемо токен
    • код перевірки
    • формат передачі коду перевірки

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

Спочатку потрібно сходити в консоль Google API: Credentials — Google API Console, вибрати потрібну програму і в розділі Credentials створити ідентифікатор OAuth клієнта. Там же (або пізніше, у властивостях створеного ідентифікатора) слід зазначити адреси, на які дозволено перенаправлення. У нашому випадку це буде кілька записів локальноїhost з різними портами (див. далі).

Щоб було зручніше читати алгоритм скрипту, можна вивести перші кроки в окрему функцію, яка поверне 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 для API Google.

Після отримання коду авторизації, програма поверне в браузер повідомлення про закриття, припинить слухати порт і надішле запит 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

Додати коментар або відгук