Creación de usuarios de Google desde PowerShell mediante API

Ola!

Este artigo describirá a implementación da interacción de PowerShell coa API de Google para manipular os usuarios de G Suite.

Usamos varios servizos internos e na nube en toda a organización. Na súa maior parte, a autorización neles redúcese a Google ou Active Directory, entre os que non podemos manter unha réplica; polo tanto, cando sae un novo empregado, cómpre crear/habilitar unha conta nestes dous sistemas. Para automatizar o proceso, decidimos escribir un script que recolla información e a envíe a ambos os servizos.

Autorización

Á hora de elaborar os requisitos, decidimos utilizar administradores humanos reais para a autorización, o que simplifica a análise das accións en caso de cambios masivos accidentais ou intencionados.

As API de Google usan o protocolo OAuth 2.0 para a autenticación e autorización. Pódense atopar casos de uso e descricións máis detalladas aquí: Usando OAuth 2.0 para acceder ás API de Google.

Escollín o script que se usa para a autorización nas aplicacións de escritorio. Tamén hai unha opción para utilizar unha conta de servizo, que non require movementos innecesarios do usuario.

A seguinte imaxe é unha descrición esquemática do escenario seleccionado na páxina de Google.

Creación de usuarios de Google desde PowerShell mediante API

  1. En primeiro lugar, enviamos ao usuario á páxina de autenticación da conta de Google, especificando os parámetros GET:
    • ID da aplicación
    • áreas ás que precisa acceder a aplicación
    • o enderezo ao que será redirixido o usuario tras completar o procedemento
    • a forma en que actualizaremos o token
    • Código de seguridade
    • formato de transmisión do código de verificación

  2. Despois de completar a autorización, o usuario será redirixido á páxina especificada na primeira solicitude, cun erro ou código de autorización pasado polos parámetros GET
  3. A aplicación (script) terá que recibir estes parámetros e, se recibiu o código, realizar a seguinte solicitude para obter tokens
  4. Se a solicitude é correcta, a API de Google devolve:
    • Token de acceso co que podemos facer solicitudes
    • O período de validez deste token
    • Requírese o token de actualización para actualizar o token de acceso.

Primeiro tes que ir á consola da API de Google: Credenciais: Google API Console, seleccione a aplicación desexada e na sección Credenciais cree un identificador OAuth de cliente. Alí (ou máis tarde, nas propiedades do identificador creado) cómpre especificar os enderezos aos que se permite o redireccionamento. No noso caso, estas serán varias entradas localhost con portos diferentes (ver a continuación).

Para que sexa máis cómodo ler o algoritmo de script, pode mostrar os primeiros pasos nunha función separada que devolverá os tokens de acceso e de actualización para a aplicación:

$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

Establecemos o ID do cliente e o segredo do cliente obtidos nas propiedades do identificador do cliente de OAuth, e o verificador de código é unha cadea de 43 a 128 caracteres que debe xerarse aleatoriamente a partir de caracteres sen reserva: [AZ] / [az] / [0-9 ] / "-" / "." / "_" / "~".

Este código será transmitido de novo. Elimina a vulnerabilidade na que un atacante podería interceptar unha resposta devolta como redirección despois da autorización do usuario.
Podes enviar un verificador de código na solicitude actual en texto claro (o que fai que non teña sentido; isto só é adecuado para sistemas que non admiten SHA256) ou creando un hash usando o algoritmo SHA256, que debe codificarse en BASE64Url (diferente de Base64 por dous caracteres de táboa) e eliminando as terminacións de liña de caracteres: =.

A continuación, necesitamos comezar a escoitar http na máquina local para recibir unha resposta despois da autorización, que será devolta como unha redirección.

As tarefas administrativas realízanse nun servidor especial, non podemos descartar a posibilidade de que varios administradores executen o script ao mesmo tempo, polo que seleccionará aleatoriamente un porto para o usuario actual, pero especifiquei portos predefinidos porque tamén deben engadirse como de confianza na consola da API.

access_type=sen conexión significa que a aplicación pode actualizar un token caducado por si mesma sen a interacción do usuario co navegador,
tipo_resposta=código establece o formato de como se devolverá o código (unha referencia ao método de autorización antigo, cando o usuario copiou o código do navegador ao script),
ámbito indica o ámbito e o tipo de acceso. Deben estar separados por espazos ou por % 20 (segundo a codificación de URL). Aquí pódese ver unha lista de zonas de acceso con tipos: Ámbitos de OAuth 2.0 para as API de Google.

Despois de recibir o código de autorización, a aplicación devolverá unha mensaxe de peche ao navegador, deixará de escoitar o porto e enviará unha solicitude POST para obter o token. Indicamos nel o id e o segredo previamente especificados da API da consola, o enderezo ao que será redirixido o usuario e grant_type segundo a especificación do protocolo.

Como resposta, recibiremos un token de acceso, o seu período de validez en segundos e un token de actualización, co que podemos actualizar o token de acceso.

A aplicación debe almacenar os tokens nun lugar seguro e cunha longa vida útil, polo que ata que non revoguemos o acceso recibido, a aplicación non devolverá o token de actualización. Ao final, engadín unha solicitude para revogar o token; se a aplicación non se completou correctamente e non se devolveu o token de actualización, iniciarase de novo o procedemento (consideramos que non era seguro almacenar tokens localmente no terminal e non non quero complicar as cousas coa criptografía ou abrir o navegador con frecuencia).

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 xa notaches, ao revogar un token, úsase Invoke-WebRequest. A diferenza de Invoke-RestMethod, non devolve os datos recibidos nun formato utilizable e mostra o estado da solicitude.

A continuación, o script pídelle que introduza o nome e apelidos do usuario, xerando un inicio de sesión + correo electrónico.

Solicitudes

As seguintes solicitudes serán: en primeiro lugar, cómpre comprobar se xa existe un usuario co mesmo inicio de sesión para obter unha decisión sobre a creación dun novo ou a habilitación do actual.

Decidín implementar todas as solicitudes no formato dunha función cunha selección, 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'])
      }
    }
  }
}

En cada solicitude, cómpre enviar unha cabeceira de autorización que conteña o tipo de token e o propio token de acceso. Actualmente, o tipo de ficha é sempre Bearer. Porque necesitamos comprobar que o token non caducou e actualizalo despois dunha hora desde o momento en que se emitiu, especifiquei unha solicitude para outra función que devolve un token de acceso. A mesma peza de código está ao comezo do script cando recibe o primeiro token de acceso:

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
}

Comprobando a existencia do inicio de sesión:

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 solicitude email:$query pedirá á API que busque un usuario con exactamente ese correo electrónico, incluídos os alias. Tamén podes usar comodíns: =, :, :{PREFIXO}*.

Para obter datos, use o método de solicitude GET, para inserir datos (crear unha conta ou engadir un membro a un grupo) - POST, para actualizar os datos existentes - PUT, para eliminar un rexistro (por exemplo, un membro dun grupo) - ELIMINAR.

O script tamén solicitará un número de teléfono (unha cadea non validada) e a inclusión nun grupo de distribución rexional. Decide que unidade organizativa debe ter o usuario en función da OU de Active Directory seleccionada e proporciona un contrasinal:

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ón comeza 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 funcións para actualizar e crear unha conta teñen unha sintaxe similar; non todos os campos adicionais son obrigatorios; na sección con números de teléfono, cómpre especificar unha matriz que pode conter ata un rexistro co número e o seu tipo.

Para non recibir un erro ao engadir un usuario a un grupo, primeiro podemos comprobar se xa é membro deste grupo obtendo unha lista de membros do grupo ou composición do propio usuario.

Consultar a pertenza ao grupo dun usuario específico non será recursiva e só mostrará a pertenza directa. A inclusión dun usuario nun grupo principal que xa teña un grupo fillo do que o usuario é membro terá éxito.

Conclusión

Só queda enviarlle ao usuario o contrasinal da nova conta. Facemos isto por SMS e enviamos información xeral con instrucións e inicio de sesión a un correo electrónico persoal que, xunto cun número de teléfono, foi proporcionado polo departamento de contratación. Como alternativa, podes aforrar cartos e enviar o teu contrasinal a un chat de telegrama secreto, que tamén pode considerarse o segundo factor (MacBooks será unha excepción).

Grazas por ler ata o final. Estarei encantado de ver suxestións para mellorar o estilo de escribir artigos e desexar que detectes menos erros ao escribir guións =)

Lista de ligazóns que poden ser temáticamente útiles ou simplemente responder a preguntas:

Fonte: www.habr.com

Engadir un comentario