用 Nginx 的 auth_request 模块集成 LDAP 认证

前言

很多时候,我们需要给一些本没有身份认证功能的业务增加一个认证模块。

  • 比如免费版的 ELK,Kibana 上是没有身份认证的;
  • 比如 0.1 版的 Open-Falcon,Dashboard 上也是没有认证的;
  • 又或者一些本来对外公开的网站,突然在某些特殊的日子,在某些特殊的时间里,不希望对外公开了。。。

直接修改业务的侵入式方案通常不太容易,非侵入式的方案一般也能实现类似的效果,比如给他增加一个代理然后做 http basic 认证。

这是一个好办法,但是 http basic 认证毕竟太简单了,也不方便集成外部的认证源,比如 LDAP

所以一个更灵活的方案是通过 Nginx 的 auth_request 模块

Nginx 的 auth_request 模块

auth_request 大抵就是在你访问 Nginx 中受 auth_reuqest 保护的路径时,去请求一个特定的服务。根据这个服务返回的状态码,auth_request 模块再进行下一步的动作,允许访问或者重定向跳走什么的。因此我们可以在上面去定制我们所有个性化的需求。

假定我们的环境是 centos ,yum 安装 nginx 就略了。由于通过 yum 等安装的 nginx 默认没有编译 auth_request 模块。我们需要重新编译一下。

先运行 nginx -V 来获取当前 nginx 的编译参数

# nginx -V
nginx version: nginx/1.14.0
built by gcc 4.8.5 20150623 (Red Hat 4.8.5-16) (GCC) 
built with OpenSSL 1.0.2k-fips  26 Jan 2017
TLS SNI support enabled
configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib64/nginx/modules --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --http-log-path=/var/log/nginx/access.log --pid-path=/var/run/nginx.pid --lock-path=/var/run/nginx.lock --http-client-body-temp-path=/var/cache/nginx/client_temp --http-proxy-temp-path=/var/cache/nginx/proxy_temp --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp --http-scgi-temp-path=/var/cache/nginx/scgi_temp --user=nginx --group=nginx --with-compat --with-file-aio --with-threads --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_ssl_module --with-http_stub_status_module --with-http_sub_module --with-http_v2_module --with-mail --with-mail_ssl_module --with-stream --with-stream_realip_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-cc-opt='-O2 -g -pipe -Wall -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong --param=ssp-buffer-size=4 -grecord-gcc-switches -m64 -mtune=generic -fPIC' --with-ld-opt='-Wl,-z,relro -Wl,-z,now -pie'

先安装一些依赖

yum -y install gcc gcc-c++ autoconf automake make
yum -y install zlib zlib-devel openssl 
yum -y install openssl-devel pcre pcre-devel
yum -y install libxslt-devel
yum -y install redhat-rpm-config
yum -y install gd-devel
yum -y install perl-devel perl-ExtUtils-Embed
yum -y install geoip-devel
yum -y install gperftools-devel

然后下载 nginx源代码 ,用刚才得到的编译参数,增加 --with-http_auth_request_module 参数重新编译

# ./configure --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib64/nginx/modules --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --http-log-path=/var/log/nginx/access.log --pid-path=/var/run/nginx.pid --lock-path=/var/run/nginx.lock --http-client-body-temp-path=/var/cache/nginx/client_temp --http-proxy-temp-path=/var/cache/nginx/proxy_temp --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp --http-scgi-temp-path=/var/cache/nginx/scgi_temp --user=nginx --group=nginx --with-compat --with-file-aio --with-threads --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_ssl_module --with-http_stub_status_module --with-http_sub_module --with-http_v2_module --with-mail --with-mail_ssl_module --with-stream --with-stream_realip_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-cc-opt='-O2 -g -pipe -Wall -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong --param=ssp-buffer-size=4 -grecord-gcc-switches -m64 -mtune=generic -fPIC' --with-ld-opt='-Wl,-z,relro -Wl,-z,now -pie' --with-http_auth_request_module

# make

# make install

nginx -V 看一下,已经带上 http_auth_request_module

# nginx -V
nginx version: nginx/1.14.0
built by gcc 4.8.5 20150623 (Red Hat 4.8.5-28) (GCC) 
built with OpenSSL 1.0.2k-fips  26 Jan 2017
TLS SNI support enabled
configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib64/nginx/modules --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --http-log-path=/var/log/nginx/access.log --pid-path=/var/run/nginx.pid --lock-path=/var/run/nginx.lock --http-client-body-temp-path=/var/cache/nginx/client_temp --http-proxy-temp-path=/var/cache/nginx/proxy_temp --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp --http-scgi-temp-path=/var/cache/nginx/scgi_temp --user=nginx --group=nginx --with-compat --with-file-aio --with-threads --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_ssl_module --with-http_stub_status_module --with-http_sub_module --with-http_v2_module --with-mail --with-mail_ssl_module --with-stream --with-stream_realip_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-cc-opt='-O2 -g -pipe -Wall -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong --param=ssp-buffer-size=4 -grecord-gcc-switches -m64 -mtune=generic -fPIC' --with-ld-opt='-Wl,-z,relro -Wl,-z,now -pie' --with-http_auth_request_module

一个简单 demo

nginx.inc 给了一个非常简单的 demo,即 nginx-auth-ldap 这个 repo。

整个逻辑就如下图所示

image.png

详细的流程图大抵如下所示:


image.png
  1. 客户端发送 HTTP 请求,以获取 Nginx 上反向代理的受保护资源。

  2. Nginx 的 auth_request 模块 将请求转发给 ldap-auth 这个服务(对应 nginx-ldap-auth-daemon.py),首次肯定会给个 401 .

  3. Nginx 将请求转发给 http:// backend / login,后者对应于这里的后端服务。它将原始请求的 uri 写入X-Target ,以便于后面跳转。

  4. 后端服务向客户端发送登录表单(表单在 demo 代码中定义)。根据 error_page 的配置,Nginx 将登录表单的 http 状态码返回 200。

  5. 用户填写表单上的用户名和密码字段并单击登录按钮,从向 / login 发起 POST 请求,Nginx 将其转发到后端的服务上。

  6. 后端服务把用户名密码以 base64 方式写入 cookie。

  7. 客户端重新发送其原始请求(来自步骤1),现在有 cookie 了 。Nginx 将请求转发给 ldap-auth 服务(如步骤2所示)。

  8. ldap-auth 服务解码 cookie,然后做 LDAP 认证。

  9. 下一个操作取决于 LDAP 认证是否成功:

    • 如果认证成功,则 ldap-auth 服务给 Nginx 返回状态码 200。Nginx 从后端服务中请求资源。在 demo 里,后端服务返回以下文本:
         Hello, world! Requested URL: URL
      
    • 如果认证失败,ldap-auth 服务会返回 401 。Nginx 再次将请求转发给后端服务的 Login(如步骤3),并重复该过程。

Demo 测试

先安装下依赖

yum install python-ldap

然后把 repo clone 下来

#git clone https://github.com/nginxinc/nginx-ldap-auth.git
# ls
backend-sample-app.py  Dockerfile  nginx-ldap-auth.conf              nginx-ldap-auth-daemon-ctl.sh  nginx-ldap-auth.default    nginx-ldap-auth.service  rpm
debian                 LICENSE     nginx-ldap-auth-daemon-ctl-rh.sh  nginx-ldap-auth-daemon.py      nginx-ldap-auth.logrotate  README.md

这其中 nginx-ldap-auth.conf 是 Nginx 的配置范例,直接 copy 过去即可

# cp nginx-ldap-auth.conf /etc/nginx/nginx.conf

Nginx 的配置文件如下,做了些精简,加了中文注释。

error_log logs/error.log debug;
# 这里把日志放在 nginx 目录下,所以要么改掉要么在 nginx 目录下建个 log 目录
events { }

http {
    # cache 路径和大小
    proxy_cache_path cache/  keys_zone=auth_cache:10m;

    # 将要被 nginx auth_request 保护的 backend 
    # 在这个 demo 里是 backend-sample-app.py.
    upstream backend {
        server 127.0.0.1:9000;
    }

    # nginx 服务起在 8081 上
    server {
        listen 8081;

        # 这个路径被 auth_request 保护了,  401 重定向到 login 上
        location / {
            auth_request /auth-proxy;

            # redirect 401 to login form
            error_page 401 =200 /login;

            proxy_pass http://backend/;
        }
        # 这里是我们认证的页面
        location /login {
            proxy_pass http://backend/login;
            # 这个 X-Target 是给认证完以后重定向的
            proxy_set_header X-Target $request_uri;
        }
        # 这是用做 auth_request 请求的路径
        location = /auth-proxy {
            internal;
            # 提供 ldap 认证服务的 auth-proxy backend
            # 这个 demo 里是 nginx-ldap-auth-daemon.py.
            proxy_pass http://127.0.0.1:8888;

            proxy_pass_request_body off;
            proxy_set_header Content-Length "";
            proxy_cache auth_cache;
            proxy_cache_valid 200 10m;

            # cookie 会加在这里
            proxy_cache_key "$http_authorization$cookie_nginxauth";

            # ldap 的地址
            proxy_set_header X-Ldap-URL      "ldap://ldap.example.org";

            # 是否开启 starttls
            # 注意 starttls 不能和 tls,也就是 ldaps 同时开启
            #proxy_set_header X-Ldap-Starttls "true";

            # ldap 的  BaseDN
            proxy_set_header X-Ldap-BaseDN   "dc=example,dc=org";

            # ldap 的 binddn,也就是有查询权限的账号
            proxy_set_header X-Ldap-BindDN   "cn=manager,dc=example,dc=org";

            # binddn 的密码
            proxy_set_header X-Ldap-BindPass "password";

            # cookie 的名字和值
            proxy_set_header X-CookieName "nginxauth";
            proxy_set_header Cookie nginxauth=$cookie_nginxauth;

            # ldap 的 searchFilter,就是拿哪个字段作为认证的用户名
            proxy_set_header X-Ldap-Template "(uid=%(username)s)";
        }
    }
}

然后分别执行 ./nginx-ldap-auth-daemon.py./backend-sample-app.py 即可。

访问 Nginx 的 8081 端口,可以看到他能够重定向到 backend 上去做认证了。

image.png
image.png

日志

# ./nginx-ldap-auth-daemon.py 
Start listening on localhost:8888...
localhost.localdomain - - [06/Jun/2018 09:18:31] using username/password from authorization header
localhost.localdomain - - [06/Jun/2018 09:18:31] "GET /auth-proxy HTTP/1.0" 401 -
localhost.localdomain - - [06/Jun/2018 09:18:35] using username/password from authorization header
localhost.localdomain - - [06/Jun/2018 09:18:35] "GET /auth-proxy HTTP/1.0" 401 -
localhost.localdomain - - [06/Jun/2018 09:18:43] using username/password from cookie nginxauth
localhost.localdomain - 20150073 [06/Jun/2018 09:18:43] searching on server "ldap://202.120.83.219" with base dn "dc=ecnu,dc=edu,dc=cn" with filter "(uid=20150073)"
localhost.localdomain - 20150073 [06/Jun/2018 09:18:43] Auth OK for user "20150073"
localhost.localdomain - 20150073 [06/Jun/2018 09:18:43] "GET /auth-proxy HTTP/1.0" 200 -
# ./backend-sample-app.py 
localhost.localdomain - - [06/Jun/2018 09:18:31] "GET /login HTTP/1.0" 200 -
localhost.localdomain - - [06/Jun/2018 09:18:35] "GET /login HTTP/1.0" 200 -
localhost.localdomain - - [06/Jun/2018 09:18:43] "POST /login HTTP/1.0" 302 -
localhost.localdomain - - [06/Jun/2018 09:18:43] "GET / HTTP/1.0" 200 -
localhost.localdomain - - [06/Jun/2018 09:18:43] "GET /favicon.ico HTTP/1.0" 200 -

代码分析

整个 demo 除了 python-ldap 外没有其他的依赖。它的 http 服务使用的是 HTTPServer 模块。

先来看 backend-sample-app.py,它是我们这个 demo 里的 backend

首先是路由:

    def do_GET(self):

        url = urlparse.urlparse(self.path)

        if url.path.startswith("/login"):
            return self.auth_form()

        self.send_response(200)
        self.end_headers()
        self.wfile.write('Hello, world! Requested URL: ' + self.path + '\n')

可以看到,它维护了两个路由。请求 /login 就跳认证页,否则就输出 Hello, world!

然后看看这个表单:

    # send login form html
    def auth_form(self, target = None):

        # try to get target location from header
        if target == None:
            target = self.headers.get('X-Target')

        # form cannot be generated if target is unknown
        if target == None:
            self.log_error('target url is not passed')
            self.send_response(500)
            return

        html="""
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
    <meta http-equiv=Content-Type content="text/html;charset=UTF-8">
    <title>Auth form example</title>
  </head>
  <body>
    <form action="/login" method="post">
      <table>
        <tr>
          <td>Username: <input type="text" name="username"/></td>
        <tr>
          <td>Password: <input type="text" name="password"/></td>
        <tr>
          <td><input type="submit" value="Login"></td>
      </table>
        <input type="hidden" name="target" value="TARGET">
    </form>
  </body>
</html>"""

        self.send_response(200)
        self.end_headers()
        self.wfile.write(html.replace('TARGET', target))

认证表单本身很简单,可以看到它把 http 头中的 X-Target 取了出来以隐藏的方式重新从表单里提交了上来。这个 X-Target 字段后面会用于认证后的重定向回原始请求页面。

看看拿到提交的数据后做了什么:

        user = form.getvalue('username')
        passwd = form.getvalue('password')
        target = form.getvalue('target')

        if user != None and passwd != None and target != None:

            # form is filled, set the cookie and redirect to target
            # so that auth daemon will be able to use information from cookie

            self.send_response(302)

            # WARNING WARNING WARNING
            #
            # base64 is just an example method that allows to pack data into
            # a cookie. You definitely want to perform some encryption here
            # and share a key with auth daemon that extracts this information
            #
            # WARNING WARNING WARNING
            enc = base64.b64encode(user + ':' + passwd)
            self.send_header('Set-Cookie', 'nginxauth=' + enc + '; httponly')

            self.send_header('Location', target)
            self.end_headers()

            return

这里写 cookie 了,把用户名和密码以 ; 号连接后做 base64 写入 cookie。(注意这很不安全!勿在生产环境中这样写)。然后写 Location 里写入 target 的值,来实现重定向跳回。
很粗暴吧,毕竟只是 demo 而已。

然后我们看 nginx-ldap-auth-daemon.py。这里负责对 nginx auth_request 的响应。

没有 cookie 或者 cookie 不对,回 401:

        auth_header = self.headers.get('Authorization')
        auth_cookie = self.get_cookie(ctx['cookiename'])

        if auth_cookie != None and auth_cookie != '':
            auth_header = "Basic " + auth_cookie
            self.log_message("using username/password from cookie %s" %
                             ctx['cookiename'])
        else:
            self.log_message("using username/password from authorization header")

        if auth_header is None or not auth_header.lower().startswith('basic '):

            self.send_response(401)
            self.send_header('WWW-Authenticate', 'Basic realm="' + ctx['realm'] + '"')
            self.send_header('Cache-Control', 'no-cache')
            self.end_headers()

cookie 正确的,base64 解码拆出用户名和密码来:

        try:
            auth_decoded = base64.b64decode(auth_header[6:])
            user, passwd = auth_decoded.split(':', 1)

        except:
            self.auth_failed(ctx)
            return True

        ctx['user'] = user
        ctx['pass'] = passwd

然后拿着用户名密码,和 http 头里的 ldap 配置信息去做 ldap 认证。这部分代码挺长的,摘取一小段:

            ldap_obj = ldap.initialize(ctx['url']);

            # Python-ldap module documentation advises to always
            # explicitely set the LDAP version to use after running
            # initialize() and recommends using LDAPv3. (LDAPv2 is
            # deprecated since 2003 as per RFC3494)
            #
            # Also, the STARTTLS extension requires the
            # use of LDAPv3 (RFC2830).
            ldap_obj.protocol_version=ldap.VERSION3

            # Establish a STARTTLS connection if required by the
            # headers.
            if ctx['starttls'] == 'true':
                ldap_obj.start_tls_s()

            # See http://www.python-ldap.org/faq.shtml
            # uncomment, if required
            # ldap_obj.set_option(ldap.OPT_REFERRALS, 0)

            ctx['action'] = 'binding as search user'
            ldap_obj.bind_s(ctx['binddn'], ctx['bindpasswd'], ldap.AUTH_SIMPLE)

            ctx['action'] = 'preparing search filter'
            searchfilter = ctx['template'] % { 'username': ctx['user'] }

            self.log_message(('searching on server "%s" with base dn ' + \
                              '"%s" with filter "%s"') %
                              (ctx['url'], ctx['basedn'], searchfilter))

            ctx['action'] = 'running search query'
            results = ldap_obj.search_s(ctx['basedn'], ldap.SCOPE_SUBTREE,
                                          searchfilter, ['objectclass'], 1)

            ctx['action'] = 'verifying search query results'
            if len(results) < 1:
                self.auth_failed(ctx, 'no objects found')
                return

            ctx['action'] = 'binding as an existing user'
            ldap_dn = results[0][0]
            ctx['action'] += ' "%s"' % ldap_dn
            ldap_obj.bind_s(ldap_dn, ctx['pass'], ldap.AUTH_SIMPLE)

            self.log_message('Auth OK for user "%s"' % (ctx['user']))

            # Successfully authenticated user
            self.send_response(200)

基本就是一个 ldap 认证的标准过程。

  1. binddnbindpasswd 先做一次 bind 获得查询权限。
  2. 拿用户名和 searchFilter 去查询,拿到用户的 dn
  3. 拿这个 dn 再于用户密码做一次 bind,进行 ldap 认证校验。

生产环境

这个 demo 的代码肯定没法直接应用在生产环境中,HTTPServer 适不适合做生产环境暂且不提,把用户名和密码直接写进 cookie 单这一条恐怕就没办法接受。即便像注释里所说的共享个密钥做诸如 AES 之类的加密,也感觉不是太很舒服。

其实这种需求,我们平时都是通过 session 来做的嘛。用户认证后记录一个 session,把 session 写到 cookie 里。下次用户再上来查 session 表里 session 没有过期就可以直接放行了。

而且我们还需要叠加一些特殊的策略,比如某些 IP 地址不认证直接放行,根据时间段选择是否需要开启认证。这些都需要 session 来支持。

这部分,我们留到下回再说吧。

参考文献

nginx-ldap-auth
nginx-plus-authenticate-users

以上

转载授权

CC BY-SA

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

推荐阅读更多精彩内容