跳至主要内容

使用 GitHub App 构建 CLI

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

简介

本教程演示如何构建由 GitHub 应用支持的命令行界面 (CLI),以及如何使用设备流为应用生成用户访问令牌。

CLI 将具有三个命令

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

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

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

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

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

有两种方法可以为 GitHub 应用生成用户访问令牌:Web 应用流程和设备流。如果您的应用是无头的或无法访问 Web 界面,则应使用设备流生成用户访问令牌。例如,CLI 工具、简单的树莓派和桌面应用程序应使用设备流。如果您的应用可以访问 Web 界面,则应改为使用 Web 应用流程。有关更多信息,请参阅“为 GitHub 应用生成用户访问令牌”和“使用 GitHub 应用构建“使用 GitHub 登录”按钮”。

先决条件

本教程假设您已注册了一个 GitHub 应用。有关注册 GitHub 应用的更多信息,请参阅“注册 GitHub 应用”。

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

本教程假设您具备 Ruby 的基本知识。有关更多信息,请参阅 Ruby

获取客户端 ID

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

  1. 在 GitHub 上任何页面右上角,点击您的个人资料照片。

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

    • 对于个人帐户拥有的应用,点击“**设置**”。
    • 对于组织拥有的应用
      1. 点击“**您的组织**”。
      2. 在组织右侧,点击“**设置**”。
  3. 在左侧边栏中,点击“ 开发者设置”。

  4. 在左侧边栏中,点击“**GitHub 应用**”。

  5. 在您要使用的 GitHub 应用旁边,点击“**编辑**”。

  6. 在应用的设置页面上,找到您的应用的客户端 ID。您将在本教程后面的步骤中使用它。请注意,客户端 ID 与应用 ID 不同。

编写 CLI

这些步骤将引导您构建 CLI 并使用设备流获取用户访问令牌。要跳至最终代码,请参阅“完整代码示例”。

设置

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

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

    文本
    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. 将以下 help 函数添加到 app_cli.rb。目前,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 语句之后,在 app_cli.rb 中添加 GitHub 应用的 CLIENT_ID 作为常量。有关查找应用的客户端 ID 的更多信息,请参阅“获取客户端 ID”。将 YOUR_CLIENT_ID 替换为您的应用的客户端 ID

    Ruby
    CLIENT_ID="YOUR_CLIENT_ID"
    
  2. 将以下 parse_response 函数添加到 app_cli.rb。此函数解析来自 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. 将以下 request_device_code 函数添加到 app_cli.rb。此函数向 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. 将以下 request_token 函数添加到 app_cli.rb。此函数向 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. 将以下 poll_for_token 函数添加到 app_cli.rb。此函数以指定的间隔轮询 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. 将以下 whoami 函数添加到 app_cli.rb。此函数使用 /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 应用选择权限”。

安全地存储令牌

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

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

有关更多信息,请参阅“创建 GitHub 应用的最佳实践”。

遵循最佳实践

您应该尽量遵循 GitHub App 的最佳实践。有关更多信息,请参阅“创建 GitHub App 的最佳实践”。