webapp用户身份认证方案 JSON WEB TOKEN 实现Deme示例,Java版

本项目依赖于下面jar包:

  • nimbus-jose-jwt-4.13.1.jar (一款开源的成熟的JSON WEB TOKEN 解决方法,本仓库的代码是对其的进一步封装)
  • json-smart-2.0-RC2.jar和asm-1.0-RC1.jar (依赖jar包,主要用于JSONObject序列化)
  • cors-filter-2.2.1.jar和java-property-utils-1.9.1.jar(用于处理跨域ajax请求)
  • junit.jar(单元测试相关jar包)

核心类Jwt.java结构:

2个静态方法createToken和validToken,分别用于生成TOKEN和校验TOKEN; 定义了枚举TokenState,用于表示验证token时的结果,用户可根据结果进行不同处理:

  • EXPIRED token过期
  • INVALID token无效(包括token不合法,token格式不对,校验时异常)
  • VALID token有效

使用示例

获取token

Map<String , Object> payload=new HashMap<String, Object>();
Date date=new Date();
payload.put("uid", "");//用户id
payload.put("iat", date.getTime());//生成时间
payload.put("ext",date.getTime()+**);//过期时间1小时
String token=Jwt.createToken(payload);
System.out.println("token:"+token);

校验token

String token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiIyOTE5Njk0NTIiLCJpYXQiOjE0NjA0MzE4ODk2OTgsImV4dCI6MTQ2MDQzNTQ4OTY5OH0.RAa71BnklRMPyPhYBbxsfJdtXBnXeWevxcXLlwC2PrY";
Map<String, Object> result=Jwt.validToken(token); String state=(String)result.get("state");
switch (TokenState.getTokenState(state)) {
case VALID:
//To do somethings
System.out.println("有效token");
break;
case EXPIRED:
System.out.println("过期token");
break;
case INVALID:
System.out.println("无效的token");
break;
} System.out.println("返回结果数据是:" +result.toString());

 

项目应用中代码:

JAT 工具类

public class Jwt {

    /**
* 秘钥
*/
private static final byte[] SECRET="3d990d2276917dfac04467df11fff26d".getBytes(); /**
* 初始化head部分的数据为
* {
* "alg":"HS256",
* "type":"JWT"
* }
*/
private static final JWSHeader header=new JWSHeader(JWSAlgorithm.HS256, JOSEObjectType.JWT, null, null, null, null, null, null, null, null, null, null, null); /**
* 生成token,该方法只在用户登录成功后调用
*
* @param Map集合,可以存储用户id,token生成时间,token过期时间等自定义字段
* @return token字符串,若失败则返回null
*/
public static String createToken(Map<String, Object> payload) {
String tokenString=null;
// 创建一个 JWS object
JWSObject jwsObject = new JWSObject(header, new Payload(new JSONObject(payload)));
try {
// 将jwsObject 进行HMAC签名
jwsObject.sign(new MACSigner(SECRET));
tokenString=jwsObject.serialize();
} catch (JOSEException e) {
System.err.println("签名失败:" + e.getMessage());
e.printStackTrace();
}
return tokenString;
} /**
* 校验token是否合法,返回Map集合,集合中主要包含 state状态码 data鉴权成功后从token中提取的数据
* 该方法在过滤器中调用,每次请求API时都校验
* @param token
* @return Map<String, Object>
*/
public static Map<String, Object> validToken(String token) {
Map<String, Object> resultMap = new HashMap<String, Object>();
try {
JWSObject jwsObject = JWSObject.parse(token);
Payload payload = jwsObject.getPayload();
JWSVerifier verifier = new MACVerifier(SECRET); if (jwsObject.verify(verifier)) {
JSONObject jsonOBj = payload.toJSONObject();
// token校验成功(此时没有校验是否过期)
resultMap.put("state", TokenState.VALID.toString());
// 若payload包含ext字段,则校验是否过期
if (jsonOBj.containsKey("ext")) {
long extTime = Long.valueOf(jsonOBj.get("ext").toString());
long curTime = new Date().getTime();
// 过期了
if (curTime > extTime) {
resultMap.clear();
resultMap.put("state", TokenState.EXPIRED.toString());
}
}
resultMap.put("data", jsonOBj); } else {
// 校验失败
resultMap.put("state", TokenState.INVALID.toString());
} } catch (Exception e) {
//e.printStackTrace();
// token格式不合法导致的异常
resultMap.clear();
resultMap.put("state", TokenState.INVALID.toString());
}
return resultMap;
} }

TokenState

package com.jwt;

/**
* 枚举,定义token的三种状态
* @author running@vip.163.com
*
*/
public enum TokenState {
/**
* 过期
*/
EXPIRED("EXPIRED"),
/**
* 无效(token不合法)
*/
INVALID("INVALID"),
/**
* 有效的
*/
VALID("VALID"); private String state; private TokenState(String state) {
this.state = state;
} /**
* 根据状态字符串获取token状态枚举对象
* @param tokenState
* @return
*/
public static TokenState getTokenState(String tokenState){
TokenState[] states=TokenState.values();
TokenState ts=null;
for (TokenState state : states) {
if(state.toString().equals(tokenState)){
ts=state;
break;
}
}
return ts;
}
public String toString() {
return this.state;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
} }

junit 测试结果

package com.jwt;

import java.util.Date;
import java.util.HashMap;
import java.util.Map; import org.junit.Test;
/**
* 单元测试(请自行引入junit4 Jar包)
*/
public class JwtTestCase {
@Test
@SuppressWarnings("unchecked")
public void test1() {
// 正常生成token----------------------------------------------------------------------------------------------------
String token = null;
Map<String, Object> payload = new HashMap<String, Object>();
Date date = new Date();
payload.put("uid", "");// 用户id
payload.put("iat", date.getTime());// 生成时间:当前
payload.put("ext", date.getTime() + * * );// 过期时间2小时
token = Jwt.createToken(payload);
System.out.println("新生成的token是:" + token+"\n马上将该token进行校验");
Map<String, Object> resultMap = Jwt.validToken(token);
System.out.println("校验结果是:" + getResult((String)resultMap.get("state")) );
HashMap<String,String> dataobj = (HashMap<String,String>) resultMap.get("data");
System.out.println("从token中取出的payload数据是:" +dataobj.toString()); } public void test2() {
// 校验过期----------------------------------------------------------------------------------------------------
String token = null;
Map<String, Object> payload = new HashMap<String, Object>();
Date date = new Date();
payload.put("uid", "");// 用户id
payload.put("iat", date.getTime());// 生成时间
payload.put("ext", date.getTime());// 过期时间就是当前
token = Jwt.createToken(payload);
System.out.println("新生成的token是:" + token+"\n马上将该token进行校验");
Map<String, Object> resultMap = Jwt.validToken(token);
System.out.println("校验结果是:" + getResult((String)resultMap.get("state")) ); } @SuppressWarnings("unchecked")
public void test2_1() {
// 不校验过期(当payload中无过期ext字段时)----------------------------------------------------------------------------------------------------
String token = null;
Map<String, Object> payload = new HashMap<String, Object>();
Date date = new Date();
payload.put("uid", "");// 用户id
payload.put("iat", date.getTime());// 生成时间
token = Jwt.createToken(payload);
System.out.println("新生成的token是:" + token+"\n马上将该token进行校验");
Map<String, Object> resultMap = Jwt.validToken(token);
System.out.println("校验结果是:" + getResult((String)resultMap.get("state")) );
HashMap<String,String> dataobj = (HashMap<String,String>) resultMap.get("data");
System.out.println("从token中取出的payload数据是:" +dataobj.toString()); } public void test3() {
// 校验非法token的情况----------------------------------------------------------------------------------------------------
String token = null;
Map<String, Object> payload = new HashMap<String, Object>();
Date date = new Date();
payload.put("uid", "");// 用户id
payload.put("iat", date.getTime());// 生成时间
payload.put("ext", date.getTime());// 过期时间就是当前 token = Jwt.createToken(payload);
System.out.println("新生成的token是:" + token);
System.out.println("将新生成的token加点调料再来进行校验");
token = token + "YouAreSB";
Map<String, Object> resultMap = Jwt.validToken(token);
System.out.println("校验结果是:" + getResult((String)resultMap.get("state")) );
System.out.println("原因是(非法token,payload参数可能经过中间人篡改,或者别人伪造的token)" ); } public void test4() {
// 校验异常的情况----------------------------------------------------------------------------------------------------
String token = "";
System.out.println("我胡乱传一个token:" + token);
Map<String, Object> resultMap = Jwt.validToken(token);
System.out.println("校验结果是:" + getResult((String)resultMap.get("state")) );
System.out.println("原因是(token格式不合法导致的程序异常)"); } public String getResult(String state) {
switch (TokenState.getTokenState(state)) {
case VALID:
//To do somethings
state = "有效token";
break;
case EXPIRED:
state = "过期token";
break;
case INVALID:
state = "无效的token";
break;
}
return state;
} }

loginServlet 使用,具体使用springmvc还是struts 可以参考servlet写法

package com.servlet;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Date;
import java.util.HashMap;
import java.util.Map; import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import net.minidev.json.JSONObject; import com.jwt.Jwt;
@WebServlet(urlPatterns="/servlet/login",loadOnStartup=)
public class LoginServlet extends HttpServlet { private static final long serialVersionUID = 5285600116871825644L; /**
* 校验用户名密码
*/
public void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException { String userName=request.getParameter("userName");
String password =request.getParameter("password");
JSONObject resultJSON=new JSONObject(); //用户名密码校验成功后,生成token返回客户端
if("admin".equals(userName)&&"".equals(password)){
//生成token
Map<String , Object> payload=new HashMap<String, Object>();
Date date=new Date();
payload.put("uid", "admin");//用户ID
payload.put("iat", date.getTime());//生成时间
payload.put("ext",date.getTime()+**);//过期时间1小时
String token=Jwt.createToken(payload); resultJSON.put("success", true);
resultJSON.put("msg", "登陆成功");
resultJSON.put("token", token); }else{
resultJSON.put("success", false);
resultJSON.put("msg", "用户名密码不对");
}
//输出结果
output(resultJSON.toJSONString(), response); } public void output(String jsonStr,HttpServletResponse response) throws IOException{
response.setContentType("text/html;charset=UTF-8;");
PrintWriter out = response.getWriter();
out.println(jsonStr);
out.flush();
out.close(); } }

每次请求都需要验证token 是否有效

package com.servlet;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Date;
import java.util.HashMap;
import java.util.Map; import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import net.minidev.json.JSONObject; import com.jwt.Jwt;
@WebServlet(urlPatterns="/author/token",loadOnStartup=1,description="生成token的方法")
public class AuthorServlet extends HttpServlet { private static final long serialVersionUID = -8463692428988705309L; public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String token=request.getHeader("token");
System.out.println(token);
Map<String, Object> result=Jwt.validToken(token);
//转JSON并输出
PrintWriter out = response.getWriter();
out.println(new JSONObject(result).toJSONString());
out.flush();
out.close();
} public void doPut(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
Map<String , Object> payload=new HashMap<String, Object>();
Date date=new Date();
payload.put("uid", "291969452");//用户id
payload.put("iat", date.getTime());//生成时间
payload.put("ext",date.getTime()+1000*60*60);//过期时间1小时
String token=null;
token=Jwt.createToken(payload); response.setContentType("text/html;charset=UTF-8;");
Cookie cookie=new Cookie("token", token);
cookie.setMaxAge(3600);
response.addCookie(cookie);
PrintWriter out = response.getWriter();
out.println(token);
out.flush();
out.close();
} }

调用获取信息的接口

mainServlet

package com.servlet;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap; import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import net.minidev.json.JSONObject;
@WebServlet(urlPatterns="/servlet/getInfo",loadOnStartup=1)
public class mainServlet extends HttpServlet { private static final long serialVersionUID = -1643121334640537359L; @SuppressWarnings("unchecked")
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println("正在调用获取信息的接口");
//将过滤器中存入的payload数据取出来
HashMap<String, String> data=(HashMap<String, String>) request.getAttribute("data");
//payload中的数据可以用来做查询,比如我们在登陆成功时将用户ID存到了payload中,我们可以将它取出来,去数据库查询这个用户的所有信息;
//而不是用request.getParameter("uid")方法来获取前端传给我们的uid,因为前端的参数时可篡改的不完全可信的,而我们从payload中取出来的数据是从token中
//解密取出来的,在秘钥没有被破解的情况下,它是绝对可信的;这样可以避免别人用这个接口查询非自己用户ID的相关信息
JSONObject resp=new JSONObject();
resp.put("success", true);
resp.put("msg", "成功");
resp.put("data", data);
output(resp.toJSONString(), response);
} public void output(String jsonStr,HttpServletResponse response) throws IOException{
response.setContentType("text/html;charset=UTF-8;");
PrintWriter out = response.getWriter();
out.println(jsonStr);
out.flush();
out.close(); } }

  

跨域过滤器

package com.filter;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.annotation.WebInitParam;
import com.thetransactioncompany.cors.CORSConfiguration;
import com.thetransactioncompany.cors.CORSFilter;
/**
* 服务端跨域处理过滤器,该过滤器需要依赖cors-filter-2.2.1.jar和java-property-utils-1.9.1.jar
* @author running@vip.163.com
*
*/
@WebFilter(urlPatterns={"/*"},asyncSupported=true,
initParams={
@WebInitParam(name="cors.allowOrigin",value="*"),
@WebInitParam(name="cors.supportedMethods",value="CONNECT, DELETE, GET, HEAD, OPTIONS, POST, PUT, TRACE"),
@WebInitParam(name="cors.supportedHeaders",value="token,Accept, Origin, X-Requested-With, Content-Type, Last-Modified"),//注意,如果token字段放在请求头传到后端,这里需要配置
@WebInitParam(name="cors.exposedHeaders",value="Set-Cookie"),
@WebInitParam(name="cors.supportsCredentials",value="true")
})
public class Filter0_CrossOriginResource extends CORSFilter implements javax.servlet.Filter{ public void init(FilterConfig config) throws ServletException {
System.out.println("跨域资源处理过滤器初始化了");
super.init(config);
} public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("跨域过滤器");
super.doFilter(request, response, chain);
} public void setConfiguration(CORSConfiguration config) {
super.setConfiguration(config);
} }

验证登陆过滤器

package com.filter;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map; import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import net.minidev.json.JSONObject; import com.jwt.Jwt;
import com.jwt.TokenState;
/**
* toekn校验过滤器,所有的API接口请求都要经过该过滤器(除了登陆接口)
* @author running@vip.163.com
*
*/
@WebFilter(urlPatterns="/servlet/*")
public class Filter1_CheckToken implements Filter { @Override
public void doFilter(ServletRequest argo, ServletResponse arg1,
FilterChain chain ) throws IOException, ServletException {
HttpServletRequest request=(HttpServletRequest) argo;
HttpServletResponse response=(HttpServletResponse) arg1;
// response.setHeader("Access-Control-Allow-Origin", "*");
if(request.getRequestURI().endsWith("/servlet/login")){
//登陆接口不校验token,直接放行
chain.doFilter(request, response);
return;
}
//其他API接口一律校验token
System.out.println("开始校验token");
//从请求头中获取token
String token=request.getHeader("token");
Map<String, Object> resultMap=Jwt.validToken(token);
TokenState state=TokenState.getTokenState((String)resultMap.get("state"));
switch (state) {
case VALID:
//取出payload中数据,放入到request作用域中
request.setAttribute("data", resultMap.get("data"));
//放行
chain.doFilter(request, response);
break;
case EXPIRED:
case INVALID:
System.out.println("无效token");
//token过期或者无效,则输出错误信息返回给ajax
JSONObject outputMSg=new JSONObject();
outputMSg.put("success", false);
outputMSg.put("msg", "您的token不合法或者过期了,请重新登陆");
output(outputMSg.toJSONString(), response);
break;
} } public void output(String jsonStr,HttpServletResponse response) throws IOException{
response.setContentType("text/html;charset=UTF-8;");
PrintWriter out = response.getWriter();
// out.println();
out.write(jsonStr);
out.flush();
out.close(); } @Override
public void init(FilterConfig arg0) throws ServletException {
System.out.println("token过滤器初始化了");
} @Override
public void destroy() { } }

  

jsp页面测试代码:

<%@ page language="java" import="java.util.*" pageEncoding="UTF-8"%>
<%
String path = request.getContextPath();
String basePath = request.getScheme()+"://"+request.getServerName()+":"+request.getServerPort()+path+"/";
%> <!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" />
<title></title>
</head>
<body>
<button id="gettoken">点击ajax获取token</button>
<textarea id="token" rows="5" cols="25" style="width: 300px;" placeholder="token值"></textarea>
<br />
<br />
<button id="validtoken">点击解析上面的token</button><br/> <textarea id="result" readonly rows="5" cols="25" style="width: 300px;" placeholder="数据解析结果"></textarea> <script src="jquery-2.1.0.js" type="text/javascript" charset="utf-8"></script>
<script>
$(function () {
$("#gettoken").on("click",function () {
$.ajax({
type:"put",
url:"http://localhost:8080/JWT/author/token",
async:true,
success:function(data){
$("#token").val(data);
}
});
}); $("#validtoken").on('click',function (e) {
var token=$.trim($("#token").val());
if(!token.length){
alert("请先获取token");
return;
}
$.ajax({
type:"get",
dataType:"json",
url:"http://localhost:8080/JWT/author/token?r="+Math.random(),
async:true,
beforeSend: function(request) {
request.setRequestHeader("token", token);
},
success:function (data) {
$("#result").val(JSON.stringify(data));
}
});
}); })
</script>
</body>
</html>

  

具体代码地址:https://github.com/bigmeow/JWT

webapp用户身份认证方案 JSON WEB TOKEN 实现的更多相关文章

  1. 身份认证:JSON Web Token

    JSON Web Token(JWT)是一种基于JSON的开放标准((RFC 7519),也是目前最流行的跨域认证解决方案. 传统的 cookie 认证方式看起来遵守了 REST 架构的无状态要求,但 ...

  2. 把旧系统迁移到.Net Core 2.0 日记 (18) --JWT 认证(Json Web Token)

    我们最常用的认证系统是Cookie认证,通常用一般需要人工登录的系统,用户访问授权范围的url时,会自动Redirect到Account/Login,登录后把认证结果存在cookie里. 系统只要找到 ...

  3. 基于 Token 的身份验证:JSON Web Token(附:Node.js 项目)

    最近了解下基于 Token 的身份验证,跟大伙分享下.很多大型网站也都在用,比如 Facebook,Twitter,Google+,Github 等等,比起传统的身份验证方法,Token 扩展性更强, ...

  4. 基于 Token 的身份验证:JSON Web Token

    最近了解下基于 Token 的身份验证,跟大伙分享下.很多大型网站也都在用,比如 Facebook,Twitter,Google+,Github 等等,比起传统的身份验证方法,Token 扩展性更强, ...

  5. 基于 Token 的身份验证:JSON Web Token(JWT)

    1.传统身份验证和JWT的身份验证 传统身份验证:       HTTP 是一种没有状态的协议,也就是它并不知道是谁是访问应用.这里我们把用户看成是客户端,客户端使用用户名还有密码通过了身份验证,不过 ...

  6. 理解JWT(JSON Web Token)认证及python实践

    原文:https://segmentfault.com/a/1190000010312468?utm_source=tag-newest 几种常用的认证机制 HTTP Basic Auth HTTP ...

  7. 理解JSON Web Token (一)

    一:理解单系统登录的原理及实现? web应用采用的 browser/server 架构的,http是无状态协议的,也就是说用户从A页面跳转到B页面会发起http请求,当服务器返回响应后,当用户A继续访 ...

  8. ASP.NET Web API 2系列(四):基于JWT的token身份认证方案

    1.引言 通过前边的系列教程,我们可以掌握WebAPI的初步运用,但是此时的API接口任何人都可以访问,这显然不是我们想要的,这时就需要控制对它的访问,也就是WebAPI的权限验证.验证方式非常多,本 ...

  9. Laravel 5 中使用 JWT(Json Web Token) 实现基于API的用户认证

    在JavaScript前端技术大行其道的今天,我们通常只需在后台构建API提供给前端调用,并且后端仅仅设计为给前端移动App调用.用户认证是Web应用的重要组成部分,基于API的用户认证有两个最佳解决 ...

随机推荐

  1. layui 常见的表单元素

    第一步:引用文件 效果图(日期.文件上传在下面): <form class="layui-form" action=""> <div clas ...

  2. 使用PHP Manager for IIS时,Windws 10自带IIS注意事项

    1)开启IIS 10:在“控制面板”的“程序和功能”的“启用或关闭Windows功能”内,勾选(启用)“Internet Information Services”,然后确定,进行安装. 2)若要使用 ...

  3. Golang的单引号、双引号与反引号

    Go语言的字符串类型string在本质上就与其他语言的字符串类型不同: Java的String.C++的std::string以及Python3的str类型都只是定宽字符序列 Go语言的字符串是一个用 ...

  4. AndroidManifest.xml 最全详解

    AndroidManifest.xml 是每个android程序中必须的文件,它位于整个项目的根目录.我们每天都在使用这个文件,往里面配置程序运行所必要的组件,权限,以及一些相关信息.但是对于这个文件 ...

  5. Android性能优化之图片压缩优化

    1 分类Android图片压缩结合多种压缩方式,常用的有尺寸压缩.质量压缩.采样率压缩以及通过JNI调用libjpeg库来进行压缩. 参考此方法:Android-BitherCompress 备注:对 ...

  6. LuoGu P1352 没有上司的舞会

    题目传送门 这可能是最简单的树形Dp了吧 对于每个人,要么他来,他的下属不来 要么他不来,他的下属爱来不来 于是设计状态: f[i][0/1]表示以i为根的子树中最大能达到的快乐值(i这个人选或者不选 ...

  7. HTML5从入门到精通(明日科技) 中文pdf扫描版

    HTML5从入门到精通(明日科技) 中文pdf扫描版

  8. ionic3 调用摄像头 当键盘弹出时候 出现摄像头 背景

    iOS 端毫无 bug,Android 端却出现了问题.当软键盘弹出后,Android 端的 tabs 移到了软键盘的上面,再仔细一看,整个界面都被压扁了,输入框也不知道去哪儿了. 于是去翻 Ioni ...

  9. java多线程快速入门(二十)

    1.Java.util的线程安全工具类 Vector(线程安全) ArrayList(线程不安全) HashTable(线程安全) HashMap(线程不安全) 2.将线程不安全集合变为线程安全集合 ...

  10. PDF如何设置书签,怎么在PDF上添加书签

    PDF文件现在作为我们使用最多的一种办公文件,当然我们在使用PDF文件的同时还会需要编辑PDF文件,在使用一个PDF文件页数比较多的时候就需要添加书签,不然每次使用的时候都需要从头开始查找是很麻烦又头 ...