1.背景

  • 在开发pingss-sys脚手架(项目地址)时,需要在微服务分布式环境中管理权限
  • 上一篇写了基本的jwt无状态权限认证,但是存在两个问题
    • 生成的token如果过期时间太短,则每次到期后,都需要用户重新登录
    • 生成的token如果过期时间太长,由于token签发后,在有效期内无法注销,存在安全隐患
  • 本篇使用RefreshToken+redis增加安全性

2.思路

  • 用户登录成功生成token(生成访问令牌和刷新令牌,刷新令牌保存到redis)
  • 用户提交请求,验证访问令牌是否过期
    • 如果没有过期,已登录,流程完
    • 如果过期,判断刷新令牌是否过期
      • 如果过期,没有登录,流程完
      • 如果没有过期,重新生成访问令牌
        jwt认证流程

3.步骤

A.新的验证工具
/**
 *********************************************************
 ** @desc  : Jwt工作组件
 ** @author   Pings
 ** @date     2019/3/21
 ** @version  v1.0
 * *******************************************************
 */
@Component
public class JwtComponent {

    @Value("${sys.jwt.access-token.expire-time}")
    private long accessTokenExpireTime;
    @Value("${sys.jwt.refresh-token.expire-time}")
    private long refreshTokenExpireTime;
    @Value("${sys.jwt.secret}")
    private String secret;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    //**默认的刷新令牌过期时间60分钟
    private static final long DEFAULT_REFRESH_EXPIRE_TIME = 60;
    //**缓存中保存refreshToken key的前缀
    private static final String REFRESH_TOKEN_PREFIX = "refresh_token_";
    //**生成签名后缓存时间(生成签名后在指定时间内不重新生成新的签名,而使用缓存),默认5S
    private int tokenSignCacheTime = 5;
    //**缓存中保存accessToken key的前缀
    private static final String ACCESS_TOKEN_PREFIX = "access_token_";

    /**
     *********************************************************
     ** @desc :生成token
     ** @author Pings
     ** @date   2019/3/21
     ** @param  userName   用户名
     ** @return String
     * *******************************************************
     */
    public String sign(String userName) {
        //**同步每个用户的签名请求,不同用户不会同步
        synchronized (userName.intern()){
            String refreshTokenkey = this.getKey(userName);
            String accessTokenKey = ACCESS_TOKEN_PREFIX + userName;

            //**refreshToken为当前时间戳
            long refreshToken = System.currentTimeMillis();
            //**获取access token
            String accessToken = JWT.create()
                    .withClaim(USER_NAME, userName)
                    .withClaim(REFRESH_TOKEN_PREFIX, refreshToken)
                    .withExpiresAt(new Date(refreshToken + accessTokenExpireTime * 60 * 1000))
                    .sign(this.generateAlgorithm(userName));

            //**如果没有有效的accessToken,则缓存新的accessToken
            Boolean success = this.redisTemplate.opsForValue().setIfAbsent(accessTokenKey, accessToken, tokenSignCacheTime, TimeUnit.SECONDS);
            //**如果缓存新的accessToken成功,则缓存新的refreshToken
            if(success != null && success){
                this.redisTemplate.opsForValue().set(refreshTokenkey, refreshToken, refreshTokenExpireTime, TimeUnit.MINUTES);
            } else {  //**否则,返回缓存的accessToken
                accessToken = this.redisTemplate.opsForValue().get(accessTokenKey) + "";
            }

            return accessToken;
        }
    }

    /**
     *********************************************************
     ** @desc : 校验token
     ** @author Pings
     ** @date   2019/3/21
     ** @param  token    令牌
     ** @return boolean
     * *******************************************************
     */
    public boolean verify(String token) {
        String key = this.getKey(JwtUtil.getUserName(token));

        //**刷新令牌不存在/过期
        Boolean hasKey = this.redisTemplate.hasKey(key);
        if(hasKey == null || !hasKey)
            throw new TokenExpiredException("The Token not existed or expired.");

        //**刷新令牌和访问令牌的时间戳不一致
        long refreshToken = (long)this.redisTemplate.opsForValue().get(key);
        if(refreshToken != JwtUtil.getSignTimeMillis(token)){
            throw new TokenExpiredException("The Token has expired.");
        }

        //**访问令牌校验
        return JwtUtil.verify(token, secret);
    }

    //**获取缓存中保存refreshToken的key
    public String getKey(String userName) {
        return REFRESH_TOKEN_PREFIX + userName;
    }
}
B.jwt工具的改进
/**
 *********************************************************
 ** @desc  : JwtUtil
 ** @author  Pings
 ** @date    2019/1/23
 ** @version v1.0
 * *******************************************************
 */
public class JwtUtil {

    //**用户名称的key
    private static final String USER_NAME = "userName";
    //**签发时间戳的key
    private static final String SING_TIME_MILLIS = "signTimeMillis";
    //**默认的过期时间5分钟
    private static final long DEFAULT_EXPIRE_TIME = 5;
    //**默认的jwt加密secret
    private static final String DEFAULT_SECRET = "pingssys";

    /**
     *********************************************************
     ** @desc :生成访问令牌
     ** @author Pings
     ** @date   2019/1/23
     ** @param  secret         secret
     ** @param  userName       用户名
     ** @param  expiresTime    过期时间
     ** @param  signTimeMillis 签发时间戳
     ** @return String
     * *******************************************************
     */
    public static String sign(String secret, String userName, long expiresTime, long signTimeMillis) {
        long currentTimeMillis = System.currentTimeMillis();
        //**签发时间戳
        signTimeMillis = signTimeMillis > 0 ? signTimeMillis : currentTimeMillis;

        //**过期时间
        expiresTime = expiresTime > 0 ? expiresTime : DEFAULT_EXPIRE_TIME;
        expiresTime = expiresTime * 60 * 1000;

        Algorithm algorithm = Algorithm.HMAC256(getSecret(secret, userName));
        return JWT.create().withClaim(USER_NAME, userName)
                  .withClaim(SING_TIME_MILLIS, signTimeMillis)
                  .withExpiresAt(new Date(currentTimeMillis + expiresTime)).sign(algorithm);
    }

    /**
     *********************************************************
     ** @desc : 校验token
     ** @author Pings
     ** @date   2019/1/23
     ** @param  token    令牌
     ** @param  secret   secret
     ** @return boolean
     * *******************************************************
     */
    public static boolean verify(String token, String secret) {
        Algorithm algorithm = Algorithm.HMAC256(getSecret(secret, JwtUtil.getUserName(token)));
        JWTVerifier verifier = JWT.require(algorithm).build();
        verifier.verify(token);
        return true;
    }

    /**
     *********************************************************
     ** @desc : 获取用户名称
     ** @author Pings
     ** @date   2019/1/23
     ** @param  token  令牌
     ** @return String
     * *******************************************************
     */
    public static String getUserName(String token) {
        Claim claim = decodeToken(token, jwt -> jwt.getClaim(USER_NAME));
        return claim == null ? null : claim.asString();
    }

    /**
     *********************************************************
     ** @desc : 获取签发时间戳
     ** @author Pings
     ** @date   2019/3/21
     ** @param  token  令牌
     ** @return long
     * *******************************************************
     */
    public static long getSignTimeMillis(String token) {
        Claim claim = decodeToken(token, jwt -> jwt.getClaim(SING_TIME_MILLIS));
        return claim == null ? -1 : claim.asLong();
    }

    /**
     *********************************************************
     ** @desc :把访问令牌存放到响应的头信息中
     ** @author Pings
     ** @date   2019/3/21
     ** @param  response  响应
     ** @param  token     令牌
     * *******************************************************
     */
    public static void setHttpServletResponse(HttpServletResponse response, String token) {
        response.setHeader("Authorization", token);
        response.setHeader("Access-Control-Expose-Headers", "Authorization");
    }

    /**
     *********************************************************
     ** @desc : token解码
     ** @author Pings
     ** @date   2019/1/23
     ** @param  token  标记
     ** @return T
     * *******************************************************
     */
    private static <T> T decodeToken(String token, Function<DecodedJWT, T> func) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return func.apply(jwt);
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    //**获取jwt加密secret
    private static String getSecret(String secret, String userName){
        return userName + (StringUtils.isNotBlank(secret) ? secret : DEFAULT_SECRET);
    }
}
C.shiro filter的改进
/**
 *********************************************************
 ** @desc  : JwtFilter
 ** @author  Pings
 ** @date    2019/1/23
 ** @version v1.0
 * *******************************************************
 */
public class JwtFilter extends BasicHttpAuthenticationFilter {

    @Autowired
    private JwtComponent jwtComponent;

    /**登录认证*/
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        //**判断用户是否要登入
        if (this.isLoginAttempt(request, response)) {
            try {
                //**登录认证
                return this.executeLogin(request, response);
            } catch (Exception e) {
                //**访问令牌过期 and 刷新令牌未过期则重新生成访问令牌
                try {
                    if (e.getCause() instanceof TokenExpiredException) {
                        String userName = JwtUtil.getUserName(this.getAuthzHeader(request));
                        String token = jwtComponent.sign(userName);
                        this.executeLogin(token, request, response);

                        //**修改响应头的访问令牌
                        JwtUtil.setHttpServletResponse((HttpServletResponse) response, token);
                        return true;
                    }
                } catch (Exception ex){
                    ex.printStackTrace();
                }

                e.printStackTrace();
                this.response401(request, response, e.getMessage());
                return false;
            }
        }

        return true;
    }

    /**去掉调用executeLogin,避免循环调用doGetAuthenticationInfo方法*/
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        this.sendChallenge(request, response);
        return false;
    }

    /**检测Header里面是否包含Authorization字段*/
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        String token = this.getAuthzHeader(request);
        return token != null;
    }

    /**调用JwtRealm进行登录认证*/
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        return this.executeLogin(this.getAuthzHeader(request), request, response);
    }

    /**支持跨域*/
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");
        httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));

        //**跨域时会首先发送一个OPTIONS请求,返回正常状态
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }

        return super.preHandle(request, response);
    }

    /**401时直接返回Response信息*/
    private void response401(ServletRequest req, ServletResponse resp, String msg) {
        HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
        httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json; charset=utf-8");

        ApiResponse response = new ApiResponse(HttpStatus.UNAUTHORIZED.value(), "Unauthorized: " + msg, null);
        String data = JSONObject.toJSONString(response);
        try(PrintWriter out = httpServletResponse.getWriter()) {
            out.append(data);
        } catch (IOException e) {
            throw new RuntimeException(e.getMessage());
        }
    }

    /**调用JwtRealm进行登录认证*/
    private boolean executeLogin(String token, ServletRequest request, ServletResponse response) throws Exception {
        //**创建JwtToken
        JwtToken jwtToken = new JwtToken(token);
        //**提交给JwtRealm认证
        this.getSubject(request, response).login(jwtToken);
        //**没有抛出异常则代表登入成功
        return true;
    }

}
D.shiro realm的改进
/**
 *********************************************************
 ** @desc  : 自定义Realm
 ** @author  Pings
 ** @date    2019/1/23
 ** @version v1.0
 * *******************************************************
 */
public class JwtRealm extends AuthorizingRealm {

    @Reference(version = "${sys.service.version}")
    private UserService userService;
    @Autowired
    private JwtComponent jwtComponent;

    /**必须重写此方法,不然Shiro会报错*/
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**权限验证*/
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        String userName = JwtUtil.getUserName(principals.toString());

        //**获取用户
        User user = this.userService.getByUserName(userName);

        //**用户角色
        Set<String> roles = user.getRoles().stream().map(Role::getCode).collect(toSet());
        authorizationInfo.addRoles(roles);

        //**用户权限
        Set<String> rights = user.getRoles().stream().map(Role::getRights).flatMap(List::stream).map(Right::getCode).collect(toSet());
        authorizationInfo.addStringPermissions(rights);

        return authorizationInfo;
    }

    /**登录验证*/
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
        String token = (String) auth.getCredentials();
        //**获取用户名称
        String userName = JwtUtil.getUserName(token);
        //**用户名称为空
        if (StringUtils.isBlank(userName)) {
            throw new AuthenticationException("The account in Token is empty.");
        }

        //**获取用户
        User user = this.userService.getByUserName(userName);
        if (user == null) {
            throw new AuthenticationException("The account does not exist.");
        }

        //**登录认证
        if (jwtComponent.verify(token)) {
            return new SimpleAuthenticationInfo(token, token, "jwtRealm");
        }

        throw new AuthenticationException("Username or password error.");
    }

    /**管理员不验证权限*/
    @Override
    public  boolean isPermitted(PrincipalCollection principal, String permission){
        AuthorizationInfo info = this.getAuthorizationInfo(principal);
        return info.getRoles().contains("admin") || super.isPermitted(principal, permission);
    }

    /**管理员不验证角色*/
    @Override
    public boolean hasRole(PrincipalCollection principal, String roleIdentifier) {
        AuthorizationInfo info = this.getAuthorizationInfo(principal);
        return info.getRoles().contains("admin") || super.hasRole(principal, roleIdentifier);
    }
}
E.配置shrio
/**
 *********************************************************
 ** @desc  : Shiro配置
 ** @author  Pings
 ** @date    2019/1/23
 ** @version v1.0
 * *******************************************************
 */
@Configuration
public class ShiroConfig {

    @Bean
    public JwtRealm jwtRealm(){
        return new JwtRealm();
    }

    @Bean
    @Scope("prototype")
    public JwtFilter jwtFilter(){
        return new JwtFilter();
    }

    @Bean("securityManager")
    public DefaultWebSecurityManager securityManager(JwtRealm jwtRealm) {
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        //**使用自定义JwtRealm
        manager.setRealm(jwtRealm);

        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        manager.setSubjectDAO(subjectDAO);

        return manager;
    }

    @Bean("shiroFilter")
    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager, JwtFilter jwtFilter) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();

        //**添加自定义过滤器jwt
        Map<String, Filter> filterMap = new LinkedHashMap<>();
        filterMap.put("jwt", jwtFilter);
        factoryBean.setFilters(filterMap);

        factoryBean.setSecurityManager(securityManager);

        //**自定义url规则
        Map<String, String> filterRuleMap = new LinkedHashMap<>();
        //不拦截请求swagger-ui页面请求
        filterRuleMap.put("/webjars/**", "anon");
        //jwt过滤器拦截请求
        filterRuleMap.put("/**", "jwt");
        factoryBean.setFilterChainDefinitionMap(filterRuleMap);

        return factoryBean;
    }

    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    @Bean
    public DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
        creator.setProxyTargetClass(true);
        return creator;
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }
}
F.LoginController中编写登录方法
/**
 *********************************************************
 ** @desc : 登录
 ** @author Pings
 ** @date   2019/1/22
 ** @param  userName  用户名称
 ** @param  password  用户密码
 ** @return ApiResponse
 * *******************************************************
 */
@ApiOperation(value="登录", notes="验证用户名和密码")
@PostMapping(value = "/account")
public ApiResponse account(String userName, String password, HttpServletResponse response){
    if(StringUtils.isBlank(userName) || StringUtils.isBlank(password))
        throw new UnauthorizedException("用户名/密码不能为空");

    //**md5加密
    password = DigestUtils.md5DigestAsHex(password.getBytes());

    User user = this.userService.getByUserName(userName);
    if(user != null && user.getPassword().equals(password)) {
        JwtUtil.setHttpServletResponse(response, jwtComponent.sign(userName));

        //**用户权限
        Set<String> rights = user.getRoles().stream().map(Role::getRights).flatMap(List::stream).map(Right::getCode).collect(toSet());
        return new ApiResponse(200, "登录成功", rights);
    } else
        return new ApiResponse(500, "用户名/密码错误");
}
G.LoginController中编写登出方法
/**
 *********************************************************
 ** @desc : 退出登录
 ** @author Pings
 ** @date   2019/3/26
 ** @return ApiResponse
 * *******************************************************
 */
@ApiOperation(value="退出登录", notes="退出登录")
@GetMapping(value = "/logout")
public ApiResponse account(){
    //**删除refresh token
    this.redisTemplate.delete(this.jwtComponent.getKey(this.getCurrentUserName()));

    //**退出登录
    SecurityUtils.getSubject().logout();

    return new ApiResponse(200, "退出登录成功");
}

4.说明

  • dubbo分布式系统权限认证
    • 使用redis保存RefreshToken,是为了在每个子系统都可以访问到。某个子系统签发的token即可访问所有其它的子系统
  • 存在的问题
    • 依赖redis实现多个子系统之间RefreshToken共享,实际和session序列化实现共享效果差不多
    • 代码没有封装好,每个子系统都需要一套重复的逻辑代码
    • 不能在基于AccessToken的方式和结合RefreshToken、AccessToken的认证方式之间进行切换
    • 理想很丰满,现实很骨感。实际使用时发现一个很严重的问题,当用户的AccessToken失效时,如果用户使用该失效的AccessToken同时发起多个请求,会导致RefreshToken认证失败。
      • 引起的原因:
        第一个请求认证失败,重新生成了RefreshToken,并把新RefreshToken保存到了redis中,第二个请求的RefreshToken还是上一次的,RefreshToken就会不相同,导致认证失败;
      • 其它问题:
        如果多个请求分别请求成功,则每个请求都会生成一个新的AccessToken,但前面生成的AccessToken都会被后面生成的AccessToken覆盖,导致前面的AccessToken失效,客户端会很难判断下一个请求时应该使用哪个AccessToken;
    • 下一篇jwt无状态权限认证(pings-shiro-jwt)解决上述问题,并把上述两种认证方式封装成一个jar包,支持对两种认证方式进行切换
Logo

Authing 是一款以开发者为中心的全场景身份云产品,集成了所有主流身份认证协议,为企业和开发者提供完善安全的用户认证和访问管理服务

更多推荐