在上一篇中,实现了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
确实禁用成功了,如下图所示
而如果把配置还原,那么我们会发现,响应头中是有session
的,如下图所示
在添加了禁用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_KEY
为base64
编码格式,具体如何取,可自己定义。
@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
登录,返回结果如下所示
然后是退出接口,退出接口,改造如下,多了一个将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
核心配置,在上一篇中有对过滤器简单提过,这里就不再赘述了。
首先修改ShiroConfig
的shiroFilterFactoryBean
,将过滤器注册进去,代码如下
@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即可,可以在后台打几个断点看下缓存是否生效,过滤器是否拦截成功。
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