From 3c4e853fea17d8e1dd6c2aa327115af1aea0e911 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Thu, 13 Aug 2026 01:18:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(oidc):=20=E6=B3=9B=E5=8C=96=20OIDC=20Provi?= =?UTF-8?q?der=20=E4=B8=BA=E5=A4=9A=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20user=20=E8=BA=AB=E4=BB=BD=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - secrets 新增 sections() 枚举配置段,客户端按 [oidc_client_] 自动注册 - 身份策略 IDENTITY 支持 gitea(绑定 Gitea,sub=用户名)/ user(sub=UserUUID,全用户开放) - authorize/token/userinfo 按 client_id 校验凭据与回调,code 绑定客户端防串用 - [gitea_oidc] 默认客户端行为零差异,兼容现有 Gitea 免密登录 --- gvsds/apps/gerp/oidc.py | 177 +++++++++++++++++++++++++++++----------- gvsds/secrets.py | 5 ++ 2 files changed, 136 insertions(+), 46 deletions(-) diff --git a/gvsds/apps/gerp/oidc.py b/gvsds/apps/gerp/oidc.py index 06b85eac..f8f05151 100644 --- a/gvsds/apps/gerp/oidc.py +++ b/gvsds/apps/gerp/oidc.py @@ -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 回调地址,形如 /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_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_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'), diff --git a/gvsds/secrets.py b/gvsds/secrets.py index 67513048..d7da8a4c 100644 --- a/gvsds/secrets.py +++ b/gvsds/secrets.py @@ -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: