diboot iam-base 是一款基于 shiro 安全框架二次开发的应用于 PC WEB 的前后端分离的认证授权框架,所以服务端的认证信息依然使用有状态管理,即 session 存储. 但是…

一、前言

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 相关的调整

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    /**
    * 认证过滤器
    * @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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /**java
    * 禁用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 来改动哦)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    /**
    * 无状态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(微信用户的基本信息表)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    /**
    * 微信登陆凭证(只需要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,不够用可以自定义)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    /**
    * 自定微信登陆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);
    }

    }
    }
  • 修改登陆接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /**
    * 用户登录获取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));
    }});
    }

    至此,我们将自定义扩展登陆、用户信息处理完毕!!!