JWT技术
- 描述:JWT是用于根据特征值生成Token(凭证)的工具库,常用于身份校验功能
- JWT特性
- JWT天然携带信息,可以快速实现“多设备登录” 管理、登出、重复登录检验等功能
- JWT支持签名加密,开发者也可以初步校验特征值,保证了一定的安全性
- token = Header + Payload + Signature
- Header:签名算法 + token类型(固定为JWT),例如
{ "alg": "HS256","type": "JWT"}
- Signature:密文最后拼接密钥的哈希值(加密钥),防止token被篡改
- Payload:存储 Token 的实际数据(如用户信息、过期时间等),以键值对形式储存
<type>:表示认证方案(统一为 Bearer 类型)
<token>:实际的 JWT 字符串
- 示例:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
JWT工具类
JWT
- 轻量级JWT:快速实现JWT基础功能(生成、验证、Claims)
Claim 类的作用:实例会实现所有类型映射关系,JWT创建Token时,Claim的其他类型映射实现方法的内容是返回null
asString(): 将 claim 的值转换为 String
asInt(): 将 claim 的值转换为 Integer
asBoolean(): 将 claim 的值转换为 Boolean
asDate(): 将 claim 的值转换为 Date (适用于 exp, iat 等标准声明)
asArray(Class<T> clazz): 将 claim 的值转换为指定类型的数组
asList(Class<T> clazz): 将 claim 的值转换为指定类型的 List
asMap(): 将 claim 的值转换为 Map<String, Object>
isNull(): 判断 claim 的值是否为 null
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
@Slf4j
public class JWTUtil {
private static final String SECRET_KEY = "7d754ec7-335d-422a-821a-e4cc08877511";
private static final Algorithm ALGORITHM = Algorithm.HMAC256(SECRET_KEY);
private static final String ISSUER = "wyh";
private static final long EXPIRATION_TIME = 3600000;
* 生成 JWT Token
*
public static String generateToken(String subject) throws JWTCreationException {
if (!StringUtils.hasText(subject)) {
throw new IllegalArgumentException("Subject cannot be null or blank");
}
Date now = new Date();
Date expiration = new Date(now.getTime() + EXPIRATION_TIME);
return JWT.create()
.withIssuer(ISSUER)
.withSubject(subject)
.withIssuedAt(now)
.withExpiresAt(expiration)
.sign(ALGORITHM);
}
* 生成 JWT Token
*
public static String generateToken(String subject, Map<String, String> map) throws JWTCreationException {
if (!StringUtils.hasText(subject)) {
throw new IllegalArgumentException("Subject cannot be null or blank");
}
Date now = new Date();
Date expiration = new Date(now.getTime() + EXPIRATION_TIME);
JWTCreator.Builder builder = JWT.create();
Optional.ofNullable(map).orElse(new HashMap<>()).forEach((name, value) ->{
if (StringUtils.hasText(name) && StringUtils.hasText(value)) {
builder.withClaim(name, value);
}
});
return builder.withIssuer(ISSUER)
.withSubject(subject)
.withIssuedAt(now)
.withExpiresAt(expiration)
.sign(ALGORITHM);
}
* 验证 JWT Token 的有效性
*
public static DecodedJWT validateToken(String token) {
try {
if (!StringUtils.hasText(token)){
return null;
}
JWTVerifier verifier = JWT.require(ALGORITHM)
.withIssuer(ISSUER)
.build();
return verifier.verify(token);
} catch (JWTVerificationException e) {
log.error("JWT verification failed: {}", e.getMessage());
return null;
}
}
* 从 Token 中提取Subject
*
public static String getSubject(String token) {
DecodedJWT jwt = validateToken(token);
if (ObjectUtils.isEmpty(jwt)) {
return null;
}
return jwt.getSubject();
}
* 从 Token 中提取 Claims
*
public static Map<String, String> getClaims(String token) {
DecodedJWT jwt = validateToken(token);
if (ObjectUtils.isEmpty(jwt)) {
return Collections.emptyMap();
}
Map<String, Claim> claimMap = jwt.getClaims();
return convertClaim2Map(claimMap);
}
* 刷新Token(重置有效期)
*
public static String refreshToken(String oldToken) {
DecodedJWT jwt = validateToken(oldToken);
if (ObjectUtils.isEmpty(jwt)) {
return null;
}
String subject = jwt.getSubject();
if (!StringUtils.hasText(subject)){
return null;
}
return generateToken(subject,convertClaim2Map(jwt.getClaims()));
}
private static Map<String, String> convertClaim2Map(Map<String, Claim> claimMap) {
HashMap<String, String> resultMap = new HashMap<>();
claimMap.forEach((name, claim) -> {
resultMap.put(name, claim.asString());
});
return resultMap;
}
}
JJWT
- 企业级首选JWT:API可读性高、支持高级特性,如灵活的密钥管理、与其他库的深度集成
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
@Slf4j
public class JJWTUtil {
private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor(
"7d754ec7-335d-422a-821a-e4cc08877511".getBytes());
private static final SignatureAlgorithm SIGNATUREALGORITHM = SignatureAlgorithm.HS256;
private static final long EXPIRATION_TIME = 3600000;
private static final String ISSUER = "wyh";
* 生成token
*
public static String generateToken(String subject) throws IllegalStateException, JwtException {
if (!StringUtils.hasText(subject)) {
throw new IllegalArgumentException("Subject cannot be null or blank");
}
Date createTime = new Date();
Date expireDate = new Date(createTime.getTime() + EXPIRATION_TIME);
return Jwts.builder()
.setSubject(subject)
.setIssuer(ISSUER)
.setIssuedAt(createTime)
.setExpiration(expireDate)
.signWith(SECRET_KEY, SIGNATUREALGORITHM)
.compact();
}
* 生成token
*
public static String generateToken(String subject, Map<String, String> map) throws IllegalStateException, JwtException {
if (!StringUtils.hasText(subject)) {
throw new IllegalArgumentException("Subject cannot be null or blank");
}
Date createTime = new Date();
Date expireDate = new Date(createTime.getTime() + EXPIRATION_TIME);
return Jwts.builder()
.setClaims(Optional.ofNullable(map).orElse(new HashMap<>()))
.setIssuer(ISSUER)
.setSubject(subject)
.setIssuedAt(createTime)
.setExpiration(expireDate)
.signWith(SECRET_KEY, SignatureAlgorithm.HS256)
.compact();
}
* 验证 Token 有效性
*
public static Claims validateToken(String token) {
if (!StringUtils.hasText(token)) {
return Jwts.claims();
}
try {
return Jwts.parserBuilder()
.setSigningKey(SECRET_KEY)
.requireIssuer(ISSUER)
.build()
.parseClaimsJws(token)
.getBody();
} catch (IllegalArgumentException | JwtException e) {
log.error("Invalid JWT claims: {}", e.getMessage());
return Jwts.claims();
}
}
* 从 Token 中提取Subject
*
public static String getSubject(String token) throws JwtException {
Claims claims = validateToken(token);
if (CollectionUtils.isEmpty(claims)) {
return null;
}
return claims.getSubject();
}
* 从 Token 中提取 完整Claims
*
public static Map<String, Object> getClaims(String token) throws JwtException {
Claims claims = validateToken(token);
if (CollectionUtils.isEmpty(claims)) {
return Collections.emptyMap();
}
return new HashMap<>(claims);
}
* 刷新token有效期
*
public static String refreshToken(String oldToken) {
Claims claims = validateToken(oldToken);
if (CollectionUtils.isEmpty(claims)) {
return null;
}
Date now = new Date();
Date expireDate = new Date(now.getTime() + EXPIRATION_TIME);
return Jwts.builder()
.setClaims(claims)
.setIssuer(ISSUER)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(SECRET_KEY, SignatureAlgorithm.HS256)
.compact();
}
}
简单测试
class JJWTUtilTest {
private String token;
@BeforeEach
void setUp() {
token = JJWTUtil.generateToken("wyh");
}
@Test
void generateToken() {
System.out.println(token);
assertNotNull(token);
}
@Test
void getSubject() {
String subject = JJWTUtil.getSubject(token);
System.out.println(subject);
assertNotNull(subject);
}
@Test
void refreshToken() {
String newToken = JJWTUtil.refreshToken(token);
System.out.println(newToken);
String subject = JJWTUtil.getSubject(newToken);
System.out.println(subject);
assertNotNull(newToken);
assertNotNull(subject);
}
}
class JWTUtilTest {
private String token;
@BeforeEach
void setUp() {
token = JWTUtil.generateToken("wyh");
}
@Test
void generateToken() {
System.out.println(token);
assertNotNull(token);
}
@Test
void getSubject() {
String subject = JWTUtil.getSubject(token);
System.out.println(subject);
assertNotNull(subject);
}
@Test
void refreshToken() {
String refreshToken = JWTUtil.refreshToken(token);
System.out.println(refreshToken);
String subject = JWTUtil.getSubject(refreshToken);
System.out.println(subject);
assertNotNull(refreshToken);
assertNotNull(subject);
}
}
JWT身份验证
- 用户发送请求时,如果没有携带token或者token非法就返回登录功能
- 用户登录时,后端会生成一个token返回给前端,后端前端均缓存这个token
- 用户再次发送请求时,后端会查询缓存校验token,如果一致就认为用户已登录,直接复用缓存的用户信息即可
token和Redis相关数据有效期要保持一致,考虑网络延迟等原因,Redis有效期可以适当延长
- 重置有效期前可以先检验是否临近过期(例如还剩5分钟),再刷新有效期
- 后端缓存可以设计两个映射关系:
{token,用户信息}、{用户特征值, 最新token}
- 设计两个映射关系优势:1.无需解密token得subje即可快速验证 2.后续请求时token若被异地更新会强制下线
登录功能
- 先检查用户身份,可以使用【用户名密码】,也可以使用【验证码】方式
- 如果用户未注册,则自动注册用户
- 请求未带token,就认为是第一次登录,生成token,存入后端缓存
- 请求带有合法token,判断是否重复登录:查询redis中最新token是否一致,不一致说明异地登录了,返回重新登陆
@Service
@Slf4j
public class LoginService {
@Autowired
private UserDao userDao;
@Autowired
private RedisClient redisClient;
@Autowired
private BasicPasswordEncryptor passwordEncryptor;
@Autowired
private UserConverter userConverter;
private static final String USERINFO_PREFIX = "login:userinfo:";
private static final String TOKEN_PREFIX = "login:latest_token:";
private static final Integer TOKEN_EXPIRE_IN = 300;
private static final Integer TOKEN_EXPIRE_TIME = 1;
public Result<String> login(UserVo userVo, String token) {
UserPo userPo = userDao.getUserByPhone(userVo.getPhone());
if (ObjectUtils.isEmpty(userPo)) {
return register(userVo);
}
if (!passwordEncryptor.checkPassword(userVo.getPassword(), userPo.getPassword())) {
return Result.fail("身份验证失败");
}
String phone = userVo.getPhone();
UserDto userDto = userConverter.toUserDto(userVo);
String latestToken = redisClient.get(TOKEN_PREFIX + phone);
if (StringUtils.hasText(token) && StringUtils.hasText(latestToken) && latestToken.equals(token)) {
return Result.ok(refreshExpire(phone, token, userDto));
}
return Result.ok(saveToken2Redis(userDto, userVo.getPhone()));
}
public Result<String> register(UserVo userVo) {
try {
UserPo userPo = userConverter.toUserPo(userVo);
String password = passwordEncryptor.encryptPassword(userVo.getPassword());
userPo.setPassword(password);
userDao.insertUser(userPo);
} catch (Exception e) {
log.error("用户注册失败,手机号: {}", userVo.getPhone(), e);
return Result.fail("用户注册失败,稍后再试");
}
if (ObjectUtils.isEmpty(userDao.getUserByPhone(userVo.getPhone()))) {
return Result.fail("注册成功,但用户信息获取失败");
}
UserDto userDto = userConverter.toUserDto(userVo);
return Result.ok(saveToken2Redis(userDto, userVo.getPhone()));
}
private String refreshExpire(String phone, String token, UserDto userDto) {
if (redisClient.getExpire(TOKEN_PREFIX + phone) < TOKEN_EXPIRE_IN) {
String newToken = JWTUtil.refreshToken(token);
redisClient.set(TOKEN_PREFIX + phone, newToken, TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
redisClient.set(USERINFO_PREFIX + newToken, JSON.toJSONString(userDto), TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
redisClient.delete(USERINFO_PREFIX + token);
return newToken;
}
return token;
}
private String saveToken2Redis(UserDto userDto, String phone) {
String newToken = JWTUtil.generateToken(phone);
String userInfo = JSON.toJSONString(userDto);
redisClient.set(TOKEN_PREFIX + phone, newToken, TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
redisClient.set(USERINFO_PREFIX + newToken, userInfo, TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
return newToken;
}
}
会话保持
- 先验证token有效性
- 再根据剩余有效期判断是否延长有效期
- 存入ThreadLocal,方便后续业务复用
@Component
public class AuthTokenInterceptor implements HandlerInterceptor {
@Autowired
private RedisClient redisClient;
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception {
String token = req.getHeader(REQUEST_TOKEN_HEADER);
if (!StringUtils.hasText(token)) {
Result<String> fail = Result.fail("用户未登录");
res.setContentType(HTTP_JSON_TYPE);
res.getWriter().write(JSON.toJSONString(fail));
return false;
}
DecodedJWT decodedJWT = JWTUtil.validateToken(token);
if (ObjectUtils.isEmpty(decodedJWT)) {
Result<String> fail = Result.fail("token非法或过期");
res.setContentType(HTTP_JSON_TYPE);
res.getWriter().write(JSON.toJSONString(fail));
return false;
}
String phone = JWTUtil.getSubject(token);
String latestToken = redisClient.get(TOKEN_PREFIX + phone);
String userInfo = redisClient.get(USERINFO_PREFIX + token);
if (!StringUtils.hasText(latestToken) || !StringUtils.hasText(userInfo)) {
Result<String> fail = Result.fail("登录失效");
res.setContentType(HTTP_JSON_TYPE);
res.getWriter().write(JSON.toJSONString(fail));
return false;
}
if (!latestToken.equals(token)){
Result<String> fail = Result.fail("异地登录风险拦截");
res.setContentType(HTTP_JSON_TYPE);
res.getWriter().write(JSON.toJSONString(fail));
return false;
}
UserVo userVo = JSON.parseObject(userInfo, UserVo.class);
Long expire = redisClient.getExpire(TOKEN_PREFIX + phone);
if (expire != -1 && expire < TOKEN_EXPIRE_IN) {
String newToken = JWTUtil.refreshToken(token);
redisClient.set(TOKEN_PREFIX + phone, newToken, TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
redisClient.set(USERINFO_PREFIX + newToken, userInfo, TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
redisClient.delete(USERINFO_PREFIX + token);
res.setHeader(REQUEST_TOKEN_HEADER, newToken);
}
res.setHeader(REQUEST_TOKEN_HEADER, token);
UserThreadLocal.USER_THREAD_LOCAL.set(userVo);
return true;
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object o, Exception ex) throws Exception {
UserThreadLocal.USER_THREAD_LOCAL.remove();
}
}
单点登录(SSO)
- 描述:一个功能可能涉及多个服务,只要登录任一服务,其他服务可以自动登录
- 流程:单体JWT区别是SSO将JWT验证API单独置于一个服务中,且前端可以共享token
- 共享令牌:前端在访问所有子系统时,携带同一个 JWT 令牌(跨域 / 跨服务共享)
- 统一认证中心:所有子系统的登录请求都转发到认证中心,认证通过后生成 JWT 令牌
- 跨系统验证:子系统接收到请求时,提取 JWT 并调用认证中心的验证接口,验证通过则识别用户身份,无需登录
@RestController
public class AuthTokenController {
@Autowired
private RedisClient redisClient;
private static final String USERINFO_PREFIX = "login:userinfo:";
private static final String TOKEN_PREFIX = "login:latest_token:";
private static final Integer TOKEN_EXPIRE_IN = 300;
private static final Integer TOKEN_EXPIRE_TIME = 1;
private static final String HTTP_JSON_TYPE = "application/json;charset=UTF-8";
private static final String REQUEST_TOKEN_HEADER = "token";
@PostMapping("/auth")
public void login(HttpServletRequest request, HttpServletResponse response) throws IOException{
String token = request.getHeader(REQUEST_TOKEN_HEADER);
if (!StringUtils.hasText(token)) {
Result<String> fail = Result.fail("用户未登录");
response.setContentType(HTTP_JSON_TYPE);
response.getWriter().write(JSON.toJSONString(fail));
return;
}
DecodedJWT decodedJWT = JWTUtil.validateToken(token);
if (ObjectUtils.isEmpty(decodedJWT)) {
Result<String> fail = Result.fail("token非法或过期");
response.setContentType(HTTP_JSON_TYPE);
response.getWriter().write(JSON.toJSONString(fail));
return;
}
String phone = JWTUtil.getSubject(token);
String latestToken = redisClient.get(TOKEN_PREFIX + phone);
String userInfo = redisClient.get(USERINFO_PREFIX + token);
if (!StringUtils.hasText(latestToken) || !StringUtils.hasText(userInfo)) {
Result<String> fail = Result.fail("登录失效");
response.setContentType(HTTP_JSON_TYPE);
response.getWriter().write(JSON.toJSONString(fail));
return;
}
if (!latestToken.equals(token)){
Result<String> fail = Result.fail("异地登录风险拦截");
response.setContentType(HTTP_JSON_TYPE);
response.getWriter().write(JSON.toJSONString(fail));
return;
}
UserVo userVo = JSON.parseObject(userInfo, UserVo.class);
Long expire = redisClient.getExpire(TOKEN_PREFIX + phone);
if (expire != -1 && expire < TOKEN_EXPIRE_IN) {
String newToken = JWTUtil.refreshToken(token);
redisClient.set(TOKEN_PREFIX + phone, newToken, TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
redisClient.set(USERINFO_PREFIX + newToken, userInfo, TOKEN_EXPIRE_TIME, TimeUnit.HOURS);
redisClient.delete(USERINFO_PREFIX + token);
response.setHeader(REQUEST_TOKEN_HEADER, newToken);
return;
}
response.setHeader(REQUEST_TOKEN_HEADER, token);
}
}
《JWT教程》 是转载文章,点击查看原文。