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

Olá!

Neste artigo, será descrita a implementação da interação do PowerShell com a API do Google para realizar operações com usuários do G Suite.

Na organização, utilizamos vários serviços internos e baseados na nuvem. Na maioria das vezes, a autenticação nesses serviços é feita via Google ou Active Directory, entre os quais não conseguimos manter uma replicação, assim, quando um novo funcionário entra, é necessário criar/ativar uma conta nesses dois sistemas. Para automatizar o processo, decidimos escrever um script que coleta informações e as envia para ambos os serviços.

Autorização

Ao elaborar os requisitos, decidimos usar para autenticação pessoas reais que atuam como administradores, o que simplifica a análise de ações em caso de alterações em massa acidentais ou intencionais.

Para autenticação e autorização, a API do Google utiliza o protocolo OAuth 2.0. Os casos de uso e uma descrição mais detalhada podem ser encontrados aqui: Usando OAuth 2.0 para Acessar APIs do Google.

Escolhi o cenário que é usado para autenticação em aplicativos de desktop. Também há a opção de usar uma conta de serviço, que não requer ações adicionais por parte do usuário.

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

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

  1. Primeiro, encaminhamos o usuário para a página de autenticação da conta do Google, especificando nos parâmetros GET:
    • o identificador do aplicativo
    • as áreas às quais o aplicativo precisa de acesso
    • o endereço para o qual o usuário será redirecionado após a conclusão do procedimento
    • o método que usaremos para atualizar o token
    • o código de verificação
    • o formato de envio do código de verificação

  2. Após a conclusão da autorização, o usuário será redirecionado para a página especificada na primeira solicitação, com um erro ou código de autorização sendo passados nos parâmetros GET.
  3. O aplicativo (script) precisará obter esses parâmetros e, no caso de receber o código, realizar a próxima solicitação para obter tokens.
  4. Em uma solicitação correta, a API do Google retorna:
    • Um token de acesso, com o qual podemos fazer solicitações
    • A validade desse token
    • Um token de atualização, necessário para renovar o token de acesso.

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

Para facilitar a leitura do algoritmo do script, você pode mover os primeiros passos para uma função separada, que retornará os tokens de Access e refresh para o aplicativo:

$client_secret = 'Nosso Client Secret'
$client_id = 'Nosso Client ID'
function Get-GoogleAuthToken {
  if (-not [System.Net.HttpListener]::IsSupported) {
    "HttpListener não é suportado."
    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 "Iniciando o navegador..."
  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 {
    "Não foi possível iniciar o listener."
    exit 1
  }
  while (($code -eq $null)) {
    $context = $listener.GetContext()
    Write-Host "Conexão aceita" -f 'mag'
    $url = $context.Request.RawUrl
    $code = $url.split('?')[1].split('=')[1].split('&')[0]
    if ($url.split('?')[1].split('=')[0] -eq 'error') {
      Write-Host "Erro!"$code -f 'red'
      $buffer = [System.Text.Encoding]::UTF8.GetBytes("Erro!"+$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("Agora você pode fechar esta aba do navegador.")
    $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

Nós definimos o Client ID e o Client Secret, obtidos nas propriedades do identificador do cliente OAuth, e o code verifier – que é uma string de 43 a 128 caracteres, gerada aleatoriamente a partir de caracteres não reservados: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~".

Em seguida, este código será transmitido novamente. Ele elimina a vulnerabilidade em que um invasor pode interceptar a resposta retornada por redirecionamento após a autorização do usuário.
O código do verificador pode ser enviado no pedido atual em texto aberto (o que o torna sem sentido — isso é adequado apenas para sistemas que não suportam SHA256), ou criando um hash com o algoritmo SHA256, que deve ser codificado em BASE64Url (diferente do Base64 por dois caracteres na tabela) e remover o caractere de término de linha: =.

Em seguida, precisamos começar a escutar http na máquina local para obter a resposta após a autorização, que virá por meio de um redirecionamento.

As tarefas administrativas são executadas em um servidor especial; não podemos excluir a possibilidade de que vários administradores executem o script ao mesmo tempo, então ele escolherá aleatoriamente uma porta para o usuário atual, mas eu indiquei portas predefinidas, pois elas também precisam ser adicionadas como confiáveis no console da API.

access_type=offline significa que o aplicativo pode atualizar o token expirado por conta própria, sem a interação do usuário com o navegador,
response_type=code define o formato de como o código será retornado (referência ao método antigo de autenticação, onde o usuário copiava e colava o código do navegador no script),
scope indica as áreas e o tipo de acesso. Devem ser separados por espaços ou (de acordo com a codificação de URL). A lista de áreas de acesso com os tipos pode ser vista aqui: Escopos OAuth 2.0 para APIs do Google.

Após receber o código de autorização, o aplicativo retornará uma mensagem de fechamento no navegador, cessará a escuta da porta e enviará um pedido POST para obter o token. Nele, indicamos os IDs e secrets previamente definidos no console da API, o endereço para onde o usuário será redirecionado e grant_type de acordo com a especificação do protocolo.

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

O aplicativo deve armazenar os tokens em um local seguro com um longo período de validade; portanto, enquanto não revocarmos o acesso obtido, o aplicativo não receberá o token de atualização. No final, adicionei uma solicitação para revogar o token, caso o aplicativo tenha sido encerrado sem sucesso e o token de atualização não tenha sido retornado; ele começará o procedimento novamente (consideramos inseguro armazenar tokens localmente no terminal, e não queremos complicar com criptografia ou abrir o navegador com frequência).

faça {
  $token_result = Get-GoogleAuthToken
  $token = $token_result.access_token
  if ($token_result.refresh_token -eq $null) {
    Write-Host ("A sessão não foi encerrada. Revogando token...")
    Invoke-WebRequest -Uri ("https://accounts.google.com/o/oauth2/revoke?token="+$token)
  }
} enquanto ($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á notou, ao revogar o token, utiliza-se o Invoke-WebRequest. Diferentemente do Invoke-RestMethod, ele não retorna os dados obtidos em um formato utilizável e mostra o status da solicitação.

Em seguida, o script pedirá que o usuário insira seu nome e sobrenome, gerando login + email.

Consultas

As próximas solicitações envolverão – em primeiro lugar, precisamos verificar se já existe um usuário com esse login para decidir se formamos um novo ou incluímos o atual.

Decidi implementar todas as solicitações em uma única 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']+" agora é um membro de "+$query['groupkey'])
      }
    }
  }
}

Em cada solicitação, é necessário enviar o cabeçalho Authorization, que contém o tipo de token e o próprio token de acesso. No momento, o tipo de token é sempre Bearer. Como precisamos verificar se o token não expirou e atualizá-lo após uma hora desde a emissão, eu incluí uma solicitação para outra função, que retorna o token de acesso. Este mesmo trecho de código está no início do script ao obter 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 Expirado. Atualizando..."
    $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
}

Verificação da 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' - suspenso: '$user.Suspended
    $GAresult = $user
  }
  if ($GAresult) {
      $return = $GAresult
  } else {$return = 'gg'}
  return $return
}

A solicitação de email: $query pedirá à API que busque um usuário exatamente com esse email, incluindo aliases. Também é possível usar um wildcard: =, :, :{PREFIX}*.

Para obtenção de dados, utiliza-se o método de solicitação GET. Para inserção de dados (criação de conta ou adição de participante a um grupo), utiliza-se o método POST. Para atualização de dados existentes, usa-se o PUT e para exclusão de registros (por exemplo, um participante de um grupo), usa-se o DELETE.

O script também pedirá o número de telefone (string não validada) e se deve incluir na lista de distribuição regional. Ele determina qual unidade organizacional deve ser atribuída ao usuário com base na OU escolhida do Active Directory e gera uma senha:

do {
  $phone = Read-Host "Telefone no formato +7хххххххх"
} while (-not $phone)
do {
    $moscow = Read-Host "No escritório de Moscovo? (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 "Será criado em \/Team delivery"
    $orgunit = "\/Team delivery"
}
$Password =  -join ( 48..57 + 65..90 + 97..122 | Get-Random -Count 12 | % {[char]$_})+"*Ba"

E em seguida começa a manipulação com a conta:

$query = @{
  email = $email
  givenName = $firstname
  familyName = $lastname
  password = $password
  phone = $phone
  orgunit = $orgunit
}
if ($GMailExist) {
  Write-Host "Iniciando a alteração da conta" -f mag
  (GoogleQuery 'UpdateAccount' $query) | fl
  write-host "Não esqueça de verificar os grupos em que $Username está incluído no Google."
} else {
  Write-Host "Iniciando a criação da conta" -f mag
  (GoogleQuery 'CreateAccount' $query) | fl
}
if ($moscow -eq "y"){
  write-host "Adicionando ao grupo moscowoffice"
  $query = @{
    groupkey = 'moscowoffice@rocketguys.com'
    email = $email
  }
  (GoogleQuery 'AddMember' $query) | fl
}

As funcionalidades de atualização e criação de contas têm uma sintaxe semelhante; nem todos os campos adicionais são obrigatórios. Na seção de números de telefone, deve-se indicar um array que pode conter de um a vários registros com o número e seu tipo.

Para evitar um erro ao adicionar um usuário a um grupo, podemos verificar previamente se ele já pertence a esse grupo, obtendo a lista de membros do grupo ou a composição do usuário.

A solicitação da composição de grupos de um determinado usuário não será recursiva e mostrará apenas a associação direta. Incluir um usuário em um grupo pai, do qual um grupo filho, do qual o usuário é membro, já faz parte será bem-sucedido.

Conclusão

Agora, precisamos enviar ao usuário a senha da nova conta. Fazemos isso por SMS, e as informações gerais com instruções e login são enviadas para o e-mail pessoal fornecido, juntamente com o número de telefone, pelo departamento de recrutamento. Como uma alternativa, podemos economizar um pouco e enviar a senha em um chat secreto do Telegram, o que também pode ser considerado como o segundo fator (exceto para MacBooks).

Obrigado por ler até o final. Fico feliz em ver sugestões para melhorar o estilo de escrita dos artigos e desejo que você tenha menos erros na escrita de scripts =)

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

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster