给没有登录认证的web应用添加登录认证(openresty lua实现)

这阵子不是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认证服务也可以的。

你可能感兴趣的:(前端,openresty,lua)