Создание 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 токен, с которым мы можем делать запросы
    • Cрок действия этого токена
    • 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 символов, которая должна быть сгенерирована случайным образом из незарезервированных символов: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~".

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

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

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

access_type=offline означает что приложение может обновлять истекший токен самостоятельно без взаимодействия пользователя с браузером,
response_type=code задает формат того, как вернется код (отсылка к старому способу авторизации, когда пользователь копипастил код из браузера в скрипт),
scope указывает области и тип доступа. Они должны разделяться пробелами или %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