如何優雅地使用 jwt 鑑權
阿新 • • 發佈:2022-05-15
1、匯入依賴
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
2、建立配置和工具類
jwt: config: key: my-secret-salt # 鹽 ttl: 10080 # token存活時間,單位分鐘 expire: 120 # 無操作過期時間,單位分鐘 singleSignOn: true # true為啟用單點登入 singleSignOnKey: singleSignOnKey_
/**
* jwt配置
*/
@Data
@Component
@ConfigurationProperties("jwt.config")
public class JwtConfig {
private String key;
private long expire;
private long ttl;
private boolean singleSignOn;
private String singleSignOnKey;
}
@Component public class JwtUtil { @Autowired private JwtConfig jwtConfig; /** * 生成token */ public String createJWT(int userId, String userName, int userType) { long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); JwtBuilder builder = Jwts.builder() .setId(String.valueOf(userId)) .setSubject(userName) .setIssuedAt(now) .signWith(SignatureAlgorithm.HS256, jwtConfig.getKey()) .claim("userType", userType) ; if (jwtConfig.getTtl() > 0) { Date date = new Date(nowMillis + (jwtConfig.getTtl() * 60 * 1000)); builder.setExpiration(date); } return builder.compact(); } /** * 解析JWT */ public Claims parseJWT(String jwtStr) { return Jwts.parser() .setSigningKey(jwtConfig.getKey()) .parseClaimsJws(jwtStr) .getBody() ; } }
3、鑑權
準備好需要使用的類
@Data @NoArgsConstructor public class UserDTO { private int id; private String name; private UserTypeEnum userType; private String mobile; private String password; private String token; public UserDTO(int id, String name, UserTypeEnum userType) { this.id = id; this.name = name; this.userType = userType; } } @Getter @AllArgsConstructor public enum UserTypeEnum { NONE(-1, null), ADMIN(1, "管理員"), USER(2, "使用者"), ; @JsonValue private int value; private String desc; @JsonCreator(mode = JsonCreator.Mode.DELEGATING) public static UserTypeEnum getByValue(int value) { for (UserTypeEnum userTypeEnum : values()) { if (userTypeEnum.getValue() == value) { return userTypeEnum; } } return null; } } public class ExpiredException extends RuntimeException { } public class MultiLoginException extends RuntimeException { } public class UnauthorizedException extends RuntimeException { }
jwt
攔截器
@Slf4j
@Component
public class JwtInterceptor extends HandlerInterceptorAdapter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private JwtConfig jwtConfig;
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 放行所有options請求
if (RequestMethod.OPTIONS.name().equalsIgnoreCase(request.getMethod())) {
return true;
}
try {
final String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
// The part after "Bearer "
final String token = authHeader.substring(7);
Claims claims = jwtUtil.parseJWT(token);
if (claims != null) {
// 提取使用者資訊
int userId = Integer.parseInt(claims.getId());
String userName = claims.getSubject();
int userType = Integer.parseInt(claims.get("userType").toString());
log.info("[{}_{}] uri: {}", userId, userName, request.getRequestURI());
// 單點登入
if (jwtConfig.isSingleSignOn()) {
String singleSignOnKey = jwtConfig.getSingleSignOnKey() + userId;
String singleToken = redisTemplate.opsForValue().get(singleSignOnKey);
if (singleToken == null) {
throw new ExpiredException();
} else if (!token.equals(singleToken)) {
throw new MultiLoginException();
}
// 續簽
redisTemplate.opsForValue().set(singleSignOnKey, token, jwtConfig.getExpire(), TimeUnit.MINUTES);
}
// 將使用者資訊存入 request,以便後續使用
request.setAttribute("currUser", new UserDTO(userId, userName, UserTypeEnum.getByValue(userType)));
return true;
}
}
} catch (ExpiredJwtException | ExpiredException e) {
throw new ExpiredException();
} catch (MultiLoginException e) {
throw new MultiLoginException();
} catch (Exception e) {
e.printStackTrace();
}
throw new UnauthorizedException();
}
}
配置攔截器,攔截除登入以外的所有介面
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private JwtInterceptor jwtInterceptor;
/***
* addPathPatterns("/**"):攔截所有請求
* excludePathPatterns: 不攔截的請求
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/**/login")
}
}
5、配置全域性異常和響應處理
@Data
public class JSONResult {
/** 響應業務狀態 */
private Integer code;
/** 響應訊息 */
private String message;
/** 響應中的資料 */
private Object data;
public JSONResult() {
}
public JSONResult(Object data) {
this.code = 20000;
this.message = "OK";
this.data = data;
}
public JSONResult(Integer code, String message, Object data) {
this.code = code;
this.message = message;
this.data = data;
}
public static JSONResult ok() {
return new JSONResult(null);
}
public static JSONResult ok(Object data) {
return new JSONResult(data);
}
public static JSONResult build(Integer code, String message) {
return new JSONResult(code, message, null);
}
public static JSONResult build(Integer code, String message, Object data) {
return new JSONResult(code, message, data);
}
public static JSONResult errorException(String message) {
return new JSONResult(500, message, null);
}
public static JSONResult errorMap(Object data) {
return new JSONResult(501, "error", data);
}
public static JSONResult errorMsg(String message) {
return new JSONResult(555, message, null);
}
public static JSONResult unauthorized() {
return new JSONResult(401, "未授權", null);
}
public static JSONResult multiLogin() {
return new JSONResult(402, "賬號已在別處登陸!", null);
}
public static JSONResult expired() {
return new JSONResult(403, "登陸超時,請重新登陸!", null);
}
}
/**
* 全域性異常和響應處理
*/
@Slf4j
@RestControllerAdvice("com.xxx.controller")
public class GlobalResponseHandler implements ResponseBodyAdvice<Object> {
@ExceptionHandler(UnauthorizedException.class)
public JSONResult unauthorizedException() {
return JSONResult.unauthorized();
}
@ExceptionHandler(MultiLoginException.class)
public JSONResult multiLoginException() {
return JSONResult.multiLogin();
}
@ExceptionHandler(ExpiredException.class)
public JSONResult expiredException() {
return JSONResult.expired();
}
/**
* 攔截之前業務處理,請求先到supports再到beforeBodyWrite
* 用法1:自定義是否攔截。若方法名稱(或者其他維度的資訊)在指定的常量範圍之內,則不攔截。
*
* @return 返回true會執行攔截;返回false不執行攔截
*/
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
final String returnTypeName = methodParameter.getParameterType().getName();
return !"com.xxx.utils.JSONResult".equals(returnTypeName)
&& !"org.springframework.http.ResponseEntity".equals(returnTypeName);
}
/**
* 向客戶端返回響應資訊之前的業務邏輯處理
* 用法1:無論controller返回什麼型別的資料,在寫入客戶端響應之前統一包裝,客戶端永遠接收到的是約定格式的內容
* 用法2:在寫入客戶端響應之前統一加密
*
* @return 最終響應內容
*/
@Override
public Object beforeBodyWrite(Object body, MethodParameter methodParameter
, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass
, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
if (!mediaType.includes(MediaType.APPLICATION_JSON)) {
return body;
}
return JSONResult.ok(body);
}
}
5、登入測試
@RequestMapping(value = "/login", method = RequestMethod.POST)
public UserDTO login(@RequestBody UserDTO userDTO) {
String mobile = userDTO.getMobile();
String password = userDTO.getPassword();
Assert.isTrue(StringUtils.isNotEmpty(mobile) && StringUtils.isNotEmpty(password), "請輸入登陸資訊");
// todo 做登入操作...
userDTO = new UserDTO();
userDTO.setId(1);
userDTO.setName("管理員");
userDTO.setUserType(UserTypeEnum.ADMIN);
// 生成token
String token = jwtUtil.createJWT(userDTO.getId(), userDTO.getName(), userDTO.getUserType().getId());
userDTO.setToken(token);
// 單點登入
if (jwtConfig.isSingleSignOn()) {
String singleSignOnKey = jwtConfig.getSingleSignOnKey() + userDTO.getId();
log.info("singleSignOnKey key --> {}", singleSignOnKey);
redisTemplate.opsForValue().set(singleSignOnKey, token, jwtConfig.getExpire(), TimeUnit.MINUTES);
}
return userDTO;
}
@RequestMapping(value = "/userinfo", method = RequestMethod.GET)
public UserDTO userinfo(HttpServletRequest request) {
return (UserDTO) request.getAttribute("currUser");
}
訪問登入介面即可獲取token
然後在不帶token
的情況訪問userinfo
介面返回未授權
填入token
後即可正確訪問並獲取當前使用者資訊
6、使用註解優雅地獲取使用者資訊
@Documented
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrUser {
}
public class CurrUserImpl implements HandlerMethodArgumentResolver {
/**
* 判斷是否支援使用@CurrUser註解的引數
*/
@Override
public boolean supportsParameter(MethodParameter methodParameter) {
// 如果該引數註解有@CurrUser且引數型別是UserModel
return methodParameter.getParameterAnnotation(CurrUser.class) != null && methodParameter.getParameterType() == UserDTO.class;
}
/**
* 注入引數值
*/
@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
// 取得HttpServletRequest
HttpServletRequest request = (HttpServletRequest) nativeWebRequest.getNativeRequest();
// 取出session中的資料
return request.getAttribute("currUser");
}
}
在WebConfig
繼承addArgumentResolvers
方法
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private JwtInterceptor jwtInterceptor;
/***
* addPathPatterns("/**"):攔截所有請求
* excludePathPatterns: 不攔截的請求
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/**/login")
}
/**
* 自定義引數處理器
* @param argumentResolvers
*/
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new CurrUserImpl());
}
}
改造userinfo
方法並再次訪問
@RequestMapping(value = "/userinfo", method = RequestMethod.GET)
public UserDTO userinfo(@CurrUser UserDTO userDTO) {
return userDTO;
}
7、使用註解限制介面的訪問許可權
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface RequiredPermission {
/**
* 允許訪問的使用者型別
*/
UserTypeEnum[] userType() default UserTypeEnum.NONE;
}
@Component
public class RequiredPermissionImpl extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 獲取方法上的註解
RequiredPermission requiredPermission = handlerMethod.getMethod().getAnnotation(RequiredPermission.class);
// 如果方法上的註解為空 則獲取類的註解
if (requiredPermission == null) {
requiredPermission = handlerMethod.getMethod().getDeclaringClass().getAnnotation(RequiredPermission.class);
}
// 如果註解為null, 說明不需要攔截, 直接放過
if (requiredPermission == null) {
return true;
}
// 判斷使用者型別許可權
int userType = (int) request.getAttribute("userType");
if (userType == UserTypeEnum.ADMIN.getId()) {
return true;
}
UserTypeEnum[] userTypeEnums = requiredPermission.userType();
for (UserTypeEnum userTypeEnum : userTypeEnums) {
if (userTypeEnum.getId() == userType) {
return true;
}
}
throw new UnauthorizedException();
}
return true;
}
}
這樣被@RequiredPermission
標記的介面普通使用者就訪問不了了
當要開放給某個使用者型別時@RequiredPermission(userType = {UserTypeEnum.USER})