您好!
本文将介绍如何实现PowerShell与Google API交互来操纵G Suite用户。
我们在整个组织中使用多种内部服务和云服务。 在大多数情况下,其中的授权归结为 Google 或 Active Directory,我们无法在它们之间维护副本;因此,当新员工离职时,您需要在这两个系统中创建/启用帐户。 为了自动化该过程,我们决定编写一个脚本来收集信息并将其发送到这两个服务。
授权
在制定需求时,我们决定使用真人管理员进行授权;这简化了在发生意外或故意的大规模更改时的操作分析。
Google API 使用 OAuth 2.0 协议进行身份验证和授权。 用例和更详细的描述可以在这里找到:
我选择了用于桌面应用程序中授权的脚本。 还可以选择使用服务帐户,这不需要用户进行不必要的移动。
下图是来自Google页面的所选场景的示意性描述。
- 首先,我们将用户发送到 Google 帐户身份验证页面,并指定 GET 参数:
- 应用程序ID
- 应用程序需要访问的区域
- 完成该过程后用户将被重定向到的地址
- 我们更新令牌的方式
- 安全码
- 验证码传输格式
- 授权完成后,用户将被重定向到第一次请求指定的页面,并通过GET参数传递错误或授权码
- 应用程序(脚本)将需要接收这些参数,如果收到代码,则发出以下请求来获取令牌
- 如果请求正确,Google API 将返回:
- 我们可以使用访问令牌来发出请求
- 该token的有效期
- 刷新访问令牌所需的刷新令牌。
首先你需要进入Google API控制台:
为了更方便地阅读脚本算法,您可以在单独的函数中显示第一步,该函数将返回应用程序的访问和刷新令牌:
$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
我们在 OAuth 客户端标识符属性中设置获取的 Client ID 和 Client Secret,代码验证符是一个 43 到 128 个字符的字符串,必须从非保留字符中随机生成:[AZ] / [az] / [0-9 ] /“-”/“。” /“_”/“~”。
然后该代码将再次传输。 它消除了攻击者可以拦截用户授权后作为重定向返回的响应的漏洞。
您可以在当前请求中以明文形式发送代码验证程序(这使得它毫无意义 - 这仅适用于不支持 SHA256 的系统),或者通过使用 SHA256 算法创建哈希,该哈希必须以 BASE64Url 进行编码(不同从 Base64 增加两个表字符)并删除字符行结尾:=。
接下来,我们需要在本地机器上开始监听http,以便在授权后接收响应,该响应将以重定向的形式返回。
管理任务是在特殊的服务器上执行的,我们不排除多个管理员同时运行脚本的可能性,因此它会为当前用户随机选择一个端口,但我指定了预定义的端口,因为它们还必须在 API 控制台中添加为受信任的。
access_type=离线 意味着应用程序可以自行更新过期的令牌,而无需用户与浏览器交互,
response_type=代码 设置如何返回代码的格式(当用户将代码从浏览器复制到脚本中时,引用旧的授权方法),
范围 表示访问的范围和类型。 它们必须用空格或 %20 分隔(根据 URL 编码)。 可以在此处查看具有类型的访问区域列表:
应用程序收到授权码后,会向浏览器返回关闭消息,停止监听端口,并发送POST请求获取token。 我们在其中指示之前从控制台 API 指定的 id 和机密、用户将被重定向到的地址以及根据协议规范的 grant_type。
作为响应,我们将收到一个访问令牌(其有效期以秒为单位)和一个刷新令牌(我们可以用它来更新访问令牌)。
应用程序必须将令牌存储在具有较长保质期的安全位置,因此在我们撤销收到的访问权限之前,应用程序将不会返回刷新令牌。 最后,我添加了一个撤销令牌的请求;如果应用程序没有成功完成并且没有返回刷新令牌,它将再次启动该过程(我们认为在终端本地存储令牌是不安全的,并且我们不这样做)不想用加密技术使事情变得复杂或频繁打开浏览器)。
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 不同,它不会以可用格式返回接收到的数据并显示请求的状态。
接下来,脚本要求您输入用户的名字和姓氏,生成登录名 + 电子邮件。
请求
下一个请求将是 - 首先,您需要检查是否已存在具有相同登录名的用户,以便决定创建新用户还是启用当前用户。
我决定使用 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'])
}
}
}
}
在每个请求中,您需要发送包含令牌类型和访问令牌本身的授权标头。 目前,令牌类型始终为 Bearer。 因为我们需要检查令牌是否已过期,并在发出后一小时后更新它,我指定了对另一个返回访问令牌的函数的请求。 当接收第一个访问令牌时,相同的代码位于脚本的开头:
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 查找具有该电子邮件地址(包括别名)的用户。 您还可以使用通配符: =, :, :{前缀}*.
获取数据,使用 GET 请求方法,插入数据(创建帐户或向群组添加成员) - POST,更新现有数据 - PUT,删除记录(例如群组中的成员) -删除。
该脚本还将要求提供电话号码(未经验证的字符串)并包含在区域通讯组中。 它根据所选的 Active Directory OU 决定用户应拥有哪个组织单位并提供密码:
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
}
用于更新和创建帐户的函数具有类似的语法;并非所有附加字段都是必需的;在包含电话号码的部分中,您需要指定一个数组,该数组最多可包含一条带有号码及其类型的记录。
为了在将用户添加到组时不出现错误,我们可以首先通过从用户本人获取组成员或组成列表来检查他是否已经是该组的成员。
查询特定用户的组成员身份不会递归,只会显示直接成员身份。 将用户包含在已具有该用户所属子组的父组中将会成功。
结论
剩下的就是向用户发送新帐户的密码。 我们通过短信来完成此操作,并发送带有说明的一般信息并登录到招聘部门提供的个人电子邮件以及电话号码。 作为替代方案,您可以省钱并将密码发送到秘密电报聊天,这也可以被视为第二个因素(MacBook 除外)。
感谢您阅读到最后。 我很高兴看到改进文章写作风格的建议,并希望您在编写脚本时发现更少的错误 =)
主题上可能有用或只是回答问题的链接列表:
适用于移动和桌面应用程序的 OAuth 2.0 将 OAuth 2.0 用于 Web 服务器应用程序 OAuth 公共客户端代码交换的证明密钥 使用 PowerShell 生成随机字母 ASCII 表和说明 PowerShell:获取字符串的哈希值 编码/解码 Base64Url Base64 编码与 Base64url 编码 在 PowerShell 5.1 中调用 RestMethod 即使 access_type 在步骤 1 中处于脱机状态,也无法获取刷新令牌 关于比较运算符 目录 API:用户帐户 搜索用户 目录 API:组 Invoke-RestMethod 的错误处理 - Powershell
来源: habr.com