feat(oidc): 泛化 OIDC Provider 为多客户端并支持 user 身份策略

- secrets 新增 sections() 枚举配置段,客户端按 [oidc_client_<id>] 自动注册
- 身份策略 IDENTITY 支持 gitea(绑定 Gitea,sub=用户名)/ user(sub=UserUUID,全用户开放)
- authorize/token/userinfo 按 client_id 校验凭据与回调,code 绑定客户端防串用
- [gitea_oidc] 默认客户端行为零差异,兼容现有 Gitea 免密登录
This commit is contained in:
TermiNexus committed 2026-08-13 01:18:00 +08:00
1 parent fc704910b0
commit 3c4e853fea
2 files changed
+136 -46

No files matched your search

+131 -46
View File
@@ -1,23 +1,32 @@
"""模块:GERP OIDC Provider(Gitea 免密登录 SSO)
"""模块:GERP OIDC Provider(多客户端 SSO)
GERP 作为 OIDC Provider(IdP),Gitea 作为 OIDC Client(RP):
用户在 GERP 已登录 → 点「免密登录 Gitea」→ 跳转 Gitea 登录页
→ Gitea 重定向到本模块 authorize 端点 → 校验 GERP 会话自动授权
→ 回跳 Gitea callback 带 code → Gitea 调 token 端点换 id_token
→ Gitea 校验签名(jwks_uri)后完成登录,全程无需输入 Gitea 密码。
GERP 作为 OIDC Provider(IdP),各业务系统(Gitea / Discourse / 未来其他)
作为 OIDC Client(RP):
用户在 GERP 已登录 → 点「免密登录」→ 跳转目标系统登录页
→ 目标系统重定向到本模块 authorize 端点 → 校验 GERP 会话自动授权
→ 回跳目标系统 callback 带 code → 目标系统调 token 端点换 id_token
→ 目标系统校验签名(jwks_uri)后完成登录,全程无需输入目标系统密码。
端点:
/.well-known/openid-configuration discovery(OIDC 标准路径)
/oidc/authorize GET 授权端点(浏览器 302 跳转)
/oidc/token POST code 换 id_token
/oidc/jwks GET RSA 公钥(JWK Set)
/oidc/userinfo GET 用户信息(Bearer id_token)
配置(secrets.ini [gitea_oidc]):
ISSUER IdP 基础 URL,如 https://gerp.gvsds.com(无尾斜杠)
CLIENT_ID 与 Gitea 认证源中的 Client ID 一致(如 gerp)
CLIENT_SECRET 与 Gitea 认证源中的 Client Secret 一致
REDIRECT_URI Gitea 回调地址,形如 <Gitea>/user/oauth2/<认证源名>/callback
PRIVATE_KEY base64 DER 编码的 RSA 私钥(首次运行自动生成并回写)
配置(secrets.ini):
全局共享段 [gitea_oidc](兼容既有 Gitea 配置,名称沿用不重命名):
ISSUER IdP 基础 URL,如 https://gerp.gvsds.com(无尾斜杠)
CLIENT_ID 默认客户端 id(现有 Gitea 为 gerp)
CLIENT_SECRET 默认客户端 Secret
REDIRECT_URI 默认客户端回调地址
PRIVATE_KEY base64 DER 编码的 RSA 私钥(首次运行自动生成并回写)
每个新增客户端一个独立段 [oidc_client_<client_id>](小写):
CLIENT_SECRET 该客户端 Secret
REDIRECT_URI 该客户端回调地址
IDENTITY 身份策略(可选,默认 gitea):
gitea -> sub=Gitea 用户名,要求用户已绑定 Gitea 账号(UserGiteaAccount)
user -> sub=GERP 用户 UserUUID,全用户开放(Discourse 用)
安全要点:
- id_token 使用 RS256 签名,私钥仅存服务端 secrets.ini,公钥经 /oidc/jwks 暴露
@@ -70,7 +79,7 @@ def _PurgeAuthCodes():
def _GetConfig(key, fallback=''):
"""读取 [gitea_oidc] 配置。"""
"""读取全局共享段 [gitea_oidc] 配置。"""
return app_secrets.get('gitea_oidc', key, fallback)
@@ -78,16 +87,53 @@ def _Issuer():
return _GetConfig('ISSUER', 'https://gerp.gvsds.com').rstrip('/')
def _ClientId():
def _DefaultClientId():
"""默认客户端 id(既有 Gitea 客户端,配置存 [gitea_oidc] 段)。"""
return _GetConfig('CLIENT_ID', 'gerp')
def _ClientSecret():
return _GetConfig('CLIENT_SECRET', '')
# === OIDC 客户端注册发现 ===
# 默认客户端(Gitea)配置位于 [gitea_oidc] 段;新增客户端每个一个独立段
# [oidc_client_<client_id>](client_id 统一小写),段内可覆盖 CLIENT_SECRET /
# REDIRECT_URI / IDENTITY。全局共享的 ISSUER / PRIVATE_KEY 始终在 [gitea_oidc]。
_OIDC_SECTION = 'gitea_oidc'
_CLIENT_SECTION_PREFIX = 'oidc_client_'
def _RedirectUri():
return _GetConfig('REDIRECT_URI', '')
def _ClientIds():
"""返回所有已注册客户端 id 集合(默认客户端 + 各独立段)。"""
ids = set()
default_id = _DefaultClientId()
if default_id:
ids.add(default_id.lower())
for section in app_secrets.sections():
if section.startswith(_CLIENT_SECTION_PREFIX):
ids.add(section[len(_CLIENT_SECTION_PREFIX):])
return ids
def _LoadClient(client_id):
"""按 client_id 加载客户端配置;未注册返回 None(fail-fast)。
返回 dict:{client_id, secret, redirect_uri, identity}
- identity='gitea':sub=Gitea 用户名,需已绑定(默认客户端既有行为)
- identity='user':sub=GERP 用户 UserUUID,全用户开放
"""
client_id = (client_id or '').lower()
if not client_id:
return None
if client_id == _DefaultClientId().lower():
section = _OIDC_SECTION
else:
section = f'{_CLIENT_SECTION_PREFIX}{client_id}'
if section not in app_secrets.sections():
return None
return {
'client_id': client_id,
'secret': app_secrets.get(section, 'CLIENT_SECRET', ''),
'redirect_uri': app_secrets.get(section, 'REDIRECT_URI', ''),
'identity': app_secrets.get(section, 'IDENTITY', 'gitea').lower(),
}
def _GetPrivateKey():
@@ -189,21 +235,51 @@ def _GetGiteaIdentity(user):
return {'login': rec.GiteaLogin, 'email': rec.Email}
def _BuildIdToken(user, gitea, nonce=None):
"""签发 id_token(RS256),claims 中携带 Gitea 用户名/邮箱供 Gitea 匹配账号。"""
def _ResolveIdentity(user, identity):
"""按客户端身份策略解析 id_token 的 sub 与元数据,返回 dict 或 None。
- identity='gitea':sub=Gitea 用户名,未绑定返回 None(拒绝授权)
- identity='user':sub=GERP 用户 UserUUID,全用户开放
"""
if identity == 'user':
from gvsdsdk.model_utils import UUIDObj
return {
'sub': str(UUIDObj(user.UserUUID).str),
'username': getattr(user, 'UserName', '') or '',
'name': getattr(user, 'UserName', '') or '',
'email': getattr(user, 'UserEmail', '') or '',
'email_verified': bool(getattr(user, 'UserEmailVerify', 0) == 1),
}
gitea = _GetGiteaIdentity(user)
if gitea is None:
return None
return {
'sub': gitea['login'],
'username': gitea['login'],
'name': getattr(user, 'UserName', '') or '',
'email': gitea['email'],
'email_verified': True,
}
def _BuildIdToken(user, client, nonce=None):
"""签发 id_token(RS256),aud=客户端 id,sub 按客户端身份策略。"""
ident = _ResolveIdentity(user, client['identity'])
if ident is None:
return None
now = int(time.time())
claims = {
'iss': _Issuer(),
'sub': gitea['login'],
'aud': _ClientId(),
'sub': ident['sub'],
'aud': client['client_id'],
'exp': now + _ID_TOKEN_TTL,
'iat': now,
'auth_time': now,
'preferred_username': gitea['login'],
'nickname': gitea['login'],
'email': gitea['email'],
'email_verified': True,
'name': getattr(user, 'UserName', '') or '',
'preferred_username': ident['username'],
'nickname': ident['username'],
'email': ident['email'],
'email_verified': ident['email_verified'],
'name': ident['name'],
}
if nonce:
claims['nonce'] = nonce
@@ -254,9 +330,11 @@ class OIDCAuthorizeView(APIView):
# 参数校验
if response_type != 'code':
return Response({'error': 'unsupported_response_type'}, status=400)
if client_id != _ClientId():
client = _LoadClient(client_id)
if client is None:
logger.warning('[OIDC] 未注册的 client_id: %s', client_id)
return Response({'error': 'unauthorized_client'}, status=400)
if redirect_uri != _RedirectUri():
if redirect_uri != client['redirect_uri']:
logger.warning('[OIDC] 未注册的 redirect_uri: %s', redirect_uri)
return Response({'error': 'invalid_request', 'error_description': 'redirect_uri 未注册'}, status=400)
if 'openid' not in (scope or '').split():
@@ -271,12 +349,12 @@ class OIDCAuthorizeView(APIView):
login_url = getattr(settings, 'LOGIN_URL', '/#/login')
return HttpResponseRedirect(login_url)
# 校验用户已绑定 Gitea
gitea = _GetGiteaIdentity(user)
if gitea is None:
logger.warning('[OIDC] 用户 %s 未绑定 Gitea 账号,拒绝授权', user)
# 按客户端身份策略解析 sub;不满足(如 gitea 未绑定)拒绝授权
if _ResolveIdentity(user, client['identity']) is None:
logger.warning('[OIDC] 用户 %s 不满足客户端 %s 身份策略,拒绝授权',
user, client['client_id'])
return Response(
{'error': 'access_denied', 'error_description': '请先在 GERP 申请 Gitea 账号'},
{'error': 'access_denied', 'error_description': '用户不满足该客户端身份要求(如未绑定 Gitea 账号)'},
status=403,
)
@@ -286,6 +364,7 @@ class OIDCAuthorizeView(APIView):
_PurgeAuthCodes()
_AUTH_CODE_STORE[code] = {
'user_uuid': str(getattr(user, 'UserUUID', '')),
'client_id': client['client_id'],
'nonce': nonce,
'exp': time.time() + _AUTH_CODE_TTL,
}
@@ -294,7 +373,7 @@ class OIDCAuthorizeView(APIView):
url = f'{redirect_uri}{sep}code={code}'
if state:
url += f'&state={state}'
logger.info('[OIDC] 授权成功 user=%s -> 回跳 %s', user, redirect_uri)
logger.info('[OIDC] 授权成功 user=%s client=%s -> 回跳 %s', user, client['client_id'], redirect_uri)
return HttpResponseRedirect(url)
@@ -305,7 +384,7 @@ class OIDCTokenView(APIView):
super().__init__(**{**OIDC_VIEW_KWARGS, **kwargs})
def _CheckClientAuth(self, request):
"""校验 client_id + client_secret(Basic 头或 POST body)。"""
"""校验 client_id + client_secret(Basic 头或 POST body),成功返回客户端配置。"""
auth = request.META.get('HTTP_AUTHORIZATION', '')
client_id, client_secret = '', ''
if auth.startswith('Basic '):
@@ -317,10 +396,14 @@ class OIDCTokenView(APIView):
if not client_id:
client_id = request.data.get('client_id', '')
client_secret = request.data.get('client_secret', '')
return client_id == _ClientId() and client_secret == _ClientSecret()
client = _LoadClient(client_id)
if client is None or client_secret != client['secret']:
return None
return client
def post(self, request):
if not self._CheckClientAuth(request):
client = self._CheckClientAuth(request)
if client is None:
return Response({'error': 'invalid_client'}, status=401)
grant_type = request.data.get('grant_type', '')
@@ -329,13 +412,15 @@ class OIDCTokenView(APIView):
if grant_type != 'authorization_code':
return Response({'error': 'unsupported_grant_type'}, status=400)
if redirect_uri != _RedirectUri():
if redirect_uri != client['redirect_uri']:
return Response({'error': 'invalid_request', 'error_description': 'redirect_uri 不匹配'}, status=400)
with _AUTH_CODE_LOCK:
entry = _AUTH_CODE_STORE.pop(code, None)
if entry is None or entry['exp'] < time.time():
return Response({'error': 'invalid_grant'}, status=400)
if entry.get('client_id') != client['client_id']:
return Response({'error': 'invalid_grant', 'error_description': 'code 与客户端不匹配'}, status=400)
# 取回用户
from gvsds.apps.core.models import User as CoreUser
@@ -345,11 +430,9 @@ class OIDCTokenView(APIView):
except Exception:
return Response({'error': 'invalid_grant', 'error_description': '用户不存在'}, status=400)
gitea = _GetGiteaIdentity(user)
if gitea is None:
return Response({'error': 'invalid_grant', 'error_description': '用户未绑定 Gitea'}, status=400)
id_token = _BuildIdToken(user, gitea, nonce=entry['nonce'])
id_token = _BuildIdToken(user, client, nonce=entry['nonce'])
if id_token is None:
return Response({'error': 'invalid_grant', 'error_description': '用户不满足该客户端身份要求'}, status=400)
return Response({
'access_token': id_token,
'token_type': 'Bearer',
@@ -380,11 +463,13 @@ class OIDCUserInfoView(APIView):
return Response({'error': 'invalid_token'}, status=401)
token = auth[7:]
try:
payload = jwt.decode(token, _GetPrivateKey().public_key(), algorithms=['RS256'],
audience=_ClientId())
payload = jwt.decode(token, _GetPrivateKey().public_key(), algorithms=['RS256'])
except jwt.PyJWTError as e:
logger.warning('[OIDC] userinfo token 校验失败: %s', e)
return Response({'error': 'invalid_token'}, status=401)
if payload.get('aud') not in _ClientIds():
logger.warning('[OIDC] userinfo aud 未注册: %s', payload.get('aud'))
return Response({'error': 'invalid_token'}, status=401)
return Response({
'sub': payload.get('sub'),
'preferred_username': payload.get('preferred_username'),
+5
View File
@@ -25,6 +25,11 @@ def get(section, key, fallback=None):
return cfg.get(section, key, fallback=fallback)
def sections():
"""返回 secrets.ini 中所有已定义配置段名列表(环境变量动态覆盖不参与)。"""
return _load_config().sections()
def getint(section, key, fallback=0):
val = get(section, key, fallback=None)
if val is None: