关于Token
**Token:**令牌,票据
Token机制是用于解决服务器端识别客户端身份的。
在使用Token机制时,当客户端首次向服务器提交请求时,或提交登录的请求时,客户端是直接将请求发送到服务器端的,并不做特殊处理,而服务器端会按需处理请求(例如客户端提交的是登录请求,则处理登录),并且将客户端的身份数据生成一个Token,并将此Token响应到客户端去,后续,客户端需要携带此Token提交各种请求,服务器端也会根据此Token数据来识别客户端的身份。
与Session不同,Token是由服务器端的程序(自行编写的)生成的数据,是一段有意义的数据,相比之下,Session机制中的Session ID是一个UUID值,仅保证唯一性,数据本身是没有意义的!Token不需要在服务器端存在匹配的数据,因为自身就是数据!
在处理过程中,服务器端只需要检查Token,并从Token中解析出客户端身份相关的数据即可,在服务器端的内存中并不需要保存Token的数据,所以,Token是可以设置较长甚至很长的有效期的,不会消耗服务器端用于存储数据的内存资源。
同时,Token天生就适用于集群或分布式系统,只需要各服务器具有相同的检查Token和解析Token的程序即可。
关于JWT
**JWT:**JSON Web Token
JWT的官网:jwt.io/
每个JWT数据都是由3大部分组成的:
- Header:声明算法与Token类型
- Payload:数据
- Verify Signature:验证签名
关于JWT编程的工具包:jwt.io/libraries?l…
例如,在项目中添加JJWT的依赖项:
<!-- JJWT(Java JWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
接下来,就可以在项目中尝试生成、解析JWT数据,例如:
package cn.tedu.csmall.passport;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtTests {
String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";
@Test
void generate() {
Map<String, Object> claims = new HashMap<>();
claims.put("id", 9527);
claims.put("username", "spring");
String jwt = Jwts.builder()
// Header:声明算法与Token类型
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload:数据,具体表现为Claims
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 1000))
// Verify Signature:验证签名
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
System.out.println(jwt);
}
@Test
void parse() {
String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjc4MjQzNDYwLCJ1c2VybmFtZSI6InNwcmluZyJ9.HZni3OQYS1YwTEpBoNPPz222UrgCcdD1j7nBDgoZxzs";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Object id = claims.get("id");
Object username = claims.get("username");
System.out.println("id = " + id);
System.out.println("username = " + username);
}
}
如果尝试解析的JWT已过期,会出现错误:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2023-03-08T10:30:16Z. Current time: 2023-03-08T10:41:32Z, a difference of 676763 milliseconds. Allowed clock skew: 0 milliseconds.
如果尝试解析的JWT数据格式有误,会出现错误:
io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"id":952name":"spring"}
如果尝试解析的JWT数据签名有误,会出现错误:
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
**注意:**即使不知道secretKey,其实也可以解析出JWT数据中的内容,例如将JWT数据粘贴到JWT的官网即可解析出内容,所以,不要在JWT中存入敏感数据!另外,即使在JWT官网或使用其它API可以解读出JWT中的数据,但是,也会提示“无法验证签名”的字样,包括解析失败时的异常信息也会提示“不要信任此次的解析结果”。
在项目中使用JWT识别用户的身份
核心流程
在项目中使用JWT识别用户的身份,大致需要:
-
当用户通过登录的验证后,服务器应该生成JWT数据,并响应到客户端
- 当通过验证后,不再需要(没有必要)将用户的认证信息存入到
SecurityContext中
- 当通过验证后,不再需要(没有必要)将用户的认证信息存入到
-
当用户尝试执行需要通过认证的操作时,用户应该自主携带JWT,并且,服务器端应该尝试解析此JWT,从而验证JWT的真伪,并识别用户的身份,如果一切无误,再将用户的认证信息存入到
SecurityContext中
登录成功后响应JWT
当用户通过登录的验证后,服务器应该生成JWT数据,并响应到客户端!
当通过验证后,不再将用户的认证信息存入到SecurityContext中,则在AdminServiceImpl的login()方法中调整:
// 使用JWT机制时,登录成功后不再需要将认证信息存入到SecurityContext,则注释或删除以下2行代码
// SecurityContext securityContext = SecurityContextHolder.getContext();
// securityContext.setAuthentication(authenticateResult);
在IAdminService中,需要将login()方法的返回值类型改为String,表示登录成功后将返回JWT数据,例如:
/**
* 管理员登录
* @param adminLoginInfoDTO 封装了用户名、密码等相关信息的对象
* @return 此管理员登录后得到的JWT数据
*/
String login(AdminLoginInfoDTO adminLoginInfoDTO);
并且,调整AdminServiceImpl中的login()的声明与实现:
@Override
public String login(AdminLoginInfoDTO adminLoginInfoDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginInfoDTO);
Authentication authentication = new UsernamePasswordAuthenticationToken(
adminLoginInfoDTO.getUsername(), adminLoginInfoDTO.getPassword());
Authentication authenticateResult
= authenticationManager.authenticate(authentication);
log.debug("认证通过!(如果未通过,过程中将抛出异常,你不会看到此条日志!)");
log.debug("认证结果:{}", authenticateResult);
log.debug("认证结果中的当事人:{}", authenticateResult.getPrincipal());
// 使用JWT机制时,登录成功后不再需要将认证信息存入到SecurityContext
// SecurityContext securityContext = SecurityContextHolder.getContext();
// securityContext.setAuthentication(authenticateResult);
// 需要存入到JWT中的数据
AdminDetails adminDetails = (AdminDetails) authenticateResult.getPrincipal();
Map<String, Object> claims = new HashMap<>();
claims.put("id", adminDetails.getId());
claims.put("username", adminDetails.getUsername());
// 权限待定
// 生成JWT,以下代码是相对固定的
String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";
String jwt = Jwts.builder()
// Header:声明算法与Token类型
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload:数据,具体表现为Claims
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000))
// Verify Signature:验证签名
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.debug("生成了JWT数据:{}", jwt);
return jwt;
}
然后,还需要调整AdminController的login()方法,在调用Service的login()方法时获取返回的JWT,并响应到客户端,例如:
@PostMapping("/login")
public JsonResult<String> login(AdminLoginInfoDTO adminLoginInfoDTO) {
log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginInfoDTO);
String jwt = adminService.login(adminLoginInfoDTO);
return JsonResult.ok(jwt);
}
解析客户端携带的JWT
客户端提交请求(无论是什么请求),都可能携带了JWT,在服务器端,处理多种不同的请求时都可能需要获取并尝试解析JWT,则应该使用过滤器(Filter)组件进行处理!
提示:过滤器(
Filter)是Java服务器端应用程序的核心组件之一,它是最早接收到请求的组件!过滤器可以选择对此请求进行“阻止”或“放行”!同一个项目中,允许存在若干个过滤器,形成“过滤器链”(FilterChain),任何一个请求,仅当过滤器链上的每个过滤器都选择“放行”才可以被控制器或其它组件进行处理!
在项目的根包下创建filter.JwtAuthorizationFilter类,继承自OncePerRequestFilter类,并在类上添加@Component注解,例如:
package cn.tedu.csmall.passport.filter;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
}
}
然后,在过滤器的方法中接收JWT数据:
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.debug("JWT过滤器开始执行……");
// 根据业内惯用的做法,客户端提交的请求中的JWT应该存放于请求头(Request Header)中的名为Authorization属性中
String jwt = request.getHeader("Authorization");
log.debug("客户端携带的JWT:{}", jwt);
// 放行
filterChain.doFilter(request, response);
}
}
然后,还需要在SecurityConfiguration中将此过滤器注册到Spring Security框架的过滤器链中:
在API文档中,通过“全局参数设置”来配置请求头中的JWT数据:
**注意:**在进行以上配置时,参数名称Authorization是严格区分大小写的,也不允许有多余的空格!
接下来,任何新打开的调试页面中都可以看到请求头中携带的数据:
package cn.tedu.csmall.passport.filter;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* <p>JWT过滤器</p>
*
* <p>此过滤器的主要作用</p>
* <ul>
* <li>接收客户端提交的请求中的JWT</li>
* <li>尝试解析客户端提交的请求中的有效JWT</li>
* <li>将解析成功得到的数据创建为Authentication对象,并存入到SecurityContext中</li>
* </ul>
*/
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
/**
* JWT的最小长度值
*/
public static final int JWT_MIN_LENGTH = 113;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.debug("JWT过滤器开始执行……");
// 根据业内惯用的做法,客户端提交的请求中的JWT应该存放于请求头(Request Header)中的名为Authorization属性中
String jwt = request.getHeader("Authorization");
log.debug("客户端携带的JWT:{}", jwt);
// 判断客户端是否携带了有效的JWT
if (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {
// 如果JWT无效,直接放行
log.debug("客户端没有携带有效的JWT,将放行,由后续的过滤器等组件继续处理此请求……");
filterChain.doFilter(request, response);
return;
}
// 尝试解析JWT
String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Object id = claims.get("id");
Object username = claims.get("username");
log.debug("解析JWT结束,id={},username={}", id, username);
// 临时处理认证信息中的权限
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("这是一个山寨的权限!"));
// 创建Authentication对象
Object principal = username;
Object credentials = null;
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
// 将Authentication对象存入到SecurityContext中
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
// 放行
filterChain.doFilter(request, response);
}
}
关于当事人
通常,当事人信息中应该至少包含用户的ID和用户名,而认证信息(Authentication)中的当事人(Principal)的类型被设计为Object,所以,你可以使用任何类型的数据作为当事人!则可以自定义类封装用户的ID和用户名!
在项目的根包下创建security.LoginPrincipal类,例如:
@Data
public class LoginPrincipal implements Serializable {
/**
* 当事人的ID
*/
private Long id;
/**
* 当事人的用户名
*/
private String username;
}
在解析JWT时,将解析结果处理为期望的类型,例如:
// 尝试解析JWT
String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Long id = claims.get("id", Long.class); // 期望的类型
String username = claims.get("username", String.class); // 期望的类型
log.debug("解析JWT结束,id={},username={}", id, username);
然后,基于解析结果创建当事人对象:
// 创建当事人对象,用于存入到Authentication对象中
LoginPrincipal loginPrincipal = new LoginPrincipal();
loginPrincipal.setId(id);
loginPrincipal.setUsername(username);
然后,将当事人对象用于创建认证信息对象:
// 创建Authentication对象
Object principal = loginPrincipal; // 当事人对象
Object credentials = null;
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
后续,在控制器类中处理请求的方法中,当需要当事人数据时,注入LoginPrincipal类型的参数即可:
@GetMapping("")
public JsonResult<List<AdminListItemVO>> list(
@ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {
log.debug("开始处理【查询管理员列表】的请求,参数:无");
log.debug("当事人信息:{}", loginPrincipal);
log.debug("当事人信息中的ID:{}", loginPrincipal.getId());
log.debug("当事人信息中的用户名:{}", loginPrincipal.getUsername());
List<AdminListItemVO> list = adminService.list();
return JsonResult.ok(list);
}
处理权限
在项目中添加fastjson依赖项:
<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
在AdminServiceImpl中的login()方法,当验证登录通过,将此管理员的权限列表转换为JSON格式的字符串,然后再存入到JWT中,例如:
则任何管理员成功登录后,得到的JWT中都将包含权限列表的信息!
在JwtAuthorizationFilter中,解析JWT时,可以从中获取到此前存入的权限列表的JSON字符串,将此字符串反序列化为原本的类型,即ArrayList<SimpleGrantedAuthority>类型,并将此对象存入到认证信息中,例如:
至此,可以继续使用Spring Security检查各请求上配置的权限!
关于清除SecurityContext
因为Spring Security是根据SecurityContext中的认证信息来识别用户的身份的,而SecurityContext本身是基于Session机制的,当携带JWT成功访问后(在SecurityContext中已经存入了认证信息),在后续的一段时间内(在Session的有效期内),即使不携带JWT也可以成功访问!
可以在JWT过滤器刚刚开始执行时,就直接清空SecurityContext,即:
// 清空SecurityContext,避免【此前携带JWT成功访问后,在接下来的一段时间内不携带JWT也能访问】
SecurityContextHolder.clearContext();
**注意:**Spring Security本身使用了ThreadLocal处理SecurityContext,所以,以上的清除做法只对当前线程有效,如果将以上代码放在doFilter()之后,并不能解决问题!
或者,在Spring Security的配置类中的void configurer(HttpSecurity http)方法中,配置创建Session的策略为“从不使用Session”,即:
// 配置Spring Security创建Session的策略:STATELESS=从不使用Session,NEVER=不主动创建Session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
关于使用配置文件
生成和解析JWT使用的secretKey应该使用配置文件进行配置,例如,在application-dev.yml中添加配置:
secretKey: gfd89uiKa89J043tAFrflkji9432kjfdsajm
然后,在AdminServiceImpl和JwtAuthorizationFilter均不再使用原本的secretKey局部变量(删除原有代码),改为通过@Value注解读取以上配置文件中的配置值:
@Value("${secretKey}")
private String secretKey;