我们首先回顾下过去登录的方式,用户页面中输入账号和密码,点击提交发送到 controller 类中,controller 类接收账号和密码,并且调用业务和数据层去判断账号和密码是否正确,如果错误,返回到登录页面,如果正确,将用户信息保存到 session 中并跳转到主页面。
这里会发现,我们是通过 session 来记录用户登录状态,跟踪用户信息,那么就要在这里提一提 session 登录的缺陷:
- session 是将客户端数据储存在服务器的内存,当客户端的数据过多,连接较多,服务器的内存开销大。
- session 的数据储存在某台服务器,在分布式的项目中无法做到共享。
- 前后端分离的项目中共享 session 比较困难。
- jwt 不需要在服务端去保留用户的认证信息或者会话信息。
什么是 JWT
jwt 全称是 json web token。是由用户以用户名、密码登录,服务端验证后,会生成一个 token,返回给客户端,客户端在下次访问的过程中携带这个 token,服务端责每次验证这个token。
JWT 的构成
jwt 由三部分组成,每一部分之间用符号"."进行分割,整体可以看做是一个长字符串。一个经典的jwt的样子:xxx.xxx.xxx。
Header 头部
头部由两部分组成:第一部分是声明类型,在 jwt 中声明类型就 jwt,第二部分是声明加密的算法,加密算法通常使用 HMAC|SHA256。一个经典的头部:
{
'typ': 'JWT', // 'typ':'声明类型'
'alg': 'HS256' // 'alg':'声明的加密算法'
}
Payload 载体、载荷
这一部分是jwt的主体部分,这一部分也是json对象,可以包含需要传递的数据,其中jwt指定了七个默认的字段选择,这七个字段是推荐但是不强制使用的:
- iss:发行人
- exp:到期时间
- sub:主题
- aud:用户
- nbf:在此之前不可用
- iat:发布时间
- jti:JWT ID 用于识别该 JWT
除了上述的七个默认字段之外,还可以自定义字段,通常我们说 JWT 用于用户登陆,就可以在这个地方放置用户的id和用户名。下面这个json对象是一个 jwt 的 Payload 部分:
{
"sub": "一个演示",
"nickname": "dailyblue",
"id": "001"
}
这里注意虽然可以放自定的信息,但是不要存放一些敏感信息,除非是加密过的,因为这里的信息可能会被截获。
signature 签证
这部分是对前两部分进行base64编码在进行加密,这个加密的方式使用的是jwt的头部声明中的加密方式,在加上一个密码(secret)组成的,secret 通常是一个随机的字符串,这个 secret是服务器特有的,不能够让其他人知道。这部分的组成公式是:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret)
JWT 的优点
- json形式,而json非常通用性可以让它在很多地方使用
- jwt所占字节很小,便于传输信息
- 需要服务器保存信息,易于扩展
使用 JWT 登录流程
- 第一次登录的时候,前端调后端的登陆接口,发送帐号和密码。
- 后端收到请求,验证帐号和密码,验证成功,就给前端返回一个 jwt。
- 前端拿到 jwt,将 jwt 存储到 localStroage 或 header 中,并跳转到页面。
- 前端每次跳转页面,就判断 localStroage 中有无 jwt ,没有就跳转到登录页面,有则跳转到对应页面。
- 每次调后端接口,都要在请求头中加 jwt。
- 后端判断请求头中有无 jwt,有 jwt,就拿到 jwt 并验证 jwt,验证成功就返回数据,验证失败(例如:jwt 过期)就返回401,请求头中没有 jwt 也返回401。
- 如果前端拿到状态码为401,就清除 jwt 信息并跳转到登录页面。
所需依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- jaxb依赖包 -->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
JWT 工具类
package com.dailyblue.java.spring.config;
import com.dailyblue.java.spring.bean.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
/**
* @author dailyblue
* @since 2022/6/23
*/
public class JwtConfig {
//常量
public static final long EXPIRE = 1000 * 60 * 60 * 24; //token过期时间
public static final String APP_SECRET = "1234"; //秘钥,加盐
// @param id 当前用户ID
// @param issuer 该JWT的签发者,是否使用是可选的
// @param subject 该JWT所面向的用户,是否使用是可选的
// @param ttlMillis 什么时候过期,这里是一个Unix时间戳,是否使用是可选的
// @param audience 接收该JWT的一方,是否使用是可选的
//生成token字符串的方法
public static String getJwtToken(User user) {
String JwtToken = Jwts.builder()
.setHeaderParam("typ", "JWT") //头部信息
.setHeaderParam("alg", "HS256") //头部信息
//下面这部分是payload部分
// 设置默认标签
.setSubject("dailyblue") //设置jwt所面向的用户
.setIssuedAt(new Date()) //设置签证生效的时间
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) //设置签证失效的时间
//自定义的信息,这里存储id和姓名信息
.claim("id", user.getId()) //设置token主体部分 ,存储用户信息
.claim("name", user.getUserName())
//下面是第三部分
.signWith(SignatureAlgorithm.HS256, APP_SECRET)
.compact();
// 生成的字符串就是jwt信息,这个通常要返回出去
return JwtToken;
}
/**
* 判断token是否存在与有效
* 直接判断字符串形式的jwt字符串
*
* @param jwtToken
* @return
*/
public static boolean checkToken(String jwtToken) {
if (StringUtils.isEmpty(jwtToken)) return false;
try {
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 判断token是否存在与有效
* 因为通常jwt都是在请求头中携带,此方法传入的参数是请求
*
* @param request
* @return
*/
public static boolean checkToken(HttpServletRequest request) {
try {
String jwtToken = request.getHeader("token");//注意名字必须为token才能获取到jwt
if (StringUtils.isEmpty(jwtToken)) return false;
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 根据token字符串获取会员id
* 这个方法也直接从http的请求中获取id的
*
* @param request
* @return
*/
public static String getMemberIdByJwtToken(HttpServletRequest request) {
String jwtToken = request.getHeader("token");
if (StringUtils.isEmpty(jwtToken)) return "";
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
Claims claims = claimsJws.getBody();
return claims.get("id").toString();
}
/**
* 解析JWT
* @param jwt
* @return
*/
public static Claims parseJWT(String jwt) {
Claims claims = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwt).getBody();
return claims;
}
}
相关工具类在网上有很多,有兴趣的童鞋也可以相互借鉴。
返回结果工具类
状态码返回
package com.dailyblue.java.spring.util;
/**
* @Author: Dailyblue
* @Description: 返回码定义
* 规定:
* #1表示成功
* #1001~1999 区间表示参数错误
* #2001~2999 区间表示用户错误
* #3001~3999 区间表示接口异常
* @Date Create in 2022/06/21 19:28
*/
public enum ResultCode {
/* 成功 */
SUCCESS(200, "成功"),
/* 默认失败 */
COMMON_FAIL(999, "失败"),
/* 参数错误:1000~1999 */
PARAM_NOT_VALID(1001, "参数无效"),
PARAM_IS_BLANK(1002, "参数为空"),
PARAM_TYPE_ERROR(1003, "参数类型错误"),
PARAM_NOT_COMPLETE(1004, "参数缺失"),
/* 用户错误 */
USER_NOT_LOGIN(2001, "用户未登录"),
USER_ACCOUNT_EXPIRED(2002, "账号已过期"),
USER_CREDENTIALS_ERROR(2003, "密码错误"),
USER_CREDENTIALS_EXPIRED(2004, "密码过期"),
USER_ACCOUNT_DISABLE(2005, "账号不可用"),
USER_ACCOUNT_LOCKED(2006, "账号被锁定"),
USER_ACCOUNT_NOT_EXIST(2007, "账号不存在"),
USER_ACCOUNT_ALREADY_EXIST(2008, "账号已存在"),
USER_ACCOUNT_USE_BY_OTHERS(2009, "账号下线"),
/* 业务错误 */
NO_PERMISSION(3001, "没有权限");
private Integer code;
private String message;
ResultCode(Integer code, String message) {
this.code = code;
this.message = message;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
/**
* 根据code获取message
*
* @param code
* @return
*/
public static String getMessageByCode(Integer code) {
for (ResultCode ele : values()) {
if (ele.getCode().equals(code)) {
return ele.getMessage();
}
}
return null;
}
}
统一返回实体
package com.dailyblue.java.spring.util;
import lombok.Data;
import java.io.Serializable;
/**
* @Author: Dailyblue
* @Description: 统一返回实体
* @Date Create in 2022/06/21 19:28
*/
@Data
public class JsonResult<T> implements Serializable {
private Boolean success;
private Integer errorCode;
private String errorMsg;
private T data;
public JsonResult() {
}
public JsonResult(boolean success) {
this.success = success;
this.errorCode = success ? ResultCode.SUCCESS.getCode() : ResultCode.COMMON_FAIL.getCode();
this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : ResultCode.COMMON_FAIL.getMessage();
}
public JsonResult(boolean success, ResultCode resultEnum) {
this.success = success;
this.errorCode = success ? ResultCode.SUCCESS.getCode() : (resultEnum == null ? ResultCode.COMMON_FAIL.getCode() : resultEnum.getCode());
this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : (resultEnum == null ? ResultCode.COMMON_FAIL.getMessage() : resultEnum.getMessage());
}
public JsonResult(boolean success, T data) {
this.success = success;
this.errorCode = success ? ResultCode.SUCCESS.getCode() : ResultCode.COMMON_FAIL.getCode();
this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : ResultCode.COMMON_FAIL.getMessage();
this.data = data;
}
public JsonResult(boolean success, ResultCode resultEnum, T data) {
this.success = success;
this.errorCode = success ? ResultCode.SUCCESS.getCode() : (resultEnum == null ? ResultCode.COMMON_FAIL.getCode() : resultEnum.getCode());
this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : (resultEnum == null ? ResultCode.COMMON_FAIL.getMessage() : resultEnum.getMessage());
this.data = data;
}
}
返回体构造器
package com.dailyblue.java.spring.util;
/**
* @Author: Dailyblue
* @Description: 返回体构造工具
* @Date Create in 2022/06/21 19:28
*/
public class ResultTool {
public static JsonResult success() {
return new JsonResult(true);
}
public static <T> JsonResult<T> success(T data) {
return new JsonResult(true, data);
}
public static JsonResult fail() {
return new JsonResult(false);
}
public static JsonResult fail(ResultCode resultEnum) {
return new JsonResult(false, resultEnum);
}
}
前端发送时携带 jwt
axios.get('url',{
headers:
{
'key': value
}
})
拦截器的跨域问题
起初解决 Springboot 跨域问题的方法是直接在 Controller 上添加 @CrossOrigin 注解,实现了前后端分离中的跨域请求。但随着业务代码的编写,做了 token 会话保持的检验,添加了拦截器后,再次出现了跨域问题。
很纳闷,按理说后台已经允许了跨域请求,之前的测试也证明了这一点,那为什么又突然出现了跨域拦截问题呢?
首先,在登录拦截器中作了校验,对于需要登录后才能访问的接口,如果请求头中没有携带 token,则是非法请求,直接返回404码。然后由于一直有对拦截到的请求中的请求头中的 token 做打印,所以出现问题的时候,控制台打印的 token 值为null,打开浏览器的开发者工具查看请求头也发现没有携带成功。而在没有添加拦截器之前上述问题都是不存在的,都是正常的,所以都不用考虑是前端问题,问题肯定出在后端,准确的说是拦截器。
那么为什么浏览器不能成功发送 token 呢?根据线索在更详细的查看了 CROS 的介绍后发现,原来 CROS 复杂请求时会首先发送一个 OPTIONS 请求做嗅探,来测试服务器是否支持本次请求,请求成功后才会发送真实的请求;而 OPTIONS 请求不会携带任何数据,导致这个请求不符合我们拦截器的校验规则被拦截了,直接返回了状态码,响应头中也没携带解决跨域需要的头部信息,进而出现了跨域问题。所以在浏览器调试工具中会发现该次请求没有携带 token,后端控制台打印 token 也为 null。
其次,就算这样,为什么会发生在添加跨域相关头部信息前就提前结束请求的这种情况呢?难道自定义的拦截器优先于 @CrossOrigin 注解执行?
通过查阅资料,解析 @CrossOrigin 注解的源码得知,如果 Controller 在类上标了 @CrossOrigin 或在方法上标了 @CrossOrigin 注解,则 Spring 在记录 mapper 映射时会记录对应跨域请求映射,将结果返回到 AbstractHandlerMethodMapping,当一个跨域请求过来时,Spring 在获取 handler 时会判断这个请求是否是一个跨域请求,如果是,则会返回一个可以处理跨域的 handler。总结起来 @CrossOrigin 的原理相当于和 Handler 进行强绑定。
于是现在的问题又到了:Handler 和拦截器的执行顺序?
DispatchServlet.doDispatch() 方法是 SpringMVC 的核心入口方法,经过分析发现所有的拦截器的 preHandle() 方法的执行都在实际 handler 的方法之前,其中任意拦截器返回 false 都会跳过后续所有处理过程。而 SpringMVC 对预检请求的处理则在 PreFlightHandler.handleRequest() 中处理,在整个处理链条中出于后置位。由于预检请求中不带数据,因此先被权限拦截器拦截了。
所以每次获取不到 token 的请求都是 OPTIONS 请求,那么解决的方法就很明了了:把所有的OPTIONS请求统统放行。
//拦截器取到请求先进行判断,如果是OPTIONS请求,则放行
if("OPTIONS".equals(httpServletRequest.getMethod().toUpperCase())) {
System.out.println("Method:OPTIONS");
return true;
}
还有另一种方式,就是通过过滤器来处理跨域请求,众所周知,过滤器在拦截器之前执行,能够有效避免拦截处理的情况发生。
@Slf4j
@WebFilter("/*")
// 跨域过滤器
public class CrosFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("过滤处理过滤器启动了...");
}
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
log.info("跨域处理过滤器");
HttpServletResponse response = (HttpServletResponse) res;
HttpServletRequest request = (HttpServletRequest) req;
if (StringUtils.isEmpty(request.getHeader("Origin"))) {
response.setHeader("Access-Control-Allow-Origin", "*");
} else {
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
}
response.setHeader("Access-Control-Allow-Headers", "*");
response.setHeader("Access-Control-Allow-Methods", "*");
response.setHeader("Access-Control-Max-Age", "3600");
if ("OPTIONS".equals(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return;
}
chain.doFilter(request, response);
}
@Override
public void destroy() {
}
}
详细的代码会在课程中涉及到,这里只罗列了流程和概念性问题。