Skip to main content

使用 GitHub Apps 生成 CLI

按照本教程操作,在 Ruby 中编写一个 CLI,该 CLI 通过设备流为 GitHub App 生成用户访问令牌。

简介

本教程演示如何生成由 GitHub App 提供支持的命令行接口 (CLI),以及如何使用设备流为应用生成用户访问令牌。

CLI 将有三个命令:

  • help:输出使用说明。
  • login:生成一个用户访问令牌,应用可以使用该令牌代表用户发出 API 请求。
  • whoami:返回有关登录用户的信息。

本教程使用 Ruby,但你可以编写 CLI 并使用设备流通过任何编程语言生成用户访问令牌。

Note

本文包含使用 github.com 域的命令或示例。 可以在其他域(例如 octocorp.ghe.com)中访问 GitHub。

关于设备流和用户访问令牌

CLI 将使用设备流对用户进行身份验证并生成用户访问令牌。 然后,CLI 可以使用用户访问令牌代表经过身份验证的用户发出 API 请求。

如果你想要将应用操作归因于用户,应用应使用用户访问令牌。 有关详细信息,请参阅“代表用户使用 GitHub 应用进行身份验证”。

可通过两种方式为 GitHub App 生成用户访问令牌:Web 应用程序流和设备流。 如果应用无外设应用或无权访问 Web 接口,你应使用设备流来生成用户访问令牌。 例如,CLI 工具、简单的 Raspberry Pi 和桌面应用程序应使用设备流。 如果应用有权访问 Web 接口,则应改用 Web 应用程序流。 有关详细信息,请参阅 为 GitHub 应用生成用户访问令牌使用 GitHub Apps 生成“使用 GitHub 登录”按钮

先决条件

本教程假定你已注册 GitHub App。 若要详细了解如何注册 GitHub App,请参阅“注册 GitHub 应用”。

在按照本教程操作之前,必须为应用启用设备流。 有关为应用启用设备流的详细信息,请参阅“修改 GitHub 应用注册”。

本教程假定你对 Ruby 有基本的了解。 有关详细信息,请参��� Ruby

获取客户端 ID

需要应用的客户端 ID 才能通过设备流生成用户访问令牌。

  1. 在 GitHub 上任意页的右上角,单击你的个人资料照片。

  2. 导航到你的帐户设置。

    • 对于由个人帐户拥有的应用,请单击“设置”****。
    • 对于组织拥有的应用:
      1. 单击“你的组织”。
      2. 在组织的右侧,单击设置
    • 对于由企业拥有的应用:
      1. 如果使用的是 Enterprise Managed Users,请单击你的企业,以直接转到企业帐户设置。
      2. 如果使用的是个人帐户,请单击你的企业,然后单击企业右侧的设置
  3. 导航到 GitHub App 设置。

    • 对于由个人帐户或组织拥有的应用:
      1. 在左侧边栏中,单击 开发人员设置,然后单击 GitHub Apps
    • 对于由企业拥有的应用:
      1. 在左侧边栏中,在“设置”下,单击 GitHub Apps
  4. 在要使用的 GitHub App 旁边,单击“编辑”。

  5. 在应用的“设置”页上,找到应用的客户端 ID。 本教程后面部分将使用它。 请注意,客户端 ID 不同于应用程序 ID。

编写 CLI

这些步骤将引导你生成 CLI 并使用设备流获取用户访问令牌。 若要跳到最终代码,请参阅完整代码示例

安装

  1. 创建 Ruby 文件以保存将生成用户访问令牌的代码。 本教程将该文件命名为 app_cli.rb

  2. 在终端中,从存储 app_cli.rb 的目录中运行以下命令,使 app_cli.rb 可执行:

    Text
    chmod +x app_cli.rb
    
  3. 将以下行添加到 app_cli.rb 顶部,以指示应使用 Ruby 解释器运行脚本:

    Ruby
    #!/usr/bin/env ruby
    
  4. 将这些依赖项添加到 app_cli.rb 顶部,如下所示 #!/usr/bin/env ruby

    Ruby
    require "net/http"
    require "json"
    require "uri"
    require "fileutils"
    

    这些都是 Ruby 标准库的一部分,因此无需安装任何 gem。

  5. 添加下面的 main 函数作为入口点。 该函数包含一个 case 语句,用于根据指定的命令执行不同操作。 稍后将展开此 case 语句。

    Ruby
    def main
      case ARGV[0]
      when "help"
        puts "`help` is not yet defined"
      when "login"
        puts "`login` is not yet defined"
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command `#{ARGV[0]}`"
      end
    end
    
  6. 在文件底部,添加以下行以调用入口点函数。 在本教程后面向此文件添加更多函数时,此函数调用应保留在文件底部。

    Ruby
    main
    
  7. (可选)检查进度:

    app_cli.rb 将如下所示:

    Ruby
    #!/usr/bin/env ruby
    
    require "net/http"
    require "json"
    require "uri"
    require "fileutils"
    
    def main
      case ARGV[0]
      when "help"
        puts "`help` is not yet defined"
      when "login"
        puts "`login` is not yet defined"
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command `#{ARGV[0]}`"
      end
    end
    
    main
    

    在终端中,从存储 app_cli.rb 的目录中运行 ./app_cli.rb help。 你应该会看到以下输出:

    `help` is not yet defined
    

    还可以在不使用命令或使用未经处理的命令的情况下测试脚本。 例如,./app_cli.rb create-issue 应输出:

    Unknown command `create-issue`
    

添加 help 命令

  1. app_cli.rb 添加以下 help 函数。 目前,help 函数会打印一行,告知用户此 CLI 需要一个命令“help”。 稍后将展开此 help 函数。

    Ruby
    def help
      puts "usage: app_cli <help>"
    end
    
  2. 更新 main 函数,以在给定 help 命令时调用 help 函数:

    Ruby
    def main
      case ARGV[0]
      when "help"
        help
      when "login"
        puts "`login` is not yet defined"
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command #{ARGV[0]}"
      end
    end
    
  3. (可选)检查进度:

    app_cli.rb 将如下所示。 只要 main 函数调用位于文件末尾,函数的顺序就无关紧要。

    Ruby
    #!/usr/bin/env ruby
    
    require "net/http"
    require "json"
    require "uri"
    require "fileutils"
    
    def help
      puts "usage: app_cli <help>"
    end
    
    def main
      case ARGV[0]
      when "help"
        help
      when "login"
        puts "`login` is not yet defined"
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command #{ARGV[0]}"
      end
    end
    
    main
    

    在终端中,从存储 app_cli.rb 的目录中运行 ./app_cli.rb help。 你应该会看到以下输出:

    usage: app_cli <help>
    

添加 login 命令

login 命令将运行设备流以获取用户访问令牌。 有关详细信息,请参阅“为 GitHub 应用生成用户访问令牌”。

  1. 在文件顶部附近,在 require 语句之后,将 GitHub App 的 CLIENT_ID 添加为 app_cli.rb 中的常量。 有关查找应用客户端 ID 的详细信息,请参阅获取客户端 ID。 将 YOUR_CLIENT_ID 替换为应用的客户端 ID:

    Ruby
    CLIENT_ID="YOUR_CLIENT_ID"
    
  2. app_cli.rb 添加以下 parse_response 函数。 此函数分析来自 GitHub REST API 的响应。 当响应状态为 200 OK201 Created 时,函数将返回分析的响应正文。 否则,函数将输出响应和正文,并退出程序。

    Ruby
    def parse_response(response)
      case response
      when Net::HTTPOK, Net::HTTPCreated
        JSON.parse(response.body)
      else
        puts response
        puts response.body
        exit 1
      end
    end
    
  3. app_cli.rb 添加以下 request_device_code 函数。 此函数向 https://github.com/login/device/code 发出 POST 请求并返回响应。

    Ruby
    def request_device_code
      uri = URI("https://github.com/login/device/code")
      parameters = URI.encode_www_form("client_id" => CLIENT_ID)
      headers = {"Accept" => "application/json"}
    
      response = Net::HTTP.post(uri, parameters, headers)
      parse_response(response)
    end
    
  4. app_cli.rb 添加以下 request_token 函数。 此函数向 https://github.com/login/oauth/access_token 发出 POST 请求并返回响应。

    Ruby
    def request_token(device_code)
      uri = URI("https://github.com/login/oauth/access_token")
      parameters = URI.encode_www_form({
        "client_id" => CLIENT_ID,
        "device_code" => device_code,
        "grant_type" => "urn:ietf:params:oauth:grant-type:device_code"
      })
      headers = {"Accept" => "application/json"}
      response = Net::HTTP.post(uri, parameters, headers)
      parse_response(response)
    end
    
  5. app_cli.rb 添加以下 poll_for_token 函数。 此函数按指定间隔轮询 https://github.com/login/oauth/access_token,直到 GitHub 使用 access_token 参数而不是 error 参数做出响应。 然后,它将用户访问令牌写入文件并限制该文件的权限。

    Ruby
    def poll_for_token(device_code, interval)
    
      loop do
        response = request_token(device_code)
        error, access_token = response.values_at("error", "access_token")
    
        if error
          case error
          when "authorization_pending"
            # The user has not yet entered the code.
            # Wait, then poll again.
            sleep interval
            next
          when "slow_down"
            # The app polled too fast.
            # Wait for the interval plus 5 seconds, then poll again.
            sleep interval + 5
            next
          when "expired_token"
            # The `device_code` expired, and the process needs to restart.
            puts "The device code has expired. Please run `login` again."
            exit 1
          when "access_denied"
            # The user cancelled the process. Stop polling.
            puts "Login cancelled by user."
            exit 1
          else
            puts response
            exit 1
          end
        end
    
        File.write("./.token", access_token)
    
        # Set the file permissions so that only the file owner can read or modify the file
        FileUtils.chmod(0600, "./.token")
    
        break
      end
    end
    
  6. 添加以下 login 函数。

    此函数将会:

    1. 调用 request_device_code 函数并从响应中获取 verification_uriuser_codedevice_codeinterval 参数。
    2. 提示用户输入上一步中的 user_code
    3. 调用 poll_for_token 轮询 GitHub 以获取访问令牌。
    4. 让用户知道身份验证成功。
    Ruby
    def login
      verification_uri, user_code, device_code, interval = request_device_code.values_at("verification_uri", "user_code", "device_code", "interval")
    
      puts "Please visit: #{verification_uri}"
      puts "and enter code: #{user_code}"
    
      poll_for_token(device_code, interval)
    
      puts "Successfully authenticated!"
    end
    
  7. 更新 main 函数,以在给定 login 命令时调用 login 函数:

    Ruby
    def main
      case ARGV[0]
      when "help"
        help
      when "login"
        login
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command #{ARGV[0]}"
      end
    end
    
  8. 更新 help 函数以包含 login 命令:

    Ruby
    def help
      puts "usage: app_cli <login | help>"
    end
    
  9. (可选)检查进度:

    app_cli.rb 现在如下所示,其中 YOUR_CLIENT_ID 是应用的客户端 ID。 只要 main 函数调用位于文件末尾,函数的顺序就无关紧要。

    Ruby
    #!/usr/bin/env ruby
    
    require "net/http"
    require "json"
    require "uri"
    require "fileutils"
    
    CLIENT_ID="YOUR_CLIENT_ID"
    
    def help
      puts "usage: app_cli <login | help>"
    end
    
    def main
      case ARGV[0]
      when "help"
        help
      when "login"
        login
      when "whoami"
        puts "`whoami` is not yet defined"
      else
        puts "Unknown command #{ARGV[0]}"
      end
    end
    
    def parse_response(response)
      case response
      when Net::HTTPOK, Net::HTTPCreated
        JSON.parse(response.body)
      else
        puts response
        puts response.body
        exit 1
      end
    end
    
    def request_device_code
      uri = URI("https://github.com/login/device/code")
      parameters = URI.encode_www_form("client_id" => CLIENT_ID)
      headers = {"Accept" => "application/json"}
    
      response = Net::HTTP.post(uri, parameters, headers)
      parse_response(response)
    end
    
    def request_token(device_code)
      uri = URI("https://github.com/login/oauth/access_token")
      parameters = URI.encode_www_form({
        "client_id" => CLIENT_ID,
        "device_code" => device_code,
        "grant_type" => "urn:ietf:params:oauth:grant-type:device_code"
      })
      headers = {"Accept" => "application/json"}
      response = Net::HTTP.post(uri, parameters, headers)
      parse_response(response)
    end
    
    def poll_for_token(device_code, interval)
    
      loop do
        response = request_token(device_code)
        error, access_token = response.values_at("error", "access_token")
    
        if error
          case error
          when "authorization_pending"
            # The user has not yet entered the code.
            # Wait, then poll again.
            sleep interval
            next
          when "slow_down"
            # The app polled too fast.
            # Wait for the interval plus 5 seconds, then poll again.
            sleep interval + 5
            next
          when "expired_token"
            # The `device_code` expired, and the process needs to restart.
            puts "The device code has expired. Please run `login` again."
            exit 1
          when "access_denied"
            # The user cancelled the process. Stop polling.
            puts "Login cancelled by user."
            exit 1
          else
            puts response
            exit 1
          end
        end
    
        File.write("./.token", access_token)
    
        # Set the file permissions so that only the file owner can read or modify the file
        FileUtils.chmod(0600, "./.token")
    
        break
      end
    end
    
    def login
      verification_uri, user_code, device_code, interval = request_device_code.values_at("verification_uri", "user_code", "device_code", "interval")
    
      puts "Please visit: #{verification_uri}"
      puts "and enter code: #{user_code}"
    
      poll_for_token(device_code, interval)
    
      puts "Successfully authenticated!"
    end
    
    main
    
    1. 在终端中,从存储 app_cli.rb 的目录中运行 ./app_cli.rb login。 应该会看到如下所示的输出。 每次代码都会有所不同:

      Please visit: https://github.com/login/device
      and enter code: CA86-8D94
      
    2. 在浏览器中导航到 https://github.com/login/device,输入上一步中的代码,然后单击“继续”。

    3. GitHub 应该会显示一个页面,提示你授权应用。 单击“授权”按钮。

    4. 终端现在应显示“已成功进行身份验证!”。

添加 whoami 命令

现在,应用可以生成用户访问令牌,你可以代表用户发出 API 请求。 添加 whoami 命令以获取经过身份验证的用户的用户名。

  1. app_cli.rb 添加以下 whoami 函数。 此函数获取有关使用 /user REST API 终结点的用户的信息。 它输出与用户访问令牌对应的用户名。 如果未找到 .token 文件,它将提示用户运行 login 函数。

    Ruby
    def whoami
      uri = URI("https://api.github.com/user")
    
      begin
        token = File.read("./.token").strip
      rescue Errno::ENOENT => e
        puts "You are not authorized. Run the `login` command."
        exit 1
      end
    
      response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
        body = {"access_token" => token}.to_json
        headers = {"Accept" => "application/vnd.github+json", "Authorization" => "Bearer #{token}"}
    
        http.send_request("GET", uri.path, body, headers)
      end
    
      parsed_response = parse_response(response)
      puts "You are #{parsed_response["login"]}"
    end
    
  2. 更新 parse_response 函数以处理令牌已过期或已吊销的情况。 现在,如果收到 401 Unauthorized 响应,CLI 将提示用户运行 login 命令。

    Ruby
    def parse_response(response)
      case response
      when Net::HTTPOK, Net::HTTPCreated
        JSON.parse(response.body)
      when Net::HTTPUnauthorized
        puts "You are not authorized. Run the `login` command."
        exit 1
      else
        puts response
        puts response.body
        exit 1
      end
    end
    
  3. 更新 main 函数,以在给定 whoami 命令时调用 whoami 函数:

    Ruby
    def main
      case ARGV[0]
      when "help"
        help
      when "login"
        login
      when "whoami"
        whoami
      else
        puts "Unknown command #{ARGV[0]}"
      end
    end
    
  4. 更新 help 函数以包含 whoami 命令:

    Ruby
    def help
      puts "usage: app_cli <login | whoami | help>"
    end
    
  5. 根据下一部分中的完整代码示例检查代码。 可以按照完整代码示例下方测试一节中概述的步骤测试代码。

完整代码示例

这是上一部分概述的完整代码示例。 将 YOUR_CLIENT_ID 替换为应用的客户端 ID。

Ruby
#!/usr/bin/env ruby

require "net/http"
require "json"
require "uri"
require "fileutils"

CLIENT_ID="YOUR_CLIENT_ID"

def help
  puts "usage: app_cli <login | whoami | help>"
end

def main
  case ARGV[0]
  when "help"
    help
  when "login"
    login
  when "whoami"
    whoami
  else
    puts "Unknown command #{ARGV[0]}"
  end
end

def parse_response(response)
  case response
  when Net::HTTPOK, Net::HTTPCreated
    JSON.parse(response.body)
  when Net::HTTPUnauthorized
    puts "You are not authorized. Run the `login` command."
    exit 1
  else
    puts response
    puts response.body
    exit 1
  end
end

def request_device_code
  uri = URI("https://github.com/login/device/code")
  parameters = URI.encode_www_form("client_id" => CLIENT_ID)
  headers = {"Accept" => "application/json"}

  response = Net::HTTP.post(uri, parameters, headers)
  parse_response(response)
end

def request_token(device_code)
  uri = URI("https://github.com/login/oauth/access_token")
  parameters = URI.encode_www_form({
    "client_id" => CLIENT_ID,
    "device_code" => device_code,
    "grant_type" => "urn:ietf:params:oauth:grant-type:device_code"
  })
  headers = {"Accept" => "application/json"}
  response = Net::HTTP.post(uri, parameters, headers)
  parse_response(response)
end

def poll_for_token(device_code, interval)

  loop do
    response = request_token(device_code)
    error, access_token = response.values_at("error", "access_token")

    if error
      case error
      when "authorization_pending"
        # The user has not yet entered the code.
        # Wait, then poll again.
        sleep interval
        next
      when "slow_down"
        # The app polled too fast.
        # Wait for the interval plus 5 seconds, then poll again.
        sleep interval + 5
        next
      when "expired_token"
        # The `device_code` expired, and the process needs to restart.
        puts "The device code has expired. Please run `login` again."
        exit 1
      when "access_denied"
        # The user cancelled the process. Stop polling.
        puts "Login cancelled by user."
        exit 1
      else
        puts response
        exit 1
      end
    end

    File.write("./.token", access_token)

    # Set the file permissions so that only the file owner can read or modify the file
    FileUtils.chmod(0600, "./.token")

    break
  end
end

def login
  verification_uri, user_code, device_code, interval = request_device_code.values_at("verification_uri", "user_code", "device_code", "interval")

  puts "Please visit: #{verification_uri}"
  puts "and enter code: #{user_code}"

  poll_for_token(device_code, interval)

  puts "Successfully authenticated!"
end

def whoami
  uri = URI("https://api.github.com/user")

  begin
    token = File.read("./.token").strip
  rescue Errno::ENOENT => e
    puts "You are not authorized. Run the `login` command."
    exit 1
  end

  response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
    body = {"access_token" => token}.to_json
    headers = {"Accept" => "application/vnd.github+json", "Authorization" => "Bearer #{token}"}

    http.send_request("GET", uri.path, body, headers)
  end

  parsed_response = parse_response(response)
  puts "You are #{parsed_response["login"]}"
end

main

测试

本教程假定应用代码存储在名为 app_cli.rb 的文件中。

  1. 在终端中,从存储 app_cli.rb 的目录中运行 ./app_cli.rb help。 应该会看到如下所示的输出。

    usage: app_cli <login | whoami | help>
    
  2. 在终端中,从存储 app_cli.rb 的目录中运行 ./app_cli.rb login。 应该会看到如下所示的输出。 每次代码都会有所不同:

    Please visit: https://github.com/login/device
    and enter code: CA86-8D94
    
  3. 在浏览器中导航到 https://github.com/login/device,输入上一步中的代码,然后单击“继续”。

  4. GitHub 应该会显示一个页面,提示你授权应用。 单击“授权”按钮。

  5. 终端现在应显示“已成功进行身份验证!”。

  6. 在终端中,从存储 app_cli.rb 的目录中运行 ./app_cli.rb whoami。 应会看到如下所示的输出,其中 octocat 是用户名。

    You are octocat
    
  7. 在编辑器中打开 .token 文件,并修改令牌。 现在,令牌无效。

  8. 在终端中,从存储 app_cli.rb 的目录中运行 ./app_cli.rb whoami。 应该会看到如下所示的输出:

    You are not authorized. Run the `login` command.
    
  9. 删除 .token 文件,

  10. 在终端中,从存储 app_cli.rb 的目录中运行 ./app_cli.rb whoami。 应该会看到如下所示的输出:

    You are not authorized. Run the `login` command.
    

后续步骤

调整代码以满足应用的需求

本教程演示如何编写使用设备流生成用户访问令牌的 CLI。 可以展开此 CLI 以接受其他命令。 例如,可以添加 create-issue 命令来提出一个问题。 针对你要发出的 API 请求,如果应用需要其他权限,请记得更新应用的权限。 有关详细信息,请参阅“为 GitHub Apps 选择权限”。

安全地存储令牌

本教程将生成用户访问令牌并将其保存在本地文件中。 切勿提交此文件或公开令牌。

根据设备,可以选择不同的方法来存储令牌。 应检查在设备上存储令牌的最佳做法。

有关详细信息,请参阅“创建 GitHub 应用的最佳做法”。

遵循最佳做法

你的目标应该是遵循 GitHub App 的最佳做法。 有关详细信息,请参阅“创建 GitHub 应用的最佳做法”。