现需要完成以下需求:
用户执行登录接口之后,需要生成一个 token 返回给前端。之后,前端在请求头中携带着 token 去请求其它的后台接口。
完成这个需求之前,咱们先来了解下什么是 JWT 吧。
1. 了解 JWT
1.1 JWT 的介绍
JWT 简介:
JWT 全称 Json Web Token。它是 RFC 7519 中定义的,用于安全地将信息作为 Json 对象进行传输的一种形式。JWT 中存储的信息是经过“数字签名”的,因此可以被信任和理解。可以使用 HMAC 算法或使用 RSA/ECDSA 的公用/专用 密钥对 JWT 进行签名。
JWT 作用:
JWT 有以下两个作用:
- 认证:一旦用户登录,后面每个请求都会包含 JWT,从而允许用户访问该令牌所允许的路由、服务和资源。单点登录是当今广泛使用 JWT 的一项功能,因为它的开销很小。
- 信息交换:JWT 是能够安全传输信息的一种方式。通过使用公钥/私钥对 JWT 进行签名认证。此外,由于签名是使用 head 和 payload 计算的,因此你还可以验证内容是否遭到篡改。
1.2 JWT 的结构
JWT 主要由三部分组成,每个部分用 . 进行分割,各个部分分别是:
- 标头 Header
- 有效载荷 Payload
- 签名 Signature
因此,JWT 通常是:xxx.yyyy.zzzzz
例如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
1.2.1 标头 Header
Header是 JWT 的标头,包括两部分信息:
- 令牌的类型:JWT
- 加密算法:HMAC SHA256 或 RSA
如 JWT 的默认标头为:
{
"alg": "HS256", // 算法
"typ": "JWT" // 类型
}
然后将 Header 进行 base64 编码,就构成了 JWT 第一部分:
eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9
Base64
在 Java 中,就可以实现 Base64 编码。如下:
这是对 JWT 的默认标头进行 Base64 编码、解码:
public class Test {
public static void main(String[] args) throws Exception{
Base64.Encoder encoder = Base64.getEncoder();
Base64.Decoder decoder = Base64.getDecoder();
String header = "{\"alg\": \"HS256\", \"typ\": \"JWT\"}";
byte[] headerBytes = header.getBytes();
// 编码
String encodeHeader = encoder.encodeToString(headerBytes);
System.out.println(encodeHeader);
// 解码
byte[] decode = decoder.decode(encodeHeader);
System.out.println(new String(decode, "UTF-8"));
}
}
1.2.2 有效载荷 Payload
Payload 中包含一个声明。声明是有关实体(通常是用户)和其他数据的声明。同样地,它会使用 base64 编码构成了 JWT 第二部分 。共有三种类型的声明:registered、public 和 private 声明
1、registered 声明
registered 声明:包含一组建议使用的预定义声明,主要包括:
- iss: jwt 签发者
- sub: jwt 所面向的用户
- aud: 接收 jwt 的一方
- exp: jwt 的过期时间,这个过期时间必须要大于签发时间
- nbf: 定义在什么时间之前,该 jwt 都是不可用的
- iat: jwt 的签发时间
- jti: jwt 的唯一身份标识,主要用来作为一次性 token,从而回避重放攻击。
2、public 声明
public 声明:公共的声明,可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密。
3、private 声明
private 声明:自定义声明,旨在在同意使用它们的各方之间共享信息,既不是注册声明也不是公共声明。
如:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
- name:自定义字段
- sub/iat:标准声明
然后将 Payload进行 base64编码 构成了第二部分:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
1.2.3 签名 Signature
Signature表示签证信息,它包含三个部分:
- header (base64后的)
- payload (base64后的)
- secret
比如我们需要 HMAC SHA256 算法进行签名:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
签名用于验证消息在此过程中没有更改,并且对于使用私钥进行签名的令牌,它还可以验证 JWT 的发送者的真实身份
2. 使用 JWT
引入依赖:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
这里是使用的 SpringBoot 的测试类。这里可以跟我不一样哈,把这两个方法换成 main 就行。
public class AppTest {
// 密钥(复杂)
private static final String SECRET = "1qazXSW2";
@Test
public void createToken() {
Map<String, Object> map = new HashMap<>();
Calendar expireTime = Calendar.getInstance();
expireTime.add(Calendar.SECOND, 2000);
String token = JWT.create()
// Header(使用默认数据,故map没有值。也可省略此行代码)
.withHeader(map)
// Payload
.withClaim("userId", 666)
.withClaim("username", "zzc")
// 过期时间
.withExpiresAt(expireTime.getTime())
// Signature
.sign(Algorithm.HMAC256(SECRET));
System.out.println(token);
}
@Test
public void verifyToken() {
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
String token = "";
DecodedJWT decodedJWT = jwtVerifier.verify(token);
// 获取 token 中的信息
System.out.println(decodedJWT.getClaim("userId").asInt());
System.out.println(decodedJWT.getClaim("username").asString());
}
}
说明:
createToken()
:生成 token 的方法。由于 token 的第二部分Payload
是可以存放信息的。所以,这里,我把userId
、username
放入 token 中。后期,可以根据 token 进行获取相应的值。verifyToken()
:验证 token 的方法。DecodedJWT decodedJWT = jwtVerifier.verify(token);
如果 token 验证失败,则这行代码会抛出异常;否则,就会执行成功,继续往下执行。然后,就可以获取 token 中的信息decodedJWT.getClaim("username").asString()
。
3. SpringBoot 集成 JWT
【开发环境】
- IDEA-2020.2
- SpringBoot-2.5.5
- MAVEN-3.5.3
- Mybatis
- Mysql
【项目结构图】
1、引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--jwt-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
<!--mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.0.0</version>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.38</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
2、添加 application.yml
配置
spring:
datasource:
url: jdbc:mysql://localhost:3306/zzc?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8
password: root
username: root
driver-class-name: com.mysql.jdbc.Driver
mybatis:
type-aliases-package: com.zzc.entity # 实体类别名
mapper-locations: classpath:mapper/*.xml # mapper 配置文件(必要)
configuration:
map-underscore-to-camel-case: true # 驼峰命名
3、添加 JWT 工具类
将生成 token 方法、验证 token 方法抽象成一个工具类:
public class JwtUtil {
private static final String SECRET = "1qazXSW2";
// 生成 Token
public static String createToken(Map<String, String> paramMap) {
Map<String, Object> headMap = new HashMap<>();
Calendar expireTime = Calendar.getInstance();
// 过期时间默认是 7 天
expireTime.add(Calendar.DATE, 7);
JWTCreator.Builder builder = JWT.create();
// Header(使用默认数据,故map没有值,可以省略)
builder.withHeader(headMap);
// Payload
paramMap.forEach((key, value) -> {
builder.withClaim(key, value);
});
// 过期时间
String token = builder.withExpiresAt(expireTime.getTime())
// Signature
.sign(Algorithm.HMAC256(SECRET));
return token;
}
// 验证token合法性
public static void verify(String token) {
JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token);
}
// 获取token信息
public static DecodedJWT getTokenInfo(String token) {
return JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token);
}
}
4、添加 Controller 类
这个 Controller 类中有两个接口:
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/login")
public Map<String, Object> login(User user) {
log.info("用户名:【{}】", user.getName());
log.info("密码:【{}】", user.getPwd());
Map<String, Object> map = new HashMap<>();
try {
User u = userService.login(user);
// 生成 token
Map<String, String> payload = new HashMap<>();
payload.put("id", u.getId());
payload.put("name", u.getName());
String token = JwtUtil.createToken(payload);
map.put("status", true);
map.put("msg", "认证成功");
map.put("token", token);
} catch (Exception e) {
map.put("status", false);
map.put("msg", e.getMessage());
}
return map;
}
@PostMapping("/testToken")
public Map<String, Object> testToken(String token) {
log.info("当前登录token:【{}】", token);
Map<String, Object> map = new HashMap<>();
try {
JwtUtil.verify(token);
map.put("status", true);
map.put("msg", "请求成功");
return map;
} catch (SignatureVerificationException e) {
log.error("【请求失败】:{}", e.getMessage());
map.put("msg", "无效签名");
} catch (TokenExpiredException e) {
log.error("【请求失败】:{}", e.getMessage());
map.put("msg", "token过期");
} catch (AlgorithmMismatchException e) {
log.error("【请求失败】:{}", e.getMessage());
map.put("msg", "token算法不一致");
} catch (Exception e) {
log.error("【请求失败】:{}", e.getMessage());
map.put("msg", "token无效");
}
map.put("status", false);
return map;
}
}
说明:
login()
:用户登录成功后,通过 JWT 生成一个 token,然后,token 中存入了用户的id
、name
,然后,直接将 token 返回给前端testToken()
:请求此接口时,需要在请求头中携带 tokenJwtUtil.verify(token);
,否则,就会报错。
5、添加 UserServiceImpl 类
UserService 接口省略
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public User login(User user) {
User u = userMapper.login(user);
if (null != u) {
return u;
}
throw new RuntimeException("认证失败");
}
}
6、添加 UserMapper 接口
public interface UserMapper {
User login(User user);
}
与之对应的 UserMapper.xml 文件:
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zzc.mapper.UserMapper">
<select id="login" parameterType="User" resultType="User">
SELECT
id,name,pwd
FROM TAB_USER
WHERE 1=1
AND name = #{name}
AND pwd = #{pwd}
</select>
</mapper>
通过用户名和密码查询用户信息。
7、运行代码
7-1、调用登录接口
认证成功后,返回了一个 token。前端可以将这个 token 进行保存,然后携带在请求头中。
7-2、调用其它接口
请求中没有携带 token,则会请求失败 “token 无效”。
请求中携带了 token,则会请求成功 。
8、优化代码
通常,一个项目中是有非常多的后台接口的,难道每一个后台接口都需要写那么多冗余的代码来校验 token 吗?
显然,不是这样滴!
这个时候我们想到了拦截器 Interceptor。如下:
8-1、添加一个拦截器
@Slf4j
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Map<String, Object> map = new HashMap<>();
String token = request.getHeader("token");
try {
JwtUtil.verify(token);
// 放行
return true;
} catch (SignatureVerificationException e) {
log.error("【请求失败】:{}", e.getMessage());
map.put("msg", "无效签名");
} catch (TokenExpiredException e) {
log.error("【请求失败】:{}", e.getMessage());
map.put("msg", "token过期");
} catch (AlgorithmMismatchException e) {
log.error("【请求失败】:{}", e.getMessage());
map.put("msg", "token算法不一致");
} catch (Exception e) {
log.error("【请求失败】:{}", e.getMessage());
map.put("msg", "token无效");
}
// 将错误消息返回给前台
map.put("status", false);
// 将Map转化为json字符串
String errorResult = new ObjectMapper().writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(errorResult);
return false;
}
}
注意:从请求头中获取 token
若 token 校验失败,则将失败信息返回给前台。
8-2、添加一个拦截器配置类
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JwtInterceptor())
.addPathPatterns("/user/testToken2")
.excludePathPatterns("/user/login");
}
}
说明:
- 上述配置:
/user/testToken2
的 url 进行拦截;/user/login
不进行拦截。
8-3-1、新增一个接口
public class UserController {
@Autowired
private UserService userService;
// ...
@PostMapping("/testToken2")
public Map<String, Object> testToken2(HttpServletRequest request) {
// 处理自己的业务逻辑
String token = request.getHeader("token");
DecodedJWT tokenInfo = JwtUtil.getTokenInfo(token);
log.info("用户ID:【{}】", tokenInfo.getClaim("id").asString());
log.info("用户名:【{}】", tokenInfo.getClaim("name").asString());
Map<String, Object> map = new HashMap<>();
map.put("status", true);
map.put("msg", "请求成功");
return map;
}
}
8-3-2、测试这个接口
若 请求头 中不携带 token,则请求失败
若 请求头 中携带 token,则请求成功
好了,SpringBoot + JWT 就到这了。