需求背景:跨協(xié)議單點(diǎn)登錄的挑戰(zhàn)
這是我們公司一位年度運(yùn)維服務(wù)客戶的真實(shí)需求場(chǎng)景與服務(wù)案例。
該用戶在內(nèi)部 IT 運(yùn)維管理上具有極其嚴(yán)格的安全規(guī)范,所有的遠(yuǎn)程運(yùn)維操作,無(wú)論是應(yīng)用系統(tǒng)、網(wǎng)絡(luò)設(shè)備還是平臺(tái)級(jí)資源,必須先通過(guò)企業(yè)零信任網(wǎng)關(guān)(aTrust)接入安全網(wǎng)絡(luò),再通過(guò)堡壘機(jī)進(jìn)行身份認(rèn)證與運(yùn)維操作,并確保全流程可審計(jì)。
運(yùn)維流程:工程師 → aTrust 網(wǎng)關(guān)登錄 → JumpServer 堡壘機(jī)登錄 → 運(yùn)維目標(biāo)系統(tǒng)
然而,在實(shí)際日常服務(wù)過(guò)程中,我們發(fā)現(xiàn)頻繁出現(xiàn)一個(gè)非常典型但嚴(yán)重影響效率的問(wèn)題:
外部遠(yuǎn)程運(yùn)維人員經(jīng)常忘記自己的 JumpServer 密碼,導(dǎo)致無(wú)法進(jìn)入堡壘機(jī),需要客戶手動(dòng)登錄后臺(tái)重置賬號(hào)密碼,嚴(yán)重影響了應(yīng)急響應(yīng)與運(yùn)維流程的連續(xù)性。
為解決這個(gè)“重復(fù)登錄 + 密碼遺忘”痛點(diǎn),客戶提出了明確需求:
希望通過(guò)登錄一次 aTrust 后,能夠無(wú)感進(jìn)入 JumpServer,完成堡壘機(jī)登錄(即單點(diǎn)登錄)。
但現(xiàn)實(shí)情況是——aTrust 使用 OAuth2 協(xié)議進(jìn)行認(rèn)證,而 JumpServer(社區(qū)版 v3.10.18) 僅支持 CAS 協(xié)議。兩者協(xié)議不兼容,無(wú)法直接打通。
于是,我們決定為客戶DIY一套協(xié)議橋接服務(wù):在 aTrust 登錄后自動(dòng)通過(guò)中轉(zhuǎn)服務(wù)轉(zhuǎn)換為符合 CAS 協(xié)議格式的登錄流程,完成用戶在 JumpServer 的自動(dòng)認(rèn)證與登錄。
技術(shù)方案設(shè)計(jì):構(gòu)建 OAuth2 與 CAS 協(xié)議的橋梁
面對(duì) OAuth2 與 CAS 認(rèn)證機(jī)制之間的“語(yǔ)言不通”,我們決定設(shè)計(jì)一個(gè)中間層——協(xié)議中轉(zhuǎn)服務(wù)。這個(gè)服務(wù)的目標(biāo)非常明確:
在用戶通過(guò) aTrust 完成 OAuth2 登錄認(rèn)證之后,自動(dòng)模擬一個(gè)符合 CAS 協(xié)議的登錄流程,將用戶無(wú)感知地送入 JumpServer。
協(xié)議差異挑戰(zhàn)
- aTrust 零信任平臺(tái) 提供的是標(biāo)準(zhǔn) OAuth2 接口,通過(guò)
auth2ssoLogin
獲取授權(quán)碼(code),然后通過(guò)getUserInfoByCode
獲取用戶信息。 - JumpServer 堡壘機(jī) 僅接受 CAS 協(xié)議認(rèn)證,包括:
/cas/login
用于跳轉(zhuǎn)和 ticket 生成/cas/serviceValidate
或/cas/p3/serviceValidate
校驗(yàn) ticket 并返回 XML 格式認(rèn)證響應(yīng)
OAuth2 的核心在于 access_token 和授權(quán)碼機(jī)制,而 CAS 依賴的是 ticket 與 service 校驗(yàn)機(jī)制。兩者在協(xié)議模型、流程細(xì)節(jié)、響應(yīng)格式等方面完全不同,無(wú)法直接互通。
中轉(zhuǎn)服務(wù)的職責(zé)
我們實(shí)現(xiàn)的中轉(zhuǎn)服務(wù)需要完成以下任務(wù):
- 接收 JumpServer 的 CAS 登錄請(qǐng)求(模擬
/cas/login
接口) - 重定向至 aTrust 登錄頁(yè)面,攜帶必要的 redirect_url 和簽名
- 處理 aTrust 的 OAuth2 回調(diào),獲取 code 后調(diào)用其 API 獲取用戶信息
- 為該用戶生成一個(gè) CAS ticket,存入臨時(shí)內(nèi)存結(jié)構(gòu),并跳轉(zhuǎn)回 JumpServer
- 響應(yīng) JumpServer 對(duì) ticket 的校驗(yàn)請(qǐng)求,返回符合 CAS 協(xié)議格式的認(rèn)證 XML
- 可選支持注銷流程,實(shí)現(xiàn)
/cas/logout
路由,用于清除緩存和重定向
這一中轉(zhuǎn)服務(wù)不依賴任何數(shù)據(jù)庫(kù)或外部存儲(chǔ),所有 ticket 信息都使用內(nèi)存字典保存,并設(shè)定自動(dòng)過(guò)期機(jī)制,滿足 JumpServer 對(duì)票據(jù)有效期的要求。
技術(shù)實(shí)現(xiàn):服務(wù)實(shí)現(xiàn)細(xì)節(jié)與關(guān)鍵模塊說(shuō)明
整個(gè)中轉(zhuǎn)服務(wù)使用 Python 3 編寫,基于輕量的 Flask 框架構(gòu)建,具備部署靈活、邏輯清晰、易于維護(hù)等優(yōu)勢(shì)。下文將按模塊說(shuō)明關(guān)鍵功能:
1. 路由與協(xié)議轉(zhuǎn)換邏輯
服務(wù)共暴露 5 個(gè)核心路由:
路徑 | 說(shuō)明 |
---|---|
/cas/login | 模擬 CAS 登錄入口,攔截 JumpServer 的登錄請(qǐng)求 |
/callback | 接收 aTrust 的 OAuth2 授權(quán)回調(diào),完成 code 交換 |
/cas/serviceValidate | CAS 2.0 校驗(yàn)接口,響應(yīng)用戶 ticket 認(rèn)證信息 |
/cas/p3/serviceValidate | CAS 3.0 校驗(yàn)接口,功能相同,響應(yīng)格式略不同 |
/cas/logout | 兼容 CAS 注銷機(jī)制,可選清理緩存并跳轉(zhuǎn)服務(wù)頁(yè) |
登錄流程中,/cas/login
接收到來(lái)自 JumpServer 的請(qǐng)求后,會(huì)拼接 redirect URL 并跳轉(zhuǎn)至 aTrust 的 auth2ssoLogin
接口。用戶完成 OAuth2 登錄后,aTrust 回調(diào) /callback
,此處會(huì)使用提供的 appid、secret、code 進(jìn)行簽名計(jì)算并調(diào)用其 getUserInfoByCode
接口獲取用戶標(biāo)識(shí)。
隨后生成一個(gè) UUID 票據(jù)(ticket),緩存于服務(wù)中,并通過(guò)帶有該 ticket 的 URL 跳轉(zhuǎn)回 JumpServer。JumpServer 隨即發(fā)起 ticket 校驗(yàn)請(qǐng)求到 /cas/serviceValidate
,中轉(zhuǎn)服務(wù)會(huì)根據(jù)用戶信息返回標(biāo)準(zhǔn) CAS XML 響應(yīng),實(shí)現(xiàn)整個(gè)鏈路的閉環(huán)。
2. 簽名生成與參數(shù)校驗(yàn)
aTrust 要求 OAuth2 請(qǐng)求帶簽名參數(shù)(sign),該簽名基于請(qǐng)求參數(shù)及 secret 使用 HMAC-SHA256 方式生成。服務(wù)內(nèi)通過(guò)標(biāo)準(zhǔn) Python 庫(kù) hmac
+ hashlib
實(shí)現(xiàn),確保認(rèn)證請(qǐng)求合法可信,避免偽造。
簽名格式為:
sign = BASE64( HMAC-SHA256(appSecret, appId + timestamp + redirectUrl) )
這一簽名機(jī)制保證了中轉(zhuǎn)服務(wù)對(duì)接上游 OAuth2 接口的安全性和可靠性。
3. Ticket 票據(jù)生成與驗(yàn)證
每一個(gè)登錄用戶會(huì)被賦予一個(gè)臨時(shí) UUID 票據(jù),結(jié)構(gòu)如下:
{
"ticket": "uuid-token",
"username": "actual_username",
"expire_at": timestamp
}
所有票據(jù)保存在一個(gè)內(nèi)存級(jí)字典 ticket_store
中,在校驗(yàn)接口中比對(duì) ticket 是否有效,并定時(shí)清理過(guò)期票據(jù)。
4. 認(rèn)證結(jié)果格式化輸出
CAS 要求返回 XML 格式的認(rèn)證信息,類似如下:
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
<cas:authenticationSuccess>
<cas:user>username</cas:user>
</cas:authenticationSuccess>
</cas:serviceResponse>
我們根據(jù) JumpServer 的要求實(shí)現(xiàn)了 CAS 2.0 與 3.0 兩種格式輸出,確保兼容。
5. 后臺(tái)運(yùn)行與日志管理
服務(wù)支持通過(guò) nohup
+ run.sh
腳本形式啟動(dòng),可自動(dòng)輸出日志至 /var/log/oauth2_cas_gateway/YYYY-MM-DD.log
,并支持每日切割、自動(dòng)清理舊日志。也可通過(guò) systemd
注冊(cè)為服務(wù),適合生產(chǎn)環(huán)境部署。
6. 主程序代碼
from flask import Flask, request, redirect, make_response
import requests
import hashlib
import hmac
import time
import uuid
import urllib3
# 禁用 SSL 警告
urllib3.disable_warnings()
app = Flask(__name__)
# ===== 配置項(xiàng) =====
ATRUST_BASE = 'https://aTrust客戶端接入地址:端口'
APP_ID = '應(yīng)用中獲取'
APP_SECRET = '應(yīng)用中獲取'
CALLBACK_URL = 'http://ip/callback' # 中轉(zhuǎn)服務(wù)地址
# ===== Ticket 緩存(內(nèi)存) =====
ticket_store = {} # {ticket: {"username": ..., "expire": ...}}
TICKET_TTL = 300 # 5分鐘有效期
# ===== 生成簽名 =====
def generate_signature(appid, code):
raw = f"appid={appid}\ncode={code}"
return hmac.new(APP_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
# ===== 路由定義 =====
@app.route('/cas/login')
def cas_login():
service = request.args.get('service')
if not service:
return 'Missing service parameter', 400
state = str(uuid.uuid4())
redirect_url = f"{CALLBACK_URL}?service={service}&state={state}"
auth_url = (
f"{ATRUST_BASE}/passport/v1/public/auth2ssoLogin"
f"?redirectUrl={redirect_url}&appid={APP_ID}&responseType=code"
)
return redirect(auth_url)
@app.route('/callback')
def callback():
code = request.args.get('code')
service = request.args.get('service')
if not code or not service:
return 'Missing code or service', 400
sign = generate_signature(APP_ID, code)
headers = {"X-SDP-Signature": sign}
params = {"appid": APP_ID, "code": code}
userinfo_url = f"{ATRUST_BASE}/passport/v1/user/getUserInfoByCode"
try:
res = requests.get(userinfo_url, headers=headers, params=params, verify=False, timeout=5)
data = res.json()
except Exception as e:
return f"Error contacting aTrust: {str(e)}", 500
if res.status_code != 200 or data.get("code") != 0:
return f"Failed to retrieve user info: {data.get('message')}", 403
username = data.get("data", {}).get("name")
if not username:
return 'Username not found in user info', 403
ticket = str(uuid.uuid4())
ticket_store[ticket] = {"username": username, "expire": time.time() + TICKET_TTL}
# 修復(fù):正確拼接 ticket
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
parsed = urlparse(service)
qs = parse_qs(parsed.query)
qs['ticket'] = [ticket]
new_query = urlencode(qs, doseq=True)
new_url = urlunparse(parsed._replace(query=new_query))
return redirect(new_url)
@app.route('/cas/p3/serviceValidate')
def cas_p3_service_validate():
return service_validate()
@app.route('/cas/serviceValidate')
def service_validate():
service = request.args.get('service')
ticket = request.args.get('ticket')
if not ticket or ticket not in ticket_store:
return make_response("""
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationFailure code="INVALID_TICKET">Ticket not found</cas:authenticationFailure>
</cas:serviceResponse>
""", 200, {'Content-Type': 'application/xml'})
entry = ticket_store[ticket]
if time.time() > entry['expire']:
del ticket_store[ticket]
return make_response("""
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationFailure code="TICKET_EXPIRED">Ticket expired</cas:authenticationFailure>
</cas:serviceResponse>
""", 200, {'Content-Type': 'application/xml'})
username = entry['username']
return make_response(f"""
<cas:serviceResponse xmlns:cas='http://www.yale.edu/t/tp/cas'>
<cas:authenticationSuccess>
<cas:user>{username}</cas:user>
</cas:authenticationSuccess>
</cas:serviceResponse>
""", 200, {'Content-Type': 'application/xml'})
@app.route('/cas/logout')
def cas_logout():
ticket_store.clear()
service = request.args.get('service')
return f"""
<html><body>
<h2>您已成功退出 CAS 登錄</h2>
<p><a href="{service}">返回系統(tǒng)</a></p>
</body></html>
"""
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
技術(shù)總結(jié):服務(wù)實(shí)現(xiàn)細(xì)節(jié)與關(guān)鍵模塊說(shuō)明
本案例成功為客戶構(gòu)建了一個(gè) OAuth2 與 CAS 協(xié)議之間的橋接服務(wù),解決了兩個(gè)認(rèn)證系統(tǒng)協(xié)議不兼容的問(wèn)題,達(dá)成以下目標(biāo):
- 單點(diǎn)登錄體驗(yàn)優(yōu)化:用戶只需登錄一次零信任平臺(tái)(aTrust),即可自動(dòng)跳轉(zhuǎn)并完成 JumpServer 登錄,無(wú)需重復(fù)輸入密碼;
- 減少人工干預(yù):徹底消除了運(yùn)維人員因密碼遺忘而頻繁找客戶重置賬號(hào)的操作;
- 協(xié)議兼容性保障:中轉(zhuǎn)服務(wù)完全模擬 CAS 協(xié)議行為,兼容 JumpServer 原生登錄機(jī)制;
- 無(wú)侵入部署:中轉(zhuǎn)服務(wù)獨(dú)立部署,不需要修改 JumpServer 或 aTrust 任一系統(tǒng);
- 輕量穩(wěn)定:使用 Python + Flask 實(shí)現(xiàn),啟動(dòng)快速,資源占用低,支持 systemd 管理或 nohup 自運(yùn)行。