资讯动态

Python HTTPS通信从证书验证到双向认证:关键在于理解TLS握手与信任链

发布时间:2026/10/14 17:48:26 来源:尧图企业网站定制
前阵子帮某同事排查一个生产环境的接口调用异常日志里躺着一行SSLError: certificate verify failed。这位同事很困惑本地明明跑得好好的怎么一到服务器就报证书错误这种问题在Python做HTTPS通信时太典型了——大多数人刚开始用requests请求接口都会在某个时刻被TLS握手、证书信任链、自签名证书这些概念卡住。用这篇把Python实现HTTPS通信这件事从客户端到服务端完整捋一遍从requests调用、证书验证机制到用Python自己搭一个HTTPS服务、再做双向认证最后整理几个高频报错的排查思路希望对正在做爬虫、写API对接或者搭内部服务的同学有帮助。1. HTTPS通信的核心机制与Python生态选型1.1 HTTPS到底在解决什么问题很多人对HTTPS的理解停留在加密两个字上这不够。HTTPS HTTP TLS/SSL它实际解决了三件事身份验证、数据加密、数据完整性。身份验证靠的是证书。客户端在握手阶段会收到服务端的证书然后判断这个证书是不是可信CA签发的、域名是否匹配、证书是否过期。这一步不是可选项是HTTPS安全模型的根基。数据加密靠的是密钥协商。说个通俗版本客户端和服务端在TLS握手时协商出一个对称密钥之后所有HTTP内容都用这个密钥加密传输即使被截获也看不懂。这个过程叫TLS握手涉及ClientHello、ServerHello、证书交换、密钥协商等几个环节实际耗时约几十到几百毫秒。数据完整性靠的是MAC签名。每一条记录都带签名接收方可以验证数据有没有被中途篡改。理解了这三件事很多报错就好判断了。报证书错误说明身份验证环节出了问题报SSL: WRONG_VERSION_NUMBER可能是协议不匹配报mac验证失败可能数据被改过或者密钥对不上。1.2 Python生态里处理HTTPS的几把利器Python里处理HTTPS通信常用的几套方案各有用武之地requests基于urllib3封装是目前最主流的HTTP客户端库API简洁证书验证、Session复用、重试机制都处理得很到位90%的HTTP请求场景用它就够了。urllibPython标准库自带的HTTP客户端不需要安装额外依赖适合脚本工具或者受限环境。但接口偏底层处理Cookie、重定向、代理要写更多代码。httpx新一代HTTP客户端API设计跟requests几乎一致同时支持HTTP/2和异步适合性能敏感或需要async的场景。ssl socket最底层的实现方式需要自己处理HTTP协议和TLS握手一般只用于协议研究或定制化极强的需求。选型建议很简单普通项目无脑requests需要异步或HTTP/2再考虑httpx脚本且没装第三方库就用urllib。这篇文章以requests为主但核心原理对httpx和urllib同样适用。1.3 为什么不能随便关掉证书验证网上很多教程喜欢写verifyFalse来跳过证书验证确实能解决眼前报错但这是一个非常危险的习惯。关掉验证意味着客户端不再确认服务端身份中间人攻击可以轻松截获你的请求和响应。你传过去的密码、Token、业务数据全都暴露在第三方眼皮底下。生产环境必须走完证书验证链路。如果是内部服务用的自签名证书正确做法是把该证书加入本地信任列表或者用verify参数指定CA文件路径。后面章节会细讲。2. 客户端HTTPS请求实践与证书处理2.1 最基础的HTTPS请求怎么写一个简单的GET请求大家都写过import requests resp requests.get(https://api.example.com/v1/users) print(resp.status_code) print(resp.json())但要注意几个容易被忽略的参数。timeout一定要设不设的话请求可能一直挂起# 第一个值是连接超时第二个值是读取超时单位秒 resp requests.get( https://api.example.com/v1/users, timeout(5, 10), )Session也比直接调用更值得养成习惯。Session内部维护连接池同域名下的多个请求可以复用TCP连接和TLS会话性能提升非常明显session requests.Session() session.headers.update({ User-Agent: MyService/1.0, Accept: application/json, }) session.verify /etc/ssl/certs/ca-certificates.crt resp session.get(https://api.example.com/v1/users, timeout(5, 10))我在实际项目中测过不用Session的情况下连续请求十个接口每次都要重新做TCP握手和TLS握手整体耗时大约翻一倍。用Session之后连接复用耗时可降低40%-60%。高并发场景下这个差距还会更大。2.2 证书验证机制与信任链很多人不理解为什么requests.get()能验证证书其实就是因为它依赖了系统或库自带的CA信任列表。requests库在安装时会捆绑一套CA证书存放在certifi这个包里由Certifi这个组织维护。请求库发起的每个HTTPS请求都会拿服务端下发的证书跟这套CA列表做比对。要理解验证逻辑可以先想一个问题你凭什么相信api.example.com这个服务器就是真正的服务方答案是信任链。服务端在握手时不仅下发自己的证书还会下发签发它的上级CA证书。客户端拿到后从叶子证书往上追直到找到信任列表里的根证书这条链路就叫证书信任链。每个环节都要验证三件事上一级证书的签名是否匹配、证书是否在有效期、域名是否与证书中的Subject Alternative NameSAN一致。任何一环失败都会报certificate verify failed或hostname mismatch。2.3 自签名证书的两种处理方式内部测试或者开发环境经常用自签名证书这类证书不在任何CA信任列表里直接请求必然报错。处理方式有正规的和临时的两种。正规做法把自签名证书或你自己的私有CA证书加到信任文件然后在请求时指定verify为这个文件路径import requests resp requests.get( https://192.168.1.100:8443/health, verify/path/to/private_ca.pem, timeout(5, 10), )注意这里verify参数既可以传布尔值也可以传字符串路径。传路径才是推荐做法它只影响当前这个请求的信任范围不会像某些教程那样直接verifyFalse一刀切。临时做法调试阶段确实图省事可以用verifyFalse同时用urllib3.disable_warnings()压制掉警告import urllib3 import requests urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) resp requests.get( https://192.168.1.100:8443/health, verifyFalse, timeout(5, 10), )但这个写法只该出现在本地调试脚本里且必须清楚它带来的风险。2.4 处理证书的几个冷门但重要的细节证书内容的实际解析可以用ssl标准库不依赖openssl命令行import ssl import socket hostname api.example.com port 443 ctx ssl.create_default_context() with socket.create_connection((hostname, port), timeout5) as sock: with ctx.wrap_socket(sock, server_hostnamehostname) as tls_sock: cert tls_sock.getpeercert() print(cert.get(subject)) print(cert.get(notAfter))getpeercert()返回的是从证书中解析出的字段notAfter是证书过期时间。生产环境可以定期检测证书剩余有效期提前预警。我在某个系统里就是这么做的用脚本每天检查各域名的证书剩余天数小于30天就发告警避免出现过期事故。3. urllib和httpx不同场景下的补位方案3.1 用urllib处理HTTPS时要注意的坑urllib在标准库中内部其实有完整的证书验证逻辑import urllib.request url https://api.example.com/v1/users req urllib.request.Request(url, headers{User-Agent: MyService/1.0}) resp urllib.request.urlopen(req, timeout10) data resp.read() print(resp.status)看起来很简单但urllib默认使用系统CA库在Windows和某些精简的Linux容器里系统CA路径可能不存在导致报CERTIFICATE_VERIFY_FAILED。解决办法是指定ssl上下文import ssl import urllib.request context ssl.create_default_context(cafile/etc/ssl/certs/ca-certificates.crt) url https://api.example.com/v1/users req urllib.request.Request(url) resp urllib.request.urlopen(req, timeout10, contextcontext)另外urllib不维护连接池每个请求都是独立的TCP连接。做批量请求时性能很差我一般不推荐在生产环境大规模用它只适合一次性脚本。3.2 httpxrequests的现代替代方案httpx在设计上比requests更现代它支持HTTP/2和多路复用。对某些服务端只支持HTTP/2的场景requests目前还不行requests基于urllib3目前HTTP/2支持仍未正式合入但httpx可以。import httpx with httpx.Client(http2True, verify/etc/ssl/certs/ca-certificates.crt, timeout10.0) as client: resp client.get(https://api.example.com/v1/users) print(resp.status_code, resp.json())异步版本也顺手import httpx import asyncio async def fetch(): async with httpx.AsyncClient(http2True) as client: resp await client.get(https://api.example.com/v1/users) return resp.json() data asyncio.run(fetch()) print(data)httpx的verify参数、证书验证逻辑与requests基本一致从requests迁移到httpx的成本很低。如果项目没有历史包袱直接用httpx也是合理选择。3.3 什么时候需要自己写SSLContextssl.SSLContext是Python中更底层的TLS配置对象。requests和httpx内部都封装了它但某些场景下必须自己创建需要自定义加密套件需要加载特定的私钥和证书做双向认证需要控制TLS最低版本需要修改会话缓存策略基本使用如下import ssl ctx ssl.create_default_context() ctx.minimum_version ssl.TLSVersion.TLSv1_2 ctx.load_verify_locations(cafile/etc/ssl/certs/ca-certificates.crt) # 做客户端时这样用 with socket.create_connection((api.example.com, 443), timeout5) as sock: with ctx.wrap_socket(sock, server_hostnameapi.example.com) as tls_sock: print(tls_sock.version())server_hostname参数是SNI后面讲排查问题时还会再提这个参数对多域名共用IP的服务器至关重要。4. 服务端HTTPS从自签名证书到双向认证4.1 openssl生成自签名证书的完整流程Python不仅能发HTTPS请求也能搭HTTPS服务。第一步永远是准备证书。自签名证书的最简单生成方式openssl req -x509 -newkey rsa:2048 \ -keyout server_key.pem \ -out server_cert.pem \ -days 365 \ -nodes \ -subj /CCN/STBeijing/LBeijing/OExample/CNlocalhost拆解一下参数-x509表示直接生成自签名证书不生成证书签名请求文件-newkey rsa:2048生成新的RSA私钥长度2048位是底线-nodes表示私钥不加密否则每次启动服务都要输入密码-days 365有效期一年-subj里的CN字段是核心属性客户端校验域名时主要看这个字段和SAN扩展生成后的server_key.pem是私钥绝不能公开server_cert.pem是证书可以分发。如果客户端请求时使用IP而非域名需要加SAN扩展。只写CNIP是没用的现代浏览器和大部分库已经忽略CN了只看SANopenssl req -x509 -newkey rsa:2048 \ -keyout server_key.pem \ -out server_cert.pem \ -days 365 \ -nodes \ -subj /CCN/STBeijing/OExample/CN192.168.1.100 \ -addext subjectAltNameIP:192.168.1.100,DNS:localhost这一步特别关键我之前就吃过亏生成证书只写了CN浏览器访问一直报NET::ERR_CERT_COMMON_NAME_INVALID补上SAN才解决。4.2 基于ssl模块把HTTP服务升级为HTTPSPython自带的http.server可以快速起一个HTTP服务搭配ssl模块就能变成HTTPSimport http.server import ssl server_address (0.0.0.0, 8443) handler http.server.SimpleHTTPRequestHandler httpd http.server.HTTPServer(server_address, handler) ctx ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain(certfileserver_cert.pem, keyfileserver_key.pem) httpd.socket ctx.wrap_socket(httpd.socket, server_sideTrue) print(HTTPS server running on https://0.0.0.0:8443) httpd.serve_forever()用https curl验证curl https://localhost:8443/ -k -v-k表示跳过证书验证用于调试。正式环境不加。4.3 常见的Web框架如何启用HTTPS生产环境很少直接用http.server更多是用FastAPI、Flask这类框架。FastAPI配合uvicorn启动命令加两个参数即可uvicorn main:app --host 0.0.0.0 --port 8443 \ --ssl-keyfile server_key.pem \ --ssl-certfile server_cert.pemFlask的配置稍微不一样直接用ssl_context参数from flask import Flask app Flask(__name__) app.route(/health) def health(): return {status: ok} if __name__ __main__: app.run( host0.0.0.0, port8443, ssl_context(server_cert.pem, server_key.pem), )注意Flask的ssl_context也支持传入一个SSLContext对象ssl_contextctx在需要自定义配置时更灵活。4.4 双向认证mTLS的实现细节双向认证即客户端也要提供证书服务端验证客户端身份后才能建立连接。常见于企业内部微服务之间通信、设备接入平台等场景。我把它称为“反过来的信任检查”因为双方都要亮明身份。服务端代码import ssl import http.server server_address (0.0.0.0, 8443) handler http.server.SimpleHTTPRequestHandler httpd http.server.HTTPServer(server_address, handler) ctx ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain(certfileserver_cert.pem, keyfileserver_key.pem) # 服务端要求客户端必须出示证书且由指定CA签发 ctx.verify_mode ssl.CERT_REQUIRED ctx.load_verify_locations(cafileclient_ca.pem) httpd.socket ctx.wrap_socket(httpd.socket, server_sideTrue) httpd.serve_forever()客户端请求时既要加载自己的客户端证书也要信任服务端证书的CAimport requests resp requests.get( https://127.0.0.1:8443/, cert(client_cert.pem, client_key.pem), verifyserver_cert.pem, # 或者指定内部CA文件 timeout(5, 10), ) print(resp.status_code)客户端证书的生成可以复用之前签发的私钥和CSR并用服务端证书作为CA签发# 生成客户端私钥和证书请求 openssl req -newkey rsa:2048 -nodes \ -keyout client_key.pem \ -out client_csr.pem \ -subj /CCN/STBeijing/OExample/CNclient-001 # 用之前生成的server_cert.pem作为CA为客户端签发证书 openssl x509 -req \ -in client_csr.pem \ -CA server_cert.pem \ -CAkey server_key.pem \ -CAcreateserial \ -out client_cert.pem \ -days 365双向认证的一个常见误区是以为客户端证书只要自签名就行。实际上服务端配置了CERT_REQUIRED后会验证客户端证书的签发者客户端证书的签发者必须在服务端load_verify_locations指定的文件里。5. 常见HTTPS通信问题与排查技巧实录5.1 certificate verify failed证书验证失败的真正原因这个报错是HTTPS通信中最常见的。原因基本有三类证书签发者不在信任列表里。自签名证书、私有CA签发的证书都会这样。解决指定verify为CA文件路径。证书链不完整。服务器只返回了叶子证书没有返回中间CA证书。解决让服务器配置完整证书链把证书文件合并成一个包含叶子证书和中间CA的文件。系统时间不对。证书有有效期本机时间如果偏离真实时间太久会导致所有证书都判定为过期或未生效。这个问题很坑之前在某容器环境遇到过一次排查了半天才发现是宿主机时间不对。5.2 hostname mismatch域名不匹配问题报错形如Hostname mismatch, certificate is not valid for xxx。原因是访问时的域名与证书中的SAN不匹配。处理思路如果访问的是IP检查证书是否有对应的IP SAN如果是开发环境考虑换用与证书匹配的域名访问在/etc/hosts里加一条映射重新生成包含正确SAN的证书这类问题在改动域名、迁移服务器后特别容易出现排查时先查证书内容再动手openssl s_client -connect example.com:443 -servername example.com /dev/null 2/dev/null | openssl x509 -noout -text | grep -A 1 Subject Alternative Name5.3 SSL版本与加密套件不兼容报错sslv3 alert handshake failure或tlsv1 alert protocol version通常是服务端和客户端支持的TLS版本或加密套件没有交集。老服务端可能只支持TLS 1.0/1.1而新版Python要求最低TLS 1.2。查看当前Python支持情况import ssl print(ssl.OPENSSL_VERSION)如果服务端确实太老可以手动调低最低版本ctx ssl.create_default_context() ctx.minimum_version ssl.TLSVersion.TLSv1但TLS 1.0/1.1已有严重安全漏洞非不得已不要这么做更推荐升级服务端的TLS版本。5.4 SNI、代理和抓包工具对通信的影响SNI是TLS握手中的一个扩展字段客户端在握手时把要访问的主机名发给服务器服务器据此选择对应的证书。用IP直连、走代理时SNI可能缺失服务端会返回默认证书导致域名不匹配。在requests中直接传URL就会自动带SNI。但如果用底层socket或用IP访问域名必须显式传server_hostnameimport ssl import socket ctx ssl.create_default_context() with socket.create_connection((192.168.1.100, 443), timeout5) as sock: # 这里不传server_hostnameSNI就是空的 with ctx.wrap_socket(sock, server_hostnameapi.example.com) as tls_sock: print(tls_sock.version())代理环境下的坑也很常见。某些公司内网代理会拦截HTTPS流量用中间证书重新签发导致requests报证书错误。解决方式是设置环境变量export HTTPS_PROXYhttp://proxy.example.com:8080或者让requests信任代理的CA证书proxies { https: http://proxy.example.com:8080, } resp requests.get( https://api.example.com, proxiesproxies, verify/path/to/proxy_ca.pem, )抓包工具比如Charles也是同样原理它们会用自签名的代理证书解密HTTPS流量抓包时如果不信任其证书就会看到证书验证失败。这种场景下直接在抓包工具设置里导出根证书并加入系统信任列表即可。一个实用经验是线上环境报证书错误时先拿到服务端实际下发的证书看一眼而不是盲目改代码重试。openssl s_client一行命令就能查完整握手信息比看日志快得多。5.5 高并发连接池与性能优化HTTPS的性能瓶颈通常不是CPU解密而是TLS握手次数。每个新连接都要经过完整的密钥协商RTT是两倍以上。请求量大时一定要复用连接使用requests.Session()并设置urllib3.util.retry.Retry如果使用httpx启用http2True如果使用urllib考虑换requests简单配置示例from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session requests.Session() retry Retry( total3, connect3, read3, backoff_factor1, status_forcelist[502, 503, 504], ) adapter HTTPAdapter(pool_connections10, pool_maxsize20, max_retriesretry) session.mount(https://, adapter)pool_maxsize是每个host的连接池上限pool_connections是host数量。这两个值要根据并发量调整不是越大越好超出上限时反而浪费socket资源。5.6 问题排查速查表报错信息可能原因处理方向certificate verify failed证书不受信任指定verify为CA路径Hostname mismatch域名与SAN不匹配换域名访问或重签证书WRONG_VERSION_NUMBER端口不是HTTPS或者TCP层被干扰检查端口协议确认没有做内网代理handshake failureTLS版本或加密套件不兼容升级服务端TLS确认双方版本certificate has expired证书过期续期或重新签发Unable to get local issuer certificateCA链不完整配置证书链文件SELF-SIGNED_CERT_IN_CHAIN自签名证书未加入信任将证书加入信任列表写在最后的几点体会做HTTPS通信调试这么久最大的心得是证书问题是第一道坎但解决起来也最机械永远先确认让对方拿到证书内容看一眼再谈别的。生产环境不要用verifyFalse自签名证书就用verify指定CA路径这是底线。另外不管客户端还是服务端先把TLS版本、加密套件的交集确认清楚很多莫名其妙的握手失败都能从这一步找到原因。最后分享一个小技巧在多个服务互相调用的场景里建议搭一个内部私有CA用它统一签发各服务的证书客户端只要信任这个私有CA的根证书即可。这样新服务上线时签个证书就能通信不用维护一堆自签名证书的例外列表后续做证书轮换和吊销也方便得多。这套实践下来内部服务的HTTPS通信会清爽很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑