跳至主要内容

使用 GitHub 应用构建 CLI

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

简介

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

CLI 将有三个命令

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

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

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

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

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

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

先决条件

本教程假设您已经注册了 GitHub App。有关注册 GitHub App 的更多信息,请参阅 "注册 GitHub App"。

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

本教程假设您对 Ruby 有基本了解。有关更多信息,请参阅 Ruby.

获取客户端 ID

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

  1. 在 GitHub 上任何页面的右上角,单击您的个人资料照片。
  2. 导航到您的帐户设置。
    • 对于个人帐户拥有的应用程序,请单击 **设置**。
    • 对于组织拥有的应用程序
      1. 单击 **您的组织**。
      2. 在组织的右侧,单击 **设置**。
  3. 在左侧边栏中,单击 ** 开发者设置**。
  4. 在左侧边栏中,单击 **GitHub Apps**。
  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 App 生成用户访问令牌”。

  1. 在文件顶部,在require语句之后,在app_cli.rb中添加 GitHub App 的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 应用程序的最佳实践。有关更多信息,请参阅“创建 GitHub 应用程序的最佳实践”。