
在上一篇中,实现了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(List 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;
}
接下来看一下配置是否生效,启动项目,打开接口文档页面,选择登录接口,使用上一篇中创建的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, Map ext) {
// 指定签名的时候使用的签名算法,也就是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 ApiResult 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配置文件中配置一下黑名单缓存
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 void 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);
Map filterMap = 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条)