如何使用elixir joken在phoenix中验证jwt

2024-08-23

基本需求

我目前存在两个主力web服务运行

  • 国内的服务 帐号主数据库在这里 同步到国外
  • 国外的服务 博客主数据库在这里 同步到国内

我希望为自己的博客增加一个添加博文的功能,但是问题来了,我希望复用国内的帐号体系,虽然数据已经同步过来了,但是自己再去做hash验证,再去签发token是不是有点麻烦呢?能不能直接复用国内服务的token呢,国外只做校验。 jwt是可以这样用的,只要用于签发token的secret相同就可以了。

那么问题就转变为了,如何使用elixir phoenix框架去校验jwt,获取claims。

实现过程

使用joken

elixir存在签发和验证jwt的lib,名为joken,我们将其添加到mix.exs中。

defp deps do
  ...
  {:joken, "~> 1.1"}
  ...
end

然后执行命令安装依赖

mix deps.get

这样,joken就添加到了phoenix项目中。

配置签发密钥

作为密钥,是不太方便直接写入配置文件里的,这里选择了环境变量的方式。 但是在开发环境又想用config/dev.exs文件来写开发环境配置,所以在参照了phoenix数据库的配置之后,作出如下代码变更。

  • 于config/dev.exs文件:
    # Joken jwt sign token
    config :joken, default_signer: "test"
    
  • 于config/runtime.exs文件:
    joken_secret =
        System.get_env("JOKEN_SECRET") ||
        raise """
        environment variable JOKEN_SECRET is missing.
        """
    
    # Joken jwt sign token
    config :joken, default_signer: joken_secret
    

这样,在开发的时候,我们可以直接使用config/dev.exs中配置的文本密钥,在程序运行的时候,则是读取环境中的JOKEN_SECRET变量来进行jwt校验与解密。

注入joken到web服务模块

根据joken的文档,它其实是可以单独调用的,不过更合适的方案是将其作为一个web模块独立起来。具体用法可以参考hexdocs。 我们这里新建lib/be_web/token.ex,内容如下

defmodule BeWeb.Token do
  use Joken.Config
end

这样就可以用BeWeb.Token去调用了。

实现鉴权中间件

接下来是业务逻辑的部分,新建lib/be_web/jwt_auth.ex文件,在其中实现对jwt内容的解析。

defmodule BeWeb.JwtAuth do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    case conn |> get_req_header("token") do
      [token_value] ->
        case BeWeb.Token.verify_and_validate(token_value) do
          {:ok, claims} ->
            exp = claims["exp"]

            # 判断JWT是否已过期
            current_time = Timex.now() |> Timex.to_unix()

            if current_time > exp do
              conn
              |> send_resp(401, "Token has expired.")
              |> halt()
            else
              conn
              |> assign(:user, claims["user"])
            end

          {:error, _} ->
            conn
            |> send_resp(401, "Invalid or expired token.")
            |> halt()
        end

      _ ->
        conn
        |> send_resp(401, "Authorization token not provided.")
        |> halt()
    end
  end
end

其中大致完成了如下内容:

  1. 从请求头中获取名为token字段的值,若不存在,则返回401
  2. 调用的BeWeb.Token.verify_and_validate函数,若校验失败,则返回401
  3. 对比claims['exp']与当前时间戳,若过期,则返回401
  4. 将claims['user']中的用户信息存入当前请求的上下文。

至此中间件就实现了,但感觉3、4这里有些粗糙,是否要增加对exp与user是否有效的校验呢?

为路由增加鉴权

事情到了这里,就变简单了许多。首先开放原先被我注释掉的:update,:delete,:create路由,修改lib/be_web/router.ex文件

  scope "/api", BeWeb do
    pipe_through :api
    # 修改这一行
    resources "/posts", PostController, only: [:index, :show, :update, :delete, :create]
  end

指定增删改接口需要经过中间件的处理,修改lib/be_web/controllers/post_controller.ex文件。

  alias Be.Blog
  alias Be.Blog.Post
  # 增加这一行
  plug(BeWeb.JwtAuth when action in [:create, :update, :delete])

  action_fallback BeWeb.FallbackController

结论与反思

至此,基于jwt的分布式鉴权校验就算完成了,就算是在国内的golang服务签发了一个jwt令牌,只要使用的是同一个签发密钥,在国外的elixir服务仍然可以进行权限鉴定。

但是,密钥到处复制粘贴并不是什么好习惯,因为选用了对称算法去做令牌的签发,所以要把密钥对两个服务器同步,这点显然并不安全。对此,可以使用非对称的算法去做令牌签发,私钥放在签发服务,而公钥放在校验服务进行分发。如此一来,就可以保证签发密钥不需要转移了。(这似乎是私钥加密的一个典例)。

参考与引用

  1. Using JWT and Auth0 for an Elixir/Phoenix web app
  2. Joken Hexdocks

致谢

感谢ChatGPT,帮助我解决了大量基础知识的问题。