Kong与SSO

2023-06-23

kong的部署与基本配置

本文将以redhat系统为例,实现jwt登陆认证请求通过kong网关进行过滤,服务端无需进行jwt登陆认证校验操作

服务器建立两个无需认证的服务

  • 安装服务运行环境
yum install python3
sudo pip3 install Flask==1.1.4
  • 编写服务:本文使用Python的Flask框架作为后端服务
# 服务A
from flask import Flask, jsonify

app_a = Flask(__name__)


@app_a.route("/")
def hello_world():
    return jsonify({'data': "server A"})


# # flask --app a run
if __name__ == '__main__':
    app_a.run(host='0.0.0.0', port=5000)

    
# 服务B
from flask import Flask, jsonify

app_b = Flask(__name__)


@app_b.route("/")
def hello_world():
    return jsonify({'data': "server B"})


# # flask --app b run
if __name__ == '__main__':
    app_b.run(host='0.0.0.0', port=5001)

  • 开启端口,此处使用centos虚拟机进行
sudo firewall-cmd --add-port=5000/tcp --permanent
sudo firewall-cmd --add-port=5001/tcp --permanent
sudo firewall-cmd --reload

安装Postgresql

kong需要数据版本大于9.5,yum直接安装的版本为9.2.24参考文章1open in new window参考文章2open in new window

补充:

Below is an example to install PostgreSQL 9.6 on RHEL/CentOS 7:

cat << EOF > /etc/yum.repos.d/pgdg-96.repo
[pgdg90]
name=PostgreSQL 9.6 RPMs for RHEL/CentOS 7
baseurl=https://yum-archive.postgresql.org/9.6/redhat/rhel-7-x86_64
enabled=1
gpgcheck=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-PGDG
EOF

Now, you can install PostgreSQL 9.6 on RHEL/CentOS 7:yum install postgresql96-server.
通过yum install postgresql96-devel方便后续python安装包的处理

  • 查看安装源
yum search postgresql
  • 通过yum安装
# yum直装老版本 yum install postgresql-server (kong网关不可用)

# 安装12
# 添加安装包
sudo yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm
# 上述步骤不成功时使用下面的步骤
curl -Lo pgdg-redhat-repo-latest.noarch.rpm $( https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm") || rpm -i pgdg-redhat-repo-latest.noarch.rpm

# 安装
sudo yum install -y postgresql12 postgresql12-server


# 安装17
sudo yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm
sudo yum install -y postgresql17-server
  • 检查安装版本 - 等初始化完成后在登录数据库
psql --version
  • 初始化数据库 - 完成后会生成目录 /var/lib/pgsql/12/ (老版本*/var/lib/pgsql/data*),配置文件在其中
# yum直装老版本 
postgresql-setup initdb
# 12
sudo /usr/pgsql-12/bin/postgresql-12-setup initdb
# 17
sudo /usr/pgsql-17/bin/postgresql-17-setup initdb
  • 启动服务
#启动PostgreSQL12服务
sudo systemctl start postgresql-12
#设置PostgreSQL12服务为开机启动
sudo systemctl enable postgresql-12

# 9.2版本 service postgresql start
# 9.6版本 sudo systemctl start postgresql-9.6
# 启动失败可尝试更新配置文件后再试
  • 通过netstat -nat可看到运行端口 5432

  • psql postgres通过root用户登录,第一次会失败。使用psql -U postgres以切换到postgres用户登录,会提示认证失败

  • 通过su - postgres切换到postgres用户后,执行psql进行登录

    • 执行 su - postgres 出现密码,输入后提示 su: 鉴定故障 ,使用 sudo su - postgres 来切换用户
  • 通过vim /var/lib/pgsql/data/pg_hba.conf修改配置

# 不切换用户登录 peer改为trust
local		all		all							peer
# kong的迁移
host		all		all		127.0.0.1/32		trust
# 远程登录 - 增加如下行
host		all		all		0.0.0.0/0			md5
  • 修改监听地址 - 非宿主机访问 vim /var/lib/pgsql/data/postgresql.conf
# 修改为 '*'
listen_addresses = 'localhost'
  • 更新完配置后重启服务。可使用psql -U postgres直接登录,\q退出
sudo systemctl restart postgresql-12
  • 在postgresql内修改登录密码.
# 方法一
\password
# 方法二
alter user postgres with password 'target_password'
  • 开启端口
sudo firewall-cmd --add-port=5432/tcp --permanent
sudo firewall-cmd --reload

补充:

  • \l 查看数据库
  • \c选择数据库
  • \d查看所有表格
  • \d 表格名查看指定表格
  • 创建表格
CREATE TABLE login_user(
   phone CHAR(11) PRIMARY KEY NOT NULL,
   username VARCHAR(15) NOT NULL,
   password VARCHAR(15) NOT NULL,
   level smallint DEFAULT 0
)

安装Kong

参考官方文档open in new window

  • 安装nignx
yum install epel-release
yum update
yum install -y nginx
  • 下载安装包 最新下载地址 https://cloudsmith.io/~kong/repos/gateway-legacy/packages/
# 该下载地址已不能使用
curl -Lo kong-enterprise-edition-3.3.0.0.rpm $( rpm --eval "https://download.konghq.com/gateway-3.x-rhel-%{rhel}/Packages/k/kong-enterprise-edition-3.3.0.0.rhel%{rhel}.amd64.rpm") || rpm -i 

# 老版本 - 该下载地址已不能使用
curl -Lo kong-1.1.3.el7.noarch.rpm $( rpm --eval "https://download.konghq.com/gateway-1.x-centos-7/Packages/k/kong-1.1.3.el7.noarch.rpm") || rpm -i kong-1.1.3.el7.noarch.rpm
  • 使用yum安装
sudo yum install kong-enterprise-edition-3.3.0.0.rpm

配置文件 /etc/kong/kong.conf.default

配置与启动

  • 复制并修改 /etc/kong/kong.config.default
  • 建立kong在postgresql内的数据库
CREATE USER kong WITH PASSWORD 'super_secret'; 
CREATE DATABASE kong OWNER kong;
grant all privileges on database kong to kong;
  • 配置数据库的账号与密码
pg_user = kong
pg_password = password
  • 迁移
KONG_PASSWORD="pwd" kong migrations bootstrap -c "PATH_TO_KONG.CONF_FILE"
  • 启动
kong start -c "PATH_TO_KONG.CONF_FILE"
  • 开放端口 - 8000服务api转发端口,8001kong管理端口
sudo firewall-cmd --add-port=8000/tcp --permanent
sudo firewall-cmd --reload

kong代理后端服务

  • 声明一个需要kong接管的后端服务(记录标识名,服务名使用0.0.0.0)
$ curl -i -X POST \
--url http://localhost:8001/services/ \
--data 'name=服务标识符' \
--data 'url=http://服务地址'
如果访问 localhost 出现 *curl: (7) Failed to connect to ::1: 没有到主机的路由* ,说明localhost默认解析为ipv6的地址,使用 `curl -4 localhost:8001` 或 `curl 127.0.0.1`
  • 转发路由:代理多个服务可使用两种方法进行

    • 以host区分服务(为声明的服务添加路由,注意字段需为客户端请求的host,详见补充)

      $ curl -i -X POST \
          # 管理地址/services/服务标识符/routers
      --url http://localhost:8001/services/服务标识符/routes \
      # 指明转发host规则:即后续请求头中携带指定host值字段将转发到对应的服务
      --data 'hosts[]=example1.com'
      

      补充::路由host字段的说明

      • 此方法使用多个host区分服务,如 http://api.service_1.example.com/*http://api.service_2.example.com/* 来指向不同的服务
      • 根据浏览器安全策略,浏览器发出的请求头中的Host字段根据为实际请求地址 http://{host}:{port|80} 中的 host 值,且不可自定义。
      • 在生产环境中,在一台服务器上部署kong网关在8000端口。如需转发多个后端请求,需全部请求到该服务器的8000端口,才可进入kong网关。
      • 由于host值不可指定,而kong网关需要host值进行路由区分,因此该方法需为每个服务准备不同的域名,所有域名皆指向kong网关所在ip
    • 以一级路由区分:如 http://api.example.com/service_1/*http://api.example.com/service_2/* 来指向不同的服务

    curl -i -X POST \
        # 管理地址/services/服务标识符/routers
    --url http://localhost:8001/services/服务标识符/routes \    
    # 指明统一的转发host
    --data 'hosts[]=example.com'
    # 指明待转发服务的一级路由
    --data 'paths[]=/service1/'
    
  • 让kong转发到指定的服务

    • host区分:配置测试服务A和B,以A为例
    # 定义服务:服务标识符命名为service_a,本地服务的ip必须为0.0.0.0
    curl -i -X POST --url http://localhost:8001/services/ \
    --data 'name=service_a' \
    --data 'url=http://0.0.0.0:5000'
    
    # 配置路由:添加kong的转发路由
    curl -i -X POST --url http://localhost:8001/services/service_a/routes \
    --data 'hosts[]=service_a.com'
    
    • 一级路由区分:配置测试服务A和B,以A为例
    # 服务标识符命名为service_a,本地服务的ip必须为0.0.0.0
    curl -i -X POST --url http://localhost:8001/services/ \
    --data 'name=service_a' \
    --data 'url=http://0.0.0.0:5000'
    
    # 添加kong的转发路由
    curl -i -X POST --url http://localhost:8001/services/service_a/routes \
    --data 'hosts[]=service.com' \
    --data 'paths[]=/service_a/'
    
  • 路由的查看,修改,删除

# 查看
curl -i -X GET --url  http://localhost:8001/services/服务标识符

# 修改
curl -i -X PATCH --url  http://localhost:8001/services/服务标识符 \ 
--data 	'name=服务的新标识符' \
--data 'retries=6' # 其他选项

# 删除
curl -i -X DELETE --url  http://localhost:8001/services/服务标识符 
  • 代理完成后移除原服务端口,只对外暴漏kong端口通过代理请求对应服务
sudo firewall-cmd --remove-port=5000/tcp --permanent
sudo firewall-cmd --remove-port=5001/tcp --permanent
sudo firewall-cmd --reload

kong日志切分

kong日志不会主动切分,长时间运行日志往往很大,查看时加载时间较长。可考虑进行切分

  • 安装 logrotate:在大多数 Linux 系统上,logrotate 工具通常已经预安装。如果没有安装,可以使用以下命令安装:
复制代码
sudo apt-get install logrotate  # Debian/Ubuntu
sudo yum install logrotate      # CentOS/RHEL
  • 配置 logrotate

    • logrotate 的配置文件通常位于 /etc/logrotate.conf,而每个应用的单独配置文件通常放在 /etc/logrotate.d/ 目录下。我们可以为 Nginx 配置一个专门的日志切分规则

    • 在 /etc/logrotate.d/ 目录下创建一个名为 kong 的配置文件,内容如下:

    /data/logs/kong/gateway/*.log {
        create 0640 red root  # 创建新日志文件时设置权限
        daily  # 每日切分
        rotate 10  # 保留10个历史日志文件
        missingok  # 如果日志文件丢失,不报错
        notifempty  # 如果日志文件为空,不切分
        compress  # 对旧日志文件进行压缩
        delaycompress  # 延迟压缩,直到下一次轮换
        sharedscripts  # 确保在所有日志切分之后运行 postrotate
        postrotate  # kong 日志切分后重载 kong
            /bin/kill -USR1 `cat /usr/local/kong/pids/nginx.pid 2>/dev/null` 2>/dev/null || true
        endscript
    }
    
    • 同理可切分nginx日志:在 /etc/logrotate.d/ 目录下创建一个名为 nginx 的配置文件
    /var/log/nginx/*.log {
        create 0640 nginx root
        daily
        rotate 10
        missingok
        notifempty
        compress
        delaycompress
        sharedscripts
        postrotate
            /bin/kill -USR1 `cat /run/nginx.pid 2>/dev/null` 2>/dev/null || true
        endscript
    }
    
  • 执行切分

sudo /usr/local/bin/kong stop  # 重启kong服务
sudo /usr/local/bin/kong start -c "/etc/kong/kong.conf"
sudo logrotate -d /etc/logrotate.conf  # 查看配置的调试输出
sudo logrotate -f /etc/logrotate.conf  # 强制执行日志切分

问题: 执行切分时出现 error: skipping "/data/logs/kong/gateway/access.log" because parent directory has insecure permissions (It's world writable or writable by group which is not "root") Set "su" directive in config file to tell logrotate which user/group should be used for rotation.

logrotate 为了防止潜在的安全风险,不允许操作所有用户可读写的文件。需要更新权限

# 调整目录权限与日志文件权限
sudo chmod 755 /<path to log file dir>
sudo chmod 644 /<path to log file>

kong网关多服务的jwt认证分析

通过kong的jwt插件可实现对用户的认证。作者思考到了两个设计方向可供选择:

  • 利用jwt认证可以对服务或路由生效(该方法只能使用host区分服务)。由于服务中一些接口需要无权限也可访问,可将一个服务的接口设置两个kong服务。将需要认证的接口放置一个服务,并设置特殊路径,如/auth/*。将无需认证接口放置另一个服务,并设置特殊路径/free/*。这样对kong来说,配置不同的服务,即可避免配置两个路由指向同一服务,需认证接口通过免认证路由请求而引起的安全性问题。(不同权限等级需不同一级路由,较为复杂)
  • 将全部数据接口与权限等级记入数据表中,根据路径与token中的用户权限查表鉴权,判断是否需要jwt认证,不需要直接放行后端,需要jwt认证的接口验证token得到用户权限,判断权限等级是否匹配。对token验证不通过的返回401,对权限不足的返回403。后续内容介绍的都是此方法的实现流程

建立需要不同用户权限的服务

# 服务A
from flask import Flask, jsonify

app_a = Flask(__name__)


@app_a.route('/')
def free():
    return jsonify({'data': 'server A - level 0'})


@app_a.route('/auth')
def auth():
    return jsonify({'data': 'server A - level 1'})


@app_a.route("/admin")
def admin():
    return jsonify({'data': 'server A - level 2'})


# # flask --app a run
if __name__ == '__main__':
    app_a.run(host='0.0.0.0', port=5000)


    
# 服务B
from flask import Flask, jsonify

app_b = Flask(__name__)


@app_b.route('/')
def free():
    return jsonify({'data': 'server B - level 0'})


@app_b.route('/auth')
def auth():
    return jsonify({'data': 'server B - level 1'})


@app_b.route('/admin')
def admin():
    return jsonify({'data': 'server B - level 2'})


# # flask --app b run
if __name__ == '__main__':
    app_b.run(host='0.0.0.0', port=5001)

部署插件

插件部署流程参考 csdn zz18435842675open in new window

kong的插件默认路径为 /usr/local/share/lua/5.1/kong/plugins

  • /etc/kong/kong.conf 中配置插件路径lua_package_path = /<path-to-plugin-location>/kong/plugins/?.lua;;。其中:;;代表默认路径;需确保最后两个路径为 /kong/plugins/
  • 将插件文件夹放入指定目录(不生效时放入默认目录)
  • 修改 /etc/kong/kong.conf 中的plugins = bundled,用逗号分隔在后方补充自定义插件
  • 修改 /usr/local/share/lua/5.1/kong/constants.lua ,添加自定义插件
  • 通过kong reload -c "PATH_TO_KONG.CONF_FILE"重启kong
  • 通过命令将插件绑定到服务
curl -X POST http://kong_ip:8001/services/<service-name-or-id>/plugins -d "name=my-custom-plugin"

插件开发

kong插件的文件结构文档open in new window

  • handler.lua : 插件的主逻辑
  • schema.lua : 配置信息。
  • daos.lua : 数据库模型类。可自行建表后通过daos实例连接。如需迁移建表需要编写迁移文件。

下面将用jwt token的认证为例,说明一个接口认证插件的开发流程,源码在此open in new window

框架搭建

一个最基本的kong插件需包含两个文件 handler.luaschema.lua

  • handler的开发需按照以下格式进行
-- 必要的导包
local kong = kong
local BasePlugin = require "kong.plugins.base_plugin"

-- 声明自定义handler类
local RequestAuthHandler = BasePlugin:extend()
-- 定义优先级与版本号
RequestAuthHandler.PRIORITY = 800
RequestAuthHandler.VERSION = "1.0.0"

-- 自定义handler的new方法
function RequestAuthHandler:new()
    -- 传入插件名
    RequestAuthHandler.super.new(self, "kong_jwt_url_auth")
end


-- 主逻辑,access方法处理请求到来时的逻辑
function RequestAuthHandler:access(conf)
    RequestAuthHandler.super.access(self)
  	-- 以下是主逻辑
    -- 例如:pass options request
    if kong.request.get_method() == "OPTIONS" then
      return
    end
  
end

-- 返回自定义handler
return RequestAuthHandler

  • schema中的配置信息作为handler实例的传入参数。可通过传入参数调用配置信息fields中的预定义变量。配置项在绑定服务时传入了数据库,如后续更新,需要 删除plugins表中对应的项后重新进行绑定 (delete from plugins where name = '<plugin_name>' ;)。
local typedefs = require "kong.db.schema.typedefs"

return {
    name = "xxx",  -- 插件名
    fields = {
        { consumer = typedefs.no_consumer },  -- 插件的消费者
        { protocols = typedefs.protocols_http },  -- 插件协议
        { config = {
            type = "record",
            -- handler的配置信息 或 所需要的预定义变量
            fields = {
                {
                    -- jwt secret key
                    secret_key = { type = "string", default = "1234567891234567891234567891234567891234567" }, 
                },
                { 
                    -- sign deliver, iss, not check
                    key_claim_name = { type = "string", default = "iss" },
                },
            },
        },
        },
    },
}

权限认证

使用kong官方插件 jwt 中的jwt认证组件jwt_parser.luaopen in new window进行验证

以下是在handler调用 jwt 插件内的认证组件并编写token认证逻辑

-- 导入所需内容
local get_header = kong.request.get_header
local set_header = kong.service.request.set_header
local jwt_decoder = require "kong.plugins.jwt.jwt_parser"

-- 省略

function RequestAuthHandler:access(conf)
    RequestAuthHandler.super.access(self)
    -- pass options request
    if kong.request.get_method() == "OPTIONS" then
      return
    end

    -- 0. 获取token
    local bear_token = get_header("authorization")
    if (bear_token == nil or string.sub(bear_token, 1, 7) ~= "Bearer ") then
        kong.log.inspect("miss bearer")
        return kong.response.exit(401, { code = 401, success = false, data = "", msg = "UnAuthorized" })
    end
    local token = string.sub(bear_token, 8, -1)

    -- 1. 解码token
  	-- 1.1 base64解码token
    local jwt, err = jwt_decoder:new(token)
    if err then
      -- 解码失败,token有问题
      kong.log.inspect("decode error")
      return kong.response.exit(401, { code = 401, success = false, data = "", msg = "UnAuthorized" })
    end

  	-- 2. 验证
    -- 2.1 获取payload
    local claims = jwt.claims
    -- 2.2 获取token header
    local header = jwt.header
    -- 2.3 获取私钥
    -- local secret_key = "1234567891234567891234567891234567891234567"
    kong.log.inspect(conf.secret_key)
    -- 2.4 ***** 验签 *****
    if not jwt:verify_signature(conf.secret_key) then
        kong.log.inspect("check secret fail")
        return kong.response.exit(401, { code = 401, success = false, data = "", msg = "UnAuthorized" })
    end
    -- 2.5 ***** 验证token有效期 *****
    local ok_claims, errors = jwt:verify_registered_claims(conf.claims_to_verify)
    if not ok_claims then
        kong.log.inspect(errors)
        return kong.response.exit(401, { code = 401, success = false, data = "", msg = "Token Expired" })
    end

    -- 2.6 获取payload中的用户标识符
    local phone = claims["phone"]
    if phone == nil then
        kong.log.inspect("miss phone in payload")
        return kong.response.exit(401, { code = 401, success = false, data = "", msg = "UnAuthorized" })
    end
    kong.log.inspect(phone)

  	-- 3. 将用户标识符塞入header,方便后端服务查询对应用户信息
    set_header("x-auth-phone", phone)

end

-- 省略

注意:

开发过程中可使用 kong.log.inspect()进行关键信息打印,对复杂类型会进行格式化处理(性能开销),在生产环境中需要移除或使用kong.log.notice()

数据库查询

通过 daos.lua 中对目标表的 daos实例(模型类) 声明,以便在 handler.lua 中进行数据库查询。

  • 关于数据库表生成的两种方式(与后端开发类似):

    • 可通过 postgresql ctl 在kong连接的数据库中创建目标table,再根据table的字段定义daos实例
    CREATE TABLE login_user(
    phone CHAR(11) PRIMARY KEY NOT NULL,
    username VARCHAR(15) NOT NULL,
    password VARCHAR(15) NOT NULL,
    level smallint DEFAULT 0);
    
    insert into login_user (phone, username, password, level) values ('13011111111', 'alice', '88888888', 1);
    
    
    CREATE TABLE api_mgr(
    sign VARCHAR(51) PRIMARY KEY,  -- kong插件只可查询主键,主键需包含查询条件
    path VARCHAR(50) NOT NULL,
    service smallint NOT NULL,
    auth_level smallint DEFAULT 0);
    
    insert into api_mgr (sign, path, service, auth_level) values ('0/', '/', 0, 0);
    insert into api_mgr (sign, path, service, auth_level) values ('0/auth', '/auth', 0, 1);
    insert into api_mgr (sign, path, service, auth_level) values ('0/admin', '/admin', 0, 2);
    insert into api_mgr (sign, path, service, auth_level) values ('1/', '/', 1, 0);
    insert into api_mgr (sign, path, service, auth_level) values ('1/auth', '/auth', 1, 1);
    insert into api_mgr (sign, path, service, auth_level) values ('1/admin', '/admin', 1, 2);
    
    • daos.lua 编写完成后,编写迁移文件 migrations/init.luamigrations/000_base_<plugin_name>.lua 。通过kong的迁移命令在数据库中生成table。
  • daos.lua 实例

return {
    {
    primary_key = { "phone" },  
    name = "login_user",  -- 数据表名称
    endpoint_key = "phone",  -- handler中的查询字段
    cache_key = { "phone" },  -- 缓存字段
    fields = {
            -- 实例中的type为lua基本类型
            { phone = { type = "string", required = true, unique = true }, },  -- 主键 手机号作为用户唯一标识符
            { username = { type = "string", required = true }, },
            { password = { type = "string", required = true }, },
            { level = { type = "number", default = 0 }, },  -- 用户权限等级
        },
    },
}
  • handler中的数据库查询核心代码
-- payload中获取用户标识符
local phone = claims["phone"]
if phone == nil then
kong.log.inspect("miss phone in payload")
return kong.response.exit(401, { code = 401, success = false, data = "", msg = "UnAuthorized" })
end
kong.log.inspect(phone)

-- 通过用户标识符查询用户对象 select用于查询主键
local user, err = kong.db.login_user:select({ phone = phone })
if err then
return error(err)
end
if not user then
kong.log.inspect("login user not found")
return kong.response.exit(401, { code = 401, success = false, data = "", msg = "UnAuthorized" })
end

-- 通过用户对象获取用户权限等级
set_header("x-auth-level", user.level)

账号多登录的判断

通过redis缓存token,kong插件查询token后进行判断
redis连接代码参考文章open in new window

  • 服务端
# 服务端redis支持
# sudo pip3 install redis==3.5.3

# ** 登录接口中的判断 **
# 查询该用户之前是否存在有效期内的token
old_token = redis_conn.get('login:' + phone)
# 该用户存在已登录的token,将旧token标记为失效token
if old_token:
    # 在token的最大过期时间内,该token都将标记为失效token
    redis_conn.setex('conflict:' + old_token, 3 * 3600 * 24, 1)
# 记录该用户的最新token,下一次出现重复登录时可获取到旧token
redis_conn.setex('login:' + phone, 3 * 3600 * 24, token)
  • 网关
local redis = require "resty.redis"
local redis_conn = redis:new()
redis_conn:set_timeouts(1000, 1000, 1000) -- 1 sec

local ok, err = redis_conn:connect(redisHost, redisPort)
if not ok then
    kong.log.warn("failed to connect redis: ", err)
else
    if(redisPwd ~= "")
    then
        local auth, err = redis_conn:auth(redisPwd)
        if not auth then
            kong.log.warn("failed to authenticate: ", err)
        end
    end

    local conflict_res, err = redis_conn:get('conflict'..token)
    if conflict_res ~= ngx.null then
        kong.log.err("user "..phone.. "  token conflict")
        kong.response.exit(401, { code = 401, success = false, data = "", msg = "Token Conflict" })
        return
    end
    -- 使用连接池
    local ok, err = redis_conn:set_keepalive(10000, 100) -- (超时时间 ms, 连接池大小)
end

服务端适配

服务端生成token

通过PyJWT包进行token的生成,需要在payload中添加 exp(过期时间)与nbf(生成时间)

import jwt

secret_key = '1234567891234567891234567891234567891234567'
now = datetime.utcnow()
expiry = now + timedelta(days=3)
# 传入payload,私钥,加密算法
token = jwt.encode({'phone': '13011111111', 'exp': expiry, 'nbf': now}, secret_key, algorithm='HS256')

服务端对Postgresql的支持

  • 通过sudo pip3 install Flask-SQLAlchemy==2.5.1安装Flask-SQLAlchemy。

    • 问题1: src/greenlet/greenlet.cpp:16:20: 致命错误:Python.h:没有那个文件或目录 。解决办法: sudo yum install -y python3-devel安装
    • 问题2: inline T borrow() const G_NOEXCEPT 即gcc报错。 解决办法:升级pip python3 -m pip install --upgrade pip
    • 问题3: 服务端报错ModuleNotFoundError: No module named 'psycopg2' 。 解决办法: 安装psycopg2
  • 通过sudo pip3 install psycopg2-binary 安装psycopg2。

    • 问题: Error: pg_config executable not found. 。 解决办法: 确保安装 postgresql96-devel 后,在环境变量PATH中指明pg_config的位置 /usr/pgsql-9.6/bin
  • Flask连接Postgresql

import jwt
from datetime import datetime, timedelta
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy

# 连接数据库
app_a = Flask(__name__)
app_a.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://kong:1234@127.0.0.1/kong'
app_a.config['SQLALCHEMY_COMMIT_ON_TEARDOWN'] = True
db = SQLAlchemy(app_a)

# 模型类
class LoginUser(db.Model):
    __tablename__ = 'login_user'
    phone = db.Column(db.String(11), primary_key=True, doc='手机号')
    username = db.Column(db.String(15), doc='昵称')
    password = db.Column(db.String(15), doc='密码')
    level = db.Column(db.SMALLINT, doc='用户权限')

    def __repr__(self):
        return '<User %r>' % self.phone


@app_a.route('/auth')
def auth():
    phone = request.args.get("phone", '')
    password = request.args.get('password', '')
    if not all([phone, password]):
        return jsonify({'success': False, 'msg': 'Missing required params!'})
    # 查库进行账号认证
    user = LoginUser.query.filter_by(phone=phone).first()
    if not user:
        return jsonify({'success': False, 'msg': 'User not exist!'})
    if user.password != password:
        return jsonify({'success': False, 'msg': 'Password incorrect!'})
    return jsonify({'success': True, 'msg': 'server A - level 1', 'data': {'header': dict(request.headers)}})


# # flask --app a run
if __name__ == '__main__':
    app_a.run(host='0.0.0.0', port=5000)

SSO服务端的完整代码

该服务端仅用于 token分发 及 接口权限管理

import jwt
from datetime import datetime, timedelta
from redis import StrictRedis
from flask import Flask, jsonify, request, current_app
from flask_sqlalchemy import SQLAlchemy

# 连接redis与postgresql
app_a = Flask(__name__)
app_a.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://kong:1234@127.0.0.1/kong'
app_a.config['SQLALCHEMY_COMMIT_ON_TEARDOWN'] = True
db = SQLAlchemy(app_a)
app_a.redis_master = StrictRedis(host='127.0.0.1', port=6379, decode_responses=True)


class Role(db.Model):
    __tablename__ = 'api_mgr'
    sign = db.Column(db.String(51), primary_key=True, doc='api签名')
    path = db.Column(db.String(50), doc='路径')
    service = db.Column(db.SMALLINT, doc='服务ID')
    auth_level = db.Column(db.SMALLINT, doc='api权限')

    def __repr__(self):
        return '<Api %r>' % self.sign


class LoginUser(db.Model):
    __tablename__ = 'login_user'
    phone = db.Column(db.String(11), primary_key=True, doc='手机号')
    username = db.Column(db.String(15), doc='昵称')
    password = db.Column(db.String(15), doc='密码')
    level = db.Column(db.SMALLINT, doc='用户权限')

    def __repr__(self):
        return '<User %r>' % self.phone


@app_a.route('/')
def free():
    # token生成
    secret_key = '1234567891234567891234567891234567891234567'
    now = datetime.utcnow()
    expiry = now + timedelta(days=3)
    token = jwt.encode({'phone': '13011111111', 'exp': expiry, 'nbf': now}, secret_key, algorithm='HS256')

    # 测试对postgresql的支持
    users = [{'phone': user.phone, 'username': user.username} for user in LoginUser.query.all()]

    # 账号多登录的处理
    old_token = current_app.redis_master.get('login:13011111111')
    if old_token:
        current_app.redis_master.setex('conflict:' + old_token, 3600*24*3, 1)
    current_app.redis_master.setex('login:13011111111', 3600*24*3, token1)

    return jsonify({
        'success': True,
        'msg': 'server A - level 0',
        'data': {
            'header': dict(request.headers),
            'token': token,
            'users': users
        }
    })


@app_a.route('/auth')
def auth():
    phone = request.args.get("phone", '')
    password = request.args.get('password', '')
    if not all([phone, password]):
        return jsonify({'success': False, 'msg': 'Missing required params!'})
    # 测试对postgresql的支持
    user = LoginUser.query.filter_by(phone=phone).first()
    if not user:
        return jsonify({'success': False, 'msg': 'User not exist!'})
    if user.password != password:
        return jsonify({'success': False, 'msg': 'Password incorrect!'})
    return jsonify({'success': True, 'msg': 'server A - level 1', 'data': {'header': dict(request.headers)}})


@app_a.route("/admin")
def admin():
    # 接口权限与用户权限不匹配的测试
    return jsonify({'success': True, 'msg': 'server A - level 2', 'data': {'header': dict(request.headers)}})


# # flask --app a run
if __name__ == '__main__':
    app_a.run(host='0.0.0.0', port=5000)

部署至生产服务器

多服务的多HOST域名部署

当设置多个不同的后端域名时(如协议不一致时)

  • 客户端请求服务通过 api.*.*.com ,nginx监听80端口后转发到服务端8000端口来传入网关

  • 由于此时option请求无法到达后端,需在nginx统一配置跨域。此时后端无需再配置跨域信息

server {
    listen       80;
    server_name  api.<service_sign>.*.com;

    if ($request_method = OPTIONS ) {
        add_header 'Access-Control-Allow-Origin' '*' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH,OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'DNT,web-token,app-token,Authorization,Accept,Origin,Keep-Alive,User-Agent,X-Mx-ReqToken,X-Data-Type,X-Auth-Token,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
        add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
        return 204;
    }

    location / {
        proxy_read_timeout 300;
        proxy_pass http://127.0.0.1:8000;  # 转发给kong
        proxy_http_version 1.1;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port 80;
        proxy_set_header Host $host;
    }
}

多服务的单HOST域名部署

当设置一个统一域名,通过地址区分不同服务(协议必须统一)。如 api.*.com/a/* 用于请求A服务, api.*.com/b/* 用于请求B服务

  • 配置主域名的nginx
server {
    listen       80;
    server_name  api.*.com;

    if ($request_method = OPTIONS ) {
        add_header 'Access-Control-Allow-Origin' '*' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH,OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'DNT,web-token,app-token,Authorization,Accept,Origin,Keep-Alive,User-Agent,X-Mx-ReqToken,X-Data-Type,X-Auth-Token,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
        add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
        return 204;
    }

    # 服务a
    location ~ ^/a/ {
        proxy_read_timeout 300;
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port 80;
        proxy_set_header Host $host;
    }
    
    # 服务b
    location ~ ^/b/ {
        proxy_read_timeout 300;
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port 80;
        proxy_set_header Host $host;
    }
}
  • 配置kong通过路径进行路由转发
curl -i -X POST --url http://localhost:8001/services/<service_name>/routes --data 'name=<router_name>' --data 'hosts[]=api.*.com' --data 'paths[]=/<service_sign>/'
  • 插件内通过 kong.router.get_route() 可直接判断需要转发的目标服务。以下为日志打印route信息
{
    created_at = <route_create_timestamp>,
    hosts = { "api.*.com",
        <metatable> = <1>{
            __class = {
                __base = <table 1>,
                __init = <function 1>,
                __name = "PostgresArray",
                <metatable> = {
                    __call = <function 2>,
                    __index = <table 1>
                }
            },
            __index = <table 1>
        }
    },
    id = "<route_id>",
    name = "<route_name>",
    paths = { "/<service_sign>/",
        <metatable> = <table 1>
    },
    preserve_host = false,
    protocols = { "http", "https",
        <metatable> = {
            __index = <function 3>
        }
    },
    regex_priority = 0,
    service = {
        id = "<service_id>"
    },
    strip_path = true,
    updated_at = <route_update_timestamp>
}
  • 通过kong.request.get_path()获取路径,与路由的路径相剪以获取真实请求路径。根据接口管理判断请求路径是否存在。
-- 举例
prefix_path = kong.router.get_route().paths[1]  -- /service_a/ lua的数组从1开始
path = kong.request.get_path()  -- /service_a/test

-- 服务端得到的真实请求地址为 /test
r_path = string.gsub(str, "^".."/service_a/", tostring(service_id).."/")

插件开发中遇到的问题

  • 在编辑完schema.lua后,加载插件时出现以下问题

    [error] 2546#0: init_by_lua error: /usr/local/share/lua/5.1/kong/init.lua:402: error loading plugin schemas: on plugin 'kong_jwt_url_auth': [postgres] schema violation (fields.3: {
        fields = {
            [2] = {
                description = "unknown field"
            }
        }
    })
    

    由报错信息可知,descrption字段不被支持。1.x版本的Kong在schema的配置中不支持descrption字段,新版本支持。插件源码中含有descrption字段。