什么是JWT?
JWT (JSON Web Token) 是目前最流行的跨域认证解决方案,是一种基于 Token 的认证授权机制。 从 JWT 的全称可以看出,JWT 本身也是 Token,一种规范化之后的 JSON 结构的 Token。
JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。
并且, 使用 JWT 认证可以有效避免 CSRF 攻击,因为 JWT 一般是存在在 localStorage 中,使用 JWT 进行身份验证的过程中是不会涉及到 Cookie 的。
JWT组成
JWT由三部分组成:Header(标头),Payload(有效载荷),Signature(签名),中间用点分开,如:Header.Payload.Signature
Header:
Header(标头)通常由两部分组成:令牌的类型(JWT)和所使用的签名算法(如HMAC SHA256或RSA),被Base64Url编码,形成JWT的第一部分。
Payload:
Payload(有效载荷)包含声明。声明是关于实体和附加数据的声明,实体一般指的就是用户,声明一般存放一些不敏感的信息,比如用户名、权限、角色等等;声明分为三种:Registered claim、Public claims、Private claims
Registered claim(已注册声明):就是官方预先定义好的几个推荐使用的声明
-
iss (issuer):jwt发行人,指的是谁发行的这个jwt签名
-
exp (expiration time):jwt的有效期时间,设置这个签名多久后失效,但是这个时间设置的必须要比iat设置的签发时间大
-
sub (subject):主题
-
aud (audience):受众,指谁来接受jwt
-
nbf (Not Before):生效时间,指定在什么时间jwt,才开始有效
-
iat (Issued At):签发时间
-
jti (JWT ID):编号,jwt唯一标识
Public claims(公共声明):使用 JWT 的人可以随意定义这些声明,可以声明一些有效的用户信息,比如:用户ID、姓名等,但是不要在次声明敏感的信息,比如密码,还有一点就是为了因为可以随意定义,为了避免冲突,应该在jwt注册表中进行注册
Private claims(私有声明):表示既不是已注册声明,也不是公开声明,而是发行人和受众人一起定义的熟悉的声明
Signature:
Signature(签名)要创建签名部分,您必须获取编码的标头、编码的有效负载、密钥、标头中指定的算法,并对其进行签名。
例如,如果您想使用 HMAC SHA256算法,签名将按以下方式创建:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
上面的JSON将会通过HMACSHA256算法结合secret进行加盐签名(私钥加密),其中header和payload将通过base64UrlEncode()方法进行base64加密然后通过字符串拼接 "."
生成新字符串,最终生成JWT的第三部分。
注意:因为secret保存在我们的服务器端,jwt也是在服务器端签发的,而jwt的签发和验证都要使用到secret,所以,可以把secret理解为我们的服务器私钥,我们不能任何场景展示我们的私钥。
Spring Boot使用JWT
1.引入依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.17</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.waitforme</groupId><!--根据你自己的来-->
<artifactId>jwtdemo</artifactId><!--根据你自己的来-->
<version>1.0</version><!--根据你自己的来-->
<name>jwtdemo</name><!--根据你自己的来-->
<properties>
<java.version>17</java.version><!--根据你自己的来-->
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.1.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.49</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
</dependencies>
2.创建JWT工具类
public class JWTUtil {
private static SecretKey secretKey = Keys.hmacShaKeyFor("qyPWD9YVe8Q7gduIHJNtsz3Kb8Jui4nf".getBytes());
//sgin使用的加密密钥
/**
* 创建token
*
* @param id
* @param account
* @return
*/
public static String createToken(Integer id, String account) {
String jwt = Jwts.builder()
.setExpiration(new Date(System.currentTimeMillis() + 3600000)) //设置过期时间,3600000毫秒为1小时
.setSubject("LOGIN_USER") //设置主题,可以不加
.claim("id", id)
.claim("account", account)
//claim添加自己自定义的字段
.signWith(secretKey, SignatureAlgorithm.HS256) //设置签名;参数1:密钥;参数2:签名算法
.compact();
return jwt;
}
/**
* 解析token
*
* @param token
*/
public static Claims parseToken(String token) {
if (token == null) {
//token为空判断
throw new CustomException("未登录");
}
try {
JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(secretKey).build();//构建jwt解析器,并设置签名密钥
return jwtParser.parseClaimsJws(token).getBody();//解析我们传入的token,并将得到的claims对象返回
//如果有异常就是验证失败
} catch (ExpiredJwtException e) {
//过期处理
throw new CustomException("token已过期");
} catch (JwtException e) {
//其他异常统一处理
throw new CustomException("token非法");
}
}
}
3.编写登录service,及其实现
service
public interface IUserService extends IService<User> {
User getUserInfo(Integer id);//根据用户id获取登录用户信息
String login(String account, String password);//登录方法
}
serviceImpl
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public User getUserInfo(Integer id) {
return this.baseMapper.selectUserInfoById(id);
}
@Override
public String login(String account, String password) {
User user = this.baseMapper.selectUserByAccount(account);
if (user == null) {
//先判断这个账号是否存在
throw new CustomException("账号不存在");
}
if (!user.getPassword().equals(DigestUtils.md5Hex(password))) {
//如果存在在判断密码是否正确,这里使用了DigestUtils的MD5算法来验证的密码,并且,数据库中保存的密码也要时MD5加密的
throw new CustomException("用户名或密码错误");
}
//调用jwtUtil的createToken方法并传入id、account作为用户的唯一标识,生成jwt返回
return JWTUtil.createToken(user.getId(), user.getAccount());
}
}
4.编写Mapper及其xml实现
Mapper
public interface UserMapper extends BaseMapper<User> {
User selectUserByAccount(@Param("account") String account);
User selectUserInfoById(Integer id);
}
Mapper.xml
<select id="selectUserByAccount" resultType="com.waitforme.jwtdemo.pojo.User">
select id,
account,
password,
name,
sex,
age
from user
where account = #{account}
</select>
<select id="selectUserInfoById" resultType="com.waitforme.jwtdemo.pojo.User">
select id,
account,
name,
sex,
age
from user
where id = #{id}
</select>
5.创建LoginUserHolder工具类
主要的作用就是从在拦截器拦截的时候,获取jwt中playload保存的用户唯一标识,保存在ThreadLocal中,供其他方法获取登录的用户信息
首先创建一个LoginUser对象,用来保存登录的用户唯一标识
@Data
public class LoginUser {
private Integer id;
private String account;
public LoginUser(Integer id, String account) {
this.id = id;
this.account = account;
}
}
6.在创建LoginUserHolder工具类
public class LoginUserHolder {
public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();
public static void setThreadLocal(LoginUser loginUser) {
threadLocal.set(loginUser);//设置线程变量的值
}
public static LoginUser getLoginUser() {
return threadLocal.get();//得到线程变量的值
}
public static void clear() {
threadLocal.remove();//情况线程变量
}
}
7.编写自定义的interceptor拦截器
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("token");
//从http请求头中获取key为access-token的值
Claims claims = JWTUtil.parseToken(token);
//对token进行解析,并得到claims对象
Integer id = claims.get("id", Integer.class);
String account = claims.get("account", String.class);
//得解析创建jwt传入的登录用户的唯一标识
LoginUserHolder.setThreadLocal(new LoginUser(id, account));
//把得到的id和userName存到ThreadLocal,供其他方法获取登录的用户信息
return true;
//直接返回true是因为,jwt里面对token已经进行了响应异常处理,也就是没有出现异常就是成功了,我们直接返回true
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
LoginUserHolder.clear();
//在请求处理完后,清理掉登录的用户信息,做这一步的原因是因为mvc用了线程池的技术,线程使用完并不会被销毁,而是会回到线程池等待分配下一次任务
}
}
8.注册自定义的拦截器
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Autowired
AuthenticationInterceptor authenticationInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册自定义的拦截器
registry.addInterceptor(this.authenticationInterceptor)
//拦截请求的范围
.addPathPatterns("/**")
//放行请求的范围
.excludePathPatterns("/login/**", "/logout/**");
}
}
9.在controller层编写获取登录用户信息的方法
@RestController
public class LoginController {
private static final Logger log = LogManager.getLogger(LoginController.class);
@Autowired
IUserService userService;
@RequestMapping("/login")
public Result login(String account, String password) {
String jwt = userService.login(account, password);
log.info("登录时的线程变量:" + LoginUserHolder.getLoginUser());
return Result.success("登录成功", jwt);
}
@RequestMapping("/getUserInfo")
public Result info() {
LoginUser loginUser = LoginUserHolder.getLoginUser();
User userInfo = userService.getUserInfo(loginUser.getId());
log.info("获取登录用户信息时的线程变量:" + LoginUserHolder.getLoginUser());
return Result.success("获取成功", userInfo);
}
@RequestMapping("/logout")
public Result logout() {
LoginUserHolder.clear();
log.info("退出登录时的线程变量:" + LoginUserHolder.getLoginUser());
return Result.success("成功");
}
}
测试(使用ApiFox)
1.打开apiFox创建一个项目
2.在这个项目里面新建三个接口:登录,获取登录用户信息,注销
创建登录接口:
登录配置:
配置环境:注:只需配置一次,创建获取登录用户信息的时候,不用配置了,直接选择测试环境即可
发送测试,并复制得到的jwt:
创建获取登录用户信息接口:
发送测试,并在Headers中添加从登录接口获取到的jwt:
创建注销接口:
三个接口调用完之后查看idea控制台的线程变量日志输出
错误案例
此错误表示,我们的密钥太弱了,用于设置签名的密钥必须要是大于等于signWith中设置的算法大小
.signWith(secretKey, SignatureAlgorithm.HS256) //256就是算法的字节大小
如果我们设置的密钥是用英文字母和数字组成的,需要设置32位的密钥,因为一个英文字母是一个字节,一个字节是八位
解决方法:
修改密钥大小位32位或更大
private static SecretKey secretKey = Keys.hmacShaKeyFor("qyPWD9YVe8Q7gduIHJNtsz3Kb8Jui4nf".getBytes());