JWT 在 Ruby 中的使用总结和 Sinatra 框架解读和构建接口

JSON Web Token 介绍了 JSON Web Token 的基本原理,接下来这篇文章结合 Ruby 分析总结在实际项目中的使用。Ruby 有对应版本的 Gem ruby-jwt ,我们这篇讨论 JWT 在 Ruby 编程语言中的使用、并简单解析 Sinatra 框架

ruby-jwt 介绍

ruby-jwt 实现了RFC 7519 OAuth JSON Web Token 标准,结合文档总结在 Ruby 中如何使用 JWT

加密和验签

在 jwt 中通常我们使用两种加密方式 RS 256HS 256 ,这两者分别对应 RSAHMAC
。如果是给第三方提供 HTTP API 接口,通常是使用 JWT 校验加密明文的特性。使用 RS 256 的话需要双方都给对方提供己方的 OpenSSL 公钥,若使用 HS256 ,则双方协商好使用同一个密钥串。

使用 RS256 的流程是:甲方服务器用己方的私钥对数据进行加签,然后乙方使用甲提供的公钥验签并解析出明文并使用己方的私钥加签返回给乙方服务器的明文,甲方服务器再使用乙提供的公钥进行解签。

使用 HS256 的流程是:双方都用协商好的密钥传解密需要传送的明文、验证和解签对方发送过来的 JWT token
ruby-jwt 加密方法的源码:

def encode(payload, key, algorithm = 'HS256', header_fields = {})
  encoder = Encode.new payload, key, algorithm, header_fields
  encoder.segments
end

def decode(jwt, key = nil, verify = true, custom_options = {}, &keyfinder)
  raise(JWT::DecodeError, 'Nil JSON web token') unless jwt

  merged_options = DEFAULT_OPTIONS.merge(custom_options)

  decoder = Decode.new jwt, verify
  header, payload, signature, signing_input = decoder.decode_segments
  decode_verify_signature(key, header, payload, signature, signing_input, merged_options, &keyfinder) if verify

  Verify.verify_claims(payload, merged_options)

  raise(JWT::DecodeError, 'Not enough or too many segments') unless header && payload

  [payload, header]
end

结合 encodedecode 源码,接下来学习签名和验证的例子。

require 'jwt'

payload = { data: 'test' }

# HS256 encode
hmac_secret = 'my$ecretK3y'
token = JWT.encode payload, hmac_secret, 'HS256'
# eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.ZxW8go9hz3ETCSfxFxpwSkYg_602gOPKearsf6DsxgY

# HS256 decode
decoded_token = JWT.decode token, hmac_secret, true, { :algorithm => 'HS256' }
# [{"data"=>"test"}, {"typ"=>"JWT", "alg"=>"HS256"}]

rsa_private = OpenSSL::PKey::RSA.generate 2048
rsa_public = rsa_private.public_key

# RS256 encode
token = JWT.encode payload, rsa_private, 'RS256'
# eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.vtkMG9wCJRWc8lwOQJxnV8vRWRiBfvIzsE-vnM168Pe4jXszc2p9_2upAi8SI5EuwR7CsVAPFO_SqNsJLCb_55srqyBxPAyy97gy-44VyFR-dsnt9xpt2meJ4DyolXwhWxHTF9WkmQPHoFlu_2ssOOszBj9MO1X7KhmgkrX9h9yBTTzT9qvZQkesAbZz1RrF3ZRhihwbBdtGCCbJvlGBI6NAoMf_b3vNqeaHawc5hMS9nfoN-5Sc9CleJdPPnWnN7OYXeI_xdhCNTok0b7nMPvgDuIj9nTW4_u3Fv-rq9IiM-62LU1JFdqFVWQU-f72nkbT4bVy_SJr11mJ9Q6pXNQ

# RS decode
decoded_token = JWT.decode token, rsa_public, true, { :algorithm => 'RS256' }
[{"data"=>"test"}, {"typ"=>"JWT", "alg"=>"RS256"}]

几个常用的 Claim

  • Expiration Time Claim(exp) 失效时间
  • Issued At Claim(iat) 表示这个JWT token 的签发时间
  • JWT ID Claim JWT token 唯一标识
hmac_secret = 'my$ecretK3y'

exp = Time.now.to_i + 4 * 3600
iat = Time.now.to_i
jti_raw = [hmac_secret, iat].join(':').to_s
jti = Digest::MD5.hexdigest(jti_raw)

payload = { :data => 'data', :exp => exp, :iat => iat, :jti => jti}
token = JWT.encode payload, hmac_secret, 'HS256'

# decode
begin
  decoded_token = JWT.decode token, hmac_secret, true, { :verify_iat => true, :verify_jti => true, :algorithm => 'HS256' }
rescue JWT::ExpiredSignature
  # Handle expired token, e.g. logout user or deny access
rescue JWT::InvalidIatError
  # Handle invalid token, e.g. logout user or deny access
rescue JWT::InvalidJtiError
  # Handle invalid token, e.g. logout user or deny access
end

Sinatra 原理解释、使用

在这部分,我们先来介绍 Sinatra 基本实现原理以及如何实现 Sinatra 扩展原理。然后搭建一个完整的 Sinatra 应用。假设的场景是我们为第三方提供 HTTP API , 使用了 JWT 两个特性:明文加密和验签。实际开发中需要双方都给对方提供 OpenSSL 生成的公钥。

Sinatra 基本原理

描述约定

  1. top-level DSL 指 Sinatra 中的路由(Rails 中的 controller)
  2. classic style 文中描述 经典风格的 Sinatra 应用
  3. module style 文中描述为 模块化风格的 Sinatra 应用
  4. Sinatra.helpers 实现的扩展为“帮助方法扩展”
  5. Sinatra.register 实现的扩展为“路由扩展”

经典风格和模块化风格的 Sinatra 应用

通常使用 Sinatra 构建项目有两种方式:经典风格和模块化风格。

一种是经典风格(classic style)这种模式通常应用在小型项目中,所有接受 HTTP 请求的路由只放在一个文件中(app.rb)。
app.rb 文件中需要引入 require 'sinatra' 然后剩下的就是直接写路由。

# app.rb
require 'sinatra'

get '/' do
  'Hello world'
end

另外一种是模块化风格(Modular style) 这种模式也是本文主要介绍的方式,通常应用在大型的 Rack-base 应用中和编写可复用的 Rack 中间件,它可以扩展为类 Rails 的 MVC 结构。通过这样的脚手架以此我们就能实现相对复杂的接口应用。


# 注意这里引入的是 sinatra/base 而不是 sinatra
require 'sinatra/base'
require "sinatra/config_file"

class MyApp < Sinatra::Base
  register Sinatra::ConfigFile
  config_file 'path/to/config.yml'

  get '/' do
    @greeting = settings.greeting
    haml :index
  end

  # The rest of your modular application code goes here...
end

实现模块化风格的 Sinatra 应用有继承 Sinatra::BaseSinatra::Application 两种方式,解释一下两者的差异。

Sinatra::Application 和 Sinatra::Base 的关系

通常模块化风格的 Sinatra 都要继承sinatra::base,但是一些设置,譬如 session, flash 这些 Sinatra 框架内部默认实现的扩展都是关闭状态;开启默认的扩展的最简单方法就是 require 'sinatra' 实现一个经典风格的 Sinatra 应用。require 'sinatra' 实现的经典风格的 Sinatra 应用的原理就是继承了 Sinatra::Application,而 Sinatra::Application 也是继承了 Sinatra::Base 。。通过看 sinatra 源码可知 Sinatra::Application 继承了 Sinatra::Base:

# https://github.com/sinatra/sinatra/blob/master/lib/sinatra/base.rb#L1902
class Application < Base
    set :logging, Proc.new { !test? }
    set :method_override, true
    set :run, Proc.new { !test? }
    set :app_file, nil

    def self.register(*extensions, &block) #:nodoc:
      added_methods = extensions.flat_map(&:public_instance_methods)
      Delegator.delegate(*added_methods)
      super(*extensions, &block)
    end
  end

在 Application 类的注释中有这么一段话

# Execution context for classic style (top-level) applications. All
# DSL methods executed on main are delegated to this class.
#
# The Application class should not be subclassed, unless you want to
# inherit all settings, routes, handlers, and error pages from the
# top-level. Subclassing Sinatra::Base is highly recommended for
# modular applications.

足以说明继承 Application 类就会开启 Sinatra 默认的设置、路由。
Sinatra::Base 中实现的一系列类方法 (e.g., get, post, before, configure, set, etc.)

经典风格和模块化风格 Sinatra 应用的关系

require 'sinatra'

get '/' do
  ...
end

结合代码,上文提到实现经典风格的 Sinatra 应用,只需在 require 'sinatra' 即可。我们来看一下 Sinatra 中的源码。

# sinatra/lib/sinatra.rb
require 'sinatra/main'

enable :inline_templates

# sinatra/lib/sinatra/main.rb
# https://github.com/sinatra/sinatra/blob/master/lib/sinatra/main.rb
require 'sinatra/base'

module Sinatra
  class Application < Base

    # we assume that the first file that requires 'sinatra' is the
    # app_file. all other path related options are calculated based
    # on this path by default.
    set :app_file, caller_files.first || $0
    ...
  end
end

经典风格的 Sinatra 应用本质也是一个最基本的模块化风格,其实现原理是继承了 Sinatra::Application 类。

每个模块化风格的Sinatra必须要继承 Sinatra::Base 。因为 Sinatra::Application 也继承了 Sinatra::Base,因而经典风格就是最基本的模块化Sinatra应用。

Sinatra 扩展

优雅的代码讲究复用和模块化,把常用的功能模块抽象出来做成 Sinatra 扩展。在这章节中,我们继续讨论实现 Sinatra 扩展必备的三点: Sinatra.registerSinatra.helpersModule.registered

扩展通常有两种,分别是使用 Sinatra.helpers 实现的帮助方法模块扩展、使用 Sinatra.register 实现的路由模块扩展。帮助方法扩展的方法在请求上下文中使用:view, routes, helper ,通常把一些能被复用的逻辑代码封装成帮助方法模块扩展,譬如页面渲染帮助方法、判断是否登录的逻辑代码;路由扩展中的方法是 Sinatra::Application 中的类方法,实现了某种功能特性的路由,通常用来封装某个功能模块,譬如�系统登录功能模块、接口访问验签功能模块。

# lib/sinatra/base.rb

# Extend the top-level DSL with the modules provided.
  def self.register(*extensions, &block)
    Delegator.target.register(*extensions, &block)
  end

  # Include the helper modules provided in Sinatra's request context.
  def self.helpers(*extensions, &block)
    Delegator.target.helpers(*extensions, &block)
  end

Extending The DSL (class) Context with Sinatra.register

通过 Sinatra.register 实现的扩展提供了是Sinatra::Application 的类方法。路由扩展例子:

require 'sinatra/base'
module Sinatra
  module LinkBlocker
    def block_links_from(host)
      before {
        halt 403, "Go Away!" if request.referer.match(host)
      }
    end
  end
  register LinkBlocker
end

Sinatra.registerSinatra::Application 添加了一个类方法。在经典风格和模块化风格中使用我们这个扩展如下

# classic style
require 'sinatra'
require 'sinatra/linkblocker'

block_links_from 'digg.com'

get '/' do
  "Hello World"
end

# modular style
require 'sinatra/base'
require 'sinatra/linkblocker'

class Hello < Sinatra::Base
  register Sinatra::LinkBlocker

  block_links_from 'digg.com'

  get '/' do
    "Hello World"
  end
end

路由扩展必须在 Sinatra 模块 中,且通过 Sinatra.register 注册之后才能被 Sinatra 应用使用。而且经典风格的 sinatra 应用直接在顶层 require 对应的扩展 module 即可,而模块化风格的 Sinatra 应用另外还必须在继承类内部通过 Sinatra.register 再次注册路由扩展。

Extending The Request Context with Sinatra.helpers

帮助方法模块扩展为路由、视图或者帮助方法添加方法。下面这个例子实现了方法名为 h 帮助方法。

require 'sinatra/base'

module Sinatra
  module HTMLEscapeHelper
    def h(text)
      Rack::Utils.escape_html(text)
    end
  end

  helpers HTMLEscapeHelper
end

在上面的例子中 helpers HTMLEscapeHelper 方法把扩展中定义的所有模块方法添加到了 Sinatra::Application 中。所以这些方法都能在经典风格中使用。

require 'sinatra'
require 'sinatra/htmlescape'

get "/hello" do
  h "1 < 2"     # => "1 < 2"
end

在经典风格中引用扩展的方法,只需 require 'sinatra/htmlescape' 即可。

但是要想在模块化风格中使用通过 sinatra.helpers 实现的扩展方法,除了在顶层 require 对应的扩展模块,还需要使用 helpers 方法引入:

require 'sinatra/base'
require 'sinatra/htmlescape'

class HelloApp < Sinatra::Base
  helpers Sinatra::HTMLEscapeHelper

  get "/hello" do
    h "1 < 2"
  end
end

使用 registered 模块方法配置路由扩展

通过在模块中实现 registered 模块方法可设置路由扩展的可选项、路由、过滤器和异常处理。模块被引用时,定义在扩展模块中的 registered 方法会被添加到 Sinatra::Base 模块中。registered 方法有一个 app 参数,路由扩展自实现注册后(体现在路由扩展中 register ExtensionsName), Sinatra 会传递当前应用的上下文给 registered 方法(也就是默认的 app 参数),而通过 app 参数就能调用 Sinatra::Base 中定义的 DSL 方法。

实现一个简单的登录验证扩展例子

# lib/sinatra/auth.rb 实现扩展
require 'sinatra/base' # 实现扩展必须要 require 'sinatra/base'
require 'sinatra/flash'

module Sinatra
  module Auth
    module Helpers
      def authorized?
        session[:admin]
      end

      def protected!
        halt 401,slim(:unauthorized) unless authorized?
      end
    end

    def self.registered(app)
      app.helpers Helpers # 注册帮助方法模块

      app.enable :sessions # 开启 Sinatra session
      app.set :username => 'frank', :password => 'sinatra' # 设置可选项

      # 调用 ```Sinatra::Base 定义的 DSL 方法
      app.get '/login' do
        slim :login
      end

      app.post '/login' do
        if params[:username] == settings.username && params[:password] == settings.password
          session[:admin] = true
          flash[:notice] = "You are now logged in as #{settings.username}"
          redirect to('/')
        else
          flash[:notice] = "The username or password you entered are incorrect"
          redirect to('/login')
        end
      end

      app.get '/logout' do
        session[:admin] = nil
        flash[:notice] = "You have now logged out"
        redirect to('/')
      end
    end
  end

  register Auth # 自注册路由扩展

end

# config/application.rb 调用扩展
require 'sinatra/base'
require_relative '../sinatra/auth'

class Application < Sinatra::Base
  set :root, File.dirname(__FILE__) # 设置项目根目录
  set :views, "#{settings.root}/../app/views" # 设置项目视图文件目录
  set :public_folder, 'public'  # 设置静态文件目录

  register Sinatra::Auth # 模块化风格的 Sinatra 应用注册 Auth 路由扩展
end

# 关于帮助方法
# 在 Auth 路由扩展中我们也实现了一个名字为 Helpers 的帮助方法扩展:
# 分别有帮助方法 authorized? 和 protected!
# 上文提到帮助扩展中的帮助方法可以在视图、路由、中使用。
# 使用例子:在视图文件中,想要现实登录按钮可以这样子写
footer
  - if authorized?
    a href="/logout" log out
  - else
    a href="/login" log in

写在最后

对于不想使用 Rails 框架的 Ruby 开发者,Sinatra 是不错的替代框架,其轻量且能够快速开发项目。笔者使用它开发了一个支付渠道中台项目,提供 API 和渲染各种 H5 前端页面开发。这篇文章三个月前就开始写了,由于拖延和惰性断断续续写,每次写都需重新看一遍以前收藏的文章梳理写作思路,异常浪费时间和痛苦。
告诉自己给自己一个交代,做个的项目如果没有总结和反思写下来过不久定会忘光。另外最近在学习 Go ,我想以后会更少地使用 Ruby 开发。

参考链接并值得一读的文章

  • writing sinatra extensions
    教大家写 Sinatra 扩展非常棒的一篇文章。
    首先分析了 Sinatra::BaseSinatra::Application 的作用和两者的差异。

    接着说明 Sinatra 扩展的四个规则

    然后就是介绍 使用 Sinatra.helpers 添加 Sinatra 请求上下文(自定义能够在控制器、视图和帮助方法中使用的方法)和使用 Sinatra.register 添加 DSL(class) 上下文(本质上就是给 Sinatra::Application 添加类方法。其使用场景通常类似 Rails 中 xx_action)

    最后介绍如何通过 module.registered 编写扩展的设置(包括如何定义设置、过滤器、和控制器异常处理)

  • Sinatra Best Practices: Part Two
    如何为sinatra配置 rspec

  • 关于项目配置文件

  • 关于项目文件加载

  • 关于项目目录结构组织

  • Sinatra 中使用 rake 和 active record

  • 构建项目文件结构详细步骤

  • Sinatra applications with RSpec

  • 命名空间

  • 完整 Sinatra 项目 demo

你可能感兴趣的:(JWT 在 Ruby 中的使用总结和 Sinatra 框架解读和构建接口)