一个OpenResty里OAuth 2认证的轮子(中)

精巧的Lua

在这个小教程的上篇,我们搭了一个非常简单的Docker容器,并把OpenResty跑起来了。在中篇我们来开始探索一下OpenResty的一些用法。作为一个Python程序员,我可能会不时地拿出Flask来做对比。当然,first things first,最重要的还是祭出OpenResty官方文档

首先我们在之前的 nginx.conf 配置里,看到了这么一句 ngx.say(...),这就相关于往请求方发信息,有点Flask里的返回一个Response对象的意思。不过这里并没有那么多面向对象设计的条条框框,一个请求结束,甭管是JSON还是HTML,都是返回一串东西。

我想大家多少都有玩过一些Nginx的基本配置,知道大概怎么写location block,Apache也是差不多一个路数。我们现在就来写这个Demo服务的登陆接口。这个接口的行为就是,在被访问的时候,返回OAuth平台对应的跳转登陆网址,所以会是一个30X的跳转请求。通过查询文档,我们看到对应的OpenResty API是 ngx.redirect。再通过查询GitHub的登陆说明,取code的API在https://github.com/login/oauth/authorize,必填的参数为 client_idredirect_urlscope。假设在GitHub里注册App后拿到的Client ID和Client Secret分别叫 client_idclient_secret,那我们的这个新location block就可以这么写:

location /auth/github/login {
  content_by_lua_block {
    local params = ngx.encode_args({
      client_id = client_id,
      redirect_uri = 'http://test.myoauth.com/auth-cb/github/login',
      scope = 'user',
    })
    local url = 'https://github.com/login/oauth/authorize?' .. params
    ngx.log(ngx.DEBUG, url)
    return ngx.redirect(url)
  }
}

稍微解释一下两个地方:ngx.encode_arg 会把一个Lua的table进行URL路径转义,变成请求参数字符串;ngx.log 经过我们一开始的配置,日志等级为DEBUG,统一写到 resty/logs/error.log 这个文件里。现在请干掉容器进程再up一遍,然后访问 http://localhost/auth/github/login

$ curl -I http://localhost/auth/github/login
HTTP/1.1 302 Moved Temporarily
Server: openresty/1.11.2.3
Date: Fri, 30 Jun 2017 06:39:29 GMT
Content-Type: text/html
Content-Length: 167
Connection: keep-alive
Location: https://github.com/login/oauth/authorize?redirect_uri=http%3A%2F%2Ftest.myoauth.com%2Fauth-callback%2Flogin%2Fcallback&scope=user

浏览器里打开就会被跳转到GitHub的登录页。登录以后跳转回来说找不到DNS?当然了,那个回调URL是我乱写的,去host文件里加成127.0.0.1就会连到本机,就会出一个404咯。好啦,接下来的东西就是自己读文档啦!

……

逗你的,不过应该通过这几个入门例子大家已经知道怎么搜文档找API了。

现在我们已经可以跳转到OAuth平台,让用户在平台上登陆,然后平台会重新把用户导回来,并带上一串 &code=blahblahblah。这个 code 就是OAuth登陆的第一步,获取认证码;我们接下来会用认证码来换取令牌符;最后再用令牌符去获得用户信息。

一个很自然的问题就是,接下来的步骤这么复杂,难道都直接把Lua代码强行写在这个Nginx配置里?有没有办法把模块分出来?

答案是肯定的,不过我要配置一个指令,在Nginx配置的http block里加上一句 lua_package_path '$prefix/lua/?.lua;;';$prefix 代表Nginx的启动位置,就是我们的 resty 文件夹。这样我们就可以再创建一个 resty/lua 文件夹,把我们的Lua代码放在里面,再从Nginx的配置里引用到它们了。

另一个问题,每次修改代码和配置,现在都需要重启一下容器来重启OpenResty,有没有在开发阶段省事一些的方法?答案也是肯定的。如果把代码写到外部的Lua脚本里再引用进来的话,可以同样在http block里加上这一句 lua_code_cache off;,就可以让每个请求独立运行一遍Lua脚本。默认的行为是在一次运行之后,OpenResty会把Lua脚本Cache住,LuaJIT也会做对应的JIT编译优化,来保证线上服务的性能。但是我们在开发阶段需要的是代码热重载,就把这个脚本缓存先关掉了。

现在我们有了一个代码模块化的机制,优先考虑的当然是创建一个OAuth相关的模块:

-- resty/lua/oauth.lua

local cjson = require('cjson.safe')
cjson.encode_empty_table_as_object(false)

local M = {}
local _conf = nil

local function code_url()
  local params = ngx.encode_args({
    client_id = _conf.client_id,
    redirect_uri = _conf.redirect_uri,
    scope = _conf.scope,
  })
  return _conf.code_endpoint .. '?' .. params
end

function M.get_code()
  return ngx.redirect(code_url())
end

function M.get_profile(code)
  ngx.say(cjson.encode({ code = code })
end

function M.init(conf)
  _conf = conf
end

return M

用一个 M 元表来装住所有暴露出去的接口是Lua常用的一种模块定义方式。cjson 是OpenResty提供的一个JSON工具库。这里把OAuth平台跳转回来的回调函数也定义好了,虽然什么都没做,只是把code原样返回。现在我们要在Nginx配置里找一个合适的时候初始化这个模块,在对应的请求到来的时候调用它们:

http {
  include /usr/local/openresty/nginx/conf/mime.types;
  lua_package_path '$prefix/lua/?.lua;;';
  lua_code_cache off;

  # 就是这里了,init 的时候也 init 我们的模块
  init_by_lua_block {
    require('oauth').init({
      code_endpoint = 'https://github.com/login/oauth/authorize',
      token_endpoint = 'https://github.com/login/oauth/access_token',
      profile_endpoint = 'https://api.github.com/user',
      client_id = 'client_id',
      client_secret = 'client_secret',
      redirect_uri = 'http://test.myoauth.com/auth-cb/github/login',
      scope = 'user',
    })
  }

  server {
    listen 80;

    location / {
      content_by_lua_block {
        ngx.say('hello world')
      }
    }

    location /auth/github/login {
      content_by_lua_block {
        return require('oauth').get_code()
      }
    }

    location /auth-cb/github/login {
      content_by_lua_block {
        return require('oauth').get_profile(ngx.var.arg_code)
      }
    }
  }
}

接下来我们要面对的问题就比较复杂了:拿到临时的谁码以后,我们需要在服务器里发两个请求,一个用来换取令牌符,一个用来查询用户信息。这时我们就会需要之前Dockerfile里最下面那行用OPM安装的resty-http库来发起服务器端的网络请求。OpenResty的库文档和就是代码仓库里的README,大家可以直接去项目主页围观。不过我习惯对这个库做一个浅封装来统一我的错误处理:

-- resty/lua/requests.lua

local http = require('resty.http')
local cjson = require('cjson.safe')
cjson.encode_empty_table_as_object(false)

local errors = {
  UNAVAILABLE = 'upstream-unavailable',
  QUERY_ERROR = 'query-failed'
}

local M = { errors = errors }

local function request(method)
  return function(url, payload, headers)
    headers = headers or {}
    headers['Content-Type'] = 'application/json'
    local httpc = http.new()
    local params = { headers = headers, method = method }
    if method == 'GET' then params.query = payload
    else params.body = payload end
    local res, err = httpc:request_uri(url, params)
    if err then
      ngx.log(ngx.ERR, table.concat(
        {method .. ' fail', url, payload}, '|'
      ))
      return nil, nil, errors.UNAVAILABLE
    else
      if res.status >= 400 then
        ngx.log(ngx.ERR, table.concat({
          method .. ' fail code', url, res.status, res.body,
        }, '|'))
        return res.status, res.body, errors.QUERY_ERROR
      else
        return res.status, res.body, nil
      end
    end
  end
end

M.jget = request('GET')
M.jput = request('PUT')
M.jpost = request('POST')

return M

上面这段脚本用了一个函数式编程里很常用的模式叫柯里化。Lua的精巧就在于它用类C的语法实现了Scheme里最核心的函数式思想,和ES5一样,提供了一个很好的语言核心;但胜过ES5的地方又在于它很早就支持了协程,让它能如丝般顺滑地被集成到Nginx的异步事件循环中……

啊扯远了,我把这个封装过的工具库叫 requests.lua(简直不要脸——用Python的童鞋轻喷)。它也是我们在下篇里会继续扩展的 oauth.lua 里要调用的一个基础库。大家可以先尝试着读一读lua-resty-http的文档,玩一玩这个小封装,我们下篇再见。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 222,183评论 6 516
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,850评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,766评论 0 361
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,854评论 1 299
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,871评论 6 398
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,457评论 1 311
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,999评论 3 422
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,914评论 0 277
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,465评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,543评论 3 342
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,675评论 1 353
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,354评论 5 351
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 42,029评论 3 335
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,514评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,616评论 1 274
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 49,091评论 3 378
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,685评论 2 360

推荐阅读更多精彩内容