前言
上回在 用 Nginx 的 auth_request 模块集成 LDAP 认证 里介绍了如何用 Nginx 的 auth_request 集成外部的第三方认证,以及官方 demo nginxinc/nginx-ldap-auth 的实现。
官方 demo 里直接把用户名密码往 cookie 里写的方式自然是太粗暴了一点,我们尝试重新写一个基于 session 来做验证的 demo。
Demo 基于 Golang 的 Beego 框架来实现,单纯只是因为方便而已。你可以用任何自己熟悉的方式来实现,意思是一样的。
Go 版的 nginx-ldap-auth
项目地址 -- https://github.com/shanghai-edu/nginx-ldap-auth
repo
中提供了一个 nginx.conf
的配置模板,auth_request
上回已经讲过了,不再赘述。简单介绍下路由
location /
# 对应后端的 backend 的 /,测试 demo 中的受保护路径
location /login
# 认证部分
location /logout
# 登出部分
location /captcha
# 验证码部分
location /static
# 静态部分,css, js 等
location /auth-proxy
# auth_request 的校验
Demo 我们用了 Beego 框架,非常适合快速的做一个简单的 Demo 示例。目录结构如下:
# tree
.
├── cfg.example.json
├── cfg.json
├── control
├── g
│ ├── cfg.go
│ └── const.go
├── http
│ ├── controllers
│ │ ├── auth-proxy.go
│ │ ├── control.go
│ │ ├── default.go
│ │ ├── error.go
│ │ ├── login.go
│ │ └── logout.go
│ ├── http.go
│ └── router.go
├── LICENSE
├── main.go
├── nginx.conf
├── README_CN.MD
├── README.MD
├── static
│ ├── css
│ │ ├── bootstrap.min.css
│ │ ├── ie10-viewport-bug-workaround.css
│ │ └── signin.css
│ ├── favicon.ico
│ └── js
│ ├── ie10-viewport-bug-workaround.js
│ └── ie-emulation-modes-warning.js
├── utils
│ ├── ipCheck.go
│ ├── ip_test.go
│ ├── ldap.go
│ ├── ldap_test.go
│ ├── time_check.go
│ ├── time_test.go
│ └── utils.go
└── views
├── deny.tpl
├── direct.tpl
└── login.tpl
路由
同样我们从路由开始看。我们通过 RESTful Controller
的方式注册了这些路由。
# http/router.go
beego.Router("/", &controllers.MainController{})
beego.Router("/login", &controllers.LoginController{})
beego.Router("/logout", &controllers.LogoutController{})
beego.Router("/auth-proxy", &controllers.AuthProxyController{})
beego.Router("/api/v1/:control", &controllers.ControlController{})
除了之前在 nginx.conf
中提及的,api/v1/
部分提供了一些简单的控制管理的 API。(例如热重载配置)
静态文件部分,我们通过 beego.SetStaticPath
建立,然后把 css
等都丢到 static
目录里就好了
# http/http.go
beego.SetStaticPath("/static", "static")
基于 session 的认证
我们说了,生成环境上的认证控制,肯定要通过 session
来做,不可能把用户名密码直接写到 cookie
里去的,所以我们在 beego
里开启 session
,用默认的内存模式就好了。
# http/router.go
beego.BConfig.WebConfig.Session.SessionOn = true
beego.BConfig.WebConfig.Session.SessionName = "sessionID"
这样我们的 login, logout 就非常好处理了。操作 session
就好了嘛。login 就加一条 session
# http/controllers/login.go
func (this *LoginController) Post() {
this.Ctx.Request.ParseForm()
username := this.Ctx.Request.Form.Get("username")
password := this.Ctx.Request.Form.Get("password")
target := this.Ctx.Request.Form.Get("target")
err := utils.LDAP_Auth(g.Config().Ldap, username, password)
if err == nil {
this.SetSession("uname", username)
this.Ctx.Redirect(302, target)
}
}
logout 就删掉 session
# http/controllers/logout.go
func (this *LogoutController) Get() {
clientIP := this.Ctx.Input.IP()
uname := this.GetSession("uname")
if uname != nil {
this.DelSession("uname")
}
this.Ctx.Redirect(302, "/")
}
/auth-proxy
上校验也就很简单了,查 session
就好了
# http/controllers/auth-proxy.go
func (this *AuthProxyController) Get() {
this.Ctx.Output.Header("Cache-Control", "no-cache")
uname := this.GetSession("uname")
if uname == nil {
this.Ctx.Abort(401, "401")
return
}
this.Ctx.Output.Body([]byte("ok"))
}
login 认证
回过头来我们看 /login
的代码。首先是 LDAP 认证,Go 上的 LDAP 认证我在 用 Go 写一个轻量级的 ldap 测试工具 写过,用 go-ldap/ldap 这个库就好,很简单。
我们把表单里拿到的用户名密码去做个 LDAP 认证,如果通过了写进 session
,然后重定向给 target
就好了。
还记得 target
吗?我们在 nginx
中送在 header
里的 X-Target
字段,我们通过这个字段来决定认证之后往哪里跳转。和之前 python 的 demo 一样,我们把这个字段放在表单里,以 hidden
的方式重新提交上来以使用。
# http/controllers/login.go
target := this.Ctx.Input.Header("X-Target")
this.Data["target"] = target
# http/views/login.tpl
<input type="hidden" name="target" value={{.target}}>
现在思考一个问题,用户认证失败的时候怎么办?
肯定会重新跳回 /login
上对不对?但是此时获得的 X-Taget
已经不是请求资源的路径了,而会变成 /login
。为什么?
我们回过来看看 nginx
中的配置:
# nginx.conf
location / {
auth_request /auth-proxy;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
error_page 401 =200 /login;
proxy_pass http://backend/;
}
location /login {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Target $request_uri;
proxy_pass http://backend/login;
}
诶? 插入 X-Target
的 location
是 /login
诶,这样说来,首次请求时获得的 X-Target
是 /
才比较奇怪吧?(这里 /
是受 auth_request
保护的路径),为什么?
因为在受保护路径中,我们的重定向是通过 error_page 401=200 /login
做的,这里其实是一种 nginx
内部的重定向,此时url
是不会变的。看看我们上回 demo 里的代码和运行截图:
if url.path.startswith("/login"):
return self.auth_form()
对不对,表单是在
/login
上的,但是我们请求资源跳过来时路由仍然是 /
。
nginxinc/nginx-ldap-auth 这个 demo 的实现里不需要考虑认证失败的问题,因为他的认证处理逻辑全部在 /auth-proxy
里面——就是用户名密码直接写进 cookie
里去了。然后在 nginx
内部请求 /auth-proxy
时从 cookie
里拆出用户名密码来做校验。所有认证错误产生的重定向,都由 /auth-proxy
返回 401 然后最终回到 nginx
的 error_page
上,变成一个循环。
我们一开始就拒绝了用户名密码进 cookie
嘛,所以这个方案不行。我们的逻辑必须放在 login
上面。
所以我们选择把 target
的信息,以 Get
的方式重新带回到认证失败的请求上去。这样我们在认证失败的 /login
上,就可以通过 Get
方式把 target
重新拿过来。
# http/controllers/login.go
this.Ctx.Redirect(302, fmt.Sprintf("/login?target=%s", target))
验证码
为了防止被暴力撞密码,基本的验证码策略还是要是做的。好在 beego
直接内置了验证码的库,所以这事就很简单了。。。
首先引入 beego
的 cache
模块和 captcha
模块
# http/controllers/login.go
import (
"github.com/astaxie/beego/cache"
"github.com/astaxie/beego/utils/captcha"
)
然后我们需要增加验证码初始化的代码。开启 cache
,验证码的字数,长度宽度什么的。
# http/controllers/login.go
func init() {
store := cache.NewMemoryCache()
cpt = captcha.NewWithFilter("/captcha/", store)
cpt.ChallengeNums = 6
cpt.StdWidth = 120
cpt.StdHeight = 40
}
考虑这个验证码其实挺考验眼力的。。。所以我们只在用户认证失败的时候再增加验证码。认证失败的信息通过 session
来记录
# http/controllers/login.go
loginFailed := this.GetSession("loginFailed")
if loginFailed != nil {
this.Data["captcha"] = true
}
在模板里,我们把这块根据 captcha
的值做个判断,来决定是否开启验证码。
# http/views/login.tpl
{{if .captcha}}
<div class="form-group">
<div class="row">
<div class="col-md-6">
<input class="form-control" name="captcha" type="text" placeholder="Captcha" required>
</div>
<div class="col-md-6">
{{create_captcha}}
</div>
</div>
</div>
{{end}}
如果开启了验证码,那么拿收到的验证码做验证就好了,验证方法也给封装好了,beego
很贴心呢。
# http/controllers/login.go
if _, ok := this.Ctx.Request.Form["captcha"]; ok {
if !cpt.VerifyReq(this.Ctx.Request) {
beego.Notice(fmt.Sprintf("%s - - [%s] Login Failed: Captcha Wrong", clientIP, logtime))
this.Ctx.Redirect(302, fmt.Sprintf("/login?loginFailed=3&target=%s", target))
return
}
}
XSRF
由于我们的请求要通过 cookie 来校验,那么开启 XSRF 就很有必要了。beego 可以很方便的开启 XSRF —— 跨站请求伪造
特殊策略
上回我们还说过,有时候我们需要根据 IP ,或者根据时间来做一些特殊的策略。
其实做法也很简单,在 /login
的时候根据请求的 IP 和时间做个判断,然后直接写入 session
或者直接拒绝访问就好了。
此外,有时候我们希望限制仅允许部分 LDAP 用户来访问,但是 LDAP 内属性又不太完整,不太方便通过 Filter
的方式来做。那么我们也可以在 login
的时候通过检查请求的用户名来直接做过滤。
最后的 control
配置就会变成这样。
"control":{
"ipAcl":{
"deny":["192.168.2.10","192.168.0.0/24","192.168.1.0-192.168.1.255"],
"direct":[]
},
"timeAcl":{
"deny":["00:00-8:00","17:00-23:59"],
"direct":[]
},
"allowUser":["user1"]
},
应用示例
ELK(Kibana) + 认证
众所周知,ELK 中的 Kibana 默认是没有认证功能的,他的认证模块集成在 X-Pack
的高级授权里。所以我们要限制 kibana 访问的时候,通常就是限下 ip 地址完事。
现在我们可以通过 shanghai-edu/nginx-ldap-auth 来实现了。
首先我们给 kibana 增加一个路径后缀,以便于在 nginx
上区分。如下修改 kibana.yml
就可以了,修改完重启 kibana 。
# Enables you to specify a path to mount Kibana at if you are running behind a proxy. This only affects
# the URLs generated by Kibana, your proxy is expected to remove the basePath value before forwarding requests
# to Kibana. This setting cannot end in a slash.
server.basePath: "/kibana"
现在我们给 nginx
增加对 Kibana 的路径保护配置。
upstream elk {
server elk.local:5601;
}
server {
…………
location /kibana/ {
auth_request /auth-proxy;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
error_page 401 =200 /login;
proxy_pass http://elk/;
}
…………
好啦,现在我们安装我们的 nginx-ldap-auth
就好了,直接下载编译好的 release
解压,修改配置文件,启动即可
# tar -zxvf nginx-ldap-auth-0.1.3.tar.gz
# mv cfg.example.json cfg.json
# ./control start
好了,现在我们访问 kibana 时就会弹出认证 Portal 了
认证之后,正常访问进入 kibana,美滋滋。
传说中的 webvpn
webvpn 本来大多指的是 ssl vpn,也就是基于 ssl (其实现在应该说 tls 了)来建立隧道的 vpn 技术。因为基于 SSL,所以大多时候仅通过浏览器就能够使用,当然通常需要装一些插件:
https://www.cisco.com/c/en/us/about/security-center/ssl-vpn-security.html
Today, this SSL/TLS function exists ubiquitously in modern web browsers. Unlike traditional IP Security (IPSec) remote-access VPN technology, which requires installation of IPSec client software on a client machine before a connection can be established, users typically do not need to install client software in order to use SSL VPN. As a result, SSL VPN is also known as “clientless VPN” or “Web VPN”.
而现在 webvpn 通常特指无需任何插件或客户端,纯 "web" 式访问的 web vpn,甚至已经有了 百度百科。
之所以能实现无浏览器依赖和无插件依赖,是因为这种模式真的是纯 "web" 的,也就是说只能使用 "webvpn" 来访问 web 资源,你想 vpn 上来然后开个 ssh 或者 mstsc 上的是没可能的。(业内有利用 webtty 这样的方式来实现这种需求,这是后话)。
在我了解的一些产品里,他的具体实现就是个叠加了认证的反向代理。这很好理解,既然本质上是反向代理,自然就无浏览器依赖了,因为最终访问的还是原来的网站嘛。
而且我们可以利用反向代理上的控制策略,还能顺便实现诸如特定时间开启日志,对特定 IP 地址(比如内网地址)直通访问等等,来灵活的实现一些复杂的需求。
2018/07/03 16:43:33.395 [N] 192.168.95.65 - 192.168.95.65 [03/Jul/2018 04:43:33] Login Successed: Direct IP
2018/07/03 16:44:08.153 [N] 127.0.0.1 - - [03/Jul/2018 04:44:08] Config Reloaded
2018/07/03 16:44:14.049 [N] 192.168.95.65 - 192.168.95.65 [03/Jul/2018 04:44:14] Logout Successed
2018/07/03 16:44:14.839 [N] 192.168.95.65 - - [03/Jul/2018 04:44:14] Login Failed: IP 192.168.95.65 is not allowed
2018/07/03 16:44:57.971 [N] 127.0.0.1 - - [03/Jul/2018 04:44:57] Config Reloaded
2018/07/03 16:45:00.398 [N] 192.168.95.65 - timeDirect [03/Jul/2018 04:45:00] Login Successed: Direct Time
2018/07/03 16:45:29.570 [N] 127.0.0.1 - - [03/Jul/2018 04:45:29] Config Reloaded
2018/07/03 16:45:32.980 [N] 192.168.95.65 - timeDirect [03/Jul/2018 04:45:32] Logout Successed
2018/07/03 16:45:32.991 [N] 192.168.95.65 - - [03/Jul/2018 04:45:32] Login Failed: This Time is not allowed
参考文献
ssl-vpn-security
webvpn_百度百科
beego 简介