在上一篇中,实现了session版本的shiro认证鉴权,这一篇中将在上一篇的基础上进行改造,实现无状态的jwt进行认证鉴权。

1、禁用会话

jwt什么的稍后再讲,我们先实现禁用session。修改配置类ShiroConfig,添加会话管理器并禁用其调度器,同时禁用session存储,修改内容如下

@Bean
public DefaultWebSessionManager defaultWebSessionManager() {
    DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
    sessionManager.setSessionValidationSchedulerEnabled(false);
    sessionManager.setSessionIdCookieEnabled(false);
    return sessionManager;
}

@Bean
public DefaultWebSecurityManager securityManager(List<Realm> realms) {
    DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    securityManager.setRealms(realms);
    securityManager.setCacheManager(shiroCacheManager());
    securityManager.setSessionManager(defaultWebSessionManager());
    DefaultSubjectDAO subjectDAO = (DefaultSubjectDAO) securityManager.getSubjectDAO();
    // 禁用session存储
    ((DefaultSessionStorageEvaluator) subjectDAO.getSessionStorageEvaluator()).setSessionStorageEnabled(false);
    return securityManager;
}

接下来看一下配置是否生效,启动项目,打开接口文档页面http://localhost:8080/doc.html,选择认证下登录接口,使用上一篇中创建的admin用户进行登录,发送请求后,用户信息可以正常返回,说明登录确实成功了,再按F12请求一次查看细节,会发现Set-Cookie中并没有JSESSIONID,说明session确实禁用成功了,如下图所示


spring 禁用druid界面 springboot禁用session_ci

而如果把配置还原,那么我们会发现,响应头中是有session的,如下图所示


spring 禁用druid界面 springboot禁用session_spring boot_02

在添加了禁用session的配置后,先执行登录,然后随便找一个查询接口请求一下,会发现返回的结果为401未认证,无论怎么试都是这样,也再次证明确实已经禁用了session

session已经禁用成功了,接下来就是改造jwt了。

2、Jwt依赖及工具类

因为使用的jwt认证,所以首先需要添加jwt相关依赖,添加如下依赖到pom.xml文件中

<properties>
    <jjwt.version>0.9.1</jjwt.version>
</properties>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>${jjwt.version}</version>
</dependency>

新建工具类JwtUtils,用于生成jwt以及jwt的校验等,代码如下,其中SECRET_KEYbase64编码格式,具体如何取,可自己定义。

@Slf4j
public class JwtUtils {
    /**
     * 秘钥-base64编码
     */
    private static final String SECRET_KEY = "bXktc2VjcmV0LWtleQ==";
    /**
     * 加密类型 三个值可取 HS256  HS384  HS512
     */
    private static final SignatureAlgorithm JWT_ALG = SignatureAlgorithm.HS256;

    private static Key generateKey() {
        // 将将密码转换为字节数组
        byte[] bytes = Base64.decodeBase64(SECRET_KEY);
        // 根据指定的加密方式,生成密钥
        return new SecretKeySpec(bytes, JWT_ALG.getJcaName());
    }

    /**
     * 生成jwt
     * <p>
     * 使用Hs256算法, 私匙使用固定JWT_SEC秘钥
     *
     * @param ttlSeconds jwt过期时间(秒) 小于0则表示永不过期
     * @param username   用户名 可根据需要传递的信息添加更多, 因为浏览器get传参url限制,不建议放置过多的参数
     * @param ext        额外参数,如用户id等
     * @return token
     */
    public static String createToken(String username, long ttlSeconds, Map<String, Object> ext) {
        // 指定签名的时候使用的签名算法,也就是header那部分
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

        // 生成JWT的时间
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);

        // 创建payload的私有声明(根据特定的业务需要添加)
        Map<String, Object> claims = new HashMap<>();
        claims.put(AuthConstant.CLAIMS_KEY_USER_NAME, username);
        if (MapUtil.isNotEmpty(ext)) {
            claims.putAll(ext);
        }

        // 设置jwt的body
        JwtBuilder builder = Jwts.builder()
                // 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
                .setClaims(claims)
                // 设置jti(JWT ID):是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。
                .setId(UUID.randomUUID().toString())
                // iat: jwt的签发时间
                .setIssuedAt(now)
                // 代表这个JWT的主体,即它的所有人,这个是一个json格式的字符串
                .setSubject(username)
                // 设置签名使用的签名算法和签名使用的秘钥
                .signWith(signatureAlgorithm, generateKey());
        if (ttlSeconds >= 0) {
            long expMillis = nowMillis + ttlSeconds * 1000;
            Date exp = new Date(expMillis);
            // 设置过期时间
            builder.setExpiration(exp);
        }
        return builder.compact();
    }


    /**
     * Token的解密
     *
     * @param token 加密后的token
     * @return Claims
     */
    public static Claims parse(String token) {
        // 得到DefaultJwtParser
        return Jwts.parser()
                // 设置签名的秘钥
                .setSigningKey(generateKey())
                // 设置需要解析的jwt
                .parseClaimsJws(token)
                .getBody();
    }

    public static boolean isValid(String token) {
        try {
            return parse(token) != null;
        } catch (Exception e) {
            log.error("token parse error: {}", e.getMessage());
            return false;
        }
    }

    public static Long getUserId(String token) {
        return parse(token).get(AuthConstant.CLAIMS_KEY_USER_ID, Long.class);
    }

    public static String getUserName(String token) {
        return parse(token).get(AuthConstant.CLAIMS_KEY_USER_NAME, String.class);
    }

    public static HttpServletRequest getRequest() {
        return ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
    }

    public static String getTokenFromHeader() {
        String header = getRequest().getHeader(AuthConstant.TOKEN_HEADER);
        return StrUtil.subSuf(header, AuthConstant.TOKEN_PREFIX.length());
    }

}

上面的代码中用到的AuthConstant接口常量如下

public interface AuthConstant {
    /**
     * 密码加盐
     */
    String SECRET_SALT = "my-secret-salt";
    /**
     * 用户id key
     */
    String CLAIMS_KEY_USER_ID = "userId";
    /**
     * 用户名 key
     */
    String CLAIMS_KEY_USER_NAME = "userName";
    /**
     * token请求头
     */
    String TOKEN_HEADER = "Authorization";
    /**
     * token前缀
     */
    String TOKEN_PREFIX = "Bearer ";
    /**
     * jwt黑名单缓存名称
     */
    String JWT_BLACKLIST_CACHE_NAME = "jwt-blacklist-cache";
    /**
     * 管理员角色编号
     */
    String ADMIN_ROLE = "admin";
}

3、重写登录退出接口

认证与鉴权稍后再讲,先将jwt这块完成再说。

首先是登录接口,session版本的登录接口中,我们是直接就登录了,但是在jwt模式下,登录接口,其实应该叫token获取接口,接口会先校验账号密码,使用账号密码登录成功后,返回jwt

至于退出,因为jwt是无状态的,所以服务器不会保存会话,所以执行退出的时候,如果当前的jwt是永不过期的,那就将它加入到黑名单,以后都不能再用,除非是人工干预将其从黑名单中移除;而如果是有过期时间的,那就将它添加到黑名单,且缓存过期时间等于其有效期即可。

登录接口修改如下

@ApiOperation("登录")
@PostMapping("login")
public ApiResult<AccessToken> login(@RequestBody @Valid LoginRegistryParam param) {
    UsernamePasswordToken token = new UsernamePasswordToken(param.getUsername(), param.getPassword());
    SecurityUtils.getSubject().login(token);
    UserPrincipalEntity userPrincipal = (UserPrincipalEntity) SecurityUtils.getSubject().getPrincipal();
    // 获取token,为了方便测试,设置有效期300秒
    String jwt = JwtUtils.createToken(userPrincipal.getUsername(), 300L,
                                      ImmutableMap.of(AuthConstant.CLAIMS_KEY_USER_ID, userPrincipal.getId()));
    AccessToken accessToken = new AccessToken();
    BeanUtils.copyProperties(userPrincipal,accessToken);
    accessToken.setToken(jwt);
    return ApiResult.ok(accessToken);
}

其中的AccessToken定义如下

@EqualsAndHashCode(callSuper = true)
@Data
public class AccessToken extends UserPrincipalEntity {
    /**
     * token
     */
    private String token;
}

可以看到,先执行登录认证,如果登录成功直接返回前台jwt即可,至于登录过程,还是使用之前的LoginRealm,无需修改。

使用admin登录,返回结果如下所示


spring 禁用druid界面 springboot禁用session_shiro_03

然后是退出接口,退出接口,改造如下,多了一个将token放入黑名单缓存的操作

@ApiOperation("退出")
@PostMapping("logout")
public ApiResult<Void> logout(HttpServletRequest request) {
    SecurityUtils.getSubject().logout();
    String header = request.getHeader(AuthConstant.TOKEN_HEADER);
    if (StrUtil.isNotBlank(header) && header.startsWith(AuthConstant.TOKEN_PREFIX)) {
        String accessToken = StrUtil.subAfter(header, AuthConstant.TOKEN_PREFIX, false);
        JwtBlacklistCache.addToBlacklist(accessToken);
    }
    return ApiResult.ok();
}

黑名单缓存类定义如下

@Component
public class JwtBlacklistCache implements InitializingBean {

    private static CacheManager cacheManager;

    @Autowired
    public void setCacheManager(CacheManager cacheManager) {
        JwtBlacklistCache.cacheManager = cacheManager;
    }

    public static void addToBlacklist(String token) {
        String jwtId = JwtUtils.parse(token).getId();
        cacheManager.getCache(AuthConstant.JWT_BLACKLIST_CACHE_NAME).put(jwtId, 1);
    }

    public static boolean isInBlacklist(String token) {
        String jwtId = JwtUtils.parse(token).getId();
        return cacheManager.getCache(AuthConstant.JWT_BLACKLIST_CACHE_NAME).get(jwtId) != null;
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        if(cacheManager.getCache(AuthConstant.JWT_BLACKLIST_CACHE_NAME) == null){
            throw new RuntimeException("ehcache.xml中黑名单缓存为空!请先进行配置!");
        }
    }
}

同时,需要在ehcache.xml配置文件中配置一下黑名单缓存

<!-- jwt 黑名单缓存,默认两小时过期 -->
<cache name="jwt-blacklist-cache"
       maxElementsInMemory="20000"
       eternal="false"
       overflowToDisk="true"
       diskPersistent="true"
       timeToLiveSeconds="7200"
       diskExpiryThreadIntervalSeconds="7200"/>

4、Realm

之前的LoginRealm是根据用户名密码来进行认证的,但现在,我们需要使用jwt来进行认证,所以LoginRealm就不适用了,毕竟jwt中虽然有username,但是没有password,所以需要编写对应的jwt认证逻辑。

首先修改原先的LoginRealm,将userPrincipalService修改为protected,同时去除缓存相关设置以保证每次获取请求登录接口获取token时都从数据库查询最新的用户信息,其余不变。代码如下

protected final UserPrincipalService userPrincipalService;

    public LoginRealm(UserPrincipalService userPrincipalService, CacheManager cacheManager) {
        this.userPrincipalService = userPrincipalService;

        // 密码比对器 SHA-256
        HashedCredentialsMatcher hashMatcher = new HashedCredentialsMatcher();
        hashMatcher.setHashAlgorithmName(Sha256Hash.ALGORITHM_NAME);
        hashMatcher.setStoredCredentialsHexEncoded(false);
        hashMatcher.setHashIterations(1024);
        this.setCredentialsMatcher(hashMatcher);
    }

然后新建jwt认证对应的BearerTokenRealm,如下

@Component
public class BearerTokenRealm extends LoginRealm {

    public BearerTokenRealm(UserPrincipalService userPrincipalService, CacheManager cacheManager) {
        super(userPrincipalService, cacheManager);

        this.setCachingEnabled(true);
        this.setCacheManager(cacheManager);
        this.setAuthenticationCachingEnabled(true);
        this.setAuthorizationCachingEnabled(true);
        this.setAuthenticationCacheName("shiro-authentication-cache");
        this.setAuthorizationCacheName("shiro-authorization-cache");

        // 凭证比对时仅校验jwt是否有效即可
        this.setCredentialsMatcher((token, info) -> {
            String jwt = token.getPrincipal().toString();
            return JwtUtils.isValid(jwt);
        });
    }

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof BearerToken;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        BearerToken bearerToken = (BearerToken) token;

        String jwt = bearerToken.getToken();
        if (StrUtil.isBlank(jwt)) {
            throw new IncorrectCredentialsException("token不能为空!");
        }
        if (!JwtUtils.isValid(jwt)) {
            throw new IncorrectCredentialsException("token不合法或已过期!");
        }
        Claims claims = JwtUtils.parse(jwt);

        String username = claims.get(AuthConstant.CLAIMS_KEY_USER_NAME, String.class);

        UserPrincipalEntity userPrincipal = userPrincipalService.getUserPrincipal(username);
        if (userPrincipal == null) {
            return null;
        }
        return new SimpleAuthenticationInfo(userPrincipal, jwt, getName());

    }

    @Override
    protected Object getAuthenticationCacheKey(PrincipalCollection principals) {
        UserPrincipalEntity userPrincipal = (UserPrincipalEntity) principals;
        return userPrincipal.getUsername();
    }

    @Override
    protected Object getAuthenticationCacheKey(AuthenticationToken token) {
        BearerToken bearerToken = (BearerToken) token;
        Claims claims = JwtUtils.parse(bearerToken.getToken());
        return claims.get(AuthConstant.CLAIMS_KEY_USER_NAME, String.class);
    }
}

对比LoginRealm可以发现,BearerTokenRealm的主要改动是在认证上,授权则与父类保持一致,同时,该类只支持BearerToken的认证。另外,重写了两个getAuthenticationCacheKey方法,以此保证缓存key的一致性,避免重复查询数据库。

5、自定义过滤器

新建自定义过滤器来拦截token,如果请求头中存在token,则进行认证,否则当做匿名用户处理。

public class JwtAuthFilter extends AuthenticatingFilter {
    
    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
        String token = getToken(request);
        if (StrUtil.isNotBlank(token)) {
            return new BearerToken(token);
        }
        return null;
    }


    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        return ((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name()) || super.isPermissive(mappedValue);
    }


    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        String token = getToken(request);
        if (StrUtil.isNotBlank(token)) {
            if (JwtBlacklistCache.isInBlacklist(token)) {
                writeResult(ApiResult.error(HttpStatus.UNAUTHORIZED, "token已失效!"));
                return false;
            }
            if (!JwtUtils.isValid(token)) {
                writeResult(ApiResult.error(HttpStatus.UNAUTHORIZED, "token不合法或已失效!"));
                return false;
            }
            return executeLogin(request, response);
        }
        // token为空,当做匿名用户处理,部分接口是不登录也允许访问的
        return true;
    }

    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
        writeResult(ApiResult.error(HttpStatus.UNAUTHORIZED, e.getMessage()));
        return false;
    }

    private String getToken(ServletRequest request) {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String header = httpRequest.getHeader(AuthConstant.TOKEN_HEADER);
        if (StrUtil.isNotBlank(header) && header.startsWith(AuthConstant.TOKEN_PREFIX)) {
            return StrUtil.subAfter(header, AuthConstant.TOKEN_PREFIX, false);
        }
        return null;
    }

    private <T> void writeResult(ApiResult<T> result) {
        HttpServletResponse response = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getResponse();
        assert response != null;
        response.setCharacterEncoding("UTF-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
        // 后台统一返回数据的状态码都是200(系统层面请求成功), 实际业务的状态码根据 ApiResult 进行判断
        response.setStatus(HttpStatus.OK.value());
        PrintWriter writer = null;
        try {
            writer = response.getWriter();
            writer.print(JSONUtil.toJsonStr(result));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

要让自定义过滤器生效,还需要修改shiro核心配置,在上一篇中有对过滤器简单提过,这里就不再赘述了。

首先修改ShiroConfigshiroFilterFactoryBean,将过滤器注册进去,代码如下

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
    ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
    factoryBean.setSecurityManager(securityManager);

    Map<String, Filter> filterMap = new LinkedHashMap<>();
    // 加下面这一行,注册自定义过滤器
    filterMap.put("jwtAuthFilter",new JwtAuthFilter());
    factoryBean.setFilters(filterMap);
    factoryBean.setFilterChainDefinitionMap(getFilterChainDefinitionMap());
    return factoryBean;
}

然后再修改拦截默认过滤规则

private Map<String, String> getFilterChainDefinitionMap() {
    Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
    filterChainDefinitionMap.put("/auth/login", "anon");
    filterChainDefinitionMap.put("/auth/registry", "anon");
    filterChainDefinitionMap.put("/auth/logout", "anon");
    filterChainDefinitionMap.put("/doc.html", "anon");
    filterChainDefinitionMap.put("/webjars/**", "anon");
    filterChainDefinitionMap.put("/favicon.ico", "anon");
    filterChainDefinitionMap.put("/swagger**", "anon");
    filterChainDefinitionMap.put("/v2/api-docs/**", "anon");
    filterChainDefinitionMap.put("/v3/api-docs/**", "anon");
    filterChainDefinitionMap.put("/error", "anon");
    // 加这一行,需要与上面filterMap中的key一致
    filterChainDefinitionMap.put("/**", "jwtAuthFilter");
    return filterChainDefinitionMap;
}

接下来,重启项目,访问login接口获取token,然后在请求其他接口时,在请求头中带上token即可,可以在后台打几个断点看下缓存是否生效,过滤器是否拦截成功。


spring 禁用druid界面 springboot禁用session_缓存_04

6、异常处理补充

因为使用到了多个realm进行不同方式的认证,默认的认证策略是只要有一个认证通过即可,而认证失败后的异常会有变化,需要我们补充一下

@ExceptionHandler(AuthenticationException.class)
public ApiResult<Void> handleAuthenticationException(AuthenticationException e) {
    log.error("AuthenticationException: {}", e.getMessage());
    return ApiResult.error(HttpStatus.UNAUTHORIZED, "认证失败!");
}

@ExceptionHandler(AuthorizationException.class)
public ApiResult<Void> handleAuthorizationException(AuthorizationException e) {
    log.error("AuthorizationException: {}", e.getMessage());
    return ApiResult.error(HttpStatus.FORBIDDEN, "没有访问权限!");
}

本篇就先讲到这了,代码已上传至gitee,见jwt分支:https://gitee.com/yang-guirong/shiro-boot/tree/jwt