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

- Първо изпращаме потребителя на страницата за аутентификация на акаунт в Google, указвайки параметри GET:
- идентификатор на приложението
- областите, до които приложението изисква достъп
- адреса, на който потребителят ще бъде пренасочен след завършване на процедурата
- метода, по който ще актуализираме токена
- код за проверка
- формат за предаване на кода за проверка
- След приключване на авторизацията, потребителят ще бъде пренасочен на посочената в първоначалната заявка страница, с грешка или код за авторизация, предадени като параметри GET
- Приложението (скрипта) ще трябва да получи тези параметри и, в случай на получаване на код, да извърши следващата заявка за получаване на токени
- При правилна заявка Google API връща:
- Access токен, с който можем да правим заявки
- Срок на валидност на този токен
- Refresh токен, необходим за обновяване на Access токена.
Първо трябва да отидем в конзолата Google API: , изберете желаното приложение и в раздела Credentials създайте идентификатор на OAuth клиента. Там (или по-късно, в свойствата на създадения идентификатор) трябва да посочите адресите, на които е позволено пренасочване. В нашия случай това ще бъдат няколко записа localhost с различни портове (вижте по-нататък).
За да бъде по-удобно да четете алгоритъма на скрипта, можете да изведете първите стъпки в отделна функция, която ще върне Access и refresh токените за приложението:
$client_secret = 'Нашият клиентски секрет'
$client_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 Encoding). Списъкът с области на достъп и типове може да се види тук: .
След получаването на кода за автентикация, приложението ще върне съобщение за затваряне в браузъра, ще спре да слуша порта и ще изпрати POST запитване за получаване на токен. В него указваме предварително зададените id и secret от API конзолата, адреса, на който ще бъде пренасочен потребителят, и grant_type в съответствие с спецификацията на протокола.
В отговор ще получим Access токен, неговото време на действие в секунди и Refresh токен, с който можем да обновим Access токена.
Приложението трябва да съхранява токените на безопасно място с дълъг срок на годност, затова, докато не отзовем получения достъп, на приложението няма да бъде върнат refresh токен. В края добавих запитване за отзов на токена, ако приложението е било завършено неуспешно и refresh токенът не е върнат, то ще започне процедурата отново (сметнахме, че е небезопасно да съхраняваме токени локално на терминала, а използването на криптография или често отваряне на браузъра не е желателно).
do {
$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)
}
} 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:
функция GoogleQuery {
параметр (
$тип,
$запрос
)
переключатель ($тип) {
"SearchAccount" {
Возврат Invoke-RestMethod -Метод Get -Uri "https://www.googleapis.com/admin/directory/v1/users" -Заголовки @{Authorization = "Bearer " + (Get-GoogleToken)} -Тело @{
домен = 'rocketguys.com'
запрос = "email:$запрос"
}
}
"UpdateAccount" {
$тело = @{
имя = @{
givenName = $запрос['givenName']
familyName = $запрос['familyName']
}
приостановленный = 'false'
пароль = $запрос['password']
изменитьПарольНаСледующемВходе = 'true'
телефоны = @(@{
основной = 'true'
значение = $запрос['phone']
тип = "мобильный"
})
orgUnitPath = $запрос['orgunit']
}
Возврат Invoke-RestMethod -Метод Put -Uri ("https://www.googleapis.com/admin/directory/v1/users/" + $запрос['email']) -Заголовки @{Authorization = "Bearer " + (Get-GoogleToken)} -Тело (ConvertTo-Json $тело) -ТипСодержимого 'application/json; charset=utf-8'
}
"CreateAccount" {
$тело = @{
primaryEmail = $запрос['email']
имя = @{
givenName = $запрос['givenName']
familyName = $запрос['familyName']
}
приостановленный = 'false'
пароль = $запрос['password']
изменитьПарольНаСледующемВходе = 'true'
телефоны = @(@{
основной = 'true'
значение = $запрос['phone']
тип = "мобильный"
})
orgUnitPath = $запрос['orgunit']
}
Возврат Invoke-RestMethod -Метод Post -Uri "https://www.googleapis.com/admin/directory/v1/users" -Заголовки @{Authorization = "Bearer " + (Get-GoogleToken)} -Тело (ConvertTo-Json $тело) -ТипСодержимого 'application/json; charset=utf-8'
}
"AddMember" {
$тело = @{
userKey = $запрос['email']
}
$ifrequest = Invoke-RestMethod -Метод Get -Uri "https://www.googleapis.com/admin/directory/v1/groups" -Заголовки @{Authorization = "Bearer " + (Get-GoogleToken)} -Тело $тело
$массив = @()
каждому ($группа в $ifrequest.groups) {$массив += $группа.email}
если ($массив -не содержит $запрос['groupkey']) {
$тело = @{
email = $запрос['email']
роль = "MEMBER"
}
Возврат Invoke-RestMethod -Метод Post -Uri ("https://www.googleapis.com/admin/directory/v1/groups/" + $запрос['groupkey'] + "/members") -Заголовки @{Authorization = "Bearer " + (Get-GoogleToken)} -Тело (ConvertTo-Json $тело) -ТипСодержимого 'application/json; charset=utf-8'
} иначе {
Возврат ($запрос['email'] + " сейчас является членом " + $запрос['groupkey'])
}
}
}
}За все запросы необходимо отправлять заголовок Authorization, содержащий тип токена и сам Access токен. В настоящее время тип токена всегда Bearer. Поскольку нам нужно проверять, что токен не просрочен, и обновлять его по истечении часа с момента выдачи, я указал запрос на другую функцию, которая возвращает Access токен. Этот же кусочек кода есть в начале скрипта при получении первого Access токена:
функция Get-GoogleToken {
если (((Get-date).Hour -gt $token_expire.hour) -или (((Get-date).Hour -ge $token_expire.hour) -и ((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
если ($minute -lt 0) {$minute += 60}
иначе если ($minute -gt 59) {$minute -=60}
$script:token_expire = @{
hour = ([int]("{0:hh}" -f ([timespan]::fromseconds($request.expires_in))))+((Get-date).Hour)
minute = $minute
}
}
вернуть $token
}Проверка существования логина:
функция Check_Google {
$query = (GoogleQuery 'SearchAccount' $username)
если ($query.users -не $null) {
$user = $query.users[0]
Write-Host $user.name.fullName' - '$user.PrimaryEmail' - приостановлен: '$user.Suspended
$GAresult = $user
}
если ($GAresult) {
$return = $GAresult
} иначе {$return = 'gg'}
вернуть $return
}Запрос email:$query попросит API найти пользователя точно с таким email, включая алиасы. Также можно использовать подстановочный знак: =, :, :{PREFIX}*.
Для получения данных используется метод GET, для вставки данных (создание аккаунта или добавление участника в группу) – POST, для обновления существующих данных – PUT, для удаления записи (например, участника из группы) – DELETE.
Скрипт также спросит номер телефона (недействительная строка) и о включении в региональную группу рассылки. Он определяет, какая организационная единица должна быть у пользователя на основе выбранной OU Active Directory и генерирует пароль:
do {
$phone = Read-Host "Телефон в формате +7хххххххх"
} while (-не $phone)
do {
$moscow = Read-Host "В Московский офис? (y/n) "
} while (-не (($moscow -eq 'y') -или ($moscow -eq 'n')))
$orgunit = 'quant'
если ($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
}
если ($GMailExist) {
Write-Host "Запускаем изменение аккаунта" -f mag
(GoogleQuery 'UpdateAccount' $query) | fl
write-host "Не забудь проверить группы у включенного $Username в Google."
} иначе {
Write-Host "Запускаем создание аккаунта" -f mag
(GoogleQuery 'CreateAccount' $query) | fl
}
если ($moscow -eq "y"){
write-host "Добавляем в группу moscowoffice"
$query = @{
groupkey = 'moscowoffice@rocketguys.com'
email = $email
}
(GoogleQuery 'AddMember' $query) | fl
}
Функциите за актуализиране и създаване на акаунт имат аналогичен синтаксис, не всички допълнителни полета са задължителни, в раздела с телефонните номера трябва да се посочи масив, който може да съдържа от една записа с номер и неговия тип.
За да не получим грешка при добавяне на потребител в група, предварително можем да проверим дали той вече е в тази група, като получим списък с членовете на групата или състава на самия потребител.
Запитването за състава на групи на определен потребител няма да бъде рекурсивно и ще покаже само непосредственото членство. Включването на потребител в родителска група, в която вече е съществуваща дъщерна група, от която е част потребителят, ще бъде успешно.
Заключение
Остава да изпратим на потребителя паролата за новия акаунт. Правим това чрез SMS, а общата информация с инструкциите и логина изпращаме на личния мейл, предоставен от отдела за набиране на персонал, заедно с телефонния номер. Като алтернативен вариант, можем да спестим пари и да изпратим паролата в тайния чат на Телеграм, което също може да се счита за втори фактор (изключение ще правят MacBook-ите).
Благодаря, че прочетохте до края. Ще се радвам да видя предложения за подобряване на стила на писане на статии и ви желая да уловите по-малко грешки при писането на скриптове =)
Списък с линкове, които могат да бъдат тематично полезни или просто да отговорят на възникнали въпроси:
Източник: habr.com
