一、前言
diboot iam-base是一款基于shiro安全框架二次开发的应用于PC WEB的前后端分离的认证授权框架,所以服务端的认证信息依然使用有状态管理,即session存储.
但是最近发现有的小伙伴将diboot-iam用在移动端,我们知道移动端是没有session,此时我们就需要对diboot-iam进行一点微小的改造,让其进入无状态管理,从而适应于移动端认证。
注:本课程基于diboot2.1.2版本
diboot2.2.0后无状态已经内置StatelessJwtAuthFilter,直接使用即可
二、你可以学到?
- shiro无状态改写
- 如何基于iam-base的扩展其他登录方式
- 如何替换框架中默认的iam_user用户类型
三、代码
业务场景:小程序登陆,改写原始的用户名密码登陆,基于微信用户的openid,无密登陆
3.1 无状态配置
基于diboot-iam的DefaultJwtAuthFilter, 创建新的shiro过滤器,并进行token相关的调整
/** * 认证过滤器 * @author : uu * @version : v1.0 * @Date 2020/10/27 19:34 */ @Slf4j public class StatelessJwtAuthFilter extends DefaultJwtAuthFilter { /** * 判断是否登录 * 这里逻辑只是进行简单的修改,增加了refreshtoken(这里我设置生成逻辑和token生成逻辑内容相同,但是该token永久有效),可以选择其他方式 * <br/> * 大致逻辑为:token没过期,使用token; * token即将过期,使用token换新的token; * token已经过期,使用refreshtoken换新的token * 最后拿到可用的token,进行登陆(登陆这里还是使用原来的realname,可以查看BaseJwtRealm逻辑,根据需要使用缓存登陆,或者直接查询数据库) * * 返回true则直接进入控制器 * @param request * @param response * @param mappedValue * @return */ @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { HttpServletRequest httpRequest = (HttpServletRequest)request; // OPTIONS类型的请求需要拦截处理(否则抛出异常),让系统执行真正的请求 if (HttpMethod.OPTIONS.matches(httpRequest.getMethod())) { return true; } // 设置当前token String currentToken = JwtUtils.getRequestToken(httpRequest); if (V.isEmpty(currentToken)) { // 当前token验证失败 return false; } boolean needRefreshToken = false; Claims claims = JwtUtils.getClaimsFromRequest(httpRequest); // 尝试使用refreshClaims替换 if (V.isEmpty(claims)) { claims = getClaimsFromRequestByRefreshtoken(httpRequest); needRefreshToken = true; } if (V.isEmpty(claims)) { return false; } else if (V.notEmpty(claims.getSubject())) { // 判断是否需要借助refreshtoken 置换token if (needRefreshToken) { log.debug("refreshToken验证成功!account={}", claims.getSubject()); currentToken = JwtUtils.generateToken(claims.getSubject(), (long)JwtUtils.EXPIRES_IN_MINUTES); JwtUtils.addTokenToResponseHeader((HttpServletResponse)response, currentToken); } else { log.debug("Token验证成功!account={}", claims.getSubject()); // 如果当前token有效,如果在指定时间内可以通过当前token进行置换新的token String newToken = JwtUtils.generateNewTokenIfRequired(claims); if (V.notEmpty(newToken)) { JwtUtils.addTokenToResponseHeader((HttpServletResponse)response, currentToken); currentToken = newToken; } } // 构建登陆的token BaseJwtAuthToken baseJwtAuthToken = new BaseJwtAuthToken(); baseJwtAuthToken.setAuthAccount(S.substringBefore(claims.getSubject(), ",")); baseJwtAuthToken.setAuthtoken(currentToken); baseJwtAuthToken.setAuthType(Cons.DICTCODE_AUTH_TYPE.WX_MP.name()); baseJwtAuthToken.setUserTypeClass(WxMember.class); IamSecurityUtils.getSubject().login(baseJwtAuthToken); return true; } else { log.debug("Token验证失败!"); return false; } } /** * 没有登录的情况下会走此方法 * @param request * @param response * @return * @throws Exception */ @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { log.debug("Token认证失败: onAccessDenied"); JsonResult jsonResult = new JsonResult(Status.FAIL_INVALID_TOKEN); this.responseJson((HttpServletResponse)response, jsonResult); return false; } /** * 获取refreshtoken的Claims * @param request * @return */ private Claims getClaimsFromRequestByRefreshtoken(HttpServletRequest request) { String authHeader = request.getHeader("refreshtoken"); String refreshtoken = null; if (authHeader != null) { refreshtoken = authHeader.startsWith("Bearer ") ? authHeader.substring("Bearer ".length()) : authHeader.trim(); } if (V.isEmpty(refreshtoken)) { log.warn("refreshtoken 为空!url={}", request.getRequestURL()); return null; } else { try { return (Claims) Jwts.parser().setSigningKey(JwtUtils.SIGN_KEY).parseClaimsJws(refreshtoken).getBody(); } catch (ExpiredJwtException var3) { log.info("refreshtoken已过期:{}", refreshtoken); } catch (Exception var4) { log.warn("refreshtoken解析异常", var4); } return null; } }
禁用shiro的session
/** * 禁用session * * @author : uu * @version : v1.0 * @Date 2020/10/28 11:06 */ public class StatelessDefaultSubjectFactory extends DefaultWebSubjectFactory { @Override public Subject createSubject(SubjectContext context) { //不创建session context.setSessionCreationEnabled(false); return super.createSubject(context); } }
增加ShiroConfig.java,重新改写diboot-iam的ShiroFilterFactoryBean定义(此处可以直接复制IamBaseAutoConfig#shiroFilterFactoryBean来改动哦)
/** * 无状态shiro配置 * * @author : uu * @version : v1.0 * @Date 2020/10/27 19:33 */ @Configuration public class StatelessShiroConfig { @Autowired private IamBaseProperties iamBaseProperties; @Bean protected ShiroFilterFactoryBean shiroFilterFactoryBean(SessionsSecurityManager securityManager) { ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); Map<String, Filter> filters = new LinkedHashMap(); // ---------更改部分 start------------- // 设置无状态的过滤器 详细见:StatelessJwtAuthFilter,替换 diboot提供的 DefaultJwtAuthFilter filters.put("jwt", new StatelessJwtAuthFilter()); shiroFilterFactoryBean.setFilters(filters); // 设置无状态session,SessionsSecurityManager默认使用DefaultWebSecurityManager实现,这里需要设置session不创建 见自定义:StatelessDefaultSubjectFactory if (securityManager instanceof DefaultWebSecurityManager) { DefaultWebSecurityManager defaultWebSecurityManager = ((DefaultWebSecurityManager) securityManager); // 设置不创建session defaultWebSecurityManager.setSubjectFactory(new StatelessDefaultSubjectFactory()); // subject禁止存储到session(这里不设置,最后还是会将subject存储) //详情见org.apache.shiro.mgt.DefaultSubjectDAO#save DefaultWebSessionStorageEvaluator webEvalutator = new DefaultWebSessionStorageEvaluator(); webEvalutator.setSessionStorageEnabled(false); ((DefaultSubjectDAO)defaultWebSecurityManager.getSubjectDAO()) .setSessionStorageEvaluator(webEvalutator); } // ---------更改部分 end------------- shiroFilterFactoryBean.setSecurityManager(securityManager); shiroFilterFactoryBean.setUnauthorizedUrl("/error"); Map<String, String> filterChainDefinitionMap = new LinkedHashMap(); filterChainDefinitionMap.put("/static/**", "anon"); filterChainDefinitionMap.put("/diboot/**", "anon"); filterChainDefinitionMap.put("/error/**", "anon"); filterChainDefinitionMap.put("/auth/**", "anon"); filterChainDefinitionMap.put("/uploadFile/download/*/image", "anon"); boolean allAnon = false; String anonUrls = this.iamBaseProperties.getAnonUrls(); if (V.notEmpty(anonUrls)) { String[] var7 = anonUrls.split(","); int var8 = var7.length; for(int var9 = 0; var9 < var8; ++var9) { String url = var7[var9]; filterChainDefinitionMap.put(url, "anon"); if (url.equals("/**")) { allAnon = true; } } } filterChainDefinitionMap.put("/login", "authc"); filterChainDefinitionMap.put("/logout", "logout"); if (allAnon && !this.iamBaseProperties.isEnablePermissionCheck()) { filterChainDefinitionMap.put("/**", "anon"); } else { filterChainDefinitionMap.put("/**", "jwt"); } shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return shiroFilterFactoryBean; } }
至此,我们已经将无状态的shiro改写完毕。接下来我们再看看如何通过iam-base自定义用户登陆
3.2 自定义登陆和替换用户类型
注:此处不会涉及小程序前端代码
iam-base默认采用iam_user为登陆用户的信息表,登陆方式为:用户名-密码,如果上述无法满足你的项目需求,你可以看这里自定义扩展替换用户为wx_member(微信用户的基本信息表)
/** * 微信登陆凭证(只需要openid登陆,不需要密码) * * 继承AuthCredential * @author : uu * @version : v1.0 * @Date 2020/10/27 17:38 */ @Getter @Setter public class WXMemberCredential extends AuthCredential { private static final long serialVersionUID = -738269438230072612L; /** * 微信登陆的标示 */ private String openid; /** * 用户类型 */ private Class userTypeClass = WxMember.class; @Override public String getAuthAccount() { return this.openid; } @Override public String getAuthSecret() { return null; } }
实现AuthService接口,定义认证方式及接口实现(默认提供了一些枚举Cons.DICTCODE_AUTH_TYPE,不够用可以自定义)
/** * 自定微信登陆service * * @author : uu * @version : v1.0 * @Date 2020/10/27 19:21 */ @Service @Slf4j public class WXMpAuthServiceImpl implements AuthService { @Autowired private IamAccountService accountService; @Autowired private HttpServletRequest request; @Autowired private IamLoginTraceService iamLoginTraceService; @Override public String getAuthType() { return Cons.DICTCODE_AUTH_TYPE.WX_MP.name(); } @Override public IamAccount getAccount(BaseJwtAuthToken jwtToken) throws AuthenticationException { // 这里我加了一个本地缓存,先从缓存查询账户是否存在,如果已经存在,那么直接返回 IamAccount cacheAccount = AccountMemoryCache.get(jwtToken.getAuthAccount()); if (V.notEmpty(cacheAccount)) { return cacheAccount; } LambdaQueryWrapper<IamAccount> queryWrapper = Wrappers.<IamAccount>lambdaQuery() .eq(IamAccount::getUserType, jwtToken.getUserType()) .eq(IamAccount::getAuthType, Cons.DICTCODE_AUTH_TYPE.WX_MP.name()) .eq(IamAccount::getAuthAccount, jwtToken.getAuthAccount()) .orderByDesc(BaseEntity::getId); IamAccount latestAccount = this.accountService.getSingleEntity(queryWrapper); if (latestAccount == null) { return null; } else if (Cons.DICTCODE_ACCOUNT_STATUS.I.name().equals(latestAccount.getStatus())) { throw new AuthenticationException("用户账号已禁用! account=" + jwtToken.getAuthAccount()); } else if (Cons.DICTCODE_ACCOUNT_STATUS.L.name().equals(latestAccount.getStatus())) { throw new AuthenticationException("用户账号已锁定! account=" + jwtToken.getAuthAccount()); } else { AccountMemoryCache.put(jwtToken.getAuthAccount(), latestAccount); return latestAccount; } } @Override public String applyToken(AuthCredential credential) { BaseJwtAuthToken authToken = this.initBaseJwtAuthToken(credential); try { Subject subject = SecurityUtils.getSubject(); subject.login(authToken); if (subject.isAuthenticated()) { log.debug("申请token成功!authtoken={}", authToken.getCredentials()); this.saveLoginTrace(authToken, true); return (String) authToken.getCredentials(); } else { log.error("认证失败"); this.saveLoginTrace(authToken, false); throw new BusinessException(Status.FAIL_OPERATION, "认证失败"); } } catch (Exception var4) { log.error("登录异常", var4); this.saveLoginTrace(authToken, false); throw new BusinessException(Status.FAIL_OPERATION, var4.getMessage()); } } /** * 初始化token * @param {Object} AuthCredential credential */ private BaseJwtAuthToken initBaseJwtAuthToken(AuthCredential credential) { BaseJwtAuthToken token = new BaseJwtAuthToken(this.getAuthType(), credential.getUserTypeClass()); token.setAuthAccount(credential.getAuthAccount()); token.setRememberMe(credential.isRememberMe()); return token.generateAuthtoken(this.getExpiresInMinutes()); } /** * 保存日志 * @param authToken * @param isSuccess */ protected void saveLoginTrace(BaseJwtAuthToken authToken, boolean isSuccess) { IamLoginTrace loginTrace = new IamLoginTrace(); loginTrace.setAuthType(this.getAuthType()).setAuthAccount(authToken.getAuthAccount()).setUserType(authToken.getUserType()).setSuccess(isSuccess); BaseLoginUser currentUser = (BaseLoginUser) IamSecurityUtils.getCurrentUser(); if (currentUser != null) { loginTrace.setUserId(currentUser.getId()); } String userAgent = this.request.getHeader("user-agent"); String ipAddress = IamSecurityUtils.getRequestIp(this.request); loginTrace.setUserAgent(userAgent).setIpAddress(ipAddress); try { this.iamLoginTraceService.createEntity(loginTrace); } catch (Exception var8) { log.warn("保存登录日志异常", var8); } } }
修改登陆接口
/** * 用户登录获取token * 基于无状态,增加了一个长期的refreshtoken,根据自己需要添加 * * @param credential * @return * @throws Exception */ @PostMapping("/auth/login") public JsonResult login(@RequestBody WXMemberCredential credential) throws Exception { // 替换service 获取token String authtoken = AuthServiceFactory.getAuthService(Cons.DICTCODE_AUTH_TYPE.WX_MP.name()).applyToken(credential); return JsonResult.OK(new HashMap<String, String>(8){{ put("authtoken", authtoken); // 刷新token,长期有效 put("refreshtoken", JwtUtils.generateToken(credential.getAuthAccount(), 60 * 24 * 30 * 12 * 10)); }}); }
至此,我们将自定义扩展登陆、用户信息处理完毕!!!
总结
diboot文档
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。