若依框架thymeleaf升级分布式--dora带你来探险

1,092 阅读5分钟

本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

若依是一个优秀的后台框架。

管理后台没有采用前后端分离,采用shiro+thymeleaf的若依框架,项目模块较多,项目分组较多,有希望进行分布式开发的需求,可以共同探讨下本文的思路。

升级思路

  • 编写通用的shiro认证模块,所有服务引入此模块。
  • 搭建认证中心portal,用户登录都走认证中心
  • 基于session的有效domain,所有服务使用相同域名, shiro通过redis存放session。

思路参见以前文章

分布式shiro权限验证 一

分布式shiro权限验证 二

通用的shiro认证模块

构建模块ruoyi-dora-starter-web ,其他web项目引入此starter即可引入框架shiro认证。

引入shiro依赖
<!-- shiro -->
<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-spring-boot-web-starter</artifactId>
</dependency>
编写shro配置

主要需注入 UserRealm credentialsMatcher SessionsSecurityManager

@Configuration
public class ShiroConfiguration {

  /**
  * 用户认证
  **/
  @Bean
  public UserRealm userRealm() {
    UserRealm userRealm = new UserRealm();
    // 设置密码认证器
    userRealm.setCredentialsMatcher(credentialsMatcher());
    return userRealm;
  }

  @Bean
  public ShiroFilterChainDefinition shiroFilterChainDefinition() {
    // filterChain 过滤静态资源
    DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
    chainDefinition.addPathDefinition("/static/**", "anon");
    chainDefinition.addPathDefinition("/ajax/**", "anon");
    chainDefinition.addPathDefinition("/css/**", "anon");
    chainDefinition.addPathDefinition("/file/**", "anon");
    chainDefinition.addPathDefinition("/fonts/**", "anon");
    chainDefinition.addPathDefinition("/html/**", "anon");
    chainDefinition.addPathDefinition("/i18n/**", "anon");
    chainDefinition.addPathDefinition("/img/**", "anon");
    chainDefinition.addPathDefinition("/js/**", "anon");
    chainDefinition.addPathDefinition("/ruoyi/**", "anon");
    chainDefinition.addPathDefinition("/login", "anon");
    chainDefinition.addPathDefinition("/captcha", "anon");
    chainDefinition.addPathDefinition("/logout", "anon");
    chainDefinition.addPathDefinition("/ruoyi.png", "anon");
    chainDefinition.addPathDefinition("/favicon.ico", "anon");
    chainDefinition.addPathDefinition("/layuiadmin/**", "anon");
    chainDefinition.addPathDefinition("/druid/**", "anon");
    chainDefinition.addPathDefinition("/api/**", "anon");
    chainDefinition.addPathDefinition("/**", "authc");
    return chainDefinition;
  }

  @Bean
  public HashedCredentialsMatcher credentialsMatcher() {
    // 密码认证器
    HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
    // Md5Hash.ALGORITHM_NAME
    credentialsMatcher.setHashAlgorithmName("SHA-256");
    credentialsMatcher.setStoredCredentialsHexEncoded(false);
    credentialsMatcher.setHashIterations(1024);

    return credentialsMatcher;
  }

  @Bean
  public SessionsSecurityManager securityManager() {
    DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    securityManager.setRealm(userRealm());

    return securityManager;
  }
认证授权 realm

为验证可行性,认证授权暂时写定

@Slf4j
public class UserRealm extends AuthorizingRealm {
​
  @Override
  protected AuthorizationInfo doGetAuthorizationInfo (PrincipalCollection principalCollection) {
    // 角色 权限信息 暂时写定
    User user = (User) SecurityUtils.getSubject().getPrincipal();
    SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
    Set<String> roles = new HashSet();
    Set<String> permissions = new HashSet();
    if ("admin".equals(user.getUserName())) {
      roles.add("admin");
      permissions.add("op:write");
    } else {
      roles.add("user");
      permissions.add("op:read");
    }
​
    authorizationInfo.setRoles(roles);
    authorizationInfo.setStringPermissions(permissions);
    return authorizationInfo;
  }
​
  @Override
  protected AuthenticationInfo doGetAuthenticationInfo (AuthenticationToken authenticationToken) throws AuthenticationException {
    String username = (String)authenticationToken.getPrincipal();
    String credentials = new String((char[])authenticationToken.getCredentials());
    User user = new User();
    user.setUserName(username);
    String password = credentials;
    // 此处暂时跳过密码验证  与注入的bean credentialsMatcher算法一致,算出 hashedCredentials
    // 实际需从数据库中取出
    String salt = "salt";
    int hashIterations = 1024;
    String encodedPassword = (new SimpleHash("SHA-256", password, Util.bytes(salt), hashIterations)).toBase64();
    log.info("password: {}  encode: {}",password,encodedPassword);
    user.setPassword(encodedPassword);
    // authenticationToken.getCredentials() + salt 经credentialsMatcher加密  与 hashedCredentials 比较
    SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user, user.getPassword(), Util.bytes(salt), this.getName());
    return authenticationInfo;
  }
Starter 自动配置

META-INF文件下spring-factories加入配置类

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.ruoyi.dora.web.config.DoraWebAutoConfiguration,\
com.ruoyi.dora.web.config.ShiroConfiguration

至此认证模块starter完成

搭建认证中心

构建独立项目 ruoyi-dora-portal-web

引入ruoyi-dora-starter-web
<dependency>
  <groupId>com.ruoyi</groupId>
  <artifactId>ruoyi-dora-starter-web</artifactId>
</dependency>
引入若依前端资源文件

引入若依admin前端的静态资源文件 static包、templates包所有文件

登录控制
@Slf4j
@Controller
public class LoginController {

  @GetMapping("/login")
  public String loginPage (Model model) {
    if(SecurityUtils.getSubject().isAuthenticated()){
      return "redirect:/index";
    }
    // 若依框架的配置 临时处理
    Map<String, Object> configMap = new HashMap<>();
    configMap.put("sys.account.registerUser", true);
    model.addAttribute("config", configMap);
    model.addAttribute("captchaEnabled", false);

    return "login";
  }

  @GetMapping("/logout")
  public String logout () {
    SecurityUtils.getSubject().logout();

    return "redirect:/login";
  }

  @PostMapping("/login")
  @ResponseBody
  public AjaxResult ajaxLogin (String username, String password, Boolean rememberMe) {
    UsernamePasswordToken token = new UsernamePasswordToken(username, password, rememberMe);
    Subject subject = SecurityUtils.getSubject();
    try {
      subject.login(token);
      return success();
    } catch (AuthenticationException e) {
      log.error("login error.",e);
      String msg = "用户或密码错误";
      if (StringUtils.isNotEmpty(e.getMessage())) {
        msg = e.getMessage();
      }
      return error(msg);
    }
  }


  @GetMapping("/unauth")
  public String unauth () {
    return "error/unauth";
  }

}
主页index

主要获取了数据库menu菜单,及UI风格设置。ISysMenuService 采用了mybatis-plus的generator生成器代码风格,menuService.selectMenusByUser 参见若依原代码,或本文gitee代码。 对一些代码做了临时处理,直接写定。

@Slf4j
@Controller
public class PortalController {

  @Autowired
  private ISysUserService sysUserService;

  @Autowired
  private ISysMenuService menuService;

  @Autowired
  private ISysConfigService configService;

  @GetMapping({"/","/index"})
  public String index(ModelMap modelMap){

    Object principal = SecurityUtils.getSubject().getPrincipal();
    log.info("principal {}",principal.toString());
    SysUser user = sysUserService.getOne(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getLoginName, "admin"), false);
    List<SysMenu> menus = menuService.selectMenusByUser(user);
    modelMap.put("menus", menus);
    modelMap.put("user", user);
    modelMap.put("sideTheme", configService.selectConfigByKey("sys.index.sideTheme").getConfigValue());
    modelMap.put("skinName", configService.selectConfigByKey("sys.index.skinName").getConfigValue());
    modelMap.put("ignoreFooter", configService.selectConfigByKey("sys.index.ignoreFooter").getConfigValue());
    // 配置临时处理
//    modelMap.put("copyrightYear", RuoYiConfig.getCopyrightYear());
//    modelMap.put("demoEnabled", RuoYiConfig.isDemoEnabled());
//    modelMap.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate()));
//    modelMap.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate()));
    modelMap.put("copyrightYear", "2021");
    modelMap.put("demoEnabled", "true");
    modelMap.put("isDefaultModifyPwd", false);
    modelMap.put("isPasswordExpired", false);

    // 菜单导航显示风格
//    String menuStyle = configService.selectConfigByKey("sys.index.menuStyle");
    String menuStyle = "default";
    // 移动端,默认使左侧导航菜单,否则取默认配置
    String indexStyle = menuStyle;
    //ServletUtils.checkAgentIsMobile(ServletUtils.getRequest().getHeader("User-Agent")) ? "index" : menuStyle;

    // 优先Cookie配置导航菜单
//    Cookie[] cookies = ServletUtils.getRequest().getCookies();
//    for (Cookie cookie : cookies)
//    {
//      if (StringUtils.isNotEmpty(cookie.getName()) && "nav-style".equalsIgnoreCase(cookie.getName()))
//      {
//        indexStyle = cookie.getValue();
//        break;
//      }
//    }
    if("topnav".equalsIgnoreCase(indexStyle)){
      return "index-topnav";
    }
    return "index";
  }


  /**
  * UI 工作区域frame  main
  **/
  @GetMapping("/system/main")
  public String sysMain(Model model){
    model.addAttribute("version","0.0.1");
    return "main";
  }
}

至此启动项目,即可进行登录,并进入到index首页(菜单项具体功能并未实现,可参见若依原码)。

redis存放session

对于多端服务将session统一管理。

引入依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
</dependency>

yaml 配置redis

启动类添加注解@EnableRedisHttpSession 将session存放在redis

spring:
  datasource:
    url: jdbc:p6spy:mysql://localhost:3306/ry?useSSL=false&useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    type: com.zaxxer.hikari.HikariDataSource
    driver-class-name: com.p6spy.engine.spy.P6SpyDriver

  #redis
  redis:
    host: localhost
    port: 6379

栗子sky-web

建立独立项目 demo-sky-web

引入上面的依赖ruoyi-dora-starter-web

<dependency>
  <groupId>com.ruoyi</groupId>
  <artifactId>ruoyi-dora-starter-web</artifactId>
</dependency>

将shrio的登录地址指向认证中心ruoyi-dora-portal-web的地址 配置reids, 启动类添加注解@EnableRedisHttpSession 将session存放在redis

shiro:
  loginUrl: http://localhost:8600/login

spring:
  redis:
    host: localhost
    port: 6379

编写简单测试页面

@Controller public class SkyController {

@GetMapping("/sky") public String skyPage(){ return "sky"; }

}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Sky</title>
</head>
<body>
<h1>Sky sky</h1>
</body>
</html>

数据库添加菜单, 将菜单地址指向 demo-sky的地址全路径http://localhost:8080/sky要求域名相同(此处域名为localhost)

INSERT INTO `ry`.`sys_menu`(`menu_id`, `menu_name`, `parent_id`, `order_num`, `url`, `target`, `menu_type`, `visible`, `is_refresh`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (510, '天天业务管理', 5, 1, 'http://localhost:8080/sky', '', 'C', '0', '1', 'system:sky:view', 'fa fa-user-o', 'admin', '2021-07-01 02:17:28', '', NULL, '天天业务管理菜单');

直接访问 http://localhost:8080/sky 跳转到登录页http://localhost:8600/login,登录后跳转到主页index,访问菜单天天业务可以正常访问。

image-20210723142848537.png

至此基于若依框架 shiro+thymeleaf的分布式项目可行性探索完成,后续工作ruoyi-dora-starter-web 中UserReaml 用户信息、角色、权限信息可以动态获取 如通过http、openfeign调用user服务获取。若依原框架功能未完全迁移,按需参见原框架。

总结

  • 利用session的domain作用域,使用相同域名,及redis统一管理session, 进行分布式session管理。
  • 抽象出一个公用start进行shiro的配置,业务模块引入start即可使用。
  • 所有模块的登录页面都指向统一的登录认证中心。 若对此方面感兴趣,可留言扣1,待功能完善后回复评论。或参见gitee代码ruoyi-dora共同探讨研究。

项目Gitee ruoyi-dora (dora 源自动画片--爱探险的朵拉)