¡Hola!
En este artículo se describirá la implementación de la interacción de PowerShell con la API de Google para realizar manipulaciones con los usuarios de G Suite.
En la organización utilizamos varios servicios internos y en la nube. En su mayoría, la autorización en ellos se reduce a Google o Active Directory, entre los cuales no podemos mantener una réplica, por lo que al ingresar un nuevo empleado, es necesario crear/activar una cuenta en estos dos sistemas. Para automatizar el proceso, decidimos escribir un script que recopila información y la envía a ambos servicios.
Autorización
Al redactar los requisitos, decidimos utilizar la autenticación de personas reales administradores, lo que simplifica el análisis de las acciones ante cambios masivos accidentales o intencionados.
Para la autenticación y autorización, la API de Google utiliza el protocolo OAuth 2.0. Los escenarios de uso y una descripción más detallada se pueden ver aquí: .
Elegí un escenario que se utiliza para la autenticación en aplicaciones de escritorio. También hay una opción para utilizar una cuenta de servicio, que no requiere acciones adicionales por parte del usuario.
La imagen a continuación es una descripción esquemática del escenario elegido de la página de Google.

- Primero, dirigimos al usuario a la página de autenticación de su cuenta de Google, especificando en los parámetros GET:
- el identificador de la aplicación
- las áreas a las que la aplicación necesita acceso
- la dirección a la que el usuario será redirigido después de completar el procedimiento
- el método con el que actualizaremos el token
- el código de verificación
- el formato para transmitir el código de verificación
- Después de completar la autorización, el usuario será redirigido a la página especificada en la primera solicitud, con un error o un código de autorización transmitido por los parámetros GET.
- La aplicación (script) necesitará obtener estos parámetros y, en caso de recibir un código, ejecutar la siguiente solicitud para obtener tokens.
- En una solicitud correcta, la API de Google devuelve:
- un token de acceso, con el que podemos hacer solicitudes
- la duración de este token
- un token de actualización, necesario para renovar el token de acceso.
Primero, necesitamos ir a la consola de Google API: , seleccione la aplicación necesaria y en la sección Credenciales cree un identificador de cliente OAuth. Allí mismo (o más tarde, en las propiedades del identificador creado) debe especificar las direcciones a las que se permite la redirección. En nuestro caso, serán varias entradas localhost con diferentes puertos (ver más adelante).
Para facilitar la lectura del algoritmo del script, se puede extraer los primeros pasos en una función separada que devolverá los tokens de Access y refresh para la aplicación:
$client_secret = 'Nuestro Secreto de Cliente'
$client_id = 'Nuestro ID de Cliente'
function Get-GoogleAuthToken {
if (-not [System.Net.HttpListener]::IsSupported) {
"HttpListener no es compatible."
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 el 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 {
"No se pudo iniciar el listener."
exit 1
}
while (($code -eq $null)) {
$context = $listener.GetContext()
Write-Host "Conexión aceptada" -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("Ahora puedes cerrar esta pestaña del 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
Establecemos el Client ID y el Client Secret obtenidos en las propiedades del identificador de cliente OAuth, y el code verifier es una cadena de entre 43 y 128 caracteres que debe generarse aleatoriamente a partir de caracteres no reservados: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~".
Este código será enviado nuevamente. Esto elimina la vulnerabilidad en la que un atacante podría interceptar la respuesta que regresa como redirección después de que el usuario haya autorizado.
Puedes enviar el code verifier en la solicitud actual de forma abierta (lo que lo hace inútil – esto solo es adecuado para sistemas que no admiten SHA256), o creando un hash según el algoritmo SHA256, que debe codificarse en BASE64Url (difiere de Base64 en dos caracteres de la tabla) y eliminar el símbolo de final de línea: =.
A continuación, necesitamos comenzar a escuchar http en la máquina local para recibir la respuesta después de la autorización, que regresará mediante redirección.
Las tareas administrativas se llevan a cabo en un servidor especial, no podemos descartar la posibilidad de que varios administradores ejecuten el script al mismo tiempo, por lo que seleccionará aleatoriamente un puerto para el usuario actual, pero he indicado puertos predefinidos, ya que también necesitan ser añadidos como confiables en la consola API.
access_type=offline significa que la aplicación puede actualizar el token caducado por sí misma sin la interacción del usuario con el navegador,
response_type=code especifica el formato en que regresará el código (referencia a un método antiguo de autorización, donde el usuario copiaba y pegaba el código del navegador al script),
scope indica las áreas y el tipo de acceso. Deben separarse con espacios o (de acuerdo con el URL Encoding). La lista de áreas de acceso con tipos se puede ver aquí: .
Después de recibir el código de autorización, la aplicación devolverá al navegador un mensaje de cierre, dejará de escuchar el puerto y enviará una solicitud POST para obtener el token. En ella indicamos el id y el secret previamente establecidos desde la consola de API, la dirección a la que será redirigido el usuario y grant_type según la especificación del protocolo.
En respuesta, recibiremos el Access token, su tiempo de validez en segundos y el Refresh token, con el cual podemos actualizar el Access token.
La aplicación debe almacenar los tokens en un lugar seguro con un largo plazo de almacenamiento, por lo que, mientras no revoquemos el acceso obtenido, no se devolverá un refresh token a la aplicación. Al final, añadí una solicitud para revocar el token, si la aplicación no se completó con éxito y no se devolvió el refresh token, comenzará el procedimiento nuevamente (consideramos inseguro almacenar tokens localmente en el terminal, y no queremos complicar con criptografía o abrir el navegador con frecuencia).
do {
$token_result = Get-GoogleAuthToken
$token = $token_result.access_token
if ($token_result.refresh_token -eq $null) {
Write-Host ("La sesión no está destruida. Revocando el 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 ya habrás notado, al revocar el token se utiliza Invoke-WebRequest. A diferencia de Invoke-RestMethod, no devuelve los datos obtenidos en un formato utilizable y muestra el estado de la solicitud.
A continuación, el script pedirá que ingreses el nombre y apellido del usuario, generando el nombre de usuario + correo electrónico.
Consultas
Los siguientes serán los pedidos: primero es necesario verificar si ya existe un usuario con ese nombre de usuario para decidir si formar uno nuevo o incluir el existente.
Decidí implementar todas las solicitudes en el formato de una sola función con selección, utilizando switch:
función 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'] + " ahora es miembro de " + $query['groupkey'])
}
}
}
}En cada solicitud hay que enviar el encabezado Authorization, que contiene el tipo de token y el token de acceso en sí. Actualmente, el tipo de token siempre es Bearer. Dado que necesitamos verificar que el token no haya expirado y actualizarlo después de una hora desde su emisión, he indicado una solicitud a otra función que devuelve el token de acceso. Este mismo fragmento de código está al principio del script al obtener el primer token de acceso:
función 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 caducado. Refrescando..."
$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
}Verificación de existencia de 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' - suspendido: '$user.Suspended
$GAresult = $user
}
if ($GAresult) {
$return = $GAresult
} else {$return = 'gg'}
return $return
}La consulta de email: $query pedirá a la API buscar un usuario con este email, incluyendo alias. También se puede usar un comodín: =, :, :{PREFIX}*.
Para obtener datos se utiliza el método de consulta GET, para insertar datos (crear cuenta o agregar miembro a un grupo) – POST, para actualizar datos existentes – PUT, para eliminar un registro (por ejemplo, un miembro de un grupo) – DELETE.
El script también pedirá un número de teléfono (cadena no validada) y sobre la inclusión en el grupo regional de envío. Determina qué unidad organizativa debe tener el usuario en base a la OU seleccionada en Active Directory y generará una contraseña:
do {
$phone = Read-Host "Teléfono en formato +7xxxxxx"
} while (-not $phone)
do {
$moscow = Read-Host "¿En la oficina de Moscú? (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 "Se creará en /Team delivery"
$orgunit = " / Team delivery"
}
$Password = -join (48..57 + 65..90 + 97..122 | Get-Random -Count 12 | % {[char]$_})+"*Ba"
Y luego comienza a manipular la cuenta:
$query = @{
email = $email
givenName = $firstname
familyName = $lastname
password = $password
phone = $phone
orgunit = $orgunit
}
if ($GMailExist) {
Write-Host "Iniciando cambio de cuenta" -f mag
(GoogleQuery 'UpdateAccount' $query) | fl
write-host "No olvides verificar grupos para el $Username habilitado en Google."
} else {
Write-Host "Iniciando creación de cuenta" -f mag
(GoogleQuery 'CreateAccount' $query) | fl
}
if ($moscow -eq "y"){
write-host "Añadiendo al grupo moscowoffice"
$query = @{
groupkey = 'moscowoffice@rocketguys.com'
email = $email
}
(GoogleQuery 'AddMember' $query) | fl
}
Las funciones de actualización y creación de cuenta tienen una sintaxis similar, no todos los campos adicionales son obligatorios; en la sección de números de teléfono, es necesario especificar un array que puede contener desde un registro con el número y su tipo.
Para evitar errores al agregar un usuario a un grupo, podemos verificar previamente si ya está en ese grupo, obteniendo la lista de miembros del grupo o la composición del propio usuario.
La solicitud de la composición de los grupos de un usuario determinado no será recursiva y mostrará solo el Membership inmediato. Incluir al usuario en el grupo padre, en el cual ya está el grupo hijo del que es miembro el usuario, será exitoso.
Conclusión
Solo queda enviar al usuario la contraseña de la nueva cuenta. Hacemos esto a través de SMS, y la información general con la instrucción y el login se envía al correo personal, que fue proporcionado junto con el número de teléfono por el departamento de selección de personal. Como opción alternativa, se puede ahorrar dinero y enviar la contraseña en un chat secreto de Telegram, lo que también se puede considerar como un segundo factor (las MacBooks serán una excepción).
Gracias por leer hasta el final. Estaré encantado de ver sugerencias para mejorar el estilo de escribir artículos y les deseo cometer menos errores al escribir scripts =)
Lista de enlaces que pueden ser temáticamente útiles o simplemente responder a las preguntas surgidas:
Fuente: habr.com
