关于Session
服务器端的程序通常是基于HTTP协议的,而HTTP协议是一种“无状态”的通信协议,所以,它并不能保存来访的客户端的状态,只是简单的“请求、响应”的处理而已!也就是说,当同一个客户端多次访问同一个服务器端时,服务器并不能识别来访的客户端就是前序曾经来访过的客户端!
在开发实践中,是需要识别客户端身份的,所以,在编程技术上,可以使用Session机制来解决此问题。
Session的本质是存储在服务器端的内存中的一个K-V结构的数据,服务器端会为每一个来访的客户端的首次访问分配一个Session ID(本质上是一个UUID值,如果客户端的请求中没有携带Session ID,则服务器端生成并发回给客户端,如果客户端的请求中已经携带Session ID,则服务器端不会生成)此Session ID就是客户端访问服务器端的Session数据时使用的Key,所以,每个客户端在服务器上都有一份对应的Session数据(K-V中的Value)。
由于Session是存储在服务器端的内存中的数据,内存是非常重要的,且容量相对较小的存储设备,所以,必须设置一些清除Session的机制,默认的典型的清除机制就是“超时自动清除”,也就是说,某个客户端在最后一次提交请求后的多长时间内(常见的超时时间是15分钟或30分钟)没有再次提交请求,则服务器端会自动清除此客户端对应的Session数据。
由于Session是存储在服务器端的内存中的数据,所以,必然存在一些缺点:
- 不适合存储大量的数据
- 可以通过规范的开发,避免此问题
- 不便于应用到集群或分布式系统中
- 可以通过共享Session解决此问题
- 不可以长时间存储
- 无解
关于Token
**Token:**令牌,或票据
使用Token机制时,当客户端第1次向服务器提交请求时,或提交登录请求时,客户端直接发起请求,而服务器端会在验证登录成功后,生成此客户端对应的Token数据并响应到客户端,后续,客户端会携带此Token数据向服务器端发起请求,而服务器端会根据Token来识别客户端的身份。
在处理过程中,服务器端只需要检查Token、从Token中解析出客户端身份相关的数据即可,并不是必须在服务器端保存各Token数据,所以,Token可以设置较长时间的有效期,并不会长时间持续消耗服务器端的存储资源!所以,Token可以用于长时间表示用户的身份!
Token天生就适用于集群或分布式系统,因为各服务器端只需要具有相同的验证并解析Token的程序,就可以识别客户端的身份。
其实,Token的传输流程与Session ID基本上是相同的,最大的区别在于Session ID只是一个UUID数据,具有唯一性、随机性(不可预测性),但是,本身并不表示数据含义,而Token本身就是有数据含义的!
关于JWT
JWT:Json Web Token
JWT的官网:jwt.io/
每个JWT数据都包含3个组成部分:
- Header(头部信息):声明算法与Token的类型
- Payload(载荷):数据
- Signature:验证签名
关于JWT编程的工具包:jwt.io/libraries?l…
例如,在项目的pom.xml中添加依赖项:
<!-- 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.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 = "kU4jrFA3iuI5jn25u743kfDs7a8pFEwS54hm";
@Test
public void generate() {
Date exp = new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000);
Map<String, Object> claims = new HashMap<>();
claims.put("id", 9527);
claims.put("username", "ZhangSan");
String jwt = Jwts.builder()
// Header(头部信息):声明算法与Token的类型
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload(载荷):数据,表现为Claims
.setClaims(claims)
.setExpiration(exp)
// Signature:验证签名
.signWith(SignatureAlgorithm.HS256, secretKey)
// 完成
.compact();
System.out.println(jwt);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjgwNzUwMjkwLCJ1c2VybmFtZSI6IlpoYW5nU2FuIn0.UV8rukk8kt9wMb0_n7xgxmjEG-ra2O32vL_7T572xXw
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjgxNjE1ODY4LCJ1c2VybmFtZSI6IlpoYW5nU2FuIn0.vzZFkGQ8mZu0dPiRlXOWma0rr9Cvz9Hn6PWov3b8wNQ
}
@Test
public void parse() {
try {
String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjgxNjE1ODY4LCJ1c2VybmFtZSI6IlpoYW5nU2FuIn0.vzZFkGQ8mZu0dPiRlXOWma0rr9Cvz9Hn6PWov3b8wNQ";
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
Long id = claims.get("id", Long.class);
String username = claims.get("username", String.class);
System.out.println("id = " + id);
System.out.println("username = " + username);
} catch (Throwable e) {
e.printStackTrace();
}
}
}
当尝试解析JWT时,如果JWT已经过期,会出现错误:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2023-04-06T11:04:50Z. Current time: 2023-04-06T11:29:13Z, a difference of 1463885 milliseconds. Allowed clock skew: 0 milliseconds.
当尝试解析JWT时,如果使用的secretKey与生成JWT时使用的不相同,会出现错误:
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
当尝试解析JWT时,JWT数据如果是篡改后的数据,可能出现以上SignatureException,也可能会出现以下错误:
io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"id":9527,"exp":1681615�͕ɹ������"}
**注意:**JWT数据是可能被篡改的,所以,一旦解析失败,应该不信任此JWT数据,例如向服务器直接响应错误,而不处理客户端的请求!并且,即使不知道生成JWT时使用的secretKey的情况下,仍有很多办法可以解析出JWT中的内容,所以,不要在JWT中存入敏感数据!
在项目中使用JWT识别用户的身份
核心流程概述
sequenceDiagram
participant Client as 客户端
participant Server as 服务器端
Client ->> + Server: 请求登录时,不携带JWT
activate Client
Server -->> - Client: 验证登录通过,响应JWT
deactivate Client
Client ->> Server: 携带JWT
note right of Server: 尝试解析JWT,将解析的结果创建为认证对象,并存入到SecurityContext
Server -->> Client: 响应结果
大致需要:
- 验证用户登录时,如果视为登录成功,服务器端应该生成此用户对应的JWT数据,并响应到客户端
- 不再需要将验证登录成功后的结果存入到
SecurityContext中
- 不再需要将验证登录成功后的结果存入到
- 当用户尝试执行某些需要认证的操作时,用户应该携带JWT,服务器端应该尝试解析JWT,并且验证JWT的真伪、识别用户的身份,将用户的相关信息存入到
SecurityContext中
验证登录成功后响应JWT
首先,在AdminServiceImpl中验证登录时,如果通过验证,不再向SecurityContext中存入认证信息:
然后,在IAdminService接口中,将登录的方法的返回值类型改为String,表示此方法在验证登录成功后,将返回JWT(String类型)数据:
/**
* 验证管理员登录
* @param adminLoginDTO 管理员的登录信息,至少封装用户名与密码原文
* @return 验证登录通过后的JWT
*/
String login(AdminLoginDTO adminLoginDTO);
并且,也修改AdminServiceImpl中的重写的方法,在验证登录成功后,生成并返回JWT数据:
@Override
public String login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
// 创建认证信息对象
Authentication authentication = new UsernamePasswordAuthenticationToken(
adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
// 调用认证管理器执行认证
Authentication authenticationResult
= authenticationManager.authenticate(authentication);
log.debug("验证登录成功,返回的Authentication为:{}", authenticationResult);
// 如果没有出现异常,则表示验证登录成功,需要将认证信息存入到Security上下文
// log.debug("即将向SecurityContext中存入Authentication");
// SecurityContext securityContext = SecurityContextHolder.getContext();
// securityContext.setAuthentication(authenticationResult);
// ========== 以下是新增的代码片段 ==========
// 处理验证登录成功后的结果中的当事人
Object principal = authenticationResult.getPrincipal();
log.debug("获取验证登录成功后的结果中的当事人:{}", principal);
AdminDetails adminDetails = (AdminDetails) principal;
// 需要写入到JWT中的数据
Map<String, Object> claims = new HashMap<>();
claims.put("id", adminDetails.getId());
claims.put("username", adminDetails.getUsername());
log.debug("即将生成JWT数据,包含的账号信息:{}", claims);
// 生成JWT,并返回JWT
String secretKey = "kU4jrFA3iuI5jn25u743kfDs7a8pFEwS54hm";
Date exp = new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000);
String jwt = Jwts.builder()
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
.setClaims(claims)
.setExpiration(exp)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.debug("生成了JWT数据,并将返回此JWT数据:{}", jwt);
return jwt;
}
然后,还要调整AdminController中处理登录请求的方法,将Service中返回的JWT数据响应到客户端去:
@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
String jwt = adminService.login(adminLoginDTO);
return JsonResult.ok(jwt);
}
通过API文档的调试功能测试登录,当登录成功后,响应的结果例如:
{
"state": 20000,
"data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiZXhwIjoxNjgxNjI2ODQwLCJ1c2VybmFtZSI6InJvb3QifQ.9atdNiIRsGb6Ll4g58rLOBi5BoGQb1MoHFNsraCjwTo"
}
以上JWT数据也可以放在测试方法中尝试解析。
解析客户端携带的JWT
客户端提交若干种不同的请求时,可能都需要携带JWT,在服务器端,处理若干种不同的请求之前也需要尝试接收并解析JWT,则应该使用**过滤器(Filter)**组件进行处理!
提示:过滤器是Java服务器端的组件中,最早接收到请求的组件,它执行在其它任何组件之前!在同一个项目中,允许存在若干个过滤器,形成过滤器链(Filter Chian),任何一个请求,必须被所有过滤器“放行”才可以被后续的组件(例如Controller等)进行处理!
在项目的根包下创建filter.JwtAuthorizationFilter类,继承自OncePerRequestFilter抽象类(将间接的实现Filter接口),并在类上添加组件注解:
package cn.tedu.csmall.passport.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
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;
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
}
}
在处理过程中,首先,需要尝试接收客户端携带的JWT:
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 根据业内惯用的做法,客户端应该将JWT保存在请求头(Request Header)中的名为Authorization的属性名
String jwt = request.getHeader("Authorization");
log.debug("尝试接收客户端携带的JWT数据,JWT:{}", jwt);
}
要使得以上过滤器生效,还需要在Spring Security的配置类中,将其添加在Spring Security的过滤器链中!则先在配置类中自动装配以上过滤器:
@Slf4j
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// 新增代码
@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;
// 暂不关心其它代码
}
然后,在configurer(HttpSecurity http)方法中,添加此过滤器:
// 将自定义的JWT过滤器添加在Spring Security的UsernamePasswordAuthenticationFilter之前
http.addFilterBefore(jwtAuthorizationFilter,
UsernamePasswordAuthenticationFilter.class);
在API文档中,通过“全局参数设置”中的“添加参数”,可以配置每个请求都将携带JWT数据:
此时,进行调试时,所有请求的反馈结果都是一片空白,并且,在服务器端的控制台中可以看到输出了客户端提交请求时携带的JWT数据!
然后,尝试解析JWT,并将解析得到的数据创建为Authentication存入到SecurityContext中:
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.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.Collection;
/**
* <p>处理JWT的过滤器类</p>
*
* <p>此过滤器类的主要职责:</p>
* <ul>
* <li>尝试接收客户端携带的JWT</li>
* <li>尝试解析接收到的JWT</li>
* <li>将解析成功后得到的结果创建为Authentication并存入到SecurityContext中</li>
* </ul>
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
public static final int JWT_MIN_LENGTH = 113;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 根据业内惯用的做法,客户端应该将JWT保存在请求头(Request Header)中的名为Authorization的属性名
String jwt = request.getHeader("Authorization");
log.debug("尝试接收客户端携带的JWT数据,JWT:{}", jwt);
// 判断客户端是否携带了基本有效的JWT
if (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {
// 客户端没有携带有铲的JWT,则“放行”,交由后续的组件继续处理
filterChain.doFilter(request, response);
// 【重要】终止当前方法的执行,不执行接下来的代码
return;
}
// TODO:1-声明secretKey不合理,应该集中管理
// TODO:2-解析JWT时可能出现异常,需要处理
// 客户端携带了基本有效的JWT,则尝试解析JWT
String secretKey = "kU4jrFA3iuI5jn25u743kfDs7a8pFEwS54hm";
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:{}", id);
log.debug("从JWT中解析得到的管理员用户名:{}", username);
// TODO:3-使用用户名的字符串作为“当事人”并不是最优解
// TODO:4-需要调整使用真实的权限
// 基于解析JWT的结果创建Authentication对象
Object principal = username; // 当事人:暂时使用用户名
Object credentials = null; // 凭证:应该为null
Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("暂时放一个山寨的权限"));
Authentication authentication = new UsernamePasswordAuthenticationToken(
principal, credentials, authorities);
// 将Authentication存入到SecurityContext中
log.debug("向SecurityContext中存入Authentication:{}", authentication);
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
// 过滤器链继续执行,相当于“放行”
filterChain.doFilter(request, response);
}
}
目前的测试结果表现为:
- 携带有效的JWT,可以访问任何请求(需要删除各处理请求的方法上的获取当事人、检查权限的代码)
- 成功的处理了某个请求后,在接下来的一段时间里,不携带JWT也可以请求成功
- 如果重启服务器后,第1次发起的请求就没有携带JWT,会响应403
关于SecurityContext中的认证信息
因为Spring Security是根据SecurityContext中的Authentication来识别用户的身份的,而SecurityContext本身是基于Session机制的,所以,当携带有效的JWT成功访问后,以上过滤器就已经将Authentication存入到了SecurityContext中,也就存在于Session中了,在接下来的一段时间内(在Session的有效期内),即使不携带JWT也可以成功访问!
以上表现并不能算是一种“错误”,不一定是必须解决的问题!
如果希望实现“携带JWT就可以访问,不携带JWT就不可以访问”那些需要登录才允许访问的资源,可以:
-
在JWT过滤器刚刚开始执行时,就直接清空
SecurityContext,即:// 清空SecurityContext,避免【此前携带JWT成功访问后,在接下来的一段时间内不携带JWT也能访问】 SecurityContextHolder.clearContext(); -
【推荐】不使用Session,在Spring Security的配置类中的
configurer(HttpSecurity http)方法中,将Session策略设置为“从不使用”即可:// 将Session策略设置为“从不使用”:STATELESS=无状态,即从不使用Session,NEVER=从不主动创建Session http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
关于当事人
通常,当事人信息中应该包含用户的ID和用户名,而Authentication中的Principal的类型是Object,所以,你可以使用任何类型的数据作为当事人,并且,在需要获取当事人信息时,添加@AuthenticationPrincipal注解的参数也是你自行决定的当事人类型。
在项目的根包下创建security.LoginPrincipal类型,用于封装当事人信息,例如:
@Data
public class LoginPrincipal implements Serializable {
/**
* 当事人ID
*/
private Long id;
/**
* 当事人用户名
*/
private String username;
}
在JwtAuthorizationFilter中,基于解析JWT的结果创建当事人对象:
并且,将此对象用为Authentication的当事人:
后续,当需要获取当事人信息时,直接注入即可,例如在AdminController中: