Привет!
本文將介紹如何實作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
來源: www.habr.com