1. JWT简介
JSON Web Token (JWT) 是一种开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息。由于这些信息是经过数字签名的,因此可以被验证和信任。JWT 通常用于身份验证和信息交换场景,特别是在 Web 应用程序的认证和授权机制中。
组成
JWT 由三部分组成:Header、Payload 和 Signature。这三部分分别用点(.)分隔,形成一个字符串。
Header(头部):
Header 通常由两部分组成:令牌的类型(JWT)和所使用的签名算法(例如,HMAC SHA256 或 RSA)。
{
"alg": "HS256",
"typ": "JWT"
}
这个 JSON 对象被 Base64Url 编码后,形成 JWT 的第一部分。
Payload(负载):
- Payload 包含声明(claims),声明是关于实体(通常是用户)和其他数据的声明。声明有三种类型:
- Registered claims(注册声明):预定义的声明,如 iss(发行者)、exp(过期时间)、sub(主题)、aud(受众)。
- Public claims(公共声明):可以自由定义的声明,但为了避免冲突,建议在 IANA JSON Web Token Claims 注册表中注册或使用 URI 作为声明名称的前缀。
- Private claims(私有声明):自定义的声明,用于共享信息,比如用户角色、权限等。
例:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
这个 JSON 对象也被 Base64Url 编码后,形成 JWT 的第二部分。
Base64编码方式是可逆的,也就是透过编码后发放的Token内容是可以被解析的。一般而言不建议在Payload放敏感讯息,比如使用者的密码。
Signature(签名):
签名部分用于验证消息在传输过程中未被篡改。
首先,需要指定一个密钥,然后使用指定的签名算法对编码后的 Header 和 Payload 以及一个密钥进行签名。签名的过程实际上是对头部以及负载内容进行签名,防止内容被窜改。如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的JWT的话,那么服务器端会判断出新的头部和负载形成的签名和JWT附带上的签名是不一样的。如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。
例子:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
生成的签名也被 Base64Url 编码,形成 JWT 的第三部分。
基于JWT的认证流程
:
- 前端通过Web表单将自己的用户名和密码发送到后端的接口。该过程一般是HTTP的POST请求。建议的方式是通过SSL加密的传输(https协议),从而避免敏感信息被嗅探;
- 后端核对用户名和密码成功后,将用户的id等其他信息作为JWT Payload(负载),将其与头部分别进行Base64编码拼接后签名,形成一个JWT(Token);
- 后端将JWT字符串作为登录成功的返回结果返回给前端。前端可以将返回的结果保存在
localStorage
(浏览器本地缓存)或cookie
上,退出登录时前端删除保存的JWT即可; - 前端在每次请求时将JWT放入HTTP的Header中的Authorization位。(解决XSS和XSRF问题)HEADER
- 后端检查是否存在,如存在验证JWT的有效性。例如,检查签名是否正确﹔检查Token是否过期等;
- 验证通过后后端使用JWT中包含的用户信息进行其他逻辑操作,返回相应结果。
注:Base64编码方式是可逆的,也就是透过编码后发放的Token内容是可以被解析的。一般而言,不建议在有效载荷内放敏感讯息
,比如使用者的密码。
2. 准备工作
环境:JDK8 + SpringBoot2.6.13
2.1 生成秘钥对
需要使用到jdk的keytool工具,在jdk安装目录的bin目录内,在cmd控制窗口执行JDK中keytool的命令:
keytool -genkeypair -alias test -keyalg RSA -keysize 2048 -validity 365 -keystore test.jks -storepass test123 -keypass test123 -dname "CN=Sakura, OU=xxb, O=ncu, L=nc, ST=JX, C=CN"
参数解释
genkeypair:
生成一个密钥对(包括公钥和私钥)。alias test
: 为生成的密钥对指定一个别名 test。别名是用来识别密钥条目的。keyalg RSA
: 指定密钥对的算法为 RSA。RSA 是一种常用的公钥加密算法。keysize 2048
: 指定密钥的大小为 2048 位。密钥越长,安全性越高,但性能开销也越大。validity 365
: 指定证书的有效期为 365 天。keystore test.jks
: 指定密钥库文件的名称为 test.jks。如果文件不存在,keytool 会创建一个新的文件。storepass test123
: 指定密钥库的密码为 test123。这是保护整个密钥库的密码。keypass test123
: 指定密钥的密码为 test123。这是保护单个密钥条目的密码。dname
“CN=Sakura, OU=xxb, O=ncu, L=nc, ST=JX, C=CN”: 指定证书的详细信息,依次为名字与姓氏,组织单位,城市,区县,国家代码,使用逗号分隔的格式。
执行完命令后,会警告:
JKS 密钥库使用专用格式。建议使用 keytool -importkeystore -srckeystore test.jks -destkeystore test.jks -deststoretype pkcs12
迁移到行业标准格式 PKCS12。
执行下上述命令即可:
keytool -importkeystore -srckeystore test.jks -destkeystore test.jks -deststoretype pkcs12
最后,将生成的test.jks
文件放到springboot的resources
目录(即类路径下)。
2.2 SpringBoo项目配置
项目目录如下:
maven
依赖:
server:
port: 9000 # 服务端口
# 自定义JWT配置
<dependencies>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 注解执行器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- lombok -->
<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>
<!-- hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.11</version>
</dependency>
<!--加密-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-rsa</artifactId>
<version>1.0.9.RELEASE</version>
</dependency>
</dependencies>
application.yml
配置文件:
server:
port: 9000 # 服务端口
# 自定义JWT配置
app:
jwt:
location: classpath:test.jks # JWT密钥存放位置,classpath为resource文件夹
alias: test # 别名
password: test123 # 密码
tokenTTL: 30m # Token有效期为30min
auth:
excludePaths: # 排除的路径,不需要认证的路径
- /auth/login
JwtApplication .java
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@SpringBootApplication
@EnableConfigurationProperties
@ConfigurationPropertiesScan("com.jwt.demo.config")
public class JwtApplication {
public static void main(String[] args) {
SpringApplication.run(JwtApplication.class, args);
}
}
AuthProperties.java
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import java.util.List;
@Data
@ConfigurationProperties(prefix = "app.auth")
public class AuthProperties {
/***
* 指定需要拦截的请求路径
*/
private List<String> includePaths;
/**
* 指定需要放行的请求路径
*/
private List<String> excludePaths;
}
JwtProperties .java
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.io.Resource;
import java.time.Duration;
@Data
@ConfigurationProperties(prefix = "app.jwt")
public class JwtProperties {
private Resource location;
private String password;
private String alias;
private Duration tokenTTL = Duration.ofMinutes(10);
}
SecurityConfig .java
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.rsa.crypto.KeyStoreKeyFactory;
import java.security.KeyPair;
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
/**
* 根据配置文件读取jks文件的密钥对
*/
@Bean
public KeyPair keyPair(JwtProperties properties){
// 获取秘钥工厂
KeyStoreKeyFactory keyStoreKeyFactory =
new KeyStoreKeyFactory(
properties.getLocation(),
properties.getPassword().toCharArray());
//读取钥匙对
return keyStoreKeyFactory.getKeyPair(
properties.getAlias(),
properties.getPassword().toCharArray());
}
}
JwtTool .java
import cn.hutool.core.exceptions.ValidateException;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTValidator;
import cn.hutool.jwt.signers.JWTSigner;
import cn.hutool.jwt.signers.JWTSignerUtil;
import com.jwt.demo.constants.UserConstants;
import com.jwt.demo.exception.UnauthorizedException;
import org.springframework.stereotype.Component;
import java.security.KeyPair;
import java.time.Duration;
import java.util.Date;
@Component
public class JwtTool {
private final JWTSigner jwtSigner;
public JwtTool(KeyPair keyPair) {
this.jwtSigner = JWTSignerUtil.createSigner(UserConstants.ALGORITHM, keyPair);
}
/**
* 创建 access-token
*
* @param userId 用户id
* @param ttl 有效时间
* @return access-token
*/
public String createToken(Long userId, Duration ttl) {
// 1.生成jws
return JWT.create()
.setPayload(UserConstants.PAY_LOAD, userId) // 设置载荷
.setExpiresAt(new Date(System.currentTimeMillis() + ttl.toMillis())) // 设置过期时间
.setSigner(jwtSigner)
.sign();
}
/**
* 解析token
*
* @param token token
* @return 解析刷新token得到的用户信息
*/
public Long parseToken(String token) {
// 1.校验token是否为空
if (token == null) {
throw new UnauthorizedException("未登录");
}
// 2.校验并解析jwt
JWT jwt;
try {
jwt = JWT.of(token).setSigner(jwtSigner);
} catch (Exception e) {
throw new UnauthorizedException("无效的token", e);
}
// 2.校验jwt是否有效
if (!jwt.verify()) {
// 验证失败
throw new UnauthorizedException("无效的token");
}
// 3.校验是否过期
try {
JWTValidator.of(jwt).validateDate();
} catch (ValidateException e) {
throw new UnauthorizedException("token已经过期");
}
// 4.数据格式校验
Object userPayload = jwt.getPayload(UserConstants.PAY_LOAD);
if (userPayload == null) {
// 数据为空
throw new UnauthorizedException("无效的token");
}
// 5.数据解析
try {
Long userId = Long.valueOf(userPayload.toString());
return userId;
} catch (RuntimeException e) {
// 数据格式有误
throw new UnauthorizedException("无效的token");
}
}
}
UserConstants .java
/**
* 常量类
*
* @date 2024-07-12 11:27
*/
public interface UserConstants {
/**
* JWT 载荷字段
*/
String PAY_LOAD = "user";
/**
* 加密算法RSA256
*/
String ALGORITHM = "rs256";
/**
* token对应的请求头字段名称
*/
String AUTHORAZATION = "authorization";
}
3. 登录拦截器
编写登录拦截器逻辑,并注入到Spring的拦截器链。
AuthInterceptor .java
import cn.hutool.core.text.AntPathMatcher;
import cn.hutool.core.util.StrUtil;
import com.jwt.demo.config.AuthProperties;
import com.jwt.demo.constants.UserConstants;
import com.jwt.demo.exception.UnauthorizedException;
import com.jwt.demo.utils.JwtTool;
import com.jwt.demo.utils.UserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
// 采用构造器注入的方式注入配置类
private final AuthProperties authProperties;
private final JwtTool jwtTool;
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 判断是否需要进行登录拦截
if (isExcludedPath(request.getRequestURI())) {
// 若不需要登录拦截,则放行
return true;
}
// 若需要登录拦截,则获取token
String header = String.valueOf(request.getHeader(UserConstants.AUTHORAZATION));
String token = null;
// 判断token是否存在
if (StrUtil.isNotBlank(header)) {
token = header;
}
// 校验并解析token
Long userId = null;
try {
userId = jwtTool.parseToken(token);
} catch (UnauthorizedException e) {
// 拦截该请求
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"" + e.getMessage() + "\"}");
return false; // 返回false以阻止请求的进一步处理
}
// 传递用户信息,放置在ThreadLocal中
UserContext.setUser(userId);
// 放行该请求
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清空ThreadLocal
UserContext.removeUser();
}
/**
* 判断是否需要进行登录拦截
*
* @param path 当前请求的路径
* @return 是否是不需要登录拦截的路径
*/
private boolean isExcludedPath(String path) {
// 判断是否是不需要登录拦截的路径
for (String excludePath : authProperties.getExcludePaths()) {
// 选择antPathMatcher实现路径匹配
if (antPathMatcher.match(excludePath, path)) {
return true;
}
}
return false;
}
}
MvcConfig .java
import com.jwt.demo.interceptor.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* MVC配置
*
* @author: hong.jian
* @date 2024-03-02 20:02
*/
@Configuration
@ConditionalOnClass(DispatcherServlet.class)
@RequiredArgsConstructor
public class MvcConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 将自定义的拦截器进行注册
registry.addInterceptor(authInterceptor);
}
}
编写控制器AuthController
AuthController.java
import com.jwt.demo.config.JwtProperties;
import com.jwt.demo.domain.dto.LoginFormDTO;
import com.jwt.demo.domain.po.User;
import com.jwt.demo.domain.vo.UserLoginVO;
import com.jwt.demo.utils.JwtTool;
import com.jwt.demo.utils.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author: hong.jian
* @date 2024-07-12 10:36
*/
@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/auth")
public class AuthController {
private final JwtTool jwtTool;
private final JwtProperties jwtProperties;
/**
* 用户登录后生成token
*
* @param loginDTO 登录表单
* @return 含token的用户信息
*/
@PostMapping("/login")
public UserLoginVO login( LoginFormDTO loginDTO) {
// 1.获取表单信息(省略)
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
// 2. 登录逻辑校验(需要对接DB,这里使用静态数据模拟)
User user = User.builder()
.id(111L)
.username("Sakura")
.build();
// 3.生成TOKEN
String token = jwtTool.createToken(user.getId(), jwtProperties.getTokenTTL());
// 4.封装VO返回
UserLoginVO vo = UserLoginVO.builder().userId(user.getId()) // 用户id
.username(user.getUsername()) // 用户名
.token(token) // token
.build();
log.info("UserLoginVO:{}", vo);
return vo;
}
/**
* 用户登录后生成token
* 测试接口
*/
@GetMapping("/test")
public void test() {
// 直接从ThreadLocal获取用户信息
log.info("userId:{}", UserContext.getUser());
}
}
4. 测试
控制台日志:
2024-07-12 21:29:51.329 INFO 25212 --- [ restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 9000 (http) with context path ''
2024-07-12 21:29:51.335 INFO 25212 --- [ restartedMain] com.jwt.demo.JwtApplication : Started JwtApplication in 2.352 seconds (JVM running for 3.21)
2024-07-12 21:29:51.710 INFO 25212 --- [nio-9000-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2024-07-12 21:29:51.710 INFO 25212 --- [nio-9000-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2024-07-12 21:29:51.711 INFO 25212 --- [nio-9000-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms
2024-07-12 21:29:58.489 INFO 25212 --- [nio-9000-exec-9] com.jwt.demo.controller.AuthController : UserLoginVO:UserLoginVO(token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1c2VyIjoxMTEsImV4cCI6MTcyMDc5Mjc5OH0.KjmVdTh1RYUOZ_okycZJoj86qkfqlRuSPrwmjMNYS2uS0IwzM1Ab2D4m53F6z4x2zZxEt4aReC-Rnb_HpAx1uj0-unxAlsbe5mW9ok1GhtWp7EuW0k1rgQRA0nx6DUPwUmxhOXIyM9tdJsN0Sae5KQ5mimKORtB6n-VhIDo-cKqdTvtwKUVSbSiCHoQRryUBI2333TjdwkrYg2o-Fdwt80LkHxWOwoGelqmThDlvIvY-Nfkb0-EFIq1IlA027QBN3-TJdohy_3ATWWXOS1h4zuNzTzeN_ML4BZI-SWa2EajQl1eBpgYWZttWTcduV2WGDhsH-zsafC2IvW9tpz6b3A, userId=111, username=Sakura)
2024-07-12 21:30:49.347 INFO 25212 --- [io-9000-exec-10] com.jwt.demo.controller.AuthController : userId:111
登录后请求需要带上token