JWT认证、drf-jwt安装和简单使用、实战之使用Django auth的User表自动签发、实战之自定义User表,手动签发
一 JWT认证
在用户注册或登录后,我们想记录用户的登录状态,或者为用户创建身份认证的凭证。
我们不再使用Session认证机制,而使用Json Web Token(本质就是token)认证机制。Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的
开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。
JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,
以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,
该token也可直接被用于认证,也可被加密




1.1 构成和工作原理
JWT的构成
JWT就是一段字符串,由三段信息构成的,将这三段信息文本用.链接一起就构成了Jwt字符串。就像这样:
'''
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
'''第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),
第三部分是签证(signature).
1.1.1 header
jwt的头部承载两部分信息:声明类型,这里是jwt
声明加密的算法 通常直接使用 HMAC SHA256
完整的头部就像下面这样的JSON:
{'typ': 'JWT','alg': 'HS256'
}然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分.
'''eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
'''
1.1.2 payload
载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分标准中注册的声明
公共的声明
私有的声明
标准中注册的声明 (建议但不强制使用) :iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避时序攻击。
公共的声明 : 公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.
但不建议添加敏感信息,因为该部分在客户端可解密.私有的声明 : 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,
因为base64是对称解密的,意味着该部分信息可以归类为明文信息。定义一个payload:{"sub": "1234567890","name": "John Doe","admin": true
}然后将其进行base64加密,得到JWT的第二部分。
'''
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
'''
1.1.3 signature
JWT的第三部分是一个签证信息,这个签证信息由三部分组成:header (base64后的)
payload (base64后的)
secret
这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。
'''
// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
'''将这三部分用.连接成一个完整的字符串,构成了最终的jwt:
'''
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
'''注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发
和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret,
那就意味着客户端是可以自我签发jwt了。关于签发和核验JWT,我们可以使用Django REST framework JWT扩展来完成。文档网站:http://getblimp.github.io/django-rest-framework-jwt/
1.2 本质原理
jwt认证算法:签发与校验
"""
1)jwt分三段式:头.体.签名 (head.payload.sgin)
2)头和体是可逆加密,让服务器可以反解出user对象;签名是不可逆加密,保证整个token的安全性的
3)头体签名三部分,都是采用json格式的字符串,进行加密,可逆加密一般采用base64算法,不可逆加密一般采用hash(md5)算法
4)头中的内容是基本信息:公司信息、项目组信息、token采用的加密方式信息
{"company": "公司信息",...
}
5)体中的内容是关键信息:用户主键、用户名、签发时客户端信息(设备号、地址)、过期时间
{"user_id": 1,...
}
6)签名中的内容时安全信息:头的加密结果 + 体的加密结果 + 服务器不对外公开的安全码 进行md5加密
{"head": "头的加密字符串","payload": "体的加密字符串","secret_key": "安全码"
}
"""
签发:根据登录请求提交来的 账号 + 密码 + 设备信息 签发 token
"""
1)用基本信息存储json字典,采用base64算法加密得到 头字符串
2)用关键信息存储json字典,采用base64算法加密得到 体字符串
3)用头、体加密字符串再加安全码信息存储json字典,采用hash md5算法加密得到 签名字符串账号密码就能根据User表得到user对象,形成的三段字符串用 . 拼接成token返回给前台
"""
校验:根据客户端带token的请求 反解出 user 对象
"""
1)将token按 . 拆分为三段字符串,第一段 头加密字符串 一般不需要做任何处理
2)第二段 体加密字符串,要反解出用户主键,通过主键从User表中就能得到登录用户,过期时间和设备信息都是安全信息,确保token没过期,且时同一设备来的
3)再用 第一段 + 第二段 + 服务器安全码 不可逆md5加密,与第三段 签名字符串 进行碰撞校验,通过后才能代表第二段校验得到的user对象就是合法的登录用户
"""
drf项目的jwt认证开发流程(重点)
"""
1)用账号密码访问登录接口,登录接口逻辑中调用 签发token 算法,得到token,返回给客户端,客户端自己存到cookies中2)校验token的算法应该写在认证类中(在认证类中调用),全局配置给认证组件,所有视图类请求,都会进行认证校验,所以请求带了token,就会反解出user对象,在视图类中用request.user就能访问登录的用户注:登录接口需要做 认证 + 权限 两个局部禁用
"""
1.2.1 补充base64编码解码
import base64
import json
dic_info={"sub": "1234567890","name": "lqz","admin": True
}
byte_info=json.dumps(dic_info).encode('utf-8')
# base64编码
base64_str=base64.b64encode(byte_info)
print(base64_str)
# base64解码
base64_str='eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogImxxeiIsICJhZG1pbiI6IHRydWV9'
str_url = base64.b64decode(base64_str).decode("utf-8")
print(str_url)
二 drf-jwt安装和简单使用
2.1 官网
http://getblimp.github.io/django-rest-framework-jwt/
2.2 安装
pip install djangorestframework-jwt
2.3 使用:
# 1 创建超级用户
python3 manage.py createsuperuser
# 2 配置路由urls.py
from django.urls import path
from rest_framework_jwt.views import obtain_jwt_token
urlpatterns = [path('login/', obtain_jwt_token),
]
# 3 postman测试
向后端接口发送post请求,携带用户名密码,即可看到生成的token# 4 setting.py中配置认证使用jwt提供的jsonwebtoken
# 5 postman发送访问请求(必须带jwt空格)
三 实战之使用Django auth的User表自动签发
3.1 配置setting.py
import datetime
JWT_AUTH = {# 过期时间1天'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1),# 自定义认证结果:见下方序列化user和自定义response# 如果不自定义,返回的格式是固定的,只有token字段'JWT_RESPONSE_PAYLOAD_HANDLER': 'users.utils.jwt_response_payload_handler',
}
3.2 编写序列化类ser.py
from rest_framework import serializers
from users import models
class UserModelSerializers(serializers.ModelSerializer):class Meta:model = models.UserInfofields = ['username']
3.3 自定义认证返回结果(setting中配置的)
#utils.py
from users.ser import UserModelSerializers
def jwt_response_payload_handler(token, user=None, request=None):return {'status': 0,'msg': 'ok','data': {'token': token,'user': UserModelSerializers(user).data}}
3.4 基于drf-jwt的全局认证:
#app_auth.py(自己创建)
from rest_framework.exceptions import AuthenticationFailed
from rest_framework_jwt.authentication import jwt_decode_handler
from rest_framework_jwt.authentication import get_authorization_header,jwt_get_username_from_payload
from rest_framework_jwt.authentication import BaseJSONWebTokenAuthenticationclass JSONWebTokenAuthentication(BaseJSONWebTokenAuthentication):def authenticate(self, request):jwt_value = get_authorization_header(request)if not jwt_value:raise AuthenticationFailed('Authorization 字段是必须的')try:payload = jwt_decode_handler(jwt_value)except jwt.ExpiredSignature:raise AuthenticationFailed('签名过期')except jwt.InvalidTokenError:raise AuthenticationFailed('非法用户')user = self.authenticate_credentials(payload)return user, jwt_value
3.5 全局使用
# setting.py
REST_FRAMEWORK = {# 认证模块'DEFAULT_AUTHENTICATION_CLASSES': ('users.app_auth.JSONWebTokenAuthentication',),
}
3.6 局部启用禁用
# 局部禁用
authentication_classes = []
# 局部启用
from user.authentications import JSONWebTokenAuthentication
authentication_classes = [JSONWebTokenAuthentication]
# 实际代码如下view.py
# 自定义Response
class CommonResponse(Response):def __init__(self,status,msg,data='',*args,**kwargs):dic={'status':status,'msg':msg,'data':data}super().__init__(data=dic,*args,**kwargs)
# 测试订单接口
from users.app_auth import JSONWebTokenAuthentication
class OrderView(APIView):# authentication_classes = [JSONWebTokenAuthentication]authentication_classes = []def get(self,request):return CommonResponse('100', '成功',{'数据':'测试'})
3.7 多方式登录:
## views.py
# 重点:自定义login,完成多方式登录
from rest_framework.viewsets import ViewSet
from rest_framework.response import Response
class LoginViewSet(ViewSet):# 需要和mixins结合使用,继承GenericViewSet,不需要则继承ViewSet# 为什么继承视图集,不去继承工具视图或视图基类,因为视图集可以自定义路由映射:# 可以做到get映射get,get映射list,还可以做到自定义(灵活)def login(self, request, *args, **kwargs):serializer = serializers.LoginSerializer(data=request.data, context={'request': request})serializer.is_valid(raise_exception=True)token = serializer.context.get('token')return Response({"token": token})## ser.py
# 重点:自定义login,完成多方式登录
class LoginSerializer(serializers.ModelSerializer):# 登录请求,走的是post方法,默认post方法完成的是create入库校验,所以唯一约束的字段,会进行数据库唯一校验,导致逻辑相悖# 需要覆盖系统字段,自定义校验规则,就可以避免完成多余的不必要校验,如唯一字段校验username = serializers.CharField()class Meta:model = models.User# 结合前台登录布局:采用账号密码登录,或手机密码登录,布局一致,所以不管账号还是手机号,都用username字段提交的fields = ('username', 'password')def validate(self, attrs):# 在全局钩子中,才能提供提供的所需数据,整体校验得到user# 再就可以调用签发token算法,将user信息转换为token# 将token存放到context属性中,传给外键视图类使用user = self._get_user(attrs)payload = jwt_payload_handler(user)token = jwt_encode_handler(payload)self.context['token'] = tokenreturn attrs# 多方式登录def _get_user(self, attrs):username = attrs.get('username')password = attrs.get('password')import reif re.match(r'^1[3-9][0-9]{9}$', username):# 手机登录user = models.User.objects.filter(mobile=username, is_active=True).first()elif re.match(r'^.+@.+$', username):# 邮箱登录user = models.User.objects.filter(email=username, is_active=True).first()else:# 账号登录user = models.User.objects.filter(username=username, is_active=True).first()if user and user.check_password(password):return userraise ValidationError({'user': 'user error'})
# utils.py
import re
from .models import User
from django.contrib.auth.backends import ModelBackend
class JWTModelBackend(ModelBackend):def authenticate(self, request, username=None, password=None, **kwargs):try:if re.match(r'^1[3-9]\d{9}$', username):user = User.objects.get(mobile=username)else:user = User.objects.get(username=username)except User.DoesNotExist:return Noneif user.check_password(password) and self.user_can_authenticate(user):return user
3.8 配置多方式登录
settings.py
AUTHENTICATION_BACKENDS = ['user.utils.JWTModelBackend']
四 实战之自定义User表,手动签发
4.1 手动签发JWT:
# 可以拥有原生登录基于Model类user对象签发JWT
from rest_framework_jwt.settings import api_settings
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLERpayload = jwt_payload_handler(user)
token = jwt_encode_handler(payload)
4.2 编写登陆视图类
# views.py
from rest_framework_jwt.settings import api_settings
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
from users.models import User
class LoginView(APIView):authentication_classes = []def post(self,request):username=request.data.get('username')password=request.data.get('password')user=User.objects.filter(username=username,password=password).first()if user: # 能查到,登陆成功,手动签发payload = jwt_payload_handler(user)token = jwt_encode_handler(payload)return CommonResponse('100','登陆成功',data={'token':token})else:return CommonResponse('101', '登陆失败')
4.3 编写认证组件
# app_auth.py
from users.models import User
class MyJSONWebTokenAuthentication(BaseAuthentication):def authenticate(self, request):jwt_value = get_authorization_header(request)if not jwt_value:raise AuthenticationFailed('Authorization 字段是必须的')try:payload = jwt_decode_handler(jwt_value)except jwt.ExpiredSignature:raise AuthenticationFailed('签名过期')except jwt.InvalidTokenError:raise AuthenticationFailed('非法用户')username = jwt_get_username_from_payload(payload)print(username)user = User.objects.filter(username=username).first()print(user)return user, jwt_value
4.4 登陆获取token
4.5 编写测试接口
from users.app_auth import JSONWebTokenAuthentication,MyJSONWebTokenAuthentication
class OrderView(APIView):# authentication_classes = [JSONWebTokenAuthentication]authentication_classes = [MyJSONWebTokenAuthentication]def get(self,request):print(request.user)return CommonResponse('100', '成功',{'数据':'测试'})
4.6 测试
相关文章:
JWT认证、drf-jwt安装和简单使用、实战之使用Django auth的User表自动签发、实战之自定义User表,手动签发
一 JWT认证 在用户注册或登录后,我们想记录用户的登录状态,或者为用户创建身份认证的凭证。 我们不再使用Session认证机制,而使用Json Web Token(本质就是token)认证机制。Json web token (JWT), 是为了在网络应用环境…...
conda常用命令及问题解决-创建虚拟环境
好久没写博文了,感觉在学习的过程中还是要注意积累与分享,这样利人利己。 conda包清理,许多无用的包是很占用空间的 conda clean -p //删除没有用的包 conda clean -y -all //删除pkgs目录下所有的无用安装包及cacheconda创建虚拟环境…...
严选算法模型质量保障
在算法模型整个生命周期**(算法模型生命周期:初始训练数据 --> 模型训练 --> 模型评估 --> 模型预估 --> 训练数据)**中,任何环节的问题引入都可能导致算法模型质量问题。所以我们在做模型质量保障的过程中࿰…...
学习Bootstrap 5的第七天
目录 徽章 徽章 实例 上下文徽章 实例 胶囊徽章 实例 元素内的徽章 实例 进度条 基础进度条 实例 进度条高度 实例 彩色进度条 实例 条纹进度条 实例 动画进度条 实例 混合色彩进度条 实例 徽章 徽章 在 Bootstrap 中,徽章(Badg…...
VirtualBox(内有Centos 7 示例安装)
1常见概念以及软件安装 1.1 虚拟化技术: 虚拟化技术指的是将计算机的各种硬件资源加以抽象、转换、分割,最后组合 起来的技术。其目的和作用主要是打破硬件资源不可分的情况,方便程序员自 己集成所需资源。 1.2 Virtual Box 其是虚拟化技术作…...
在 Git 中删除不再位于远程仓库中的本地分支
git 删除远端已经被删除然而本地还存在的分支 1. 修剪不在远程仓库上的跟踪分支 git remote prune origin如果git仓库将branch1被删除,可以用用git remote prune origin删除在本地电脑上的remotes/origin/branch1 git remote show origin可以看到下面所示…...
容器编排学习(九)服务管理与用户权限管理
一 service管理 1 概述 容器化带来的问题 自动调度:在 Pod 创建之前,用户无法预知 Pod 所在的节点,以及 Pod的IP 地址一个已经存在的 Pod 在运行过程中,如果出现故障,Pod也会在新的节点使用新的IP 进行部署应用程…...
【C刷题】day1
一、选择题 1.正确的输出结果是 int x5,y7; void swap() { int z; zx; xy; yz; } int main() { int x3,y8; swap(); printf("%d,%d\n",x, y); return 0; } 【答案】: 3,8 【解析】: 考点: ÿ…...
zabbix配置钉钉告警、和故障自愈、监控java
文章目录 1.配置钉钉告警server 配置web界面创建媒介给用户添加媒介测试告警 实现故障自愈功能监控Javazabbix server 安装java gateway配置 Zabbix Server 支持 Java gateway使用系统内置模板监控 tomcat 主机 1.配置钉钉告警 server 配置 钉钉告警python脚本 脚本1 cd /…...
第九章 Linux实际操作——Linux磁盘分区、挂载
第九章 Linux实际操作——Linux磁盘分区、挂载 9.1 Linux分区9.1.1原理介绍9.1.2 硬盘说明9.1.3 查看所有设备搭载情况 9.2 挂载的经典案例9.2.1 说明9.2.2 如何增加一块硬盘9.2.3 虚拟机增加硬盘步骤 9.3 磁盘情况查询9.3.1 查询系统整体磁盘使用情况9.3.2 查询指定目录的磁盘…...
设计模式-解释器设计模式
文章目录 前言一、 解释器模式的结构1、抽象表达式(Abstract Expression)2、终结符表达式(Terminal Expression)3、非终结符表达式(Non-terminal Expression)4、上下文(Context)5、客…...
实现 js 中所有对象的深拷贝(包装对象,Date 对象,正则对象)
通过递归可以简单实现对象的深拷贝,但是这种方法不管是 ES6 还是 ES5 实现,都有同样的缺陷,就是只能实现特定的 object 的深度复制(比如数组和函数),不能实现包装对象 Number,String ࿰…...
PathVariable注解
postman测试传参:http://localhost:8080/admin/employee/2 PathVariable PathVariable注解用法和作用...
宋浩高等数学笔记(十二)无穷级数
完结,宋浩笔记系列的最后一更~ 之后会出一些武忠祥老师的错题&笔记总结,10月份就要赶紧做真题了...
使用Clipboard插件实现Vue的剪贴板功能
在Web开发中,剪贴板功能是一个常见但又非常有用的功能。通过将数据复制到剪贴板,用户可以方便地将数据粘贴到其他应用程序或网站上。在本文中,我们将介绍如何使用Clipboard插件结合Vue框架实现剪贴板功能。 Clipboard插件简介 Clipboard插件…...
Latex参考文献中大写字母编译后自动变成了小写,如何保持原字母大写形式
一、问题 1.1 bib文件原有内容 以下参考文献中MANET为大写 inproceedings{Miao2013FullySK, title{Fully Self-organized Key Management Scheme in MANET and Its Applications}, author{Fuyou Miao and Wenjing Ruan and Xianchang Du and Suwan Wang}, year{2013} } …...
Jest单元测试相关
官方文档:jest 中文文档 1、模拟某个函数,并返回指定的结果 使用Jest测试JavaScript(Mock篇) 有这样一个需求,mock掉Math.random方法(0(包括)~1之间),使其返回指定的0…...
Scrum敏捷开发流程及关键环节
Scrum是一种敏捷开发流程,它旨在使软件开发更加高效和灵活。Scrum将软件开发过程分为多个短期、可重复的阶段,称为“Sprint”。每个Sprint通常为两周,旨在完成一部分开发任务。 在Scrum中,有一个明确的角色分工: 产…...
微服务04-Gateway网关
作用 身份认证:用户能不能访问 服务路由:用户访问到那个服务中去 负载均衡:一个服务可能有多个实例,甚至集群,负载均衡就是你的请求到哪一个实例上去 请求限流功能:对请求进行流量限制,对服务…...
YOLOV7改进-针对小目标的NWD(损失函数)
link 1、复制这些 2、utils-loss,这里加 3、把这几行复制到utiils的loss.py 4、先对CoputerLoss类做修改 5、把那一行替换成这个 6、修改 7、iou_ration是超参,可以调,如果小目标比较多的话,这个值可以低一些,…...
css实现圆环展示百分比,根据值动态展示所占比例
代码如下 <view class""><view class"circle-chart"><view v-if"!!num" class"pie-item" :style"{background: conic-gradient(var(--one-color) 0%,#E9E6F1 ${num}%),}"></view><view v-else …...
逻辑回归:给不确定性划界的分类大师
想象你是一名医生。面对患者的检查报告(肿瘤大小、血液指标),你需要做出一个**决定性判断**:恶性还是良性?这种“非黑即白”的抉择,正是**逻辑回归(Logistic Regression)** 的战场&a…...
PHP和Node.js哪个更爽?
先说结论,rust完胜。 php:laravel,swoole,webman,最开始在苏宁的时候写了几年php,当时觉得php真的是世界上最好的语言,因为当初活在舒适圈里,不愿意跳出来,就好比当初活在…...
通过Wrangler CLI在worker中创建数据库和表
官方使用文档:Getting started Cloudflare D1 docs 创建数据库 在命令行中执行完成之后,会在本地和远程创建数据库: npx wranglerlatest d1 create prod-d1-tutorial 在cf中就可以看到数据库: 现在,您的Cloudfla…...
为什么需要建设工程项目管理?工程项目管理有哪些亮点功能?
在建筑行业,项目管理的重要性不言而喻。随着工程规模的扩大、技术复杂度的提升,传统的管理模式已经难以满足现代工程的需求。过去,许多企业依赖手工记录、口头沟通和分散的信息管理,导致效率低下、成本失控、风险频发。例如&#…...
华为云Flexus+DeepSeek征文|DeepSeek-V3/R1 商用服务开通全流程与本地部署搭建
华为云FlexusDeepSeek征文|DeepSeek-V3/R1 商用服务开通全流程与本地部署搭建 前言 如今大模型其性能出色,华为云 ModelArts Studio_MaaS大模型即服务平台华为云内置了大模型,能助力我们轻松驾驭 DeepSeek-V3/R1,本文中将分享如何…...
3-11单元格区域边界定位(End属性)学习笔记
返回一个Range 对象,只读。该对象代表包含源区域的区域上端下端左端右端的最后一个单元格。等同于按键 End 向上键(End(xlUp))、End向下键(End(xlDown))、End向左键(End(xlToLeft)End向右键(End(xlToRight)) 注意:它移动的位置必须是相连的有内容的单元格…...
基于matlab策略迭代和值迭代法的动态规划
经典的基于策略迭代和值迭代法的动态规划matlab代码,实现机器人的最优运输 Dynamic-Programming-master/Environment.pdf , 104724 Dynamic-Programming-master/README.md , 506 Dynamic-Programming-master/generalizedPolicyIteration.m , 1970 Dynamic-Programm…...
Web 架构之 CDN 加速原理与落地实践
文章目录 一、思维导图二、正文内容(一)CDN 基础概念1. 定义2. 组成部分 (二)CDN 加速原理1. 请求路由2. 内容缓存3. 内容更新 (三)CDN 落地实践1. 选择 CDN 服务商2. 配置 CDN3. 集成到 Web 架构 …...
如何在网页里填写 PDF 表格?
有时候,你可能希望用户能在你的网站上填写 PDF 表单。然而,这件事并不简单,因为 PDF 并不是一种原生的网页格式。虽然浏览器可以显示 PDF 文件,但原生并不支持编辑或填写它们。更糟的是,如果你想收集表单数据ÿ…...
