4. OpenID Connect身份认证集成

任务说明

本案例为数字银行开放银行API网关集成OpenID Connect(OIDC)身份认证。需要完成:银行开发者门户作为OIDC Relying Party(RP),对接银行统一身份提供方(IdP),实现第三方应用用户的身份认证与ID 签发。核心交付物为一个可运行的OIDC认证流程模拟器,覆盖Authorization Code Flow的核心交互,并输出可供API网关校验的ID 。

OIDC认证流程设计

本案例聚焦银行开放平台最常用的Authorization Code Flow,参与方包括:第三方应用(客户端)、银行API网关(RP)、银行IdP。流程如下:

  1. 第三方应用引导用户访问银行IdP的授权端点,携带client_id、redirect_uri、response_type=code、scope=openid;
  2. 用户完成身份认证后,IdP返回授权码(code)至回调地址;
  3. 第三方应用使用授权码向IdP的令牌端点换取ID 和Access ;
  4. API网关校验ID 的签名、issuer、audience和过期时间,校验通过后允许访问受保护的银行API。
第三方应用 银行API网关 (RP) 银行IdP 1. 认证请求 (client_id, redirect_uri, scope=openid) 2. 用户认证 & 授权 3. 授权码 (code) 回调 redirect_uri 4. 令牌请求 (code, client_secret) 5. ID + Access 6. API请求 + ID 7. 校验签名/iss/aud/exp 8. 返回受保护资源

核心代码实现

以下Python实现模拟银行IdP的授权端点与令牌端点,并包含API网关的ID 校验逻辑。使用cryptography库生成RSA密钥对并签名JWT。


# oidc_provider.py
import json
import time
import uuid
from urllib.parse import urlencode
from http.server import HTTPServer, BaseHTTPRequestHandler
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature

# ---------- 密钥生成(实际部署中由IdP保管私钥) ----------
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()

# 模拟用户数据库
USERS = {"alice": {"password": "pass123", "sub": "user-001", "name": "Alice"}}
# 模拟授权码存储
AUTH_CODES = {}

def base64url_encode(data: bytes) -> str:
    import base64
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

def base64url_decode(data: str) -> bytes:
    import base64
    padding = '=' * (4 - len(data) % 4)
    return base64.urlsafe_b64decode(data + padding)

def create_jwt_header() -> str:
    header = {"alg": "RS256", "typ": "JWT", "kid": "bank-idp-key-1"}
    return base64url_encode(json.dumps(header).encode())

def create_id_(sub: str, aud: str, nonce: str = "") -> str:
    """生成符合OIDC规范的ID """
    now = int(time.time())
    payload = {
        "iss": "https://idp.bank.example.com",
        "sub": sub,
        "aud": aud,
        "exp": now + 3600,
        "iat": now,
        "nonce": nonce,
        "auth_time": now,
        "amr": ["pwd"]
    }
    header = create_jwt_header()
    payload_b64 = base64url_encode(json.dumps(payload).encode())
    signing_input = f"{header}.{payload_b64}".encode()
    signature = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
    # 将DER签名转为JWT所需的r||s格式
    r, s = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256()).hex()[:64], \
           private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256()).hex()[64:]
    # 简化处理:直接使用原始签名(实际应使用encode_dss_signature)
    sig_b64 = base64url_encode(signature)
    return f"{header}.{payload_b64}.{sig_b64}"

class OIDCHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        # 授权端点 /authorize
        if self.path.startswith("/authorize"):
            query = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(self.path).query))
            client_id = query.get("client_id")
            redirect_uri = query.get("redirect_uri")
            scope = query.get("scope")
            state = query.get("state")
            nonce = query.get("nonce")
            
            if client_id != "bank-api-client" or "openid" not in scope:
                self.send_response(400)
                self.end_headers()
                self.wfile.write(b"Invalid client or scope")
                return
            
            # 模拟用户登录(实际应跳转登录页)
            # 此处直接生成授权码
            code = str(uuid.uuid4())
            AUTH_CODES[code] = {"client_id": client_id, "redirect_uri": redirect_uri, "sub": "user-001"}
            
            # 重定向回第三方应用
            redirect_url = f"{redirect_uri}?code={code}&state={state}"
            self.send_response(302)
            self.send_header("Location", redirect_url)
            self.end_headers()
    
    def do_POST(self):
        # 令牌端点 /
        if self.path.startswith("/"):
            content_length = int(self.headers["Content-Length"])
            body = self.rfile.read(content_length).decode()
            params = dict(urllib.parse.parse_qsl(body))
            
            code = params.get("code")
            client_id = params.get("client_id")
            client_secret = params.get("client_secret")
            redirect_uri = params.get("redirect_uri")
            
            if client_secret != "bank-secret-2024":
                self.send_response(401)
                self.end_headers()
                self.wfile.write(b"Invalid client secret")
                return
            
            stored = AUTH_CODES.get(code)
            if not stored or stored["client_id"] != client_id or stored["redirect_uri"] != redirect_uri:
                self.send_response(400)
                self.end_headers()
                self.wfile.write(b"Invalid authorization code")
                return
            
            # 生成ID 
            id_ = create_id_(sub=stored["sub"], aud=client_id)
            del AUTH_CODES[code]  # 一次性使用
            
            response = {
                "access_": "access--" + uuid.uuid4().hex,
                "_type": "Bearer",
                "expires_in": 3600,
                "id_": id_
            }
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            self.wfile.write(json.dumps(response).encode())
    
    def log_message(self, format, *args):
        pass  # 静默日志

def run_server():
    server = HTTPServer(("localhost", 8080), OIDCHandler)
    print("OIDC Provider running on http://localhost:8080")
    server.serve_forever()

if __name__ == "__main__":
    run_server()

# api_gateway_validator.py
import json
import time
import base64
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import load_pem_public_key

# 从IdP获取的公钥(实际通过JWKS端点动态获取)
PUBLIC_KEY_PEM = b"""-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"""

def base64url_decode(data: str) -> bytes:
    padding = '=' * (4 - len(data) % 4)
    return base64.urlsafe_b64decode(data + padding)

def validate_id_(id_: str, expected_aud: str) -> dict:
    """验证ID 签名、iss、aud、exp"""
    parts = id_.split(".")
    if len(parts) != 3:
        raise ValueError("Invalid ID  format")
    
    header_b64, payload_b64, signature_b64 = parts
    signing_input = f"{header_b64}.{payload_b64}".encode()
    signature = base64url_decode(signature_b64)
    
    # 加载公钥并验证签名
    public_key = load_pem_public_key(PUBLIC_KEY_PEM)
    try:
        public_key.verify(signature, signing_input, padding.PKCS1v15(), hashes.SHA256())
    except Exception as e:
        raise ValueError(f"Signature verification failed: {e}")
    
    # 解析payload
    payload = json.loads(base64url_decode(payload_b64))
    
    # 校验必要声明
    if payload.get("iss") != "https://idp.bank.example.com":
        raise ValueError("Invalid issuer")
    if payload.get("aud") != expected_aud:
        raise ValueError("Invalid audience")
    if payload.get("exp", 0) < int(time.time()):
        raise ValueError(" expired")
    
    return payload

# 使用示例(在API网关中调用)
if __name__ == "__main__":
    # 此处的id_由OIDC Provider实际签发
    # 实际运行时从Authorization头获取
    sample_ = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2lkcC5iYW5rLmV4YW1wbGUuY29tIiwic3ViIjoidXNlci0wMDEiLCJhdWQiOiJiYW5rLWFwaS1jbGllbnQiLCJleHAiOjE3MTAwMDAwMDAsImlhdCI6MTcwOTk5NjQwMH0.signature"
    # 实际验证需使用真实签发的,此处仅演示调用方式
    # payload = validate_id_(sample_, "bank-api-client")
    # print("验证通过:", payload["sub"])
    print("API网关校验模块就绪。实际运行时请传入IdP签发的ID 。")

运行与验证

依赖安装:

pip install cryptography

启动OIDC Provider:

python oidc_provider.py

验证步骤:

  1. 浏览器访问 http://localhost:8080/authorize?client_id=bank-api-client&redirect_uri=http://localhost:3000/callback&scope=openid&state=xyz&nonce=abc,观察是否302重定向至回调地址并携带code参数;
  2. 使用curl模拟令牌请求:
curl -X POST http://localhost:8080/ \
  -d "code=上一步获取的code" \
  -d "client_id=bank-api-client" \
  -d "client_secret=bank-secret-2024" \
  -d "redirect_uri=http://localhost:3000/callback"

预期返回JSON包含id_字段;

  1. 将返回的id_粘贴至api_gateway_validator.py的sample_变量,运行验证脚本,观察是否输出"验证通过"及sub声明。

判定标准:授权码流程完整走通,ID 包含正确的iss、aud、exp声明,签名验证通过且无异常抛出,即视为本案例实现成功。