開發(fā)一個(gè)網(wǎng)站的步驟推廣軟件賺錢的app
這陣子不是deepseek火么?我也折騰了下本地部署,ollama、vllm、llama.cpp都弄了下,webui也用了幾個(gè),發(fā)現(xiàn)nextjs-ollama-llm-ui小巧方便,挺適合個(gè)人使用的。如果放在網(wǎng)上供多人使用的話,得接入登錄認(rèn)證才好,不然所有人都能蹭玩,這個(gè)可不太妙。
我是用openresty反向代理將webui發(fā)布出去的,有好幾種方案實(shí)現(xiàn)接入外部登錄認(rèn)證系統(tǒng)。首先是直接修改nextjs-ollama-llm-ui的源碼,其實(shí)我就是這么做的,因?yàn)檫@樣接入能將登錄用戶信息帶入應(yīng)用,可以定制頁面,將用戶顯示在頁面里,體驗(yàn)會(huì)更好。其次openresty是支持auth_request的,你需要編碼實(shí)現(xiàn)幾個(gè)web接口就可以了,進(jìn)行簡(jiǎn)單配置即可,這種方式也很靈活,邏輯你自行編碼實(shí)現(xiàn)。還有一種就是在openresty里使用lua來對(duì)接外部認(rèn)證系統(tǒng),也就是本文要介紹的內(nèi)容。
在折騰的過程中,開始是想利用一些現(xiàn)有的輪子,結(jié)果因?yàn)橥祽蟹炊攘瞬簧倏?。包括但不限于openssl、session,后來一想,其實(shí)也沒有多難,手搓也不復(fù)雜。
首先是這樣設(shè)計(jì)的,用戶的標(biāo)識(shí)信息寫入cookie,比如用一個(gè)叫做SID的字段,其構(gòu)成為時(shí)間戳+IP,aes加密后的字符串;當(dāng)用戶的IP發(fā)生變化或者其他客戶端偽造cookie訪問,openresty可以識(shí)別出來,歸類到未認(rèn)證用戶,跳轉(zhuǎn)到認(rèn)證服務(wù)器(帶上回調(diào)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-128local iv = "1234567890123456" -- 16 bytes IV for AES-128local 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) -- 連接超時(shí)、發(fā)送超時(shí)、讀取超時(shí)local ok, err = red:connect("127.0.0.1", 6379)if not ok thenngx.say("Failed to connect to Redis: ", err)returnendfunction get_client_ip()local headers = ngx.req.get_headers()local client_ip-- 優(yōu)先從 X-Forwarded-For 獲取local x_forwarded_for = headers["X-Forwarded-For"]if x_forwarded_for thenclient_ip = x_forwarded_for:match("([^,]+)")end-- 如果 X-Forwarded-For 不存在,嘗試從 X-Real-IP 獲取if not client_ip thenclient_ip = headers["X-Real-IP"]end-- 如果以上都不存在,回退到 remote_addrif not client_ip thenclient_ip = ngx.var.remote_addrendreturn client_ipendlocal function hex_to_bin(hex_str)-- 檢查輸入是否為有效的十六進(jìn)制字符串if not hex_str or hex_str:len() % 2 ~= 0 thenreturn nil, "Invalid hex string: length must be even"endlocal bin_data = ""for i = 1, #hex_str, 2 do-- 每?jī)蓚€(gè)字符表示一個(gè)字節(jié)local byte_str = hex_str:sub(i, i + 1)-- 將十六進(jìn)制字符轉(zhuǎn)換為數(shù)字local byte = tonumber(byte_str, 16)if not byte thenreturn nil, "Invalid hex character: " .. byte_strend-- 將數(shù)字轉(zhuǎn)換為對(duì)應(yīng)的字符bin_data = bin_data .. string.char(byte)endreturn bin_dataendlocal cookies = ngx.var.http_Cookieif cookies thenlocal my_cookie = ngx.re.match(cookies, "sid=([^;]+)")if my_cookie thenlocal 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() thenreturn ngx.exit(ngx.HTTP_BAD_REQUEST)endlocal userinfo, err = red:get(ckv)if not userinfo thenreturn ngx.redirect('https://sso.yourdomain.com/oauth2/authorize?redirect_uri=https://webapp.yourdomain.com/sso/callback')endelsereturn 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;
}...
用戶認(rèn)證信息是存放在后端redis中,key是SID,value是認(rèn)證訪問返回的userid,在認(rèn)證成功后寫入,看是否需要在用戶注銷時(shí)主動(dòng)刪除記錄??梢栽趎ginx.conf里添加logout路徑,但是可能需要在相關(guān)頁面中放進(jìn)去才好工作,否則用戶估計(jì)不會(huì)在瀏覽器中手工輸入logout的url來注銷的??梢栽赾ookie設(shè)置時(shí)設(shè)定有效時(shí)長(zhǎng),在redis添加記錄時(shí)設(shè)置有效時(shí)長(zhǎng)。
-- callback
location /callback {content_by_lua_block {local http = require "resty.http"-- 獲取授權(quán)碼local args = ngx.req.get_uri_args()local code = args.codelocal state = args.state-- 驗(yàn)證 stateif state ~= "some_random_state" thenngx.status = ngx.HTTP_BAD_REQUESTngx.say("Invalid state")return ngx.exit(ngx.HTTP_BAD_REQUEST)end-- 獲取 access tokenlocal 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 thenngx.status = ngx.HTTP_INTERNAL_SERVER_ERRORngx.say("Failed to request token: ", err)return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)endlocal 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 thenngx.status = ngx.HTTP_INTERNAL_SERVER_ERRORngx.say("Failed to request user info: ", err)return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)endlocal user_info = res.bodylocal redis = require "resty.redis"local red = redis:new()red:set_timeouts(1000, 1000, 1000) -- 連接超時(shí)、發(fā)送超時(shí)、讀取超時(shí)local ok, err = red:connect("127.0.0.1", 6379)if not ok thenngx.say("Failed to connect to Redis: ", err)returnendfunction get_client_ip()local headers = ngx.req.get_headers()local client_ip-- 優(yōu)先從 X-Forwarded-For 獲取local x_forwarded_for = headers["X-Forwarded-For"]if x_forwarded_for thenclient_ip = x_forwarded_for:match("([^,]+)")end-- 如果 X-Forwarded-For 不存在,嘗試從 X-Real-IP 獲取if not client_ip thenclient_ip = headers["X-Real-IP"]end-- 如果以上都不存在,回退到 remote_addrif not client_ip thenclient_ip = ngx.var.remote_addrendreturn client_ipendlocal 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-128local iv = "1234567890123456" -- 16 bytes IV for AES-128local 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_valuelocal value = user_info.useridlocal expire_time = 14400 -- 四小時(shí)后過期local res, err = red:set(key, value, "EX", expire_time)if not res thenngx.say("Failed to set key: ", err)returnend-- 重定向到受保護(hù)的頁面ngx.redirect("/webui")}
}
其實(shí)也有現(xiàn)成的oauth2的輪子,不過我們自己手寫lua代碼的話,可以更靈活的配置,對(duì)接一些非標(biāo)準(zhǔn)的web認(rèn)證服務(wù)也可以的。