在上一篇中,实现了session版本的shiro认证鉴权,这一篇将在上一篇的基础上进行改造,实现无状态的jwt进行认证鉴权。
下篇-jwt模式- 1、禁用会话
- 2、Jwt依赖及工具类
- 3、重写登录退出接口
- 4、Realm
- 5、自定义过滤器
- 6、异常处理补充
jwt什么的稍后再讲,我们先实现禁用session。修改配置类ShiroConfig,添加会话管理器并禁用其调度器,同时禁用session存储,修改内容如下
@Bean public DefaultWebSessionManager defaultWebSessionManager() { DefaultWebSessionManager sessionManager = new DefaultWebSessionManager(); sessionManager.setSessionValidationSchedulerEnabled(false); sessionManager.setSessionIdcookieEnabled(false); return sessionManager; } @Bean public DefaultWebSecurityManager securityManager(Listrealms) { 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; }
接下来看一下配置是否生效,启动项目,打开接口文档页面,选择登录接口,使用上一篇中创建的admin用户进行登录,发送请求后,用户信息可以正常返回,说明登录确实成功了,再按F12请求一次查看细节,会发现Set-cookie中并没有JSESSIONID,说明session确实禁用成功了,如下图所示
而如果把配置还原,那么我们会发现,响应头中是有session的,如下图所示
在添加了禁用session的配置后,先执行登录,然后随便找一个查询接口请求一下,会发现返回的结果为401未认证,无论怎么试都是这样,也再次证明确实已经禁用了session。
session已经禁用成功了,接下来就是改造jwt了。
2、Jwt依赖及工具类因为使用的jwt认证,所以首先需要添加jwt相关依赖,添加如下依赖到pom.xml文件中
0.9.1 io.jsonwebtoken jjwt${jjwt.version}
新建工具类JwtUtils,用于生成jwt以及jwt的校验等,代码如下,其中SECRET_KEY为base64编码格式,具体如何取,可自己定义。
@Slf4j public class JwtUtils { private static final String SECRET_KEY = "bXktc2VjcmV0LWtleQ=="; 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()); } public static String createToken(String username, long ttlSeconds, Mapext) { // 指定签名的时候使用的签名算法,也就是header那部分 SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; // 生成JWT的时间 long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); // 创建payload的私有声明(根据特定的业务需要添加) Map 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(); } 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"; String CLAIMS_KEY_USER_ID = "userId"; String CLAIMS_KEY_USER_NAME = "userName"; String TOKEN_HEADER = "Authorization"; String TOKEN_PREFIX = "Bearer "; 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 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 { private String token; }
可以看到,先执行登录认证,如果登录成功直接返回前台jwt即可,至于登录过程,还是使用之前的LoginRealm,无需修改。
使用admin登录,返回结果如下所示
然后是退出接口,退出接口,改造如下,多了一个将token放入黑名单缓存的 *** 作
@ApiOperation("退出") @PostMapping("logout") public ApiResultlogout(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配置文件中配置一下黑名单缓存
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; } privatevoid writeResult(ApiResult 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核心配置,在上一篇中有对过滤器简单提过,这里就不再赘述了。
首先修改ShiroConfig的shiroFilterFactoryBean,将过滤器注册进去,代码如下
@Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); MapfilterMap = new linkedHashMap<>(); // 加下面这一行,注册自定义过滤器 filterMap.put("jwtAuthFilter",new JwtAuthFilter()); factoryBean.setFilters(filterMap); factoryBean.setFilterChainDefinitionMap(getFilterChainDefinitionMap()); return factoryBean; }
然后再修改拦截默认过滤规则
private MapgetFilterChainDefinitionMap() { Map 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即可,可以在后台打几个断点看下缓存是否生效,过滤器是否拦截成功。
6、异常处理补充因为使用到了多个realm进行不同方式的认证,默认的认证策略是只要有一个认证通过即可,而认证失败后的异常会有变化,需要我们补充一下
@ExceptionHandler(AuthenticationException.class) public ApiResulthandleAuthenticationException(AuthenticationException e) { log.error("AuthenticationException: {}", e.getMessage()); return ApiResult.error(HttpStatus.UNAUTHORIZED, "认证失败!"); } @ExceptionHandler(AuthorizationException.class) public ApiResult handleAuthorizationException(AuthorizationException e) { log.error("AuthorizationException: {}", e.getMessage()); return ApiResult.error(HttpStatus.FORBIDDEN, "没有访问权限!"); }
本篇就先讲到这了,代码已上传至gitee,见jwt分支:https://gitee.com/yang-guirong/shiro-boot/tree/jwt
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)