使用 Lua 编写一个 Nginx 认证模块

In the last two days, I’ve had to solve a rather interesting problem. I have an nginx instance proxying various servers, and I need to be able to add an authentication layer that will authenticate people with an external source (such as a web app) and allow them to pass through the proxy if they have an account on the authentication source (the web app, in this example).

Exploring the requirements

I considered various solutions for this, and I will list a few alternatives:

  • A simple Python/Flask module that would do the actual proxying and authentication.
  • An nginx module that would authenticate using subrequests (nginx can now do that).
  • Using nginx’s Lua module to write some authentication code.

 

译者信息

过去两天里,我解决了一个非常有趣的问题。我用一个nginx服务器作为代理,需要能够向其中添加一个认证层,使其能够使用外部的认证源(比如某个web应用)来进行验证,如果用户在外部认证源有账号,就可以在代理里认证通过。

需求一览

我考虑了几种解决方案,罗列如下:

  • 用一个简单的Python/Flask模块来做代理和验证。
  • 一个使用subrequests做验证的nginx模块(nginx目前可以做到这一点)
  • 使用Lua编写一个nginxren认证模块

It became clear early on that adding another request to the whole system wouldn’t work very well, because of the added latency (it would be annoying to do this on every single request for every file on a page). This means that the subrequest module is out. The Python/Flask solution also feels like it’s bypassing a lot of nginx, so it’s also out. This leaves Lua, which nginx supports natively.

 

Since I don’t want to authenticate with the external server on every request, I decided to generate tokens which people can store and just present to the server so they are let through. However, since the Lua module doesn’t have any way (that I have found) to keep state, we can’t actually store these tokens anywhere. How do you verify that the user is who they say they are when you have no memory?

译者信息

很显然,给整个系统添加额外请求将执行的不是很好,因为这将会增加延迟(特别是给每一个页面文件都增加一个请求是很让人烦恼的).这就意味着我们把subrequest模块排除在外了。Python/Flash解决方案好像对nginx支持的也并不好,所以咱也把它排除了。就剩Lua了,当然nginx对原生化支持得不错的。

因为我不想再扩展的服务器上对每一个请求都做认证,所以我决定生成一些令牌,这样人们就可以将它保存起来,并把它呈现给服务器,然后服务器就让请求通过。然而,因为Lua模块没有一种保持状态的方式(我已经发现),所以我们不能将令牌随处存储。当你没有更多的内存时,怎样来验证用户所说的话呢?

 

Solving the problem

Cryptographic signing to the rescue! We can give our users signed cookies with a username and expiration date, and very easily verify that they actually are who they say they are, while easily being able to expire the tokens as well.

With nginx, all we need to do is specify theaccess_by_lua_file /our/file.luadirective in the location config, and that location will be protected with our script. Now, to write the actual checking code:

-- Some variable declarations.
local cookie = ngx.var.cookie_MyToken
local hmac = ""
local timestamp = ""
local timestamp_time = 0

-- Check that the cookie exists.
if cookie == nil or cookie:find(":") == nil then
    -- Internally rewrite the URL so that we serve
    -- /auth/ if there's no cookie.
    ngx.exec("/auth/")
else
    -- If there's a cookie, split off the HMAC signature
    -- and timestamp.
    local divider = cookie:find(":")
    hmac = cookie:sub(divider+1)
    timestamp = cookie:sub(0, divider-1)
end

-- Verify that the signature is valid.
if hmac_sha1("some very secret string", timestamp) ~= hmac or tonumber(timestamp) < os.time() then
    -- If invalid, send to /auth/ again.
    ngx.exec("/auth/")
end

The code above should be pretty straightforward. The signing happens by taking some plaintext (in this case, a timestamp, but it can be anything you want), and HMACing it with a secret key, producing a signature which only we can generate, and which the user cannot tamper with without invalidating it.

译者信息

解决问题

加密签名的方式可是咱的救星!我们可以拿用户的用户名和过期时间数据来给用户添加签名的cookies,这样就能很容易的验证每个用户是谁了,同时我们就不用令牌了。

在nginx中,我们要做的就是直接在指定位置配置access_by_lua_file /our/file.lua,这样这个指定位置就可以保护我们的脚本了。现在,让我们一起来写代码:

 

-- Some variable declarations.
local cookie = ngx.var.cookie_MyToken
local hmac = ""
local timestamp = ""
local timestamp_time = 0

-- Check that the cookie exists.
if cookie == nil or cookie:find(":") == nil then
    -- Internally rewrite the URL so that we serve
    -- /auth/ if there's no cookie.
    ngx.exec("/auth/")
else
    -- If there's a cookie, split off the HMAC signature
    -- and timestamp.
    local divider = cookie:find(":")
    hmac = cookie:sub(divider+1)
    timestamp = cookie:sub(0, divider-1)
end

-- Verify that the signature is valid.
if hmac_sha1("some very secret string", timestamp) ~= hmac or tonumber(timestamp) < os.time() then
    -- If invalid, send to /auth/ again.
    ngx.exec("/auth/")
end

上面的代码可以直接运行。我们用一些明文来签名(这种情况下用的是一个时间戳,当然你可以用任何你想用的),之后我们用密文生成HMAC(哈希信息认证码),然后一个签名就生成了,这样用户就不能篡改为无效信息了。

 

 

When the user tries to load a resource, we check that the signature in the cookie is valid, and let them pass if it is. Otherwise, we redirect them to a token issuing server, which will authenticate and give them a signed token if not.

The more astute of you will have noticed that the code above contains a timing vulnerability. If you didn’t notice it, don’t feel bad. Well, maybe just a little bit.

Here’s a piece of Lua code that should compare two strings for equality and be constant-time (thus foiling any timing attack), unless I’ve overlooked something, which is highly probable:

function compare_strings(str1, str2)
    -- Constant-time string comparison function.
    local same = true
    for i = 1, #str1 do
        -- If the two strings' lengths are different, sub()
        -- will just return nil for the remaining length.
        c1 = str1:sub(i,i)
        c2 = str2:sub(i,i)
        if c1 ~= c2 then
            same = false
        end
    end
    return same
end

I have timed this function and, as far as I can tell, it is constant-time with equal-length strings. Strings with different lengths will change the timings a bit, perhaps becausesub()takes a different branch. Also, theif c1 ~= c2branch is obviously not constant-time, but in practice it’s close enough that it won’t matter for our case. I’d prefer XORing the two strings together and seeing if the result is 0, but Lua doesn’t seem to include a bitwise XOR operator. If I’m wrong on this, I would appreciate a correction.

译者信息

当用户试图载入一个资源的时候,我们会检查cookie里面的签名是否有效,如果是,就通过他的请求。反之,我们会把他们重定向到一个发行口令的服务器,这个服务器会验证并且在没有的情况下给予他们一个签名的口令。

明锐的你可能会发现,上面的代码存在时间上的漏洞。如果你没有发现,别难过。嗯,也许会有点难过。

这里是一段Lua的代码,用来比较两个字符串在恒定时间上的等值关系(因而能够阻止任何时间上的攻击,除非我忽视了什么,这极为可能

function compare_strings(str1, str2)
    -- Constant-time string comparison function.
    local same = true
    for i = 1, #str1 do
        -- If the two strings' lengths are different, sub()
        -- will just return nil for the remaining length.
        c1 = str1:sub(i,i)
        c2 = str2:sub(i,i)
        if c1 ~= c2 then
            same = false
        end
    end
    return same
end

我已经在函数上应用了时间来区分,如我所知,这是一个在恒定时间下的等值字符串。不同长度的字符串会稍稍改变时间,也许是因为子过程sub应用了一个不同的分支而导致的。而且,c1~=c2分支显然不是恒定时间的,但是在实际中,它相当接近恒定,所以于我们的例子不会有影响。我更倾向于使用XOR操作,从而确定两个字符串的XOR结果是否为0, 不过Lua似乎不包括二进制位的XOR操作。如果我在这个判断上有误,对于任何纠正我都很感激。

 

The token issuing server

Now that we have some amazing token-checking code written, all we need to do is write a server to actually issue these tokens. I could have written this in Python with Flask, but I wanted to give Go a shot because I’m a language hipster and Go seems cool. It would probably take a bit less time to do it in Python, but I enjoy it.

This server will pop up an HTTP basic auth form, check the credentials you enter and, if they are correct, it will give give you a signed token which is good for one (1) hour of proxy access. That way, you only have to authenticate with the external service once, and subsequent authentication checks are done at the nginx layer and are pretty fast.

译者信息

口令发行服务器

现在,我们已经写了一些很棒的口令检查代码,所有需要做的,只是写一个服务器来真正的发行这些口令。我本可以用Python以及Flask来写这个服务器,不过我还是想用Go做一个尝试,因为我是一个计算机语言潮人而且Go看上去“酷”。使用Python大概会快一些,不过我乐意用Go。

这个服务器会弹出一个HTTP基础验证的表单,检查你输入的帐户,如果正确,它会给你一个签名的口令,适合于一个小时的代理服务器访问。这样,你只需要验证外部服务一次,而随后的身份验证的检查将在nginx层面,而且会相当的快。

 

The request handler

Writing a handler that would pop up a basic auth box wasn’t very hard, but Go isn’t terribly well documented, so I had to hunt around a bit. It was pretty simple, in the end, and here it is, HTTP basic authentication in Go:

func handler(w http.ResponseWriter, r *http.Request) {
    if username := checkAuth(r); username == "" {
        w.Header().Set("WWW-Authenticate", `Basic realm="The kingdom of Stavros"`)
        w.WriteHeader(401)
        w.Write([]byte("401 Unauthorized\n"))
    } else {
        fmt.Printf("Authenticated user %v.\n", username)
        token := getToken()
        setTokenCookie(w, token)
        fmt.Fprintf(w, "<html><head><script>location.reload()</script></head></html>")
    }
}

 

译者信息

请求处理器

写一个处理器,来弹出一个基本的验证窗体不是很难,但是Go没有完美的文档,所以我必须自己一点点寻猎。其实非常简单,最终,这里就是HTTP基本验证的Go代码:

func handler(w http.ResponseWriter, r *http.Request) {
    if username := checkAuth(r); username == "" {
        w.Header().Set("WWW-Authenticate", `Basic realm="The kingdom of Stavros"`)
        w.WriteHeader(401)
        w.Write([]byte("401 Unauthorized\n"))
    } else {
        fmt.Printf("Authenticated user %v.\n", username)
        token := getToken()
        setTokenCookie(w, token)
        fmt.Fprintf(w, "<html><head><script>location.reload()</script></head></html>")
    }
}

 

Setting the token and cookie

Once we’ve authenticated the user, we need to set a cookie with their token. We just do the same thing we did in Lua, above, only much more easily because Go includes actual crypto packages in the standard library. The code for this is equally straightforward, if underdocumented:

func getToken() string {
    expiration := int(time.Now().Unix()) + 3600
    mac := hmac.New(sha1.New, []byte("some very secret string"))
    mac.Write([]byte(fmt.Sprintf("%v", expiration)))
    expectedMAC := fmt.Sprintf("%x", mac.Sum(nil))

    return fmt.Sprintf("%v:%s", expiration, expectedMAC)
}

func setTokenCookie(w http.ResponseWriter, token string) {
    rawCookie := fmt.Sprintf("MyToken=%s", token)
    expire := time.Now().Add(time.Hour)
    cookie := http.Cookie{"MyToken",
        token,
        "/",
        ".example.com",
        expire,
        expire.Format(time.UnixDate),
        3600,
        false,
        true,
        rawCookie,
        []string{rawCookie}}
    http.SetCookie(w, &cookie)
}

 

译者信息

设置口令和cookie

一旦我们验证了一个用户之后,我们需要给他们的口令设置一个cookie。我门只需要做我们用Lua做过的同样的事情,如上,只是更加简单,因为Go在标准库里面就包括一个真加密包。这个代码一样很直接明了,即使没有完全文档化:

func getToken() string {
    expiration := int(time.Now().Unix()) + 3600
    mac := hmac.New(sha1.New, []byte("some very secret string"))
    mac.Write([]byte(fmt.Sprintf("%v", expiration)))
    expectedMAC := fmt.Sprintf("%x", mac.Sum(nil))

    return fmt.Sprintf("%v:%s", expiration, expectedMAC)
}

func setTokenCookie(w http.ResponseWriter, token string) {
    rawCookie := fmt.Sprintf("MyToken=%s", token)
    expire := time.Now().Add(time.Hour)
    cookie := http.Cookie{"MyToken",
        token,
        "/",
        ".example.com",
        expire,
        expire.Format(time.UnixDate),
        3600,
        false,
        true,
        rawCookie,
        []string{rawCookie}}
    http.SetCookie(w, &cookie)
}

 

Tying it all together

To finish our big bundle of amazingness, we just need a function that checks the authentication provided by the user, and we’re done! Here’s what I lifted off some library, it currently only checks for a specific username/password combination, so integrating with third-party services is left as an exercise for the reader:

func checkAuth(r *http.Request) string {
    s := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
    if len(s) != 2 || s[0] != "Basic" {
        return ""
    }

    b, err := base64.StdEncoding.DecodeString(s[1])
    if err != nil {
        return ""
    }
    pair := strings.SplitN(string(b), ":", 2)
    if len(pair) != 2 {
        return ""
    }
    if pair[0] != "username" || pair[1] != "password" {
        return ""
    }
    return pair[0]
}

 

译者信息

尝试把他们放在一起

来完成我们这一大段美妙的组合,我们只需要一个函数,用来检查由用户提供的验证信息,而且我们做到了!这里是我从一些库里面汲取出来的代码,当前它只是检查一个特定的用户名/密码的组合,所以和第三方的服务的集成就做为留给读者的作业吧:

func checkAuth(r *http.Request) string {
    s := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
    if len(s) != 2 || s[0] != "Basic" {
        return ""
    }

    b, err := base64.StdEncoding.DecodeString(s[1])
    if err != nil {
        return ""
    }
    pair := strings.SplitN(string(b), ":", 2)
    if len(pair) != 2 {
        return ""
    }
    if pair[0] != "username" || pair[1] != "password" {
        return ""
    }
    return pair[0]
}

 

Conclusion

I have taken quite a liking to nginx’s Lua module. It allows you to perform simple operations right in the web server’s request/response cycle, and it makes a lot of sense for some things like authentication checks to proxied web servers. This sort of thing would have been very hard to do without a programmable web server, as we would pretty much have to write our own HTTP proxy.

The code above is pretty short and rather elegant, so I’m very happy with it overall. I’m not sure how much time it adds to the response, but, given that authentication is necessary, I think it will be worth it (and fast enough not to be a problem anyway).

Another good thing about it is that you can enable this with just a single directive in an nginxlocationblock, so there’s no long configuration to keep track of. I find it a very elegant solution overall, and am very glad to know nginx lets me do something like this if I need it in the future.

If you have any recommendations or feedback, please leave a comment (especially if I’ve screwed something up).

 

 

 

 

 

译者信息

结论

我到目前对于nginx的Lua模块还是有着相当的喜欢。它允许你在web服务器的请求/响应周期里面做一些简单的操作,而且对于某些操作,比如为代理服务器做验证的检查,是很有意义的。这些事情对于一个不可编程的web服务器,一直很难,因此我们极可能需要写自己的HTTP代理服务。

上面的代码相当的简短,而且优雅,所以我对于上面的所有都感到高兴。我不能确定,这对于响应添加了多少额外的时间,不过,做一个验证是有好处的,我想这将值得去做(而且应该足够快,所以不是一个问题)。

另一个好处就是,你可以仅使用一个在nginxlocationblock里面的单独的directive来开启它,所以没有需要跟踪的配置项。我发现,总体而言,这是一个非常优雅的解决方案,而且我很高兴的了解到nginx可以让我去做这样的事情,可能是将来我需要去做的。

如果你有任何建言或者是反馈,请留下你的评语(特别是如果我把某些地方给弄错了)。

猜你喜欢

转载自hugoren.iteye.com/blog/2269503
今日推荐