Hi!
บทความนี้จะอธิบายการใช้งานการโต้ตอบของ PowerShell กับ Google API เพื่อจัดการผู้ใช้ G Suite
เราใช้บริการภายในและคลาวด์หลายแห่งทั่วทั้งองค์กร โดยส่วนใหญ่ การอนุญาตจะตกอยู่ที่ Google หรือ Active Directory ซึ่งเราไม่สามารถรักษาแบบจำลองไว้ได้ ดังนั้น เมื่อพนักงานใหม่ลาออก คุณจะต้องสร้าง/เปิดใช้งานบัญชีในทั้งสองระบบนี้ เพื่อให้กระบวนการเป็นอัตโนมัติ เราตัดสินใจเขียนสคริปต์ที่รวบรวมข้อมูลและส่งไปยังบริการทั้งสอง
การอนุญาต
เมื่อร่างข้อกำหนด เราตัดสินใจใช้ผู้ดูแลระบบที่เป็นมนุษย์จริงในการอนุญาต ซึ่งช่วยลดความยุ่งยากในการวิเคราะห์การดำเนินการในกรณีที่มีการเปลี่ยนแปลงครั้งใหญ่โดยไม่ได้ตั้งใจหรือโดยเจตนา
Google API ใช้โปรโตคอล OAuth 2.0 สำหรับการตรวจสอบสิทธิ์และการอนุญาต กรณีการใช้งานและคำอธิบายโดยละเอียดเพิ่มเติมสามารถพบได้ที่นี่:
ฉันเลือกสคริปต์ที่ใช้สำหรับการอนุญาตในแอปพลิเคชันเดสก์ท็อป นอกจากนี้ยังมีตัวเลือกในการใช้บัญชีบริการซึ่งผู้ใช้ไม่ต้องการการเคลื่อนไหวที่ไม่จำเป็น
รูปภาพด้านล่างเป็นคำอธิบายแผนผังของสถานการณ์ที่เลือกจากเพจ Google
- ขั้นแรก เราจะส่งผู้ใช้ไปที่หน้าการตรวจสอบบัญชี Google โดยระบุพารามิเตอร์ GET:
- รหัสแอปพลิเคชัน
- พื้นที่ที่แอปพลิเคชันต้องการเข้าถึง
- ที่อยู่ที่ผู้ใช้จะถูกเปลี่ยนเส้นทางหลังจากเสร็จสิ้นขั้นตอน
- วิธีที่เราจะอัปเดตโทเค็น
- รหัสรักษาความปลอดภัย
- รูปแบบการส่งรหัสยืนยัน
- หลังจากการอนุญาตเสร็จสิ้น ผู้ใช้จะถูกเปลี่ยนเส้นทางไปยังหน้าที่ระบุในคำขอแรก โดยมีข้อผิดพลาดหรือรหัสการอนุญาตที่ส่งผ่านพารามิเตอร์ GET
- แอปพลิเคชัน (สคริปต์) จะต้องได้รับพารามิเตอร์เหล่านี้ และหากได้รับรหัส ให้ส่งคำขอต่อไปนี้เพื่อรับโทเค็น
- หากคำขอถูกต้อง Google API จะส่งคืน:
- โทเค็นการเข้าถึงที่เราใช้ในการร้องขอได้
- ระยะเวลาที่ถูกต้องของโทเค็นนี้
- ต้องมีโทเค็นการรีเฟรชเพื่อรีเฟรชโทเค็นการเข้าถึง
ก่อนอื่นคุณต้องไปที่คอนโซล 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 และผู้ตรวจสอบโค้ดคือสตริงที่มีความยาว 43 ถึง 128 อักขระที่ต้องสร้างขึ้นแบบสุ่มจากอักขระที่ไม่ได้สงวนไว้: [AZ] / [az] / [0-9 ] / "-" / "." / "_" / "~"
รหัสนี้จะถูกส่งอีกครั้ง ช่วยขจัดช่องโหว่ที่ผู้โจมตีสามารถสกัดกั้นการตอบสนองที่ส่งคืนเป็นการเปลี่ยนเส้นทางหลังจากผู้ใช้ให้สิทธิ์แล้ว
คุณสามารถส่งตัวตรวจสอบรหัสในคำขอปัจจุบันเป็นข้อความที่ชัดเจน (ซึ่งทำให้ไม่มีความหมาย - เหมาะสำหรับระบบที่ไม่รองรับ SHA256 เท่านั้น) หรือโดยการสร้างแฮชโดยใช้อัลกอริทึม SHA256 ซึ่งจะต้องเข้ารหัสใน BASE64Url (ต่างกัน จาก Base64 ด้วยอักขระตารางสองตัว) และลบการสิ้นสุดบรรทัดอักขระ: =
ต่อไป เราต้องเริ่มฟัง http บนเครื่องท้องถิ่นเพื่อรับการตอบกลับหลังจากการอนุญาต ซึ่งจะถูกส่งกลับเป็นการเปลี่ยนเส้นทาง
งานด้านการดูแลระบบจะดำเนินการบนเซิร์ฟเวอร์พิเศษ เราไม่สามารถตัดความเป็นไปได้ที่ผู้ดูแลระบบหลายคนจะเรียกใช้สคริปต์ในเวลาเดียวกัน ดังนั้นมันจะสุ่มเลือกพอร์ตสำหรับผู้ใช้ปัจจุบัน แต่ฉันระบุพอร์ตที่กำหนดไว้ล่วงหน้าเพราะ จะต้องเพิ่มว่าเชื่อถือได้ในคอนโซล API ด้วย
access_type=ออฟไลน์ หมายความว่าแอปพลิเคชันสามารถอัปเดตโทเค็นที่หมดอายุได้ด้วยตัวเองโดยที่ผู้ใช้ไม่ต้องโต้ตอบกับเบราว์เซอร์
ตอบ_ประเภท=รหัส กำหนดรูปแบบวิธีการส่งคืนรหัส (อ้างอิงถึงวิธีการอนุญาตแบบเก่าเมื่อผู้ใช้คัดลอกรหัสจากเบราว์เซอร์ลงในสคริปต์)
ขอบเขต ระบุขอบเขตและประเภทของการเข้าถึง ต้องคั่นด้วยช่องว่างหรือ %20 (ตามการเข้ารหัส URL) รายชื่อพื้นที่การเข้าถึงพร้อมประเภทต่างๆ สามารถดูได้ที่นี่:
หลังจากได้รับรหัสอนุญาตแล้ว แอปพลิเคชันจะส่งข้อความปิดไปยังเบราว์เซอร์ หยุดฟังบนพอร์ต และส่งคำขอ POST เพื่อรับโทเค็น เราระบุรหัสและความลับที่ระบุก่อนหน้านี้จากคอนโซล API ที่อยู่ที่ผู้ใช้จะถูกเปลี่ยนเส้นทางและ 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
}
ดังที่คุณสังเกตเห็นแล้ว เมื่อเพิกถอนโทเค็น จะใช้ Inrigg-WebRequest ต่างจาก Invivo-RestMethod ตรงที่จะไม่ส่งคืนข้อมูลที่ได้รับในรูปแบบที่ใช้งานได้และแสดงสถานะของคำขอ
ถัดไป สคริปต์จะขอให้คุณป้อนชื่อและนามสกุลของผู้ใช้เพื่อสร้างข้อมูลเข้าสู่ระบบ + อีเมล
การร้องขอ
คำขอถัดไปจะเป็น - ก่อนอื่นคุณต้องตรวจสอบว่ามีผู้ใช้ที่มีการเข้าสู่ระบบเดียวกันอยู่แล้วหรือไม่ เพื่อรับการตัดสินใจในการสร้างใหม่หรือเปิดใช้งานผู้ใช้ปัจจุบัน
ฉันตัดสินใจที่จะดำเนินการคำขอทั้งหมดในรูปแบบของฟังก์ชันเดียวโดยมีตัวเลือกโดยใช้สวิตช์:
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'])
}
}
}
}
ในคำขอแต่ละรายการ คุณต้องส่งส่วนหัวการอนุญาตที่มีประเภทโทเค็นและโทเค็นการเข้าถึงนั้นเอง ปัจจุบันประเภทโทเค็นจะเป็นผู้ถือเสมอ เพราะ เราจำเป็นต้องตรวจสอบว่าโทเค็นยังไม่หมดอายุและอัปเดตหลังจากผ่านไปหนึ่งชั่วโมงนับจากเวลาที่ออก ฉันระบุคำขอสำหรับฟังก์ชันอื่นที่ส่งคืนโทเค็นการเข้าถึง รหัสชิ้นเดียวกันอยู่ที่จุดเริ่มต้นของสคริปต์เมื่อได้รับโทเค็นการเข้าถึงแรก:
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
}
ฟังก์ชั่นสำหรับการอัปเดตและสร้างบัญชีมีไวยากรณ์ที่คล้ายกัน ไม่จำเป็นต้องกรอกฟิลด์เพิ่มเติมทั้งหมด ในส่วนที่มีหมายเลขโทรศัพท์คุณจะต้องระบุอาร์เรย์ที่สามารถมีได้สูงสุดหนึ่งรายการพร้อมหมายเลขและประเภทของมัน
เพื่อไม่ให้เกิดข้อผิดพลาดเมื่อเพิ่มผู้ใช้ในกลุ่ม อันดับแรกเราสามารถตรวจสอบได้ว่าเขาเป็นสมาชิกของกลุ่มนี้อยู่แล้วหรือไม่ โดยรับรายชื่อสมาชิกกลุ่มหรือองค์ประกอบจากผู้ใช้เอง
การสืบค้นความเป็นสมาชิกกลุ่มของผู้ใช้รายใดรายหนึ่งจะไม่เกิดซ้ำและจะแสดงเฉพาะการเป็นสมาชิกโดยตรงเท่านั้น รวมผู้ใช้ในกลุ่มผู้ปกครองที่มีกลุ่มลูกที่ผู้ใช้เป็นสมาชิกอยู่แล้วจะสำเร็จ
ข้อสรุป
สิ่งที่เหลืออยู่คือส่งรหัสผ่านให้กับผู้ใช้สำหรับบัญชีใหม่ เราทำเช่นนี้ผ่านทาง SMS และส่งข้อมูลทั่วไปพร้อมคำแนะนำและเข้าสู่ระบบไปยังอีเมลส่วนตัว ซึ่งแผนกจัดหางานให้ไว้พร้อมกับหมายเลขโทรศัพท์ อีกทางเลือกหนึ่ง คุณสามารถประหยัดเงินและส่งรหัสผ่านของคุณไปที่การแชททางโทรเลขลับ ซึ่งถือได้ว่าเป็นปัจจัยที่สองด้วย (MacBooks จะเป็นข้อยกเว้น)
ขอบคุณที่อ่านจนจบ ยินดีรับฟังข้อเสนอแนะในการปรับปรุงรูปแบบการเขียนบทความ และขอให้พบข้อผิดพลาดในการเขียนสคริปต์น้อยลงครับ =)
รายการลิงก์ที่อาจเป็นประโยชน์ตามหัวข้อหรือเพียงตอบคำถาม:
OAuth 2.0 สำหรับแอปบนมือถือและเดสก์ท็อป การใช้ OAuth 2.0 สำหรับแอปพลิเคชันเว็บเซิร์ฟเวอร์ รหัสพิสูจน์สำหรับการแลกเปลี่ยนรหัสโดยไคลเอนต์สาธารณะ OAuth สร้างตัวอักษรสุ่มด้วย PowerShell ตาราง ASCII และคำอธิบาย PowerShell: รับค่าแฮชสำหรับสตริง เข้ารหัส/ถอดรหัส Base64Url การเข้ารหัส Base64 กับการเข้ารหัส Base64url เรียกใช้-RestMethod ใน PowerShell 5.1 ไม่ได้รับโทเค็นการรีเฟรชแม้ว่า access_type จะออฟไลน์ในขั้นตอนที่ 1 เกี่ยวกับตัวดำเนินการเปรียบเทียบ API ไดเรกทอรี: บัญชีผู้ใช้ ค้นหาผู้ใช้ API ไดเรกทอรี: กลุ่ม การจัดการข้อผิดพลาดสำหรับการเรียกใช้-RestMethod - Powershell
ที่มา: will.com