1. 程式人生 > 其它 >如何優雅地使用 jwt 鑑權

如何優雅地使用 jwt 鑑權

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})