【全网最细致】SpringBoot整合Spring Security + JWT实现用户认证
登录和用户认证是一个网站最基本的功能,在这篇博客里,将介绍如何用SpringBoot整合Spring Security + JWT实现登录及用户认证
文章目录
- 【全网最细致】SpringBoot整合Spring Security + JWT实现用户认证
-
-
- 前置知识:Session、Cookie与Token
- JWT
- 登录及用户认证流程设计
- 前置知识:Spring Security
- 根据自己的项目需求实现SpringSecurity中的部分过滤器
- 正式开始整合Spring Security和JWT
-
- pom.xml添加相应依赖
- 写一个JWT工具类
- 写登录认证成功、失败处理器LoginSuccessHandler、LoginFailureHandler
- 写RedisUtil工具类以及验证码配置
- 后端写获取验证码接口
- 验证码过滤器CaptchaFilter
- JWT过滤器JwtAuthenticationFilter
- JWT认证失败处理器JwtAuthenticationEntryPoint
- 从数据库中验证用户名、密码:UserServiceDetails、AuthenticationManager、UserDetails
- 无权限访问的处理:AccessDenieHandler
- 登出处理器LogoutSuccessHandler
- 密码加密解密:PasswordEncoder
- 整合所有组件,进行Spring Security全局配置:SecurityConfig
- 前端需要做什么
- 最后说说SpringSecurity和Shiro的权限管理
-
前置知识:Session、Cookie与Token
session与cookie
在一些传统项目中,我们或许会用session来保存用户信息,进行用户认证。而现在基本上都用token来代替session,为什么会出现这样的变化,我们来聊聊session、cookie以及token
在谈session和cookie前,首先我们来谈谈会话。http本身是无状态协议,服务器无法识别每一次HTTP请求的出处(不知道来自于哪个终端),它只会接受到一个请求信号,所以就存在一个问题:将用户的响应发送给相应的用户,必须有一种技术来让服务器知道请求来自哪,这就是会话技术。
会话就是客户端和服务器之间发生的一系列连续的请求和响应的过程。会话状态指服务器和浏览器在会话过程中产生的状态信息,借助于会话状态,服务器能够把属于同一次会话的一系列请求和响应关联起来。
实现会话有两种方式:session和cookie。Session通过在服务器端记录信息确定用户身份,相应的也增加了服务器的存储压力。客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上,这就是Session。属于同一次会话的请求都有一个相同的标识符,sessionID,客户端浏览器再次访问时只需要通过sessionID从Session中查找该客户的状态就可以了。那么后端是怎么把sessionID返回给客户端的?可以通过设置cookie的方式返回给客户端,若浏览器禁止cookie,则可以通过URL重写的方式发送。
刚刚提到了cookie,Cookie是服务端在HTTP响应中附带传给浏览器的一个小的文本文件,一旦浏览器保存了某个Cookie,在之后的请求和响应过程中,会将此Cookie来回传递,这样就可以通过Cookie这个载体完成客户端和服务端的数据交互。
使用Session进行用户认证时,当用户第一次通过浏览器使用用户名和密码访问服务器时,服务器会验证用户数据,验证成功后在服务器端写入session数据,向客户端浏览器返回sessionid,浏览器将sessionid保存在cookie中,当用户再次访问服务器时,会携带sessionid,服务器会拿着sessionid从服务器获取session数据,然后进行用户信息查询,查询到,就会将查询到的用户信息返回,从而实现状态保持,流程如下图:
session的弊端
- 服务器压力增大
通常session是存储在内存中的,每个用户通过认证之后都会将session数据保存在服务器的内存中,而当用户量增大时,服务器的压力增大。 - CSRF跨站伪造请求攻击
一般session是基于cookie进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。即使不用cookie,用重写url方式发送sessionId,那就更容易被截获信息了 - 扩展性不强
想象这么一个场景,若项目在多个服务器上部署,那我再其中一台登录了,称为A,session也保存到A中,万一下次我访问到另外一台服务器B怎么办?B上没有A的session呢?为了解决这个问题,我们需要将session保存到数据库中,所以每次保存这些session信息就是一个负担了,增加了服务器的存储压力。
token
token的意思是“令牌”,是服务端生成的一串加密字符串(服务器端并不进行保存),作为客户端进行请求的一个标识。当用户第一次登录后,服务器生成一个token并将此token返回给客户端浏览器,以后客户端只需带上这个token前来请求数据即可,无需再次带上用户名和密码。
浏览器会将接收到的token值存储在Local Storage中,浏览器再次访问时服务器端时,服务器对浏览器传来的token值进行解密,解密完成后进行用户数据的查询,如果查询成功,则通过认证,实现状态保持,所以,即使有了多台服务器,服务器也只是做了token的解密和用户数据的查询,它不需要在服务端去保留用户的认证信息或者会话信息,这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利,同时服务器也不需要保存token,token的出现就解决了session的弊端,成为了session的替代品。
使用token进行用户认证的流程如下图:
session与token的总结
token 是无状态的,后端不需要记录信息,每次请求过来进行解密就能得到对应信息。
session 是有状态的,需要后端每次去检索id的有效性。不同的session都需要进行保存,但也可以设置单点登录,减少保存的数据。
session与token的选择是空间与时间博弈,为什么这么说呢,是因为token不需要保存,不占存储空间,但每次访问都需要进行解密,消耗了一定的时间。
在一般的前后端分离项目中,token展现出了它的优势,成为了比session更好的选择
JWT
JWT其实就是一种被广泛使用的token,它的全称是JSON Web Token,它通过数字签名的方式,以JSON对象为载体,在不同的服务终端之间安全地传输信息。
JWT最常见的使用场景就是授权认证,一旦用户登录,后续每个请求都将包含JWT,系统在每次处理用户请求之前,都要先进行JWT安全校验,通过之后再进行处理。
JWT由3部分组成,用.拼接,如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
这三部分分别是:
- Header
Header中保存了令牌类型type和所使用的的加密算法,例如:
{
'typ': 'JWT',
'alg': 'HS256'
}
- Payload
Payload中包含的是请求体和其它一些数据,例如包含了和用户相关的一些信息
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
- Signature
Signature签名属于jwt的第三部分。主要是把头部的base64UrlEncode与负载的base64UrlEncode拼接起来,再进行HMACSHA256加密等最终得到的结果作为签名部分。例如:
// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
登录及用户认证流程设计
假设我们要设计这样一个登录功能:用户输入用户名、密码以及图片验证码进行登录,用户认证功能则是对于用户的每次请求,都需要校验用户信息,若不正确,则拒绝请求
这里不同于最简单的用户名密码登录,加入了图片验证码,验证码是为了防止非正常用户伪造请求进行登录,是一个较为重要的功能。
根据我们之前对于token和JWT的介绍,我们知道,对于首次登录,浏览器是没有JWT信息的,用户需输入用户名、密码和验证码完成登录,后端对验证码、用户名、密码进行校验后,若校验成功,则返回JWT给前端,完成登录。登录后的每次请求,请求头都将携带Jwt进行身份认证,若认证成功则能访问后端接口
我们先来看首次登录,后端需要对验证码和用户信息进行校验,我们不妨先校验验证码,再校验用户名密码。
对于验证码的校验,我们知道一般的前后端分离使用token,不使用session,那么后台必须保证当前用户输入的验证码是用户开始请求页面时候的验证码,必须保证验证码的唯一性。举个例子:
A用户看到的验证码是:ABC;B用户看到的验证码是:DEF。后台存储了ABC和DEF这2个验证码,如果不限定A用户输入的验证码是ABC,那么当A用户碰巧输入DEF,然后用户名和密码也是正确的话,A用户也是可以登录系统的。
也就是说,每个用户的请求都需要对应一个唯一的验证码,这一切的麻烦都源于http本身是无状态协议,我们需要保存用户与验证码之间的对应关系,而现在我们又不能使用session,不能用SessionID的字段和验证码对应,那么我们该如何做呢?
有一种方式是前端生成一个随机数(UUID形式),保存在localstorage里,对应着某个用户,前端带上随机数参数访问后端接口,后端用加密算法加密该随机数rand,生成验证码,即verify_code = f(rand)。当用户提交验证码的时候,之前的随机数一起带过来,后端再通过之前的加密规则验证输入的验证码是否正确。也即构造了随机数和验证码的对应关系
其实上述做法有点类似于token的做法,不过这种做法有几个问题,一是验证码强行和一个前端给的一串随机数通过一个算法f产生了联系,前端的请求可以随意伪造,随机数参数也可以五花八门,可能会导致一些意想不到的bug发生,验证码应保持随机性和独立性,不应该和一个随机数强行通过函数f关联。二是采用这种方式,后端需要进行两次加密过程生成验证码,会造成不必要的时间开销。
我们采用另一种方式类似于session的做法来完成验证码校验过程,首先,我们还是得构造随机数(UUID形式,代表着某个用户),它和验证码一一对应。不过这次我们将随机数的生成把握在后端手里,毕竟老话说得好:作为一个后端,不要相信前端传过来的任何参数(手动狗头),把随机数的生成把握在后端手中,这种方式更加安全。我们仿照session的原理,牺牲一部分存储空间,将随机数和对应的验证码作为key-value键值对形式进行存储,然后将生成的随机数返回给前端,前端在登录请求时将该随机数以及用户输入的验证码传给后端,后端就能通过该随机数进行查询,校验输入的验证码和正确验证码是否一致。我们可以引入redis中间件来完成随机数和验证码的存储,因为一个验证码对应一个用户的一次登录过程,所以当验证成功时,我们将redis中存储的验证码和随机码删除,采用这种方式也不会消耗多少存储空间。
解决了令人头疼的验证码,登录流程就很清晰了,流程如下:
在首次登录过后,浏览器将保存jwt,在之后的所有请求中(包括再次登录请求),请求头都将携带Jwt进行身份认证,若认证成功则能访问后端接口。
弄清楚登录及用户认证流程后,接下来我们将使用SpringBoot整合Spring Security + JWT来实现上述流程,在使用Spring Security之前,我们先来看看它的基本原理,要不然用起来会很懵
前置知识:Spring Security
Spring Security是Spring家族中的安全框架,可以用来做用户验证和权限管理等。Spring Security是一款重型框架,不过功能十分强大。一般来说,如果项目中需要进行权限管理,具有多个角色和多种权限,我们可以使用Spring Security。如果是较为简单的项目,只需要控制一下某些接口只有登录后才能访问,则可以使用Shiro框架,Shiro也是一款安全框架,它是一款轻量级框架,功能没有Spring Security多,但使用起来要简单不少。
SpringSecurity 采用的是责任链的设计模式,是一堆过滤器链的组合,它有一条很长的过滤器链。 Spring Security 的执行流程图如下所示:
现在来一一解释每一个过滤器链的功能是什么:
-
1、WebAsyncManagerIntegrationFilter:
将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成。 -
2、SecurityContextPersistenceFilter:
在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,然后在该次请求处理完成之后,将 SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将 SecurityContextHolder 中的信息清除,例如在Session中维护一个用户的安全信息就是这个过滤器处理的。 -
3、HeaderWriterFilter:
用于将头信息加入响应中。 -
4、CsrfFilter:
用于处理跨站请求伪造。 -
5、LogoutFilter:
用于处理退出登录。 -
6、UsernamePasswordAuthenticationFilter:
用于处理基于表单的登录请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的usernameParameter 和 passwordParameter 两个参数的值进行修改。 -
7、DefaultLoginPageGeneratingFilter:
如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录时生成一个登录表单页面。 -
8、BasicAuthenticationFilter:
检测和处理 http basic 认证。 -
9、RequestCacheAwareFilter:
用来处理请求的缓存。 -
10、SecurityContextHolderAwareRequestFilter:
主要是包装请求对象request。 -
11、AnonymousAuthenticationFilter:
检测 SecurityContextHolder 中是否存在 Authentication 对象,如果不存在为其提供一个匿名 Authentication。 -
12、SessionManagementFilter:
管理 session 的过滤器 -
13、ExceptionTranslationFilter:
处理 AccessDeniedException 和 AuthenticationException 异常。 -
14、FilterSecurityInterceptor:
可以看做过滤器链的出口。 -
15、RememberMeAuthenticationFilter:
当用户没有登录而直接访问资源时, 从 cookie 里找出用户的信息, 如果 Spring Security 能够识别出用户提供的remember me cookie, 用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。
看到Spring Security这么复杂,我们可能已经崩溃了,但其实它并没有看上去那么吓人,因为Spring Security已经对很多过滤器部分提供了默认实现,程序员只需要按照自己项目的需求增加和修改少量代码即可。不过这么做也有个坏处,那就是不懂Spring Security原理的程序员,可能会看不懂代码中的登录逻辑,觉得莫名其妙就进行完用户验证了。
根据自己的项目需求实现SpringSecurity中的部分过滤器
我们可以根据自己的项目需求来设计一个security的认证方案,结合我们之前提到的登录和认证需求,可以得到这样一个流程:
需要注意的是,SpringSecurity不提供图片验证码过滤器,因此我们在UsernamePasswordAuthenticationFilter前加入自定义的图片验证码过滤器
根据上述流程,我们列出需要自己实现的过滤器和处理器等:
- 1、LogoutSuccessHandler:
表示登出处理器 - 2、验证码过滤器Filter
- 3、登录认证成功、失败处理器
- 4、BasicAuthenticationFilter:
该过滤器用于普通http请求进行身份认证 - 5、AuthenticationEntryPoint:
表示认证失败处理器 - 6、AccessDenieHandler:
用户发起无权限访问请求的处理器 - 7、UserServiceDatils 接口:
该接口十分重要,用于从数据库中验证用户名密码 - 8、PasswordEncoder密码验证器
正式开始整合Spring Security和JWT
弄清了我们需要实现哪些代码,接下来我们就正式开始整合过程
pom.xml添加相应依赖
我们需添加Spring Security和JWT依赖,还需添加redis依赖,以及一些工具类,例如hutool,编码工具类,以及google的验证码工具类等:
<!-- springboot security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>com.github.axet</groupId>
<artifactId>kaptcha</artifactId>
<version>0.0.9</version>
</dependency>
<!-- hutool工具类-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.3.3</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
写一个JWT工具类
我们需要写一个JWT工具类JwtUtils,该工具类需要有3个功能:生成JWT、解析JWT、判断JWT是否过期。直接上代码:
import io.jsonwebtoken.*;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Date;
@Data
@Component
@ConfigurationProperties(prefix = "xiaolinbao.jwt")
public class JwtUtils {
private long expire;
private String secret;
private String header;
// 生成JWT
public String generateToken(String username) {
Date nowDate = new Date();
Date expireDate = new Date(nowDate.getTime() + 1000 * expire);
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(username)
.setIssuedAt(nowDate)
.setExpiration(expireDate) // 7天过期
.signWith(SignatureAlgorithm.HS512