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:
1 parent
fc704910b0
commit
3c4e853fea
2 files changed
+136
-46
No files matched your search
+131
-46
@@ -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'),
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user