feat: 完善注销功能逻辑,通过黑名单的方式实现注销场景JWT失效控制

This commit is contained in:
haoxr
2023-03-25 14:25:29 +08:00
parent c19b27f1d9
commit c590fd7607
18 changed files with 160 additions and 101 deletions

View File

@@ -1,16 +0,0 @@
package com.youlai.system.common.constant;
/**
* Redis 缓存常量
*
* @author: haoxr
* @date: 2023/03/24
*/
public interface CacheConstants {
/**
* 验证码缓存前缀
*/
String VERIFY_CODE_CACHE_PREFIX = "AUTH:VERIFY_CODE:";
}

View File

@@ -0,0 +1,42 @@
package com.youlai.system.common.constant;
/**
* Security 常量
*
* @author: haoxr
* @date: 2023/03/24
*/
public interface SecurityConstants {
/**
* 登录接口路径
*/
String LOGIN_PATH = "/api/v1/auth/login";
/**
* Token 前缀
*/
String TOKEN_PREFIX = "Bearer ";
/**
* 请求头Token的Key
*/
String TOKEN_KEY = "Authorization";
/**
* 验证码缓存前缀
*/
String VERIFY_CODE_CACHE_PREFIX = "AUTH:VERIFY_CODE:";
/**
* 用户权限集合缓存前缀
*/
String USER_PERMS_CACHE_PREFIX = "AUTH:USER_PERMS:";
/**
* 黑名单Token缓存前缀
*/
String BLACK_TOKEN_CACHE_PREFIX = "AUTH:BLACK_TOKEN:";
}

View File

@@ -0,0 +1,24 @@
package com.youlai.system.common.util;
import cn.hutool.core.util.StrUtil;
import com.youlai.system.common.constant.SecurityConstants;
import jakarta.servlet.http.HttpServletRequest;
/**
* 请求工具类
*
* @author haoxr
*/
public class RequestUtils {
/**
* 请求头解析获取 Token
*/
public static String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(SecurityConstants.TOKEN_KEY);
if (StrUtil.isNotBlank(bearerToken) && bearerToken.startsWith(SecurityConstants.TOKEN_PREFIX)) {
return bearerToken.substring(SecurityConstants.TOKEN_PREFIX.length());
}
return null;
}
}

View File

@@ -11,7 +11,7 @@ import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* response 响应工具类
* 响应工具类
*
* @author haoxr
* @date 2022/10/18

View File

@@ -1,21 +1,30 @@
package com.youlai.system.controller;
import cn.hutool.core.util.StrUtil;
import com.youlai.system.common.constant.SecurityConstants;
import com.youlai.system.common.result.Result;
import com.youlai.system.common.util.RequestUtils;
import com.youlai.system.framework.easycaptcha.service.EasyCaptchaService;
import com.youlai.system.pojo.dto.CaptchaResult;
import com.youlai.system.pojo.dto.LoginResult;
import com.youlai.system.framework.security.JwtTokenManager;
import io.jsonwebtoken.Claims;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Date;
import java.util.concurrent.TimeUnit;
@Tag(name = "01.认证中心")
@RestController
@RequestMapping("/api/v1/auth")
@@ -25,6 +34,8 @@ public class AuthController {
private final JwtTokenManager jwtTokenManager;
private final EasyCaptchaService easyCaptchaService;
private final RedisTemplate redisTemplate;
@Operation(summary = "登录")
@PostMapping("/login")
public Result<LoginResult> login(
@@ -48,7 +59,22 @@ public class AuthController {
@Operation(summary = "注销", security = {@SecurityRequirement(name = "Authorization")})
@DeleteMapping("/logout")
public Result login() {
public Result login(HttpServletRequest request) {
String token = RequestUtils.resolveToken(request);
if (StrUtil.isNotBlank(token)) {
Claims claims = jwtTokenManager.getTokenClaims(token);
String jti = claims.get("jti", String.class);
Date expiration = claims.getExpiration();
if (expiration != null) {
// 有过期时间在token有效时间内存入黑名单超出时间移除黑名单节省内存占用
long ttl = (expiration.getTime() - System.currentTimeMillis()) ;
redisTemplate.opsForValue().set(SecurityConstants.BLACK_TOKEN_CACHE_PREFIX + jti, null, ttl, TimeUnit.MILLISECONDS);
} else {
// 无过期时间,永久加入黑名单
redisTemplate.opsForValue().set(SecurityConstants.BLACK_TOKEN_CACHE_PREFIX + jti, null);
}
}
SecurityContextHolder.clearContext();
return Result.success("注销成功");
}

View File

@@ -12,7 +12,7 @@ import lombok.SneakyThrows;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "08.文件接口")
@Tag(name = "07.文件接口")
@RestController
@RequestMapping("/api/v1/files")
@RequiredArgsConstructor

View File

@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "07.字典接口")
@Tag(name = "06.字典接口")
@RestController
@RequestMapping("/api/v1/dict")
@RequiredArgsConstructor

View File

@@ -1,6 +1,6 @@
package com.youlai.system.framework.easycaptcha.config;
import com.youlai.system.framework.easycaptcha.enums.VerifyCodeTypeEnum;
import com.youlai.system.framework.easycaptcha.enums.CaptchaTypeEnum;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@@ -21,7 +21,7 @@ public class EasyCaptchaConfig {
/**
* 验证码类型
*/
private VerifyCodeTypeEnum verifyCodeType = VerifyCodeTypeEnum.ARITHMETIC;
private CaptchaTypeEnum type = CaptchaTypeEnum.ARITHMETIC;
/**

View File

@@ -6,7 +6,7 @@ package com.youlai.system.framework.easycaptcha.enums;
* @author: haoxr
* @date: 2023/03/24
*/
public enum VerifyCodeTypeEnum {
public enum CaptchaTypeEnum {
/**
* 算数

View File

@@ -27,7 +27,7 @@ public class EasyCaptchaProducer {
int length = easyCaptchaConfig.getLength();
String fontName = easyCaptchaConfig.getFontName();
switch (easyCaptchaConfig.getVerifyCodeType()) {
switch (easyCaptchaConfig.getType()) {
case ARITHMETIC:
captcha = new ArithmeticCaptcha(width, height);
//固定设置为两位,图片为算数运算表达式
@@ -50,7 +50,7 @@ public class EasyCaptchaProducer {
captcha.setLen(length);
break;
default:
throw new RuntimeException("验证码配置信息错误!正确配置查看 VerifyCodeTypeEnum ");
throw new RuntimeException("验证码配置信息错误!正确配置查看 CaptchaTypeEnum ");
}
captcha.setFont(new Font(fontName, easyCaptchaConfig.getFontStyle(), easyCaptchaConfig.getFontSize()));
return captcha;

View File

@@ -2,7 +2,7 @@ package com.youlai.system.framework.easycaptcha.service;
import cn.hutool.core.util.IdUtil;
import com.wf.captcha.base.Captcha;
import com.youlai.system.common.constant.CacheConstants;
import com.youlai.system.common.constant.SecurityConstants;
import com.youlai.system.framework.easycaptcha.config.EasyCaptchaConfig;
import com.youlai.system.framework.easycaptcha.producer.EasyCaptchaProducer;
import com.youlai.system.pojo.dto.CaptchaResult;
@@ -39,9 +39,9 @@ public class EasyCaptchaService {
String captchaText = captcha.text(); // 验证码文本
String captchaBase64 = captcha.toBase64(); // 验证码图片Base64字符串
// 验证码文本缓存至Redis用于登录比较
// 验证码文本缓存至Redis用于登录校验
String verifyCodeKey = IdUtil.fastSimpleUUID();
redisTemplate.opsForValue().set(CacheConstants.VERIFY_CODE_CACHE_PREFIX + verifyCodeKey, captchaText,
redisTemplate.opsForValue().set(SecurityConstants.VERIFY_CODE_CACHE_PREFIX + verifyCodeKey, captchaText,
easyCaptchaConfig.getTtl(), TimeUnit.SECONDS);
CaptchaResult captchaResult = CaptchaResult.builder()

View File

@@ -1,11 +1,10 @@
package com.youlai.system.framework.security;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.IdUtil;
import com.youlai.system.common.constant.SecurityConstants;
import com.youlai.system.framework.security.userdetails.SysUserDetails;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.DecodingException;
import io.jsonwebtoken.security.Keys;
@@ -15,8 +14,8 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
@@ -24,7 +23,6 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* JWT token manager
*
@@ -35,16 +33,16 @@ import java.util.stream.Collectors;
public class JwtTokenManager {
/**
* secret key.
* token加密密钥
*/
@Value("${auth.token.secret_key}")
private String secretKey;
/**
* Token validity time(seconds).
* token有效期(单位:秒)
*/
@Value("${auth.token.token_validity}")
private long tokenValidity;
@Value("${auth.token.ttl}")
private Long tokenTtl;
/**
* secret key byte array.
@@ -63,13 +61,6 @@ public class JwtTokenManager {
* @return token
*/
public String createToken(Authentication authentication) {
long now = System.currentTimeMillis();
Date validity;
validity = new Date(now + tokenValidity * 1000L);
Claims claims = Jwts.claims().setSubject(authentication.getName());
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
claims.put("userId", userDetails.getUserId());
@@ -81,29 +72,32 @@ public class JwtTokenManager {
Set<String> roles = userDetails.getAuthorities().stream()
.map(item -> item.getAuthority()).collect(Collectors.toSet());
claims.put("authorities", roles);
claims.put("jti",IdUtil.fastSimpleUUID());
// 权限数据多放入Redis
Set<String> perms = userDetails.getPerms();
redisTemplate.opsForValue().set("USER_PERMS:" + userDetails.getUserId(), perms);
redisTemplate.opsForValue().set(SecurityConstants.USER_PERMS_CACHE_PREFIX + userDetails.getUserId(), perms);
return Jwts.builder().setClaims(claims).setExpiration(validity)
.signWith( Keys.hmacShaKeyFor(this.getSecretKeyBytes()),SignatureAlgorithm.HS256).compact();
// 过期时间
Date expirationTime = new Date(System.currentTimeMillis() + tokenTtl * 1000L);
return Jwts.builder()
//.setId(IdUtil.fastSimpleUUID()) TODO 设置jti无效
.setClaims(claims)
.setExpiration(expirationTime)
.signWith(Keys.hmacShaKeyFor(this.getSecretKeyBytes()), SignatureAlgorithm.HS256).compact();
}
/**
* 获取认证信息
*/
public Authentication getAuthentication(String token) {
if (jwtParser == null) {
jwtParser = Jwts.parserBuilder().setSigningKey(this.getSecretKeyBytes()).build();
}
Claims claims = jwtParser.parseClaimsJws(token).getBody();
Claims claims = this.getTokenClaims(token);
SysUserDetails principal = new SysUserDetails();
principal.setUserId(Convert.toLong(claims.get("userId")));
principal.setUsername(Convert.toStr(claims.get("username")));
principal.setDeptId(Convert.toLong(claims.get("deptId")));
principal.setDataScope(Convert.toInt(claims.get("dataScope")));
principal.setUserId(Convert.toLong(claims.get("userId"))); // 用户ID
principal.setUsername(Convert.toStr(claims.get("username"))); // 用户名
principal.setDeptId(Convert.toLong(claims.get("deptId"))); // 部门ID
principal.setDataScope(Convert.toInt(claims.get("dataScope"))); // 数据权限
List<SimpleGrantedAuthority> authorities = ((ArrayList<String>) claims.get("authorities"))
.stream()
@@ -114,13 +108,21 @@ public class JwtTokenManager {
}
/**
* 验证token
* 验证 token
*/
public void validateToken(String token) {
if (jwtParser == null) {
jwtParser = Jwts.parserBuilder().setSigningKey(this.getSecretKeyBytes()).build();
}
jwtParser.parseClaimsJws(token);
// 验证 JWT无异常解析成功说明JWT有效
Jws<Claims> claimsJws = jwtParser.parseClaimsJws(token);
// 验证 JWT 是否在黑名单(注销场景会存入黑名单)
Claims claims = claimsJws.getBody();
Boolean isBlack = redisTemplate.hasKey(SecurityConstants.BLACK_TOKEN_CACHE_PREFIX + claims.get("jti"));
if (isBlack) {
throw new RuntimeException("token 已被禁用");
}
}
public byte[] getSecretKeyBytes() {
@@ -130,9 +132,19 @@ public class JwtTokenManager {
} catch (DecodingException e) {
secretKeyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
}
}
return secretKeyBytes;
}
}
/**
* get token claims
*/
public Claims getTokenClaims(String token) {
if (jwtParser == null) {
jwtParser = Jwts.parserBuilder().setSigningKey(this.getSecretKeyBytes()).build();
}
Claims claims = jwtParser.parseClaimsJws(token).getBody();
return claims;
}
}

View File

@@ -1,6 +1,6 @@
package com.youlai.system.framework.security.config;
import com.youlai.system.framework.security.constant.SecurityConstants;
import com.youlai.system.common.constant.SecurityConstants;
import com.youlai.system.framework.security.filter.JwtAuthenticationFilter;
import com.youlai.system.framework.security.exception.MyAccessDeniedHandler;
import com.youlai.system.framework.security.exception.MyAuthenticationEntryPoint;

View File

@@ -1,16 +0,0 @@
package com.youlai.system.framework.security.constant;
/**
* Security 常量
*
* @author: haoxr
* @date: 2023/03/24
*/
public interface SecurityConstants {
/**
* 登录接口路径
*/
String LOGIN_PATH = "/api/v1/auth/login";
}

View File

@@ -1,10 +1,11 @@
package com.youlai.system.framework.security.filter;
import cn.hutool.core.util.StrUtil;
import com.youlai.system.common.constant.SecurityConstants;
import com.youlai.system.common.result.ResultCode;
import com.youlai.system.common.util.RequestUtils;
import com.youlai.system.common.util.ResponseUtils;
import com.youlai.system.framework.security.JwtTokenManager;
import com.youlai.system.framework.security.constant.SecurityConstants;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@@ -23,8 +24,6 @@ import java.io.IOException;
*/
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String TOKEN_PREFIX = "Bearer ";
private final JwtTokenManager tokenManager;
public JwtAuthenticationFilter(JwtTokenManager tokenManager) {
@@ -37,10 +36,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 登录接口放行
chain.doFilter(request, response);
}else{
String jwt = resolveToken(request);
String jwt = RequestUtils.resolveToken(request);
if (StrUtil.isNotBlank(jwt) && SecurityContextHolder.getContext().getAuthentication() == null) {
try {
// 验证JWT
// 验证 JWT
this.tokenManager.validateToken(jwt);
// JWT验证有效获取Authentication存入Security上下文
@@ -56,15 +55,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
}
}
/**
* Get token from header.
*/
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StrUtil.isNotBlank(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) {
return bearerToken.substring(TOKEN_PREFIX.length());
}
return null;
}
}

View File

@@ -3,10 +3,9 @@ package com.youlai.system.framework.security.filter;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.youlai.system.common.constant.CacheConstants;
import com.youlai.system.common.result.ResultCode;
import com.youlai.system.common.util.ResponseUtils;
import com.youlai.system.framework.security.constant.SecurityConstants;
import com.youlai.system.common.constant.SecurityConstants;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@@ -49,7 +48,7 @@ public class VerifyCodeFilter extends OncePerRequestFilter {
}
// 缓存中的验证码
String verifyCodeKey = request.getParameter(VERIFY_CODE_KEY);
Object cacheVerifyCode = redisTemplate.opsForValue().get(CacheConstants.VERIFY_CODE_CACHE_PREFIX + verifyCodeKey);
Object cacheVerifyCode = redisTemplate.opsForValue().get(SecurityConstants.VERIFY_CODE_CACHE_PREFIX + verifyCodeKey);
if (cacheVerifyCode == null) {
ResponseUtils.writeErrMsg(response, ResultCode.VERIFY_CODE_TIMEOUT);
} else {