Skip to main content

使用 GitHub Apps 生成 CLI

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

简介

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

CLI 将有三个命令:

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

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

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

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. 在组织右侧,单击“设置”。
  3. 在左侧边栏中,单击“ 开发人员设置”。
  4. 在左侧边栏中,单击“GitHub Apps”。
  5. 在要使用的 GitHub App 旁边,单击“编辑”。
  6. 在应用的“设置”页上,找到应用的客户端 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 应用的最佳做法”。