大家好,我是程序员田同学。
公司开始了新项目,新项目的认证采用的是Shiro实现。由于涉及到多端登录用户,而且多端用户还是来自不同的表。
这就涉及到了Shiro的多realm,今天的demo主要是介绍Shiro的多realm实现方案,文中包含所有的代码,需要的朋友可以无缝copy。
前后端分离的背景下,在认证的实现中主要是两方面的内容,一个是用户登录获取到token,二是从请求头中拿到token并检验token的有效性和设置缓存。
登录和以往单realm实现逻辑一样,使用用户和密码生成token返回给前端,前端每次请求接口的时候携带token。
@ApiOperation(value="登录", notes="登录") public Result<JSONObject> wxappLogin(String username,String password){ Result<JSONObject> result = new Result<JSONObject>(); JSONObject obj = new JSONObject(); // 生成token String password="0"; String token = JwtUtil.sign(username, password); obj.put("token", token); result.setResult(obj); result.success("登录成功"); return result; }
生成token的工具类
/** * 生成签名,5min后过期 * * @param username 用户名 * @param secret 用户的密码 * @return 加密的token */ public static String sign(String username, String secret) { Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME); Algorithm algorithm = Algorithm.HMAC256(secret); // 附带username信息 return JWT.create().withClaim("username", username).withExpiresAt(date).sign(algorithm); }
以上就实现了简单的登录逻辑,和Shiro的单realm设置和SpringSecurity的登录逻辑都没有什么区别。
使用Shiro登录拦截器的只需要继承Shiro的 BasicHttpAuthenticationFilter 类 重写 isAccessAllowed()方法,在该方法中我们从ServletRequest中获取到token和login_type。
需要特别指出的是,由于是多realm,我们在请求头中加入一个login_type来区分不同的登录类型。
通过token和login_type我们生成一个JwtToken对象提交给getSubject。
JwtFilter过滤器
@Slf4j public class JwtFilter extends BasicHttpAuthenticationFilter { /** * 默认开启跨域设置(使用单体) */ private boolean allowOrigin = true; public JwtFilter(){} public JwtFilter(boolean allowOrigin){ this.allowOrigin = allowOrigin; } /** * 执行登录认证 * * @param request * @param response * @param mappedValue * @return */ @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { try { executeLogin(request, response); return true; } catch (Exception e) { JwtUtil.responseError(response,401,CommonConstant.TOKEN_IS_INVALID_MSG); return false; //throw new AuthenticationException("Token失效,请重新登录", e); } } /** * */ @Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = (HttpServletRequest) request; String token = httpServletRequest.getHeader(CommonConstant.X_ACCESS_TOKEN); String loginType = httpServletRequest.getHeader(CommonConstant.LOGIN_TYPE); // update-begin--Author:lvdandan Date:20210105 for:JT-355 OA聊天添加token验证,获取token参数 if (oConvertUtils.isEmpty(token)) { token = httpServletRequest.getParameter("token"); } // update-end--Author:lvdandan Date:20210105 for:JT-355 OA聊天添加token验证,获取token参数 JwtToken jwtToken = new JwtToken(token,loginType); // 提交给realm进行登入,如果错误他会抛出异常并被捕获 getSubject(request, response).login(jwtToken); // 如果没有抛出异常则代表登入成功,返回true return true; } }
JwtToken类
public class JwtToken implements AuthenticationToken { private static final long serialVersionUID = 1L; private String token; private String loginType; // public JwtToken(String token) { // this.token = token; // } public JwtToken(String token,String loginType) { this.token = token; this.loginType=loginType; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } public String getLoginType() { return loginType; } public void setLoginType(String loginType) { this.loginType = loginType; } @Override public Object getPrincipal() { return token; } @Override public Object getCredentials() { return token; } }
再往下的逻辑肯定会先根据我们的login_type来走不同的realm了,然后在各自的realm中去检查token的有效性了,那Shiro怎么知道我们的Realm都是哪些呢?
接下来就该引出使用Shiro的核心配置文件了——ShiroConfig.java类
shiro的配置文件中会注入名字为securityManager的Bean。
在该bean中首先注入ModularRealmAuthenticator,ModularRealmAuthenticator会根据配置的AuthenticationStrategy(身份验证策略)进行多Realm认证过程。
由于是多realm我们需要重写ModularRealmAuthenticator类,ModularRealmAuthenticator类中用于判断逻辑走不同的realm,接着注入我们的两个realm,分别是myRealm和clientShiroRealm。
重新注入 ModularRealm类
@Bean public ModularRealm ModularRealm(){ //自己重写的ModularRealmAuthenticator ModularRealm modularRealm = new ModularRealm(); // modularRealmAuthenticator.setAuthenticationStrategy(new AtLeastOneSuccessfulStrategy());//这里为默认策略:如果有一个或多个Realm验证成功,所有的尝试都被认为是成功的,如果没有一个验证成功,则该次尝试失败 return modularRealm; }
securityManager-bean。
@Bean("securityManager") public DefaultWebSecurityManager securityManager(ShiroRealm myRealm, ClientShiroRealm clientShiroRealm,ModularRealm modularRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); // securityManager.setRealm(myRealm); securityManager.setAuthenticator(modularRealm); List<Realm> realms = new ArrayList<>(); //添加多个Realm realms.add(myRealm); realms.add(clientShiroRealm); securityManager.setRealms(realms); /* * 关闭shiro自带的session,详情见文档 * http://shiro.apache.org/session-management.html#SessionManagement- * StatelessApplications%28Sessionless%29 */ DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO(); DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator(); defaultSessionStorageEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator); securityManager.setSubjectDAO(subjectDAO); //自定义缓存实现,使用redis securityManager.setCacheManager(redisCacheManager()); return securityManager; }
ModularRealm实现类
public class ModularRealm extends ModularRealmAuthenticator { @Override protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException { assertRealmsConfigured(); Collection<Realm> realms = getRealms(); // 登录类型对应的所有Realm HashMap<String, Realm> realmHashMap = new HashMap<>(realms.size()); for (Realm realm : realms) { // 这里使用的realm中定义的Name属性来进行区分,注意realm中要加上 realmHashMap.put(realm.getName(), realm); } JwtToken token = (JwtToken) authenticationToken; if (StrUtil.isEmpty(token.getLoginType())){ return doSingleRealmAuthentication(realmHashMap.get(LoginType.DEFAULT.getType()),token); } else { return doSingleRealmAuthentication(realmHashMap.get(token.getLoginType()),token); } // return super.doAuthenticate(authenticationToken); } }
然后会根据不同的login_type到不同的realm,下面为我的Shiro认证realm。
myrealm类.
@Component @Slf4j public class ShiroRealm extends AuthorizingRealm { @Lazy @Resource private CommonAPI commonApi; @Lazy @Resource private RedisUtil redisUtil; @Override public String getName() { return LoginType.DEFAULT.getType(); } /** * 必须重写此方法,不然Shiro会报错 */ @Override public boolean supports(AuthenticationToken token) { // return token instanceof JwtToken; if (token instanceof JwtToken){ return StrUtil.isEmpty(((JwtToken) token).getLoginType()) || LoginType.CLIENT.getType().equals(((JwtToken) token).getLoginType()); } else { return false; } } /** * 权限信息认证(包括角色以及权限)是用户访问controller的时候才进行验证(redis存储的此处权限信息) * 触发检测用户权限时才会调用此方法,例如checkRole,checkPermission * * @param principals 身份信息 * @return AuthorizationInfo 权限信息 */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { log.debug("===============Shiro权限认证开始============ [ roles、permissions]=========="); String username = null; if (principals != null) { LoginUser sysUser = (LoginUser) principals.getPrimaryPrincipal(); username = sysUser.getUsername(); } SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); // 设置用户拥有的角色集合,比如“admin,test” Set<String> roleSet = commonApi.queryUserRoles(username); System.out.println(roleSet.toString()); info.setRoles(roleSet); // 设置用户拥有的权限集合,比如“sys:role:add,sys:user:add” Set<String> permissionSet = commonApi.queryUserAuths(username); info.addStringPermissions(permissionSet); System.out.println(permissionSet); log.info("===============Shiro权限认证成功=============="); return info; } /** * 用户信息认证是在用户进行登录的时候进行验证(不存redis) * 也就是说验证用户输入的账号和密码是否正确,错误抛出异常 * * @param auth 用户登录的账号密码信息 * @return 返回封装了用户信息的 AuthenticationInfo 实例 * @throws AuthenticationException */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException { log.debug("===============Shiro身份认证开始============doGetAuthenticationInfo=========="); String token = (String) auth.getCredentials(); if (token == null) { HttpServletRequest req = SpringContextUtils.getHttpServletRequest(); log.info("————————身份认证失败——————————IP地址: "+ oConvertUtils.getIpAddrByRequest(req) +",URL:"+req.getRequestURI()); throw new AuthenticationException("token为空!"); } // 校验token有效性 LoginUser loginUser = null; try { loginUser = this.checkUserTokenIsEffect(token); } catch (AuthenticationException e) { JwtUtil.responseError(SpringContextUtils.getHttpServletResponse(),401,e.getMessage()); e.printStackTrace(); return null; } return new SimpleAuthenticationInfo(loginUser, token, getName()); } /** * 校验token的有效性 * * @param token */ public LoginUser checkUserTokenIsEffect(String token) throws AuthenticationException { // 解密获得username,用于和数据库进行对比 String username = JwtUtil.getUsername(token); if (username == null) { throw new AuthenticationException("token非法无效!"); } // 查询用户信息 log.debug("———校验token是否有效————checkUserTokenIsEffect——————— "+ token); LoginUser loginUser = TokenUtils.getLoginUser(username,commonApi,redisUtil); //LoginUser loginUser = commonApi.getUserByName(username); if (loginUser == null) { throw new AuthenticationException("用户不存在!"); } // 判断用户状态 if (loginUser.getStatus() != 1) { throw new AuthenticationException("账号已被锁定,请联系管理员!"); } // 校验token是否超时失效 & 或者账号密码是否错误 if (!jwtTokenRefresh(token, username, loginUser.getPassword())) { throw new AuthenticationException(CommonConstant.TOKEN_IS_INVALID_MSG); } //update-begin-author:taoyan date:20210609 for:校验用户的tenant_id和前端传过来的是否一致 String userTenantIds = loginUser.getRelTenantIds(); if(oConvertUtils.isNotEmpty(userTenantIds)){ String contextTenantId = TenantContext.getTenant(); String str ="0"; if(oConvertUtils.isNotEmpty(contextTenantId) && !str.equals(contextTenantId)){ //update-begin-author:taoyan date:20211227 for: /issues/I4O14W 用户租户信息变更判断漏洞 String[] arr = userTenantIds.split(","); if(!oConvertUtils.isIn(contextTenantId, arr)){ throw new AuthenticationException("用户租户信息变更,请重新登陆!"); } //update-end-author:taoyan date:20211227 for: /issues/I4O14W 用户租户信息变更判断漏洞 } } //update-end-author:taoyan date:20210609 for:校验用户的tenant_id和前端传过来的是否一致 return loginUser; } /** * JWTToken刷新生命周期 (实现: 用户在线操作不掉线功能) * 1、登录成功后将用户的JWT生成的Token作为k、v存储到cache缓存里面(这时候k、v值一样),缓存有效期设置为Jwt有效时间的2倍 * 2、当该用户再次请求时,通过JWTFilter层层校验之后会进入到doGetAuthenticationInfo进行身份验证 * 3、当该用户这次请求jwt生成的token值已经超时,但该token对应cache中的k还是存在,则表示该用户一直在操作只是JWT的token失效了,程序会给token对应的k映射的v值重新生成JWTToken并覆盖v值,该缓存生命周期重新计算 * 4、当该用户这次请求jwt在生成的token值已经超时,并在cache中不存在对应的k,则表示该用户账户空闲超时,返回用户信息已失效,请重新登录。 * 注意: 前端请求Header中设置Authorization保持不变,校验有效性以缓存中的token为准。 * 用户过期时间 = Jwt有效时间 * 2。 * * @param userName * @param passWord * @return */ public boolean jwtTokenRefresh(String token, String userName, String passWord) { String cacheToken = String.valueOf(redisUtil.get(CommonConstant.PREFIX_USER_TOKEN + token)); if (oConvertUtils.isNotEmpty(cacheToken)) { // 校验token有效性 if (!JwtUtil.verify(cacheToken, userName, passWord)) { //生成token String newAuthorization = JwtUtil.sign(userName, passWord); // 设置超时时间 redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, newAuthorization); redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME *2 / 1000); log.debug("——————————用户在线操作,更新token保证不掉线—————————jwtTokenRefresh——————— "+ token); } //update-begin--Author:scott Date:20191005 for:解决每次请求,都重写redis中 token缓存问题 // else { // // 设置超时时间 // redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, cacheToken); // redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME / 1000); // } //update-end--Author:scott Date:20191005 for:解决每次请求,都重写redis中 token缓存问题 return true; } //redis中不存在此TOEKN,说明token非法返回false return false; } /** * 清除当前用户的权限认证缓存 * * @param principals 权限信息 */ @Override public void clearCache(PrincipalCollection principals) { super.clearCache(principals); } }
ClientShiroRealm类.
@Component @Slf4j public class ClientShiroRealm extends AuthorizingRealm { @Lazy @Resource private ClientAPI clientAPI; @Lazy @Resource private RedisUtil redisUtil; @Override public String getName() { return LoginType.CLIENT.getType(); } /** * 必须重写此方法,不然Shiro会报错 */ @Override public boolean supports(AuthenticationToken token) { // return token instanceof JwtToken; if (token instanceof JwtToken){ return LoginType.CLIENT.getType().equals(((JwtToken) token).getLoginType()); } else { return false; } } /** * 权限信息认证(包括角色以及权限)是用户访问controller的时候才进行验证(redis存储的此处权限信息) * 触发检测用户权限时才会调用此方法,例如checkRole,checkPermission * * @param principals 身份信息 * @return AuthorizationInfo 权限信息 */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { log.debug("===============Shiro权限认证开始============ [ roles、permissions]=========="); //String username = null; //if (principals != null) { // LoginUser sysUser = (LoginUser) principals.getPrimaryPrincipal(); // username = sysUser.getUsername(); //} //SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); //// 设置用户拥有的角色集合,比如“admin,test” //Set<String> roleSet = commonApi.queryUserRoles(username); //System.out.println(roleSet.toString()); //info.setRoles(roleSet); // //// 设置用户拥有的权限集合,比如“sys:role:add,sys:user:add” //Set<String> permissionSet = commonApi.queryUserAuths(username); //info.addStringPermissions(permissionSet); //System.out.println(permissionSet); log.info("===============Shiro权限认证成功=============="); return null; } /** * 用户信息认证是在用户进行登录的时候进行验证(不存redis) * 也就是说验证用户输入的账号和密码是否正确,错误抛出异常 * * @param auth 用户登录的账号密码信息 * @return 返回封装了用户信息的 AuthenticationInfo 实例 * @throws AuthenticationException */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException { log.debug("===============Shiro身份认证开始============doGetAuthenticationInfo=========="); String token = (String) auth.getCredentials(); if (token == null) { HttpServletRequest req = SpringContextUtils.getHttpServletRequest(); log.info("————————身份认证失败——————————IP地址: "+ oConvertUtils.getIpAddrByRequest(req) +",URL:"+req.getRequestURI()); throw new AuthenticationException("token为空!"); } // 校验token有效性 LoginUser loginUser = null; try { loginUser = this.checkUserTokenIsEffect(token); } catch (AuthenticationException e) { JwtUtil.responseError(SpringContextUtils.getHttpServletResponse(),401,e.getMessage()); e.printStackTrace(); return null; } return new SimpleAuthenticationInfo(loginUser, token, getName()); } /** * 校验token的有效性 * * @param token */ public LoginUser checkUserTokenIsEffect(String token) throws AuthenticationException { // 解密获得username,用于和数据库进行对比 String username = JwtUtil.getUsername(token); if (username == null) { throw new AuthenticationException("token非法无效!"); } // 查询用户信息 log.debug("———校验token是否有效————checkUserTokenIsEffect——————— "+ token); LoginUser loginUser = TokenUtils.getClientLoginUser(username,clientAPI,redisUtil); //LoginUser loginUser = commonApi.getUserByName(username); if (loginUser == null) { throw new AuthenticationException("用户不存在!"); } // 校验token是否超时失效 & 或者账号密码是否错误 if (!jwtTokenRefresh(token, username, loginUser.getPassword())) { throw new AuthenticationException(CommonConstant.TOKEN_IS_INVALID_MSG); } return loginUser; } /** * JWTToken刷新生命周期 (实现: 用户在线操作不掉线功能) * 1、登录成功后将用户的JWT生成的Token作为k、v存储到cache缓存里面(这时候k、v值一样),缓存有效期设置为Jwt有效时间的2倍 * 2、当该用户再次请求时,通过JWTFilter层层校验之后会进入到doGetAuthenticationInfo进行身份验证 * 3、当该用户这次请求jwt生成的token值已经超时,但该token对应cache中的k还是存在,则表示该用户一直在操作只是JWT的token失效了,程序会给token对应的k映射的v值重新生成JWTToken并覆盖v值,该缓存生命周期重新计算 * 4、当该用户这次请求jwt在生成的token值已经超时,并在cache中不存在对应的k,则表示该用户账户空闲超时,返回用户信息已失效,请重新登录。 * 注意: 前端请求Header中设置Authorization保持不变,校验有效性以缓存中的token为准。 * 用户过期时间 = Jwt有效时间 * 2。 * * @param userName * @param passWord * @return */ public boolean jwtTokenRefresh(String token, String userName, String passWord) { String cacheToken = String.valueOf(redisUtil.get(CommonConstant.PREFIX_USER_TOKEN + token)); if (oConvertUtils.isNotEmpty(cacheToken)) { // 校验token有效性 if (!JwtUtil.verify(cacheToken, userName, passWord)) { //生成token String newAuthorization = JwtUtil.sign(userName, passWord); // 设置超时时间 redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, newAuthorization); redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME *2 / 1000); log.debug("——————————用户在线操作,更新token保证不掉线—————————jwtTokenRefresh——————— "+ token); } return true; } //redis中不存在此TOEKN,说明token非法返回false return false; } /** * 清除当前用户的权限认证缓存 * * @param principals 权限信息 */ @Override public void clearCache(PrincipalCollection principals) { super.clearCache(principals); } }
这两个realm更多的是需要实现我们自身的realm,我把我的全部代码贴上,读者可根据自己的需要进行修改,两个方法大致的作用都是检验token的有效性,只是查询的用户从不同的用户表中查出来的。
至此,Shiro的多Realm实现方案到这里就正式结束了。