在实现案例之前,要先去理解 JWT 的概念 以及 它与传统方式的区别和优点。
1、什么是 JWT?
JSON WEB TOKEN (JWT),是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准(RFC 7519),该 TOKEN 被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其他业务逻辑所必须的声明信息,该 TOKEN 也可以直接被用于认证,也可被加密。
2、为什么要使用 JWT?
这里我们说一下基于 TOKEN 的认证 和 传统的 session 认证 的区别。
2.1、传统的 SESSION 认证
什么是无状态协议?
无状态 指的是客户端(WEB浏览器)和服务器之间不需要建立持久的连接。当一个客户端向服务器发起请求的时候,服务器收到了请求并且返回响应结果,这次的通信就结束了,同时服务器不保留连接的相关信息。所以每次请求都需要包含所需的所有信息,这样消息结构会比较复杂,同时也会导致相同的数据在多个请求里面反复传输,协议效率也会因此降低。
我们知道,HTTP 协议本身是一种无状态的协议,而这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,那么下一次请求的时候,用户还要再一次进行用户认证才行,因为根据 HTTP 协议,我们并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应的时候传递给浏览器,告诉其保存为 COOKIE,以便下次请求的时候发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的基于 SESSION 认证。
基于SESSION 认证所显露的问题
每个用户经过我们的应用认证之后,我们的用户都要在服务端做一次记录,以便用户下次请求的鉴别,通常而言 SESSION 都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
扩展性:用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求这台服务器上,这样才嗯那个拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡的能力。这也意味着限制了应用的扩展能力。
2.2、什么是 CSS 攻击,如何避免?
CSS 攻击,即跨站脚本攻击(Cross Site Scripting),它是 web 程序中最常见的漏洞。
原理:
攻击者会往 WEB 页面里面插入恶意的 HTML 代码(JavaScript、CSS、HTML标签等),当某个用户浏览该页面的时候,嵌入其中的 HTML 代码会被执行,从而达到恶意攻击的用户的目的。如盗取用户 Cookie 执行一系列操作,破坏页面结构,重定向到其他网站。
案例:恶意的在某个网站的登录页面加入 HTML 代码(JavaScript、CSS、HTML标签等),当用户输入账号面面登录后,这段恶意的 HTML 代码就会被执行,从而获取用户的账号密码信息。
预防思路:
- WEB 页面中可由用户输入的地方,对输入的数据转义、过滤处理。
- 后台输出页面的时候,也需要对输出内容进行转移、过滤处理(因为攻击者可能以其他方式把恶意脚本写入数据库)。
- 前端对 HTML 标签属性、CSS 属性赋值的地方进行校验。
2.3、什么是 CSRF 攻击,如何避免?
CSRF:Cross Site Request Forgery(跨站点请求伪造)。
CSRF 攻击者在用户已经登录目标网站之后,诱使用户访问一个攻击页面,利用目标网站对用户的信任,以用户身份在攻击页面对目标网站发起伪造用户操作的请求,达到攻击目的。
案例:就好比用户A,在自己电脑上通过自己的账号密码登录了B网站(登录用户身份标识被 XXX 网站信任),当 A 在浏览网页的时候,不经意间打开了钓鱼网站 X,这时候 X 就可以借助着 B 网站对用户A的信任标识,以用户A的身份去访问 B 网站,并对其进行攻击!
预防方法:
- 添加并验证 TOKEN
- 添加自定义 HTTP 请求头
- 使用 POST 请求
- 敏感操作添加验证码
2.4、基于 TOKEN 的鉴权机制
基于 TOKEN 的鉴权机制类似于 HTTP 协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于 TOKEN 认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。
基本流程:
- 用户使用用户名和密码来请求服务器。
- 服务器进行验证用户的信息
- 服务器验证用户名、密码通过,然后发送给用户一个 TOKEN
- 客户端存储 TOKEN,并在每次请求的时候附送上这个 TOKEN 值
- 服务端验证 TOKEN 值,并返回数据
这个 TOKEN 必须要在每次请求的时候传递给服务器端,它应该保存在请求头里,另外,服务端要支持 CORS(跨站资源共享)
策略,一般我们在服务端这么做就可以了 Access-Control-ALLOW-Origin: *
就是支持了所有策略。
分析:
Access-Control-ALLOW-Origin: *
直译过来就是 访问控制允许同源,这是经常出现于 AJAX 跨域引起的。所谓跨域就是,在 a.com 域下,访问 b.com 域下的资源;处于安全考虑,浏览器允许跨域写,而不允许跨域读。写就是发送请求,Send Request;读就是接受响应,Receive Response;
- 表单默认提交(GET、POST),超链接访问访问域外的资源这都是允许的,因为在点击超链接或者按钮的时候,浏览器地址已经变了,这就是一个普通的请求,不存在跨域。
- AJAX (借助 XmlHttpRequest)跨域请求,这是被禁止的,因为 AJAX 就是为了接受响应回来的数据,这违背了不允许跨域读的原则
- JSONP 属于跨域读且形式限制为 GET 方式,它利用了 Script 标签的特性;这是允许的。因为浏览器把跨域读脚本当作例外,类似的 img、iframe 的 src 属性都可以请求域外资源。
跨域请求前总会先发送一个 options 请求,这是为什么?
先来了解一下什么是 OPTIONS 请求?
跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站点通过浏览器有权限访问哪些资源。另外,规范要求,对那些可能对服务器产生副作用的 HTTP 请求方法(特别是以 GET 以外的 HTTP 请求,或者搭配一些 MIME 类型的POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许后,才发起实际的 HTTP 请求,在预检请求的返回中,服务端也可以通知服务端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。
总结一下:
OPTIONS 请求即预检测请求,可用于检测服务器允许的 HTTP 方法。当发起跨域请求的时候,由于安全原因,触发一定条件的时候浏览器就会在正式请求之前自动线发起 OPTIONS 请求,即 CORS
预检测请求,服务器若接受该跨域请求,浏览器才继续发起正式请求。
想要了解更详细的可以参考这篇博客:为什么会发送OPTIONS请求?
3、JWT 的请求流程
总结:
- 用户使用账户和密码发出 POST 请求
- 服务器使用私钥创建一个 JWT
- 服务器返回这个 JWT 给浏览器
- 浏览器将该 JWT 串在请求头中向服务器发送请求
- 服务器验证该 JWT
- 返回响应的资源给浏览器
3.1、JWT 的主要应用场景
身份认证在这种场景下,一旦用户完成了登录,在接下来的每个请求中都需要包含 JWT 串,可以用来验证用户身份以及对路由,服务和资源的访问权限进行验证。由于它的开销很小,可以轻松的在不同域名的系统中传递,所有目前在单点登录(SSO)中比较广泛的使用了该技术。信息交换在通信双方之间使用 JWT 对数据进行编码是一种非常安全的方式,由于它的信息是经过签名的,可以确保发送者发送的信息是没有经过伪造的。
优点:
- 简洁:(Compact),可以通过
URL ,POST
参数或者在HTTP Header
发送,因为数据量小,传输速率也很快。 - 自包含:(Self-Contained) ,负载中包含了所有用户所需要的信息,避免了多次查询数据库
- 因为 TOKEN 是以 JSON 加密的形式保存在客户端的,所以 JWT 是跨语言的,原则上任何 web 形式都支持。
- 不需要再服务器端保存会话信息,特别适合于分布式微服务。
3.2、JWT 的结构
JWT 是由三段信息构成的,将这三段信息文本用,连接再一起就构成了 JWT 字符串。
就像这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
JWT 包含了三部分:
- Header 头部(标题包含了令牌的元数据,并且包含签名或者加密算法的类型)
- Payload 负载(类似于飞机上承载的物品)
- Signature 签名 / 签证
1)Header
JWT 的头部承载了两部分信息:TOKEN 类型和采用的加密算法。
{
"alg": "SHA256",
"type": "JWT"
}
声明类型:这里是 JWT
声明加密的算法:通常直接使用 HMAC、SHA256。
加密算法通常是单向数散列算法,常见的有 MD5、SHA256、HAMC
MD5:(Message-digest algorithm 5)(信息摘要算法)缩写,广泛用于加密和解密技术,常用于文件校验。不管文件多大,经过 MD5 后都能生成唯一的 MD5 值。
SHA:(Secure Hash Algorithm。安全散列算法),数字签名等密码学应用中重要的工具,安全性高于 MD5
HMAC:(Hash Message Authentication Code),散列消息鉴别码,基于密钥的 Hash 算法的认证协议。用公开函数和密钥产生一个固定长度的值作为认证标识,用这个标识鉴别消息的完整性。常用于接口签名验证
2)Payload
载荷就是存放有效信息的地方。
有效信息包含三个部分:
- 标准中注册的声明
- 公共的声明
- 私有的声明
标准中注册的声明(建议但不强制使用):
iss:
JWT 签发者
sub:
面对的用户(JWT 所面对的用户)
aud:
接收 JWT 的一方
exp:
过期的时间戳(JWT 的过期时间,这个过期时间必须要大于签发的时间)
nbf:
定义在什么时间之前,该 JWT 都是不可用的
iat:
JWT 的签发时间
jti:
JWT 的唯一身份标识,主要用来作为一次性TOKEN
,从而回避重放攻击
公共的声明:
公共的声明可以添加任何的信息,一般添加用户的相关信息或者其他业务需要的必要信息,但是不建议添加敏感信息,因为该部分在客户端可以解密
私有的声明:
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为 base64
是对称解密的,意味着该部分信息可以归类于明文信息。
3)Signature
JWT 的第三部分是一个签证信息
这个部分需要 base64
加密后的 header
和 base64
加密后的 payload
使用,连接组成的字符串,然后通过 header
中声明的加密方式进行 secret
组合加密,然后就构成了 JWT 的第三部分。
密钥 secret
是保存在服务端的,服务端会根据这个密钥进行生成 TOKEN
和进行验证,所以需要保护好。
SpringBoot 整个 JWT 实现 TOKEN 登录验证的简单实现
引入 Pom 依赖:
<!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.18.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.json/json -->
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20210307</version>
</dependency>
在实际的应用中,一般需要一个生成 TOKEN 的工具类和一个拦截器对请求进行拦截。
/**
* @author wcc
* @date 2021/11/19 10:57
* 生成 Token 的工具类
*/
@Slf4j
public class TokenUtils {
private static final Long EXPIRE_TIME = 10*60*60*1000L; //设置过期时间为10个小时
// 生成 TOKEN 的密钥 注意 TOKEN都是根据该密钥组合加密进行生成的
private static final String TOKEN_SECRET =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4g";
//生成 Token
public static String sign(User user){
String token = null;
try {
Date expire = new Date(System.currentTimeMillis() + EXPIRE_TIME);
token = JWT.create()
.withIssuer("auth0") //发行人
.withClaim("username",user.getUsername()) //存放数据
.withExpiresAt(expire) //过期时间
.sign(Algorithm.HMAC256(TOKEN_SECRET));
}catch (Exception e){
e.printStackTrace();
}
return token;
}
// TOKEN 验证
public static Boolean verfiry(String token){
try {
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(TOKEN_SECRET))
.withIssuer("auth0")
.build();
DecodedJWT decodedJWT = jwtVerifier.verify(token);
log.info("TOKEN 验证通过");
log.info("username :"+ decodedJWT.getClaim("username").asString());
log.info("过期时间:"+ decodedJWT.getExpiresAt());
}catch (Exception e){
// 抛出错误即为验证不通过
log.error("TOKEN 验证不通过,请再次输入");
return false;
}
return true;
}
}
编写注解类
/**
* @author wcc
* @date 2021/11/19 16:41
*/
//用来跳过验证的PassToken 不要token验证的方法加入PassToken
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PassToken {
boolean required() default true;
}
/**
* @author wcc
* @date 2021/11/19 16:42
*/
//需要登录才能进行操作的注解UserLoginToken
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface UserLoginToken {
boolean required() default true;
}
/**
* @Target:注解的作用目标
* @Target(ElementType.TYPE):接口、类、枚举、注解
* @Target(ElementType.PARAMETER):方法参数
* @Target(ElementType.METHOD)——方法
*/
编写一个拦截器类
/**
* @author wcc
* @date 2021/11/19 11:13
*/
@Component
@Slf4j
public class TokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 如果不是映射到方法直接通过
if(!(handler instanceof HandlerMethod)){
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
System.out.println(method);
//跨域请求会首先发送一个options请求,直接返回正常状态并通过拦截器
if(request.getMethod().equalsIgnoreCase("OPTIONS"))
{
response.setStatus(HttpServletResponse.SC_OK);
log.info("走过了option请求");
return true;
}
// 检查该方法上是否有 PassToken 的注解
System.out.println(method.isAnnotationPresent(PassToken.class));
if(method.isAnnotationPresent(PassToken.class)){
PassToken passToken = method.getAnnotation(PassToken.class);
if(passToken.required()){
return true;
}
}
System.out.println(method.isAnnotationPresent(UserLoginToken.class));
// 检查该方法是否有 UserLoginToken 权限的注解
if(method.isAnnotationPresent(UserLoginToken.class)){
UserLoginToken annotation = method.getAnnotation(UserLoginToken.class);
if(annotation.required()){
response.setCharacterEncoding("UTF-8");
String token = request.getHeader("token");
System.out.println(token);
if(token!=null){
//这里,其实少了一些步骤,没有连接数据库的缘故
/**
* 具体操作是这样实现的,创建 TOKEN 的时候可以根据用户的ID 来去创建
* 所以当验证 TOKEN 不为空时获取其中的数据ID 并查找数据库看是否存在
* 如果存在再去验证 TOKEN
*/
boolean result = TokenUtils.verfiry(token);
if(result){
log.info("TOKEN 验证通过,TokenInterceptor拦截器放行");
return true;
}
}
}
}
response.setContentType("application/json;charset=utf8");
try {
JSONObject jsonObject = new JSONObject();
jsonObject.put("message","token verify fail");
jsonObject.put("code",HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.getWriter().append(jsonObject.toString());
log.info("验证失败,未通过TokenInterceptor拦截器");
}catch (Exception e){
log.error(e.getMessage());
return false;
}
//后面还可以进行其他判断 用户是否存在等等,我这里就不使用数据库了 不再进行判断
return false;
}
配置拦截器类
/**
* @author wcc
* @date 2021/11/19 11:54
*/
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Autowired
private TokenInterceptor tokenInterceptor;
/**
* WebMvcConfigurer配置类其实是 Spring 内部的一种配置方式,采用 javaBean 的形式来代替传统的xml
* 配置文件的形式进行针对框架的个性化定制,可以自定义实现一些 Handler、Interceptor、ViewResolver、MessageConverter
*/
/**
* 解决跨域请求
* @param registry
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedHeaders("*")
.allowedMethods("*")
.allowedOriginPatterns("*")
.allowCredentials(true);
}
/**
* 异步请求配置
* @param configurer
*/
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
configurer.setTaskExecutor(new ConcurrentTaskExecutor(Executors.newFixedThreadPool(3)));
configurer.setDefaultTimeout(30000);
}
/**
* 配置拦截器、拦截路径
* 每次请求到拦截的路径,就回去执行拦截器中的方法
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
List<String> excludePath = new ArrayList<>();
// 排除拦截,除了注册登录(此时还没TOKEN),其他都拦截
excludePath.add("/register"); // 登录
excludePath.add("/login/**"); // 注册
excludePath.add("/static/**"); // 把静态资源的访问也排除
excludePath.add("/assets/**"); // 把静态资源的访问也排除
registry.addInterceptor(tokenInterceptor)
.addPathPatterns("/**") // 添加拦截的路径模式 /** 代表全部拦截
.excludePathPatterns(excludePath);
WebMvcConfigurer.super.addInterceptors(registry);
}
}
控制器类
/**
* @author wcc
* @date 2021/11/19 13:34
*/
@RestController
@Slf4j
@RequestMapping("/jwt")
public class LoginController {
@PostMapping("/login")
@ResponseBody
@PassToken
public String login(@RequestParam("username") String username,@RequestParam("password") String password) throws Exception{
// 可以在此处校验用户名密码
User user = new User();
user.setUsername(username);
user.setPassword(password);
String token = TokenUtils.sign(user);
HashMap<String,Object> hashMap = new HashMap<>();
hashMap.put("token",token);
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.writeValueAsString(hashMap);
}
@PostMapping("/info")
@UserLoginToken(required = true)
public String getInfo(){
return "使用 Token 登录验证成功。";
}
}
测试结果如下: