这阵子不是deepseek火么?我也折腾了下本地部署,ollama、vllm、llama.cpp都弄了下,webui也用了几个,发现nextjs-ollama-llm-ui小巧方便,挺适合个人使用的。如果放在网上供多人使用的话,得接入登录认证才好,不然所有人都能蹭玩,这个可不太妙。
我是用openresty反向代理将webui发布出去的,有好几种方案实现接入外部登录认证系统。首先是直接修改nextjs-ollama-llm-ui的源码,其实我就是这么做的,因为这样接入能将登录用户信息带入应用,可以定制页面,将用户显示在页面里,体验会更好。其次openresty是支持auth_request的,你需要编码实现几个web接口就可以了,进行简单配置即可,这种方式也很灵活,逻辑你自行编码实现。还有一种就是在openresty里使用lua来对接外部认证系统,也就是本文要介绍的内容。
在折腾的过程中,开始是想利用一些现有的轮子,结果因为偷懒反而踩了不少坑。包括但不限于openssl、session,后来一想,其实也没有多难,手搓也不复杂。
首先是这样设计的,用户的标识信息写入cookie,比如用一个叫做SID的字段,其构成为时间戳+IP,aes加密后的字符串;当用户的IP发生变化或者其他客户端伪造cookie访问,openresty可以识别出来,归类到未认证用户,跳转到认证服务器(带上回调url)。
location /webui {
content_by_lua_block {
local resty_string = require "resty.string"
local resty_aes = require "resty.aes"
local key = "1234567890123456" -- 16 bytes key for AES-128
local iv = "1234567890123456" -- 16 bytes IV for AES-128
local aes = resty_aes:new(key, nil, resty_aes.cipher(128, "cbc"), {iv=iv})
local redis = require "resty.redis"
local red = redis:new()
red:set_timeouts(1000, 1000, 1000) -- 连接超时、发送超时、读取超时
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.say("Failed to connect to Redis: ", err)
return
end
function get_client_ip()
local headers = ngx.req.get_headers()
local client_ip
-- 优先从 X-Forwarded-For 获取
local x_forwarded_for = headers["X-Forwarded-For"]
if x_forwarded_for then
client_ip = x_forwarded_for:match("([^,]+)")
end
-- 如果 X-Forwarded-For 不存在,尝试从 X-Real-IP 获取
if not client_ip then
client_ip = headers["X-Real-IP"]
end
-- 如果以上都不存在,回退到 remote_addr
if not client_ip then
client_ip = ngx.var.remote_addr
end
return client_ip
end
local function hex_to_bin(hex_str)
-- 检查输入是否为有效的十六进制字符串
if not hex_str or hex_str:len() % 2 ~= 0 then
return nil, "Invalid hex string: length must be even"
end
local bin_data = ""
for i = 1, #hex_str, 2 do
-- 每两个字符表示一个字节
local byte_str = hex_str:sub(i, i + 1)
-- 将十六进制字符转换为数字
local byte = tonumber(byte_str, 16)
if not byte then
return nil, "Invalid hex character: " .. byte_str
end
-- 将数字转换为对应的字符
bin_data = bin_data .. string.char(byte)
end
return bin_data
end
local cookies = ngx.var.http_Cookie
if cookies then
local my_cookie = ngx.re.match(cookies, "sid=([^;]+)")
if my_cookie then
local ckv=my_cookie[1]
local ckvr=hex_to_bin(ckv)
local decrypted = aes:decrypt(ckvr)
local getip=string.sub(decrypted,12)
if getip ~= get_client_ip() then
return ngx.exit(ngx.HTTP_BAD_REQUEST)
end
local userinfo, err = red:get(ckv)
if not userinfo then
return ngx.redirect('https://sso.yourdomain.com/oauth2/authorize?redirect_uri=https://webapp.yourdomain.com/sso/callback')
end
else
return ngx.redirect('https://sso.yourdomain.com/oauth2/authorize?redirect_uri=https://webapp.yourdomain.com/sso/callback')
end
}
proxy_pass http://127.0.0.1:3000;
}
...
用户认证信息是存放在后端redis中,key是SID,value是认证访问返回的userid,在认证成功后写入,看是否需要在用户注销时主动删除记录。可以在nginx.conf里添加logout路径,但是可能需要在相关页面中放进去才好工作,否则用户估计不会在浏览器中手工输入logout的url来注销的。可以在cookie设置时设定有效时长,在redis添加记录时设置有效时长。
-- callback
location /callback {
content_by_lua_block {
local http = require "resty.http"
-- 获取授权码
local args = ngx.req.get_uri_args()
local code = args.code
local state = args.state
-- 验证 state
if state ~= "some_random_state" then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say("Invalid state")
return ngx.exit(ngx.HTTP_BAD_REQUEST)
end
-- 获取 access token
local httpc = http.new()
local res, err = httpc:request_uri("https://sso.yourdomain.com/oauth2/token", {
method = "POST",
body = ngx.encode_args({
code = code,
client_id = "YOUR_CLIENT_ID",
client_secret = "YOUR_CLIENT_SECRET",
grant_type = "authorization_code"
}),
headers = {
["Content-Type"] = "application/x-www-form-urlencoded"
}
})
if not res then
ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
ngx.say("Failed to request token: ", err)
return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
local token = res.body.access_token
-- 获取用户信息
local res, err = httpc:request_uri("https://sso.yourdomain.com/oauth2/v1/userinfo", {
method = "GET",
body = ngx.encode_args({
client_id = "YOUR_CLIENT_ID",
accesstoken = token
}),
})
if not res then
ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
ngx.say("Failed to request user info: ", err)
return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
local user_info = res.body
local redis = require "resty.redis"
local red = redis:new()
red:set_timeouts(1000, 1000, 1000) -- 连接超时、发送超时、读取超时
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.say("Failed to connect to Redis: ", err)
return
end
function get_client_ip()
local headers = ngx.req.get_headers()
local client_ip
-- 优先从 X-Forwarded-For 获取
local x_forwarded_for = headers["X-Forwarded-For"]
if x_forwarded_for then
client_ip = x_forwarded_for:match("([^,]+)")
end
-- 如果 X-Forwarded-For 不存在,尝试从 X-Real-IP 获取
if not client_ip then
client_ip = headers["X-Real-IP"]
end
-- 如果以上都不存在,回退到 remote_addr
if not client_ip then
client_ip = ngx.var.remote_addr
end
return client_ip
end
local timestamp = os.time()
local text = timestamp ..":"..get_client_ip()
local resty_string = require "resty.string"
local resty_aes = require "resty.aes"
local key = "1234567890123456" -- 16 bytes key for AES-128
local iv = "1234567890123456" -- 16 bytes IV for AES-128
local aes = resty_aes:new(key, nil, resty_aes.cipher(128, "cbc"), {iv=iv})
local encrypted = aes:encrypt(text)
local my_value=resty_string.to_hex(encrypted)
ngx.header["Set-Cookie"] = "sid=" .. my_value .. "; Path=/; Expires=" .. ngx.cookie_time(ngx.time() + 14400) .. "; HttpOnly"
local key = my_value
local value = user_info.userid
local expire_time = 14400 -- 四小时后过期
local res, err = red:set(key, value, "EX", expire_time)
if not res then
ngx.say("Failed to set key: ", err)
return
end
-- 重定向到受保护的页面
ngx.redirect("/webui")
}
}
其实也有现成的oauth2的轮子,不过我们自己手写lua代码的话,可以更灵活的配置,对接一些非标准的web认证服务也可以的。