Здравейте!
В тази статия ще опиша как да осъществим взаимодействие между PowerShell и Google API за извършване на манипулации с потребителите на G Suite.
В организацията използваме няколко вътрешни и облачни услуги. По-голямата част от време авторизацията в тях се свежда до Google или Active Directory, между които не можем да поддържаме реплика, следователно, при постъпването на нов служител трябва да създадем/активираме акаунт в тези две системи. За автоматизация на процеса решихме да напишем скрипт, който събира информация и я изпраща и в двата сервиса.
Авторизация
При формулирането на изискванията решихме да използваме за авторизация реални лица-администратори, което опростява анализа на действията при случайни или умишлени масивни промени.
За аутентификация и авторизация Google API използва протокола OAuth 2.0. Можете да видите сценарии на приложение и по-подробно описание тук: .
Избрах сценарий, който се използва при авторизация в десктоп приложения. Има и опция да се използва служебен акаунт, който не изисква допълнителни действия от потребителя.
Снимката по-долу е схематично описание на избрания сценарий от страницата на Google.

- Първо изпращаме потребителя на страницата за аутентификация в Google акаунта, посочвайки параметрите в GET:
- идентификатор на приложението
- области, до които приложението има нужда от достъп
- адрес, на който потребителят ще бъде пренасочен след завършване на процедурата
- метод, по който ще обновяваме токена
- код за проверка
- формат на предаване на код за проверка
- След завършване на авторизацията потребителят ще бъде пренасочен към посочената в първоначалната заявка страница, с грешка или код за авторизация, предадени чрез GET параметри.
- Приложението (скриптът) трябва да получи тези параметри и, в случай на получаване на код, да направи следващата заявка за получаване на токени.
- При коректна заявка Google API връща:
- Access токен, с който можем да правим заявки
- Срок на валидност на този токен
- Refresh токен, необходим за обновяване на Access токена.
Първо трябва да отидете в конзолата на Google API: , изберете необходимото приложение и в раздела Credentials създайте идентификатор на OAuth клиента. Също така (или по-късно, в свойствата на създадения идентификатор) трябва да укажете адресите, на които е разрешено пренасочването. В нашия случай това ще бъдат няколко записа localhost с различни портове (вж. по-долу).
За да бъде по-удобно да се чете алгоритъмът на скрипта, можете да изведете първите стъпки в отделна функция, която да върне Access и refresh токените за приложението:
$client_secret = 'Нашият клиентски секрет'
$client_id = 'Нашият клиентски ID'
function Get-GoogleAuthToken {
if (-not [System.Net.HttpListener]::IsSupported) {
"HttpListener не е поддържан."
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-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 {
"Не може да се стартира слушателя."
exit 1
}
while (($code -eq $null)) {
$context = $listener.GetContext()
Write-Host "Свързването е прието" -f 'mag'
$url = $context.Request.RawUrl
$code = $url.split('?')[1].split('=')[1].split('&')[0]
if ($url.split('?')[1].split('=')[0] -eq 'error') {
Write-Host "Грешка!"$code -f 'red'
$buffer = [System.Text.Encoding]::UTF8.GetBytes("Грешка!"+$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("Сега можете да затворите тази разделка в браузъра.")
$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
Установяваме Client ID и Client Secret, получени в свойствата на идентификатора на клиента OAuth, а code verifier – това е низ с дължина от 43 до 128 символа, който трябва да бъде генериран случайно от незарезервирани символи: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~".
Този код ще бъде предаден отново. Той изключва уязвимостта, при която злоумышленник може да прихване отговора, върнат от пренасочването след авторизацията на потребителя.
Можете да изпратите code verifier в текущата заявка в открит вид (което го прави безсмислен – това е подходящо само за системи, които не поддържат SHA256), или като създадете хеш по алгоритъм SHA256, който трябва да бъде кодирани в BASE64Url (различава се от Base64 с две символа от таблицата) и да премахнете символа за край на реда: =.
След това трябва да започнем да слушаме http на локалната машина, за да получим отговор след авторизация, който ще се върне с редирект.
Административните задачи се изпълняват на специален сървър, не можем да изключим вероятността няколко администратори да стартират скрипта едновременно, затова той ще избере произволно порт за текущия потребител, но посочих предварително определени портове, тъй като ги трябва също да бъдат добавени като доверени в конзолата на API.
access_type=offline означава, че приложението може да обновява изтеклия токен само без взаимодействие на потребителя с браузера,
response_type=code определя формата на това, как ще се върне кодът (препратка към стария метод на авторизация, когато потребителят копираше кода от браузера в скрипта),
scope посочва областите и типа на достъпа. Те трябва да бъдат разделени с интервали или (съгласно URL кодиране). Списъкът с области на достъп и типове може да се види тук: .
След получаване на кода за авторизация, приложението ще върне в браузера съобщение за затваряне, ще спре да слуша порта и ще изпрати POST заявка за получаване на токен. В нея указываваме зададените по-рано id и secret от конзолата на API, адреса, на който ще бъде пренасочен потребителят и grant_type в съответствие с спецификацията на протокола.
В отговор ще получим Access токен, времето за действие на което в секунди и Refresh токен, с помощта на който можем да обновим Access токен.
Приложението трябва да съхранява токените на безопасно място с дълъг срок на съхранение, затова, докато не отзовем получения достъп, приложението няма да получи refresh токен. В края добавих заявка за отзов на токена, ако приложението не е завършило успешно и refresh токенът не е върнат, то ще започне процедурата отново (счита се за небезопасно да се съхраняват токените локално на терминала, а не ни се иска да усложняваме с криптография или да отваряме браузера често).
правете {
$token_result = Get-GoogleAuthToken
$token = $token_result.access_token
if ($token_result.refresh_token -eq $null) {
Write-Host ("Сесията не е разрушена. Отказване на токена...")
Invoke-WebRequest -Uri ("https://accounts.google.com/o/oauth2/revoke?token="+$token)
}
} докато ($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'])
}
}
}
}Във всеки запит е необходимо да се изпраща заглавка Authorization, съдържаща типа на токена и самия Access токен. В момента типът на токена винаги е Bearer. Тъй като трябва да проверяваме дали токенът не е изтекъл и да го актуализираме след изтичането на час от момента на издаване, посочих запитване към друга функция, която връща Access токен. Този фрагмент код също присъства в началото на скрипта при получаването на първия Access токен:
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 "Токенът изтече. Освежаваме..."
$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' - суспендиран: '$user.Suspended
$GAresult = $user
}
if ($GAresult) {
$return = $GAresult
} else {$return = 'gg'}
return $return
}Запитването на email:$query ще накара API-то да търси потребител с точно такъв email, включително ще бъдат намерени и алиаси. Можете също така да използвате wildcard: =, :, :{PREFIX}*.
За получаване на данни се използва GET метод, за добавяне на данни (създаване на акаунт или добавяне на участник в група) – POST, за актуализиране на съществуващи данни – PUT, за изтриване на запис (например, участник от група) – DELETE.
Скриптът ще пита за телефонен номер (невалидируема строка) и за желание да бъде добавен в регионалната група за разпространение. Той решава коя организационна единица да бъде на потребителя на базата на избраната OU в Active Directory и генерира парола:
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 = 'moscowoffice@rocketguys.com'
email = $email
}
(GoogleQuery 'AddMember' $query) | fl
}
Функциите за обновяване и създаване на акаунт имат аналогична синтаксиса, не всички допълнителни полета са задължителни, в раздела с телефонни номера е необходимо да се посочи масив, който може да съдържа поне една запис с номер и неговия тип.
За да избегнем грешка при добавянето на потребителя в група, предварително можем да проверим дали той вече е в тази група, получавайки списък на членовете на групата или състава на самия потребител.
Запитването за състава на групите на определен потребител няма да бъде рецесивно и ще покаже само непосредственото членство. Включването на потребителя в родителската група, в която вече присъства дъщерната група, член на която е потребителят, ще бъде успешно.
Заключение
Остава да изпратим на потребителя паролата за новия акаунт. Правим това чрез SMS, а общата информация с инструкциите и логина изпращаме на личната поща, която, заедно с телефонния номер, е предоставил отделът за подбор на персонал. Като алтернативен вариант, можем да спестим пари и да изпратим паролата в секретен чат на Telegram, което също може да се счита за втори фактор (изключение ще бъдат MacBook-ите).
Благодаря, че прочетохте до края. Ще се радвам да видя предложения за подобряване на стила на писане на статии и ви желая да улавяте по-малко грешки при написването на скриптове =)
Списък на линковете, които могат да бъдат тематично полезни или просто да отговорят на възникнали въпроси:
Източник: habr.com
