Version: Next

Spring Security集成JWT

实现原理


认证流程

image-20200727164559486

  • 当客户端发送“/authentication”请求的时候,实际上是请求JwtAuthenticationController。该Controller的功能是:一是用户登录功能的实现,二是如果登录成功,生成JWT令牌。在使用JWT的情况下,这个类需要我们自己来实现
  • 具体到用户登录,就需要结合Spring Security实现。通过向Spring Security提供的AuthenticationManager的authenticate()方法传递用户名密码,由spring Security帮我们实现用户登录认证功能。
  • 如果登陆成功,我们就要为该用户生成JWT令牌了。通常此时我们需要使用UserDetailsService的loadUserByUsername方法加载用户信息,然后根据信息生成JWT令牌,JWT令牌生成之后返回给客户端。(spring security的UserDetailsService的功能以及实现,笔者之前的文章已经讲过)
  • 另外,我们需要写一个工具类JwtTokenUtil,该工具类的主要功能就是根据用户信息生成JWT,解签JWT获取用户信息,校验令牌是否过期,刷新令牌等

接口鉴权流程

image-20200727165823173

  • 当客户端请求“/hello”资源的时候,他应该在HTTP请求的Header带上JWT字符串。Header的名称前后端服务自己定义,但是要统一
  • 服务端需要自定义JwtRequestFilter,拦截HTTP请求,并判断请求Header中是否有JWT令牌。如果没有,就执行后续的过滤器。因为Spring Security是有完成的鉴权体系的,你没赋权该请求就是非法的,后续的过滤器链会将该请求拦截,最终返回无权限访问的结果
  • 如果在HTTP中解析到JWT令牌,就调用JwtTokenUtil对令牌的有效期及合法性进行判定。如果是伪造的或者过期的,同样返回无权限访问的结果
  • 如果JWT令牌在有效期内并且校验通过,我们仍然要通过UserDetailsService加载该用户的权限信息,并将这些信息交给Spring Security。只有这样,该请求才能顺利通过Spring Security一系列过滤器的关卡,顺利到达HelloWorldcontroller并访问“/hello”接口

认证流程实现

环境搭建

  • HelloController
@RestController
public class HelloController {
@RequestMapping("/hello")
public String hello() {
return "world";
}
}
  • 在配置类禁用formLogin
// .formLogin()
// .loginPage("/login.html")//用户未登录时,访问任何资源都转跳到该路径,即登录页面
// .loginProcessingUrl("/login")//登录表单form中action的地址,也就是处理认证请求的路径
// .usernameParameter("username")///登录表单form中用户名输入框input的name名,不修改的话默认是username
// .passwordParameter("password")//form中密码输入框input的name名,不修改的话默认是password
//// .defaultSuccessUrl("/index")//登录认证成功后默认转跳的路径
// .successHandler(myAuthenticationSuccessHandler)
// .failureHandler(myAuthenticationFaliureHandler)
// .and()
  • 确保CSRF关闭
.csrf().disable()

编写JWT工具类

  • 引入jjwt
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
  • application.yaml
jwt:
header: JWTHeaderName
secret: aabbccdd
expiration: 3600000
  • 其中header是携带JWT令牌的HTTP的Header的名称。虽然我这里叫做JWTHeaderName,但是在实际生产中可读性越差越安全
  • secret是用来为JWT基础信息加密和解密的密钥。虽然我在这里在配置文件写死了,但是在实际生产中通常不直接写在配置文件里面。而是通过应用的启动参数传递,并且需要定期修改
  • expiration是JWT令牌的有效时间

新建/auth/jwt/JwtTokenUtil

  • @ConfigurationProperties(prefix = "jwt")用来从application.yaml的jwt: xxx中取属性
jwt:
header: JWTHeaderName #http or https对应的JWT请求头的名字
secret: aabbccdd
expiration: 3600000
  • 因为在Bean实例化阶段通过set注入配置文件中的值,所以需要getter setter方法,此处使用Lombok注解完成
@Data
@ConfigurationProperties(prefix = "jwt")
@Component
public class JwtTokenUtil {
/***
* 从配置文件jwt配置段取出名称一致的配置
*/
private String secret;
private Long expiration;
private String header;
/**
* 生成token令牌
* 前端发来了用户名密码,后端进行验证,验证完生成一个
* userDetails,里面包含用户名密码、权限等
* 弄了个Map,里面放上用户名和创建时间
* @param userDetails 用户
* @return 令token牌
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>(2);
claims.put("sub", userDetails.getUsername());
claims.put("created", new Date());
// 又去找一个私有重载方法
return generateToken(claims);
}
/**
* 从令牌中获取用户名
* 前端所有的后续请求都携带token,后端键查token对应的用户信息
* 从而完成用户识别
* claims.getSubject就能获得token中的用户名
* @param token 令牌
* @return 用户名
*/
public String getUsernameFromToken(String token) {
String username;
try {
Claims claims = getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 判断令牌是否过期
*
* @param token 令牌
* @return 是否过期
*/
public Boolean isTokenExpired(String token) {
try {
Claims claims = getClaimsFromToken(token);
Date expiration = claims.getExpiration();
return expiration.before(new Date());
} catch (Exception e) {
return false;
}
}
/**
* 刷新令牌
* 从旧token的Claims中取出所有信息
* 将其中的created设置为新日期
* 根据新的信息Claims生成新token
* @param token 原令牌
* @return 新令牌
*/
public String refreshToken(String token) {
String refreshedToken;
try {
Claims claims = getClaimsFromToken(token);
claims.put("created", new Date());
refreshedToken = generateToken(claims);
} catch (Exception e) {
refreshedToken = null;
}
return refreshedToken;
}
/**
* 验证令牌
* token用户名和数据库用户名一致
* 且
* token没过期,则有效
* @param token 令牌
* @param userDetails 用户
* @return 是否有效
*/
public Boolean validateToken(String token, UserDetails userDetails) {
String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
/**
* 从claims生成令牌,如果看不懂就看谁调用它
* 用当前时间加上配置文件中的过期时间,生成过期日期
* setClaims把包含创建时间和用户名的Map扔进去
* setExpiration传入过期日期
* signWith指定数字签名加密算法,并传入配置文件中设置的secret进行签名
* compact打包
* @param claims 数据声明
* @return 令牌
*/
private String generateToken(Map<String, Object> claims) {
Date expirationDate = new Date(System.currentTimeMillis() + expiration);
return Jwts.builder().setClaims(claims)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
/**
* 从令牌中获取数据声明,如果看不懂就看谁调用它
* 传入token字符串,获得token的体
* 设置解签用的secret内容
* 解析成claims
* @param token 令牌
* @return 数据声明
*/
private Claims getClaimsFromToken(String token) {
Claims claims;
try {
claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
} catch (Exception e) {
claims = null;
}
return claims;
}
}

Controller

  • "/authentication"接口用于登录验证,并且生成JWT返回给客户端
  • "/refreshtoken"接口用于刷新JWT,更新JWT令牌的有效期
@RestController
public class JwtAuthController {
@Resource
JwtAuthServiceImpl jwtAuthService;
/***
* JWT验证登录
* @param map
* @return
*/
@PostMapping("/authentication")
public AjaxResponse login(@RequestBody Map<String, String> map) {
String username = map.get("username");
String password = map.get("password");
if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
return AjaxResponse.error(
new CustomException(CustomExceptionType.USER_INPUT_ERROR, "用户名或密码不能为空")
);
}
try {
// jwtAuthService.login(username, password); 直接返回JWT字符串了
// JWT字符串作为data直接响应前端
return AjaxResponse.success(jwtAuthService.login(username, password));
} catch (CustomException e) {
return AjaxResponse.error(e);
}
}
/***
* 刷新令牌
* @param token 放置在请求头中,头的名字从application.yaml的jwt:header字段取
* @return
*/
@PostMapping("/refreshtoken")
public AjaxResponse refresh(@RequestHeader("${jwt.header}") String token) {
return AjaxResponse.success(jwtAuthService.refreshToken(token));
}
}

Service

@Service
public class JwtAuthServiceImpl {
@Resource
AuthenticationManager authenticationManager;
@Resource
UserDetailsService userDetailsService;
@Resource
JwtTokenUtil jwtTokenUtil;
/***
* 登录认证换取JWT令牌
* 使用用户名密码进行登录验证
* @return 字符串类型的JWT token
*/
public String login(String username, String password) throws CustomException {
try {
// 根据用户名密码生成UsernamePasswordAuthenticaitonToken
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(username, password);
// 注入一个AuthenticationManager,对其进行认证
// 认证成功,返回一个认证主题
Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
// 将认证主题设置到上下文
SecurityContextHolder.getContext().setAuthentication(authenticate);
} catch (AuthenticationException e) {
throw new CustomException(CustomExceptionType.USER_INPUT_ERROR, "用户名密码错误");
}
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return jwtTokenUtil.generateToken(userDetails);
}
/***
* 刷新token, 如果token没过期,旧刷一个新token直接返回
* @param oldToken 旧token
* @return
*/
public String refreshToken(String oldToken) {
if (!jwtTokenUtil.isTokenExpired(oldToken)) {
return jwtTokenUtil.refreshToken(oldToken);
}
// 过期就返回null
return null;
}
}

配置类

为AuthenticationManager注册Bean

@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

开放JWT的两个Controller路径访问权限

.antMatchers("/login.html", "/login", "/authentication", "/refreshtoken").permitAll()//不需要通过登录验证就可以被访问的资源路径

使用POSTMAN测试

POST方式请求http://127.0.0.1:8081/authentication

  • 选择Body
  • 选择raw
  • 数据格式选择JSON
{
"username": "admin",
"password": "123456"
}
  • 返回响应信息

其中"data"就是生成的Jwt Token

{
"isok": true,
"code": 200,
"message": "success",
"data": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE1OTYwODIyMzAsInN1YiI6ImFkbWluIiwiY3JlYXRlZCI6MTU5NjA3ODYzMDI5Nn0.tlEJ5KYLm6VuQjuKWQQ2xrG2TY0sf7Cdhm_N_6_cUiYFTRxeQW0mg6W8JTMBwj0zaqrmiy2x9jeM0wGBR1dXaQ"
}

image-20200730111125836

  • 可以把Jwt Token复制下来,去在线BASE64解密网站解密一下
{"alg":"HS512"}{"exp":1596082230,"sub":"admin","created":1596078630296}-”By)‚æé[Žâ–A
±¬m“cKì'a˜ÞœR&M^Am&ƒ¥¼%3Â=3jªæ‹-±ö7ŒÓGWWi

可以看到解密出的加密算法,载荷信息,数字签名部分无法解密


测试刷新Token,要求传入旧的Token,旧Token是放在请求头里的,头的名字被我们定义在了application.yaml中

  • 响应
{
"isok": true,
"code": 200,
"message": "success",
"data": "eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE1OTYwODI5MjUsInN1YiI6ImFkbWluIiwiY3JlYXRlZCI6MTU5NjA3OTMyNTIwOH0.DlrVwZ4rUYDl6VcSRpxBEOck5d7ylT7IGgLM58p5fLCicmLRRa9Hyq43Ulcrfp2slHhjUeU-dfmbXk1N5KelXg"
}

image-20200730112244549

鉴权流程实现


JwtAuthenticationTokenFilter

Jwt鉴权过滤器

/***
* 1.从请求头中获取Jwt Token
* 2.从Token中获取用户名
* 3.由用户名查询数据库获得用户信息
* 4.验证token是否过期
* 5.根据用户实体和用户权限,生成UsernamePasswordAuthenticationToken
* 6.通过SpringSecurity认识的Token将用户放置到上下文
* 7.继续执行过滤器链
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Resource
JwtTokenUtil jwtTokenUtil;
@Resource
UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// Jwt Header的名字定义在application.yaml中,并且已经再初始化阶段注入到了JwtTokenUtil中
String jwtToken = request.getHeader(jwtTokenUtil.getHeader());
if (!StringUtils.isEmpty(jwtToken)) {
// 从Jwt Token中提取用户名,能拿到这个用户名,说明Jwt解签成功,至少Jwt是有效的
String usernameFromToken = jwtTokenUtil.getUsernameFromToken(jwtToken);
// 接下来判断上面这个Jwt携带的用户名是不是空的,这个Jwt虽然有效,但里面的东西不一定有效
// 只有用户名不为空,并且,这个Token中的用户还没有被后端认证过,也就是不存在与安全上下文中
if (usernameFromToken != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 才对用户进行认证,通过UserDetailService来做
UserDetails userDetails = userDetailsService.loadUserByUsername(usernameFromToken);
// 判断数据库用户名与token用户名是否一致,jwt是否过期
if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
// 认证成功,构建UsernamePasswordAuthenticationToken
// 这样后续的UsernamePasswordAuthenticationFilter就会放行
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
// Jwt Token -> Spring Security Token -> 交给Spring Security管理
// 执行完这句,UsernamePasswordAuthenticationFilter会放行请求
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
}
// 继续执行过滤器链
filterChain.doFilter(request, response);
}
}

在配置类中,配置我们写的这个过滤器

  • 因为我们使用了JWT,表明了我们的应用是一个前后端分离的应用,所以我们可以开启STATELESS禁止使用session。当然这并不绝对,前后端分离的应用通过一些办法也是可以使用session的,这不是本文的核心内容不做赘述。
  • 将我们的自定义jwtAuthenticationTokenFilter,加载到UsernamePasswordAuthenticationFilter的前面。
@Resource
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)

使用POSTMAN测试

访问http://127.0.0.1:8081/authentication

  • 拿到后端生成的token

  • eyJhbGciOiJIUzUxMiJ9.eyJleHAiOjE1OTYwOTUxODgsInN1YiI6ImFkbWluIiwiY3JlYXRlZCI6MTU5NjA5MTU4ODA1N30.L88FpUUqHV2UIkPpW0Afc7_nwt61aNFYAg-MBRDk5_vEjhE1aRMufsYcozTKuDALiL8q1sTjnsNm3sJRVXY8DQ
  • 之后访问任何系统资源,都必须携带这个token
  • 在请求头中设置JWTHeaderName,具体叫什么定义在application.yaml中,值为上面访问后端获取到的Token
  • 在数据库sys_menu表为hello添加一条记录
INSERT INTO sys_menu VALUES (NULL, 1, 1, 1, 'hello', '/hello', NULL, NULL, 5, 2, 0)
  • sys_role_menu表,为admin角色添加/hello权限
INSERT INTO sys_role_menu VALUES (NULL, 1, 6)
  • /hello发送请求
  • 情况1:在JWTHeaderName中写个空,后端响应拒绝访问

image-20200730145131115

  • 情况2:携带正确的Token,后端响应合法资源

image-20200730150231101