通过API从PowerShell创建Google用户

你好

本文将介绍PowerShell如何与Google API交互以操纵G Suite用户。

在组织中,我们使用多种内部和云服务。 在大多数情况下,它们的授权归于Google或Active Directory,在这两者之间我们无法分别维护副本,而在释放新员工时,您需要在这两个系统中创建/启用帐户。 为了使该过程自动化,我们决定编写一个脚本来收集信息并将其发送到两个服务。

登入


组成需求后,我们决定使用真实的人作为管理员进行授权,这简化了在意外或有意的大变动时采取的措施的分析。

Google API使用OAuth 2.0协议进行身份验证和授权。 您可以在这里找到用例和更详细的描述: 使用OAuth 2.0访问Google API

我选择了用于桌面应用程序中授权的脚本。 还有一个选项可以使用不需要用户不必要的移动的服务帐户。

下图是从Google页面选择的方案的示意图。



  1. 首先,我们将用户发送到Google帐户中的身份验证页面,指示GET参数:
    • 申请编号
    • 应用程序需要访问的区域
    • 完成该过程后,用户将被重定向到的地址
    • 我们更新令牌的方式
    • 验证码
    • 验证码传输格式

  2. 授权完成后,用户将被重定向到第一个请求中指定的页面,并带有由GET参数传输的错误或授权代码
  3. 应用程序(脚本)将需要获取这些参数,如果接收到代码,则执行以下令牌请求
  4. 如果请求正确,则Google API返回:

    • 可以用来发出请求的访问令牌
    • 该令牌的有效性
    • 更新访问令牌所需的刷新令牌。

首先,您需要转到Google API控制台: 凭据-Google API控制台 ,选择所需的应用程序,然后在“凭据”部分中创建客户端OAuth标识符。 在同一位置(或稍后,在创建的标识符的属性中),您需要指定允许重定向的地址。 在我们的例子中,它将是几个具有不同端口的localhost条目(请参见下文)。

为了使阅读脚本算法更容易,您可以在一个单独的函数中输出第一步,该函数将为应用程序返回Access和刷新令牌:

$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客户端标识符的属性中获得的客户端ID和客户端密钥,并且代码验证程序是长度为43至128个字符的字符串,应从非保留字符中随机生成:[AZ] / [az] / [0-9 ] /“-” /“。 /“ _” /“〜”。

此外,该代码将被重新发送。 它消除了攻击者可以拦截用户授权后返回重定向的响应的漏洞。
您可以采用当前格式的明文形式发送代码验证程序(这使其毫无意义-仅适用于不支持SHA256的系统),也可以创建需要在BASE64Url中编码的SHA256哈希(与表中两个字符的Base64不同),然后删除该字符行尾:=。

接下来,我们需要开始侦听本地计算机上的http,以获得授权后的响应,该响应将作为重定向返回。

管理任务是在特殊的服务器上执行的,我们不能排除几个管理员同时运行脚本的可能性,因此他将为当前用户随机选择一个端口,但是我指定了预定义的端口,因为 它们还必须在API控制台中添加为受信任的。

access_type = offline意味着应用程序可以独立更新过期的令牌,而无需用户与浏览器进行交互,
response_type = code设置返回代码的格式(当用户将代码从浏览器复制到脚本时,指的是旧的授权方法),
scope指示访问的范围和类型。 它们之间必须用空格或%20分隔(根据URL编码)。 可以在此处查看具有类型的访问区域列表: Google API的OAuth 2.0范围

收到授权码后,应用程序将向浏览器返回关闭消息,停止监听端口并发送POST请求以接收令牌。 根据协议规范,我们在其中指出先前从控制台API设置的ID和机密,将用户重定向到的地址以及grant_type。

作为响应,我们将获得一个Access令牌,以秒为单位的持续时间和一个Refresh令牌,通过它们我们可以更新Access令牌。

应用程序应将令牌存储在具有长保质期的安全地方,因此,在我们取消获得的访问权限之前,刷新令牌将不会返回给应用程序。 最后,我添加了一个撤销令牌的请求,如果应用程序未成功完成并且刷新令牌没有返回,它将再次启动该过程(我们认为将令牌本地存储在终端上是不安全的,但是我们不想使加密复杂化或经常打开浏览器)。

 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']) } } } } 

在每个请求中,您需要发送一个Authorization标头,其中包含令牌的类型和Access令牌本身。 目前,令牌的类型始终为Bearer。 因为 我们需要检查令牌是否过期,并在发布令牌后的一个小时后对其进行更新,这表明我要求另一个函数返回访问令牌。 收到第一个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 "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 } 

电子邮件请求:$查询将要求API搜索与该电子邮件完全相同的用户,包括别名。 您也可以使用通配符: =,:,:{PREFIX} *

要获取数据,可以使用GET请求方法来插入数据(创建帐户或向组中添加成员)-POST,更新现有数据-PUT,删除条目(例如,组中的参与者)-DELETE。

该脚本还将询问电话号码(无效的字符串)并将其包括在区域通讯组中。 它根据所选的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 = 'moscowoffice@rocketguys.com' email = $email } (GoogleQuery 'AddMember' $query) | fl } 

更新和创建帐户的功能具有相同的语法,不需要所有其他字段,在包含电话号码的部分中,您需要指定一个数组,该数组可以包含一条带有编号及其类型的记录。

为了在将用户添加到组中时不会出错,我们可以首先通过从用户本人那里接收组成员列表或组成来检查他是否已经在该组中。

对于特定用户的组组成的请求将不会递归,只会显示直接成员身份。 已成功将用户包含在该用户所属的子组的父组中。

结论


仍然需要向用户发送新帐户的密码。 我们通过SMS进行此操作,并发送带有说明的常规信息并登录到个人邮件,该邮件与电话号码一起由人员选拔部门提供。 另一种选择是,您可以省钱并向秘密电报聊天发送密码,这也可以视为第二个因素(macbook是例外)。

感谢您阅读到最后。 我很高兴看到有关改善文章写作风格的建议,并希望您在编写脚本时少犯错误=)

可能在主题上有用或仅回答您的问题的链接列表:

Source: https://habr.com/ru/post/zh-CN468969/


All Articles