Criando usuários do Google a partir do PowerShell via API

Oi!

Este artigo descreverá a implementação da interação do PowerShell com a API do Google para manipular os usuários do G Suite.

Usamos vários serviços internos e em nuvem em toda a organização. Na maioria das vezes, a autorização neles se resume ao Google ou Active Directory, entre os quais não podemos manter uma réplica; portanto, quando um novo funcionário sai, é necessário criar/habilitar uma conta nestes dois sistemas. Para automatizar o processo, decidimos escrever um script que coleta informações e as envia para ambos os serviços.

Autorização

Na elaboração dos requisitos, optamos por utilizar administradores humanos reais para autorização, o que simplifica a análise das ações em caso de mudanças massivas acidentais ou intencionais.

As APIs do Google usam o protocolo OAuth 2.0 para autenticação e autorização. Casos de uso e descrições mais detalhadas podem ser encontrados aqui: Usando OAuth 2.0 para acessar APIs do Google.

Escolhi o script usado para autorização em aplicativos de desktop. Também existe a opção de utilizar uma conta de serviço, que não exige movimentos desnecessários do usuário.

A imagem abaixo é uma descrição esquemática do cenário selecionado na página do Google.

Criando usuários do Google a partir do PowerShell via API

  1. Primeiro, enviamos o usuário para a página de autenticação da Conta do Google, especificando os parâmetros GET:
    • ID do aplicativo
    • áreas às quais o aplicativo precisa acessar
    • o endereço para o qual o usuário será redirecionado após concluir o procedimento
    • a forma como atualizaremos o token
    • Código de segurança
    • formato de transmissão do código de verificação

  2. Após a autorização ser concluída, o usuário será redirecionado para a página especificada na primeira solicitação, com um erro ou código de autorização passado pelos parâmetros GET
  3. A aplicação (script) precisará receber estes parâmetros e, caso receba o código, fazer a seguinte requisição para obtenção de tokens
  4. Se a solicitação estiver correta, a API do Google retornará:
    • Token de acesso com o qual podemos fazer solicitações
    • O período de validade deste token
    • Token de atualização necessário para atualizar o token de acesso.

Primeiro você precisa acessar o console da API do Google: Credenciais – Console de APIs do Google, selecione o aplicativo desejado e na seção Credenciais crie um identificador OAuth do cliente. Lá (ou posteriormente, nas propriedades do identificador criado) você precisa especificar os endereços para os quais o redirecionamento é permitido. No nosso caso, serão várias entradas de localhost com portas diferentes (veja abaixo).

Para tornar mais conveniente a leitura do algoritmo do script, você pode exibir as primeiras etapas em uma função separada que retornará tokens de acesso e atualização para o aplicativo:

$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

Definimos o Client ID e o Client Secret obtidos nas propriedades do identificador do cliente OAuth, e o verificador de código é uma string de 43 a 128 caracteres que deve ser gerada aleatoriamente a partir de caracteres não reservados: [AZ] / [az] / [0-9] / "-" / "." / "_" / "~".

Este código será então transmitido novamente. Elimina a vulnerabilidade na qual um invasor poderia interceptar uma resposta retornada como redirecionamento após autorização do usuário.
Você pode enviar um verificador de código na solicitação atual em texto não criptografado (o que o torna sem sentido - isso só é adequado para sistemas que não suportam SHA256) ou criando um hash usando o algoritmo SHA256, que deve ser codificado em BASE64Url (diferente de Base64 por dois caracteres de tabela) e removendo as terminações de linha de caracteres: =.

A seguir, precisamos começar a ouvir http na máquina local para receber uma resposta após a autorização, que será retornada como um redirecionamento.

As tarefas administrativas são realizadas em um servidor especial, não podemos descartar a possibilidade de vários administradores executarem o script ao mesmo tempo, portanto ele selecionará aleatoriamente uma porta para o usuário atual, mas especifiquei portas predefinidas porque eles também devem ser adicionados como confiáveis ​​no console da API.

access_type=off-line significa que o aplicativo pode atualizar um token expirado por conta própria, sem interação do usuário com o navegador,
tipo_de_resposta=código define o formato de como o código será retornado (uma referência ao antigo método de autorização, quando o usuário copiava o código do navegador para o script),
escopo indica o escopo e o tipo de acesso. Devem ser separados por espaços ou %20 (conforme Codificação da URL). Uma lista de áreas de acesso com tipos pode ser vista aqui: Escopos OAuth 2.0 para APIs do Google.

Após receber o código de autorização, a aplicação retornará uma mensagem de fechamento ao navegador, parará de escutar na porta e enviará uma solicitação POST para obtenção do token. Indicamos nele o id e segredo previamente especificados da API do console, o endereço para o qual o usuário será redirecionado e grant_type de acordo com a especificação do protocolo.

Em resposta, receberemos um token de acesso, seu período de validade em segundos, e um token de atualização, com o qual podemos atualizar o token de acesso.

A aplicação deve armazenar os tokens em um local seguro e com longa vida útil, portanto, até revogarmos o acesso recebido, a aplicação não retornará o token de atualização. Ao final, adicionei uma solicitação para revogar o token; se a aplicação não foi concluída com sucesso e o token de atualização não foi retornado, ele iniciará o procedimento novamente (consideramos inseguro armazenar tokens localmente no terminal, e não não quero complicar as coisas com criptografia ou abrir o navegador com frequência).

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
}

Como você já percebeu, ao revogar um token, é utilizado Invoke-WebRequest. Ao contrário do Invoke-RestMethod, ele não retorna os dados recebidos em um formato utilizável e mostra o status da solicitação.

A seguir, o script solicita que você insira o nome e sobrenome do usuário, gerando um login + e-mail.

pedidos

As próximas solicitações serão - em primeiro lugar, é necessário verificar se já existe um usuário com o mesmo login para poder tomar a decisão de criar um novo ou habilitar o atual.

Decidi implementar todas as solicitações no formato de uma função com seleção, usando 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'])
      }
    }
  }
}

Em cada solicitação, você precisa enviar um cabeçalho de autorização contendo o tipo de token e o próprio token de acesso. Atualmente, o tipo de token é sempre Bearer. Porque precisamos verificar se o token não expirou e atualizá-lo após uma hora a partir do momento em que foi emitido, especifiquei uma solicitação para outra função que retorna um token de acesso. O mesmo trecho de código está no início do script ao receber o primeiro token de acesso:

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
}

Verificando a existência do login:

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
}

A solicitação email:$query solicitará que a API procure um usuário com exatamente esse email, incluindo aliases. Você também pode usar curinga: =, :, :{PREFIXO}*.

Para obter dados, utilize o método de solicitação GET, para inserir dados (criando uma conta ou adicionando um membro a um grupo) - POST, para atualizar dados existentes - PUT, para excluir um registro (por exemplo, um membro de um grupo) - EXCLUIR.

O script também solicitará um número de telefone (uma sequência não validada) e a inclusão em um grupo de distribuição regional. Ele decide qual unidade organizacional o usuário deve ter com base na UO do Active Directory selecionada e cria uma senha:

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"

E então ele começa a manipular a conta:

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

As funções de atualização e criação de conta possuem sintaxe semelhante, nem todos os campos adicionais são obrigatórios, na seção com números de telefone é necessário especificar um array que pode conter até um registro com o número e seu tipo.

Para não receber erro ao adicionar um usuário a um grupo, podemos primeiro verificar se ele já é membro deste grupo obtendo uma lista de membros do grupo ou composição do próprio usuário.

Consultar a associação ao grupo de um usuário específico não será recursiva e mostrará apenas a associação direta. A inclusão de um usuário em um grupo pai que já possui um grupo filho do qual o usuário é membro será bem-sucedida.

Conclusão

Resta enviar ao usuário a senha da nova conta. Fazemos isso por SMS, e enviamos informações gerais com instruções e login para um email pessoal, que, juntamente com um número de telefone, foi fornecido pelo departamento de recrutamento. Como alternativa, você pode economizar dinheiro e enviar sua senha para um chat secreto por telegrama, que também pode ser considerado o segundo fator (MacBooks serão uma exceção).

Obrigado por ler até o fim. Ficarei feliz em ver sugestões para melhorar o estilo de redação de artigos e desejo que você detecte menos erros ao escrever scripts =)

Lista de links que podem ser tematicamente úteis ou simplesmente responder a perguntas:

Fonte: habr.com

Adicionar um comentário