用JavaScript自定义nginx:OpenID Connect反向代理

我为本地PC的开发环境搭建了基于nginx的反向代理型OpenID Connect(OIDC)用户认证,以下是我介绍。

我們使用nginx的njs(NGINX JavaScript)來實現OIDC授權碼流程。 njs是nginx的一個模塊,可以通過JavaScript擴展nginx的功能。它也可以在官方的nginx docker映像中使用。

在使用nginx和JavaScript的组合时,其优势在于可以创建接近生产环境配置的自由度较高。

反向代理类型是什么意思?

用户认证由反向代理和授权服务器处理。应用程序可以专注于业务逻辑的开发。

反向代理将浏览器的HTTP请求转发到后端的WEB服务器,同时验证HTTP请求中的会话Cookie。

如果不存在会话Cookie,反向代理服务器将重定向到授权服务器并提示用户登录。用户登录后再重定向回反向代理服务器,然后从授权服务器获取令牌并使用该令牌获取用户信息,同时创建会话Cookie。

如果存在会话Cookie,反向代理会将用户信息添加到HTTP请求头中并转发给Web服务器。

请参阅下文以获得关于反向代理类型的详细信息。

 

用njs实现的功能

使用njs时,可以从nginx调用JavaScript函数,就像Web服务器调用HTTP请求处理程序一样。

然而,njs的目的并不是将nginx变为一个应用程序服务器。

    NGINX JavaScript Is Not Node.js

这次,我们在 njs 中实现了以下功能。

    • OIDC 認可コードフローでトークン取得

 

    • 認可サーバからユーザ情報を取得し JWT に変換後、セッション Cookie に格納

 

    セッション Cookie の JWT を検証
请注意,我们没有实施 OIDC 的状态 state 或 nonce 等弱点防护措施。

运动环境

使用Docker容器进行环境设置。

    • Docker version 23.0

 

    • 認可サーバ: Keycloak 21.0

 

    • リバースプロキシ: nginx 1.23

 

    WEBサーバ: nginx 1.23

执行方法

请按照以下文件夹结构将各种配置文件和njs的JavaScript代码放置,并执行docker compose。详细的配置文件和代码内容将在后面列出。

文件夹结构

.
├── backend/                      # WEBサーバ
│   └── nginx.conf
├── docker-compose.yml
├── keycloak/                     # 認可サーバ
│   └── import/
│       └── realm-develop.json    # Keycloak の Realm エクスポートファイル
└── proxy/                        # リバースプロキシ
    ├── conf.d/
    │   └── app.conf
    ├── nginx.conf
    └── njs/                      # njs の JavaScript コード
        ├── jwt.js
        └── oidc.js
version: "3.9"
services:
  keycloak:
    image: quay.io/keycloak/keycloak:21.0
    container_name: idp
    restart: always
    command: start-dev --import-realm
    environment:
      KEYCLOAK_ADMIN: "admin"
      KEYCLOAK_ADMIN_PASSWORD: "admin"
      KC_HOSTNAME_URL: "http://localhost:8080"
      KC_HOSTNAME_STRICT_BACKCHANNEL: false
    ports:
      - 0.0.0.0:8080:8080
    volumes:
      - ./keycloak/import:/opt/keycloak/data/import/:ro
  proxy:
    container_name: proxy
    image: nginx:1.23
    restart: always
    environment:
      OIDC_ISSUER: "http://localhost:8080/realms/develop"
      OIDC_AUTH_ENDPOINT: "http://localhost:8080/realms/develop/protocol/openid-connect/auth"
      OIDC_LOGOUT_ENDPOINT: "http://localhost:8080/realms/develop/protocol/openid-connect/logout"
      OIDC_TOKEN_ENDPOINT: "http://idp:8080/realms/develop/protocol/openid-connect/token"
      OIDC_USER_INFO_ENDPOINT: "http://idp:8080/realms/develop/protocol/openid-connect/userinfo"
      OIDC_CLIENT_ID: "reverse-proxy"
      OIDC_CLIENT_SECRET: "hogehogehoge"
      OIDC_SCOPE: "openid" # 空白文字区切り
      JWT_GEN_KEY: "mySecretKey"
    ports:
      - 0.0.0.0:80:80
    volumes:
      - ./proxy/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./proxy/conf.d:/etc/nginx/conf.d/:ro
      - ./proxy/njs:/etc/nginx/njs/:ro
  backend:
    container_name: backend
    image: nginx:1.23
    volumes:
      - ./backend/nginx.conf:/etc/nginx/nginx.conf:ro

开始容器。

$ docker compose up -d
$ docker compose ps
NAME      IMAGE                           ...  SERVICE   ...  PORTS
backend   nginx:1.23                      ...  backend   ...  80/tcp
idp       quay.io/keycloak/keycloak:21.0  ...  keycloak  ...  0.0.0.0:8080->8080/tcp, 8443/tcp
proxy     nginx:1.23                      ...  proxy     ...  0.0.0.0:80->80/tcp

当启动容器时,Keycloak的初始化程序会运行,所以需要等待约一分钟直到它完成。

当您在浏览器中打开 http://localhost/ ,将显示 Keycloak 的登录界面。首次登录时需要进行用户注册(自助注册)。登录成功后,将显示后端 WEB 服务器 nginx 的欢迎页面。

qt_1_1.png

请在浏览器开发工具中确认是否已创建名为MY_SESSION的会话Cookie。

qt_1_9.png

确认在WEB服务器nginx(后端容器)的日志中,HTTP请求头中附加了用户信息(JWT)。其中,日志的 “eyJ0eX … CnARo” 部分即为用户信息的JWT。

$ docker compose logs backend
backend  | 192.168.208.2 - - [05/Apr/2023:09:42:58 +0000] "GET / HTTP/1.0" 200 615 "-" "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1YjhiNWZjZS1jN2JjLTQ2NmMtOTJlMy0wOTQxYTc0NGJmNzkiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJhIGIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZXZAZXhhbXBsZS5jb20iLCJnaXZlbl9uYW1lIjoiYSIsImZhbWlseV9uYW1lIjoiYiIsImVtYWlsIjoiZGV2QGV4YW1wbGUuY29tIn0.Hhl9aBQznkfQTjjShyR1-5kkxYFvdXtKVAQgXjCnARo"
qt_1_10.png

当您在浏览器中访问 http://localhost/logout,便可实现登出操作。

qt_1_2.png

反向代理服务器nginx的设置

请在proxy/nginx.conf文件中添加以下配置。

    • njs モジュールをロード

conf.d/app.conf をインクルード

user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;

load_module modules/ngx_http_js_module.so;      # njs モジュールをロード

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/app.conf;     # conf.d/app.conf をインクルード
}

将以下内容设置到 proxy/conf.d/app.conf 中。

    • Docker resolver

 

    • インポートするJSモジュールのパス追加

 

    • インポートするJSモジュール

 

    • nginx 変数に JS関数の戻り値を設定

server セクションで URLパスと JSモジュール関数を紐づけ

resolver 127.0.0.11;                # Docker resolver

js_path "/etc/nginx/njs/";          # インポートするJSモジュールのパス追加
js_import oidc from oidc.js;        # インポートするJSモジュール

js_set $user_info oidc.user_info;   # nginx 変数に JS関数の戻り値を設定

server {
    listen 80;

    location / {
        auth_request /auth/validate;                   # /auth/validate でログイン有無を確認
        error_page 401 = @login;                       # 未ログインなら @login へ
        expires -1;
        proxy_set_header X-Amzn-Oidc-Data $user_info;  # ヘッダーにユーザ情報を付加
        proxy_pass http://backend;                     # WEBサーバへ転送
    }

    location /auth/validate {
        internal;
        js_content oidc.validate;           # JSモジュール oidc の関数 validate を呼び出し
    }

    location @login {
        js_content oidc.login;              # JSモジュール oidc の関数 login を呼び出し
    }

    location /auth/postlogin {
        js_content oidc.postlogin;          # JSモジュール oidc の関数 postlogin を呼び出し
    }

    location /logout {
        js_content oidc.logout;             # JSモジュール oidc の関数 logout を呼び出し
    }

    location /auth/postlogout {
        js_content oidc.postlogout;         # JSモジュール oidc の関数 postlogout を呼び出し
    }

    location @bye {
        add_header 'Content-Type' 'text/html';
        return 200 "See you again.\n";
    }
}

在与ロケーション匹配后,我们会去/auth/validate验证登录状态。/auth/validate会调用JS函数oidc.validate()。oidc.validate()会检查会话Cookie,如果已登录则返回HTTP状态200,否则返回401。如果状态码为200,则会在头部添加用户信息并将请求转发给后端。如果返回401,则会在@login中进行内部重定向,并调用JS函数oidc.login()开始登录序列。登录序列基于OIDC授权码流程。

qt_1_11.png
以@开头的位置是nginx的命名位置。它用于执行不会重新写入URI路径的内部重定向。

逆向代理服务器 nginx 的 JS 函数

与/auth/validate绑定的JS函数oidc.validate()将验证存储在会话Cookie中的JWT的签名。如果验证成功,将返回HTTP状态码200;如果失败,将返回401。

    JS関数 oidc.validate()
// ログイン済みか否かをチェック
async function validate(r) {
    let secret_key = process.env.JWT_GEN_KEY;
    let session_data = r.variables.cookie_MY_SESSION;
    let valid = await jwt.verify(session_data, secret_key);
    if( valid ) {
        r.return(200);
    } else {
        r.return(401);
    }
}

在这里,函数validate(r)的参数r是表示HTTP请求的对象。process是提供有关进程信息的全局对象。

参考来源在这里↓

 

process.env.JWT_GEN_KEY 获取环境变量 JWT_GEN_KEY 的值。

在参考文献的注释中如下所示,虽然在运行环境测试中不使用env指令也可以引用环境变量,但原因不明。

默认情况下,nginx会删除从父进程继承来的所有环境变量,但会保留TZ变量。如果需要保留继承变量的一部分,可以使用env指令。

r.variables.cookie_MY_SESSION 是 nginx 内置变量。它用于引用 Cookie MY_SESSION 的值。这样,我们也可以在 JS 代码中引用 nginx 的内置变量。

与 @login 相关的 JS 函数 oidc.login() 将重定向到授权服务器(Keycloak)的授权端点。授权服务器将显示登录页面。

    JS関数 oidc.login()
const postlogin_uri = "http://localhost:80/auth/postlogin";


// OIDC認可エンドポイントへリダイレクト
function login(r) {
    let referer = r.variables.uri;
    let params = qs.stringify({
        response_type : "code",
        scope         : process.env.OIDC_SCOPE,
        client_id     : process.env.OIDC_CLIENT_ID,
        redirect_uri  : postlogin_uri + "?" + qs.stringify({p: referer}),
    });
    let url = process.env.OIDC_AUTH_ENDPOINT + "?" + params;
    r.return(302, url);
}

当用户登录后,将重定向回指定的 redirect_uri(= /auth/postlogin)作为认证终点的请求参数,并调用 JS 函数 oidc.postlogin()。

JS函数oidc.postlogin()用于从授权服务器的令牌端点获取并验证ID令牌。使用该令牌获取用户信息并转换为JWT。将JWT设置为会话Cookie,并将页面重定向到调用者页面。

    JS関数 oidc.postlogin()
// ログイン後の処理
async function postlogin(r) {
    try {
        let referer = r.args.p;

        // OIDCトークン取得
        let redirect_uri = postlogin_uri + "?" + qs.stringify({p: referer});
        let tokens = await get_token(r.args.code, redirect_uri);

        // IDトークン検証
        validate_id_token(tokens.id_token, process.env.OIDC_ISSUER, process.env.OIDC_CLIENT_ID);

        // OIDCユーザ情報取得
        let claims = await get_userinfo(tokens.access_token);

        // ユーザ情報を JWT に変換
        let secret_key = process.env.JWT_GEN_KEY;
        let session_data = await jwt.encode(claims, secret_key);

        r.headersOut["Set-Cookie"] = [
            "MY_SESSION=" + session_data + "; Path=/",
        ];
        r.return(302, referer);
    }  catch (e) {
        r.error(e.message);
        r.return(403);  // Forbidden
    }
}

注意:ID令牌验证只针对部分声明,并未验证签名。

代理/ njs / oidc.js(摘录)
// OIDC ID令牌验证
function validate_id_token(token, issuer, audience) {
let payload = jwt.decode(token).payload;
if(payload.iss!=发行方)抛出新错误(“无效令牌”);
if(payload.aud!=受众)抛出新错误(“无效令牌”);
if(payload.exp <Math.floor(Date.now()/ 1000))抛出新错误(“无效令牌”);
}

如果您想对存储在会话Cookie中的JWT进行加密,可以查看njs的示例代码中的加密示例。
    njs サンプルコード

 

登出时,还需要同时结束认证服务器Keycloak的会话。

与“/logout”相关的JS函数oidc.logout()将重定向到Keycloak的注销端点。当Keycloak注销后,它将重定向回“/postlogout”,然后通过JS函数oidc.postlogout()删除会话Cookie。

    JS関数 oidc.logout()
// ログアウトエンドポイントへリダイレクト
function logout(r) {
    let params = qs.stringify({
        client_id : process.env.OIDC_CLIENT_ID,
        post_logout_redirect_uri : postlogout_uri,
    });
    let url = process.env.OIDC_LOGOUT_ENDPOINT + "?" + params;
    r.return(302, url);
}
    JS関数 oidc.postlogout()
// ログアウト後の処理. セッション Cookie 削除
function postlogout(r) {
    r.headersOut['Set-Cookie'] = ["MY_SESSION=; Path=/; Max-Age=-1; Expires=Wed, 21 Oct 2015 07:28:00 GMT"];
    r.internalRedirect("@bye");
}

后端 nginx 的配置

后端的Web服务器和应用服务器可以通过反向代理查看HTTP请求头中附加的用户信息(JWT),以识别请求来源,并进行资源访问控制。

在这里,我们没有实现到那一步。我们将仅限于将附加到HTTP请求头的用户信息(JWT)记录到日志中。

将以下设置添加到后端/nginx.conf。

log_format にHTTPリクエストヘッダの出力を追加

user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # ユーザ情報(ヘッダ X-Amzn-Oidc-Data)をログへ出力
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_x_amzn_oidc_data"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
}

在这里,我们将用户信息(JWT)存储在头部 X-Amzn-Oidc-Data 中,因此在 log_format 中添加'”$http_x_amzn_oidc_data”‘。

输出日志示例

$ docker compose logs backend
backend  | 192.168.208.2 - - [05/Apr/2023:09:42:58 +0000] "GET / HTTP/1.0" 200 615 "-" "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1YjhiNWZjZS1jN2JjLTQ2NmMtOTJlMy0wOTQxYTc0NGJmNzkiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsIm5hbWUiOiJhIGIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJkZXZAZXhhbXBsZS5jb20iLCJnaXZlbl9uYW1lIjoiYSIsImZhbWlseV9uYW1lIjoiYiIsImVtYWlsIjoiZGV2QGV4YW1wbGUuY29tIn0.Hhl9aBQznkfQTjjShyR1-5kkxYFvdXtKVAQgXjCnARo"
如果对于选择此标题名称的理由感兴趣的话,请查看附录中的参考文章《使用应用程序负载均衡器进行用户认证》。

准备Keycloak Realm导出文件

在从授权服务器获取令牌时,反向代理需要客户端ID和客户端密码。为此,需要在Keycloak的领域中注册应用程序客户端。

由于每次启动/停止容器时重新注册应用程序客户端很麻烦,因此我们将预先定义的 Realm 导入。

不考虑客户秘密的安全处理。

执行步骤

    1. 创建领域

 

    1. 注册应用程序客户端

 

    1. 激活自助注册

 

    1. 导出领域

 

    1. 设置客户端密钥

 

    配置领域导出文件

在没有 Realm エクスポートファイル keycloak/import/realm-develop.json 的情况下, 执行 Docker Compose 并启动容器。

$ docker compose up -d

等待大约一分钟,直到Keycloak初始化完成。在浏览器中打开http://localhost:8080/,将显示Keycloak的欢迎页面,然后选择[Administration Console],使用admin/admin进行登录。

qt_1_3.png

创造领域

构建实现 “develop” 的领域。

从显示为“master”的下拉菜单中选择“Create Realm”。
在Realm名称中输入“develop”,然后点击“Create”。

qt_1_4.png

应用程序客户端注册

将应用程序客户端 “reverse-proxy” 注册到创建的Realm “develop” 中。

在侧边栏中选择“客户”然后点击“创建客户”。

qt_1_5.png

需要填写的项目

FieldValueClient IDreverse-proxyClient authenticationONValid redirect URIs*Valid post logout redirect URIs*
请注意,上述的重定向URI设置值是一个容易受攻击的不安全设置。

自助注册激活

允许用户自行注册。

在侧边菜单中选择 “Realm 设置”。
打开 “登录” 选项卡并启用 “用户注册”。

qt_1_6.png

领域出口

将创建的 Realm “develop” 导出到文件中。

从画面右上方的动作下拉菜单中选择“部分导出”。

qt_1_6.png

将“Include clients”设为ON,执行“Export”。

qt_1_7.png

客户端秘钥设置

由于在导出的文件中没有包含客户端密钥,所以需要手动填写客户端密钥。

在编辑器中打开保存的Realm导出文件。
搜索clientId为”reverse-proxy”并在secret中输入”hogehogehoge”,然后保存。

qt_1_8.png

领域的导出文件位置安排

将Realm导出文件放置在keycloak/import/realm-develop.json文件中。

重新启动 Docker Compose 后,将导入 Realm 导出文件。

$ docker compose down -v

$ docker compose up -d

可以在 keycloan 容器的日志中看到一条消息,表示已成功导入。

$ docker compose logs keycloak

... Full importing from file /opt/keycloak/bin/../data/import/realm-develop.json
... Realm 'develop' imported

请参考 Keycloak 文档了解 Realm 的导入/导出功能。

 

njs 代码的完整内容

代理/ njs / jwt.js

// JWTデコード
function decode(jot) {
    var parts = jot.split('.').slice(0,2)
        .map(v=>Buffer.from(v, 'base64url').toString())
        .map(JSON.parse);
    return { headers:parts[0], payload: parts[1] };
}

// JWTエンコード
async function encode(claims, key) {
    let header = { typ: "JWT",  alg: "HS256" };

    let s = [header, claims]
        .map(JSON.stringify)
        .map(v=>Buffer.from(v).toString('base64url'))
        .join('.');

    let wc_key = await crypto.subtle.importKey(
        'raw', key, {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']);

    let sign = await crypto.subtle.sign({name: 'HMAC'}, wc_key, s);

    return s + '.' + Buffer.from(sign).toString('base64url');
}

// JWT署名検証
async function verify(jot, key) {
    if( !jot ) return false;

    let parts = jot.split('.');
    let data = parts.slice(0,2).join('.');
    let sign = Buffer.from(parts[2], 'base64url');
    let wc_key = await crypto.subtle.importKey(
        'raw', key, {name: 'HMAC', hash: 'SHA-256'}, false, ['verify']);

    return crypto.subtle.verify({name: 'HMAC'}, wc_key, sign, data);
}

export default {decode, encode, verify}

代理/njs/oidc.js

import qs from "querystring";
import jwt from "jwt.js";

const postlogin_uri = "http://localhost:80/auth/postlogin";
const postlogout_uri = "http://localhost:80/auth/postlogout";

// OIDC IDトークン検証
function validate_id_token(token, issuer, audience) {
    let payload = jwt.decode(token).payload;
    if( payload.iss != issuer ) throw new Error("invalid token");
    if( payload.aud != audience ) throw new Error("invalid token");
    if( payload.exp < Math.floor(Date.now()/1000) ) throw new Error("invalid token");
}

// OIDCトークンエンドポイントからトークン取得
async function get_token(code, redirect_uri) {
    let reply = await ngx.fetch(process.env.OIDC_TOKEN_ENDPOINT, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: qs.stringify({
            grant_type    : "authorization_code",
            client_id     : process.env.OIDC_CLIENT_ID,
            client_secret : process.env.OIDC_CLIENT_SECRET,
            redirect_uri  : redirect_uri,
            code          : code,
        }),
    });
    return await reply.json();
}

// OIDCユーザ情報取得
async function get_userinfo(access_token) {
    let reply = await ngx.fetch(process.env.OIDC_USER_INFO_ENDPOINT, {
        method: "GET",
        headers: { "Authorization": "Bearer " + access_token },
    });
    return await reply.json();
}

// ログイン済みか否かをチェック
async function validate(r) {
    let secret_key = process.env.JWT_GEN_KEY;
    let session_data = r.variables.cookie_MY_SESSION;
    let valid = await jwt.verify(session_data, secret_key);
    if( valid ) {
        r.return(200);
    } else {
        r.return(401);
    }
}

// OIDC認可エンドポイントへリダイレクト
function login(r) {
    let referer = r.variables.uri;
    let params = qs.stringify({
        response_type : "code",
        scope         : process.env.OIDC_SCOPE,
        client_id     : process.env.OIDC_CLIENT_ID,
        redirect_uri  : postlogin_uri + "?" + qs.stringify({p: referer}),
    });
    let url = process.env.OIDC_AUTH_ENDPOINT + "?" + params;
    r.return(302, url);
}

// ログイン後の処理
async function postlogin(r) {
    try {
        let referer = r.args.p;

        // OIDCトークン取得
        let redirect_uri = postlogin_uri + "?" + qs.stringify({p: referer});
        let tokens = await get_token(r.args.code, redirect_uri);

        // IDトークン検証
        validate_id_token(tokens.id_token, process.env.OIDC_ISSUER, process.env.OIDC_CLIENT_ID);

        // OIDCユーザ情報取得
        let claims = await get_userinfo(tokens.access_token);

        // ユーザ情報を JWT に変換
        let secret_key = process.env.JWT_GEN_KEY;
        let session_data = await jwt.encode(claims, secret_key);

        r.headersOut["Set-Cookie"] = [
            "MY_SESSION=" + session_data + "; Path=/",
        ];
        r.return(302, referer);
    }  catch (e) {
        r.error(e.message);
        r.return(403);  // Forbidden
    }
}

// ログアウトエンドポイントへリダイレクト
function logout(r) {
    let params = qs.stringify({
        client_id : process.env.OIDC_CLIENT_ID,
        post_logout_redirect_uri : postlogout_uri,
    });
    let url = process.env.OIDC_LOGOUT_ENDPOINT + "?" + params;
    r.return(302, url);
}

// ログアウト後の処理. セッション Cookie 削除
function postlogout(r) {
    r.headersOut['Set-Cookie'] = ["MY_SESSION=; Path=/; Max-Age=-1; Expires=Wed, 21 Oct 2015 07:28:00 GMT"];
    r.internalRedirect("@bye");
}

// バックエンドへ送るユーザ情報
function user_info(r) {
    return r.variables.cookie_MY_SESSION;
}

export default {validate, login, logout, postlogin, postlogout, user_info}

请提供一篇参考文章。

 

bannerAds