魔改lenosp脚手架

写在前面

lenosp简单描述

lenos(p为spring boot 2.0 版本扩展名)一款快速开发模块化脚手架,能够帮助我们快速搭建后台管理系统大体框架,给我们的开发节约时间成本。这里附上作者一枚码农lenosp开源项目Gitee地址。最近写的几个涉及到后台管理系统的项目我都是使用lenosp脚手架,感觉上还是不错的,作者在很多地方都写了注释,不会说看不懂源码。与此同时,作者还是在不断地维护这个项目的,预计今年提高SpringBoot版本、通用Mapper换成MybatisPlus等多项更新,还是很期待的。

至于标题魔改lenosp脚手架,是想记录下Shiro整合JWT做多Realm认证的。想看具体内容的可以按F7直接开始正片(往下翻)。在此之前我还是要叨叨一会的。

我们的小团队

距离小程序大赛拉开帷幕已经过去了半个多月了,RUSH 9 VANS这个年轻却的小团队也有了10多天的历史。

  • 无所事事疯狂想甩锅的小桃*(后台管理系统)*:早买早享受,晚买享折扣,再晚就是下一代了!
  • 努力学习希望接锅的燕姐*(后台接口)*:待人温良,处事刚毅
  • 兼具颜值和实力的高冷男神大圣*(前端)*:(空白就对了)
  • 以及能够发现世界美的鱿鱼圈*(设计)*:停

就是这么简单无奇的四个大二同学,同为计算机专业网络工程,却又有着不同的专业技能以及独一无二的个性,赋予了团队澎湃的活力以及有趣的灵魂。共同向着获奖的目标一步一个脚印地推进着。

在这两周我们也遇到了不少问题,例如

  • 小程序访问需要HTTPS协议,需要配置SSL证书
  • 四六级准考证查询、图书查询接口代理
  • 教务系统登录和统一认证登录的抉择

…这些不常见的问题也是我们前期最大的绊脚石——尝试过Python爬虫,Nginx配置删了又改、改了又删,四处寻找可以用的学校接口…好在最后问题都经过讨论及处理,得以解决。我们又向前迈出了一小步

不积跬步,何以至千里?

正片开始

(空降失败)上面只是简单聊聊目前团队的情况,也算是记录一下小程序开发的历程。下面开始本篇文章的主题——魔改lenosp脚手架

但是在此之前还是要讲讲小程序的开发流程,才能更好地切入主题而不显得突兀。

一、小程序开发模式

小程序的传统开发模式

客户端:用户UI界面,属于前端部分,前端会展示很多数据,例如文字信息,图片等,有些数据不是写死的,往往是从后端的数据库读取出来,通过json格式交互获取到数据。因此在后端需要写相应的业务代码。

服务端:后端(php/java/python/go/node)+ 数据库(MySQL/Redis/MongoDB等)

过程:需要购买域名,备案,申请SSL证书,前后端沟通成本,DB运维,图片、文件存储,内容加速(CDN),网络防护,扩容,负载均衡,安全加固等。团队(公司)需要自己去搭建服务器,还需要考虑流量,带宽,专门的技术人员去维护。

缺点:开发效率低,成本高,迭代周期长

优点:更好更强的可扩展性,放在下面讲。有点类似于轻量级应用服务器ECS服务器的比较。

云开发模式

客户端:同上,在小程序端上直接操作云数据库和云存储以及调用云函数。

云开发:云函数(Node),云数据库(MongoDB,NoSQL),云存储,交给腾讯云去部署,无需运维,省去了传统复杂的开发流程,可以做到一站式全家桶的开发。(在云函数中操作云资源)

特点:无服务的serverless开发方式,弱化了后端和运维的操作,不需要考虑硬件等基础设施,开发者只关注自身的业务逻辑,做到快速迭代,上线,无中间阻碍的开发

至于云开发有什么基础能力以及如何开通云开发,这里就不展开了,不是本篇文章的主题。

目前看来,云开发优势很大,但他又有什么坑呢?

二、微信小程序云开发的坑

1. 基础版的CDN流量太少

在阅读其他使用过云开发博主的博文后了解到——

在我最近做的一个项目中,仅在开发与测试期间,上传/下载了相册原画质的图片就用了765MB(四五天时间),当时我就意识到了事情的严重性,因为这个项目上线后需要每天为百名用户来使用,如果像我测试的那样,可能CDN流量两天就用完了。一旦CDN浏览用完升级配置,一个月最少都要30块钱,这个价格可以在外面购买一个很好的对象存储服务了。

2. 云数据库限制多

云数据库的限制有两方面。第一个方面是小程序端获取数据条数限制。第二个方面是云数据库读写权限限制。

  • 小程序端读取限制

    小程序端直接请求数据库,每次最多可以读取20条数据;使用云函数请求数据库,再通过小程序端触发云函数,每次最多读取100条数据。要是每次需要请求的数据超过100条,那就需要使用ship分次请求再进行组合了,具体操作可查看官方文档或其他博客

  • 云数据库读写权限控制

    小程序云数据库为非关系型数据库,不能使用外键内键联合查询。云数据库最开放的权限是:所有用户可读,仅创建者可改。也就是说你创建了一条记录,他人无法进行修改或删除,这也就导致了一系列的问题。具体可去百度了解。

3. 对外开放限制多

一个正常的消除程序项目一般都会配一个后台管理系统(划重点,后台管理系统是后文的重点),这个后台管理系统与小程序公用一个数据库,来对数据进行管理。由于小程序云开发自带的云数据库在小程序内部,外部要是想访问这个数据库则需要一个稍微复杂的流程:先使用官方接口获取到调用凭证,再通过这个凭证使用指定的接口来对数据库进行增删查改。此外这个流程中消耗的资源也是算在基础配置里的。每日请求上限5万次

4. 总结

介绍完了小程序云开发的优点和坑之后,我觉得小程序云开发的适用人群就非常明确了:如果你没有一个已经备案过的域名和一台云服务器,又想使项目快速上线,且对云存储、云数据库的要求不高,那么小程序云开发非常适合你,0开发成本即可发布一款微信小程序。如果你的日活用户非常多,又不想花钱升级云开发的配置,那么小程序云开发并不太适合你。

嗯,结合上面的来看,我们团队最终还是选择了传统开发模式,原因有以下几点

  • 已经有了几个备案通过了的域名
  • 有几台ECS云服务器
  • 目前团队没有人会使用云开发
  • 我们的业务要求我们要搭建一个后台管理系统,云开发要实现这个点较为复杂

三、魔改lenosp脚手架

这是第三次谈到魔改lenosp脚手架了吧,相信点进文章希望看到一些技术干货的你看到这里已经不耐烦了。稍安勿躁,你看这不就来了?

1. 了解业务需求

目前后台项目开发使用的是JWT+Shiro来做接口认证、权限认证的。这期间就涉及到几个问题

  • 后台管理系统端:管理员应该是RBAC模型,即用户-角色-权限,用shiro可以实现
  • 用户端:学生使用小程序并不需要RBAC模型,甚至学生没有任何角色,单纯的是小程序的使用者,只需要JWT接口认证过了就行

这里两端涉及到了两项相关的技术,JWT\Shiro,虽说都是用来保证项目安全性的,用于认证、授权。但是二者如果没有很好的结合在一起,很容易会出现问题。例如

  • 后台管理系统管理员操作被JWT拦截了
  • 学生使用小程序被Shiro拦截了

这都会给用户留下不好的影响,给程序的打分大打折扣,这是我们在线上应该极力避免的。

2. 如何解决

这里我使用的解决方案是Shiro多Realm认证+Shiro过滤器链添加自定义JWTFilter,之后再通过细致到方法的接口拦截定义来实现。学生端走完JWT认证后在其中做Shiro认证,后端只走Shiro认证,不走JWT认证。

具体实现思路如下

clone种子项目后,在项目结构下会有一个len-blog模块,这里面写的是lenosp未来想集成的博客模块,但是我们用其来开发后台管理系统就不适用了。但是可以修改以便自己使用。我们先来看看len-blog的目录结构

len-blog目录结构

这里的其他代码主要都是对博客模块的增删查改,我们不需要去详细分析。这里主要看上图红框框出来的两个类——BlogRealm,MyBasicHttpAuthenticationFilter

  • BlogRealm:这是博客登录认证、授权的Realm,其源码如下
@Service
public class BlogRealm extends AuthorizingRealm {

    @Autowired
    private SysUserService userService;

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 获取授权
     *
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        CurrentUser user = (CurrentUser) principalCollection.getPrimaryPrincipal();
        JWTUtil.getUsername(user.getUsername());
        //根据用户获取角色 根据角色获取所有按钮权限
        CurrentUser cUser = (CurrentUser) Principal.getSession().getAttribute("currentPrincipal");
        for (CurrentRole cRole : cUser.getCurrentRoleList()) {
            info.addRole(cRole.getId());
        }
        for (CurrentMenu cMenu : cUser.getCurrentMenuList()) {
            if (!StringUtils.isEmpty(cMenu.getPermission())) {
                info.addStringPermission(cMenu.getPermission());
            }
        }
        return info;
    }

    /**
     * 获取认证
     *
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
            throws AuthenticationException {
        JwtToken token = (JwtToken) authenticationToken;
        String username = JWTUtil.getUsername(token.getToken());
        if (StringUtils.isEmpty(username)) {
            throw new UnknownAccountException("令牌无效");
        }
        SysUser s = userService.login(username);
        if (s == null) {
            throw new UnknownAccountException("用户名或密码错误");
        }
        if (!JWTUtil.verify(token.getToken(), username, s.getPassword())) {
            throw new UnknownAccountException("用户名或密码错误");
        }

        return new SimpleAuthenticationInfo(token.getToken(), token.getToken(), getName());
    }
}

代码不复杂,授权方法doGetAuthenticationInfo中调用

  1. JWTUtil.getUsername(token.getToken())从Token中解析出用户名
  2. 调用userService.login(username从数据库中查询该用户是否存在,若存在,则进行下一步
  3. if (!JWTUtil.verify(token.getToken(), username, s.getPassword()))校验Token的真实性。(这一步很迷,因为上方已经通过Token解析出username了,按道理应该都是能通过verify的),或许是为了逻辑的严密吧。
  4. return new SimpleAuthenticationInfo(token.getToken(), token.getToken(), getName())返回用户认证时的信息

下面再看看MyBasicHttpAuthenticationFilter

public class MyBasicHttpAuthenticationFilter extends BasicHttpAuthenticationFilter {

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        if (isLoginAttempt(request, response)) {
            try {
                executeLogin(request, response);
                return true;
            } catch (Exception e) {
            }
        }
        return false;
    }

    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("Authorization");
        JwtToken jwtToken = new JwtToken(token, "BlogLogin");
        getSubject(request, response).login(jwtToken);
        return true;
    }

    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        HttpServletRequest req = (HttpServletRequest) request;
        String authorization = req.getHeader("Authorization");
        return authorization != null;
    }

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        WebUtils.toHttp(response).sendError(HttpServletResponse.SC_UNAUTHORIZED,"访问被拒绝");
        return false;
    }
}

这里如果没有一些前置知识可能还是会有些不理解的。我们按方法执行顺序挨个分析,

  1. 该类extendsBasicHttpAuthenticationFilter,BasicHttpAuthenticationFilter是Shiro的Basic Filter,继承之可以自定义shiro的拦截器,注册进shiro的过滤器链,以此实现自己的拦截规则。
  2. isLoginAttempt,顾名思义,是否是尝试登录,这里通过从request中获取请求头的Authorization参数,从中取得前端传递过来的Token,如果Token不为空返回true,为空返回false;返回true继续该过滤器的流程,返回false则直接被过滤,返回错误信息。
  3. isAccessAllowed:通过isLoginAttempt方法判断请求是否携带Token,若携带了则执行executeLogin方法。
  4. executeLogin:Token封装成JwtToken后,调用getSubject(request, response).login(jwtToken)进行shiro认证。也就是上面说的BlogRealm认证
  5. onAccessDenied:若是没有携带Token或者是Token认证未通过,则执行该方法,返回访问被拒绝的错误信息。

讲到这里,还要说说一个类的源码,那就是JWTUtil

public class JWTUtil {

    /**
     * 过期时间3小时
     */
    private static final long EXPIRE_TIME = 3 * 60 * 60 * 1000;

    /**
     * 校验token是否正确
     *
     * @param token  密钥
     * @param secret 用户的密码
     * @return 是否正确
     */
    public static boolean verify(String token, String username, String secret) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(secret);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("username", username)
                    .build();
            DecodedJWT jwt = verifier.verify(token);
            return true;
        } catch (Exception exception) {
            return false;
        }
    }

    /**
     * @return token中包含的用户名
     */
    public static String getUsername(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    /**
     * 获取当前用户
     *
     * @param token jwt加密信息
     * @return 解析的当前用户信息
     */
    public static Principal getPrincipal(String token) {
        try {
            Principal principal = new Principal();
            DecodedJWT jwt = JWT.decode(token);
            principal.setUserId(jwt.getClaim("userId").asString());
            principal.setUserName(jwt.getClaim("username").asString());
            String[] roleArr = jwt.getClaim("roles").asArray(String.class);
            if (roleArr != null) {
                principal.setRoles(Arrays.asList(roleArr));
            }
            return principal;
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    /**
     * 获取角色组
     *
     * @param token
     * @return
     */
    public static String[] getRoles(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("roles").asArray(String.class);
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    /**
     * 生成签名
     *
     * @param username 用户名
     * @param userId   用户id
     * @param secret   用户的密码
     * @return 加密的token
     */
    public static String sign(String username, String userId, List<String> roles, String secret) {
        Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        Algorithm algorithm = Algorithm.HMAC256(secret);
        String[] roleArr = new String[roles.size()];
        roleArr = roles.toArray(roleArr);
        // 附带username信息
        return JWT.create()
                .withClaim("userId", userId)
                .withClaim("username", username)
                .withArrayClaim("roles", roleArr)
                .withExpiresAt(date)
                .sign(algorithm);
    }

每个方法都有注释,解释得也很清楚了。其主要作用的是

  • 生成Token
  • 校验Token
  • 解析Token获取相应的信息

好的,到目前为止,魔改涉及到的三个类都介绍完了,下面开始我们的魔改之旅吧~

3. 修改源码

这里提一个我们后台项目希望实现的技术点。因为在编写业务代码中,很多时候都是需要用户id的,这里既然用户的操作都需要通过Token认证,那么我们可以在生成Token时把stuId也带上,再通过Token解析获取到用户的idset进ThradLocal线程域中,在一次请求中使用静态方法MyBasicHttpAuthenticationFilter.getLoginStudentId()直接拿到请求接口的用户id,就不需要前端每次请求接口都带上stuId,方便很多。

  1. 何时生成Token

    心思敏锐的你可能已经发现了,Token是什么时候生成的好像还没有见到,没有Token也就没有了上述的解决方案。其实Token生成可以放在微信登录流程中后端服务器调用code2Session度三方微信接口获取openId和session_key的时候生成,携带上唯一id,例如stuId,openId,通过加密算法生成一个Token放回给前端。也就是说在登录的时候下放Token,之后用户相关的请求都携带Token进行认证即可,这里就不贴代码了,大家根据自身情况实现。

  2. MyBasicHttpAuthenticationFilter中添加线程域类属性以及获取学生id的静态方法

...
private static final ThreadLocal<WeStudent> t1 = new ThreadLocal<>();

public static WeStudent getLoginStudentId() {
        WeStudent weStudent = t1.get();
        t1.remove();
        return weStudent;
    }
...

这里使用了t1.get(),因此还要有t1.set(stuInfo),在executeLogin方法添加即可

    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("Authorization");
        boolean res = jwtUtil.verifyToken(token);
        if (!res) {
            // 如果token认证失败,直接返回null,进入isAccessAllowed()的异常处理逻辑,抛出IllegalStateException异常并捕获
            return false;
        }
        // 根据token解析stuId和openId,设置进线程域
        t1.set(jwtUtil.getStuInfoByToken(token));
        JwtToken jwtToken = new JwtToken(token, "StuLogin");
        getSubject(request, response).login(jwtToken);
        return true;
    }

如此就实现了在自定义JWTFilter过滤器中设置学生id进ThreadLocal线程域,并在业务代码中通过MyBasicHttpAuthenticationFilter.getLoginStudentId()快速拿到stuId和openId,操作相关的数据库。

  1. 修改BlogRealm

这里要根据自身的业务情况来修改Realm认证,如果是自建用户体系,之前的BlogRealm是用不了的,这里贴上我修改过后的代码,都不难理解。

@Service
public class StudentRealm extends AuthorizingRealm {

    @Autowired
    private WeStudentService weStudentService;

    @Autowired
    private JWTUtil jwtUtil;


    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 获取认证
     *
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        return info;
    }

    /**
     * 获取授权
     *
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
            throws AuthenticationException {
        JwtToken token = (JwtToken) authenticationToken;
        String openId = JWTUtil.getOpenId(token.getToken());
        if (StringUtils.isEmpty(openId)) {
            throw new UnknownAccountException("Token令牌无效");
        }
        // 通过openId查询数据库,获取学生信息
        WeStudent stuLogin = weStudentService.login(openId);
        if (stuLogin == null) {
            // 根据openId找不到学生信息,但是这是从Token中解析出来的OpenId,一般情况下不会进入到这个逻辑里边
            throw new UnknownAccountException("根据openId" + openId + "找不到学生信息");
        }
        if (!jwtUtil.verifyToken(token.getToken())) {
            // secret默认123456即可
            throw new UnknownAccountException("用户名或密码错误");
        }

        return new SimpleAuthenticationInfo(token.getToken(), token.getToken(), getName());
    }

好的,到这里任务就差不多收工了,作为一个务实的程序员,还是要写一个接口来测试一下的。

4. 编写测试接口

  1. 首先是获取Token的接口,先拿到Token再访问后续接口。
    @RequestLog(module = "微信用户登录", operationDesc = "微信用户登录获取token")
    @ApiOperation(value = "微信用户登录", notes = "执行成功后返回用户对应的token")
    @PostMapping("/stuLogin")
    public Result wxStuLogin(@NotEmpty StudentLoginVO studentLoginVO) {
        try {
            String token = loginService.wxStuLogin(studentLoginVO);
            return new Result(ResultCode.SUCCESS,token);
        } catch (Code2SessionException e) {
            return new Result(ResultCode.Code2SessionException);
        }
    }

返回结果如下图:

获取Token

  1. 编写业务接口,测试JWTFilter是否起作用以及能否获取到stuId
    @PutMapping("/update")
    @ResponseBody
    public Result update(WeStudentBaseVO weStudentBaseVO) {
        WePrint.print("进入了更新学生信息的方法");
        int stuId = MyBasicHttpAuthenticationFilter.getLoginStudentId().getId();
        WePrint.print("学生id为"+stuId);
        WeStudent weStudent = new WeStudent();
        BeanUtil.copyNotNullBean(weStudentBaseVO,weStudent);
        int res = weStudentService.update(weStudent);
        return Result.SUCCESS();
    }

运行结果如下图:

  • IDEA控制台输出:

获取到学生id

  • Postman请求:

接口请求

okkkk,我们的目标—— Shiro多Realm认证+Shiro过滤器链添加自定义JWTFilter整合JWT 就这样实现啦。

最后,文章有什么看不懂的地方或者文章有什么可以改进的地方欢迎大家提出,一起交流,共同进步!

后记…

最近因为安卓开发和小程序大赛开发忙得不可开交,很多次想抄起键盘写博写笔记都腾不出时间来。就在昨天,师兄又提出了我的开发进度的问题。

师兄的“温馨”提醒

我也是狠心塞,在上次周会过后的两天时间,本着这周任务量不大的想法,我都在做小程序的后台开发。但通用商城毕竟是一个商业项目,是签了合同了,耽误不了,任务少不代表可以拖缓进度,因此师兄也提出了建议——

我们大家有兴趣一起做这个项目,希望是带着责任心,要保证项目进度做完,再到做好。
毕竟这是个商业项目,对客户是有承诺的,如果确实学业比较忙,事情比较多,可以提前找彭老师或者我说一声,我这边有准备能找其他同学加进来一起帮忙保证对客户的承诺哈。

所以我看到这条消息后坦诚地跟师兄说出了我的困惑——时间分配问题。师兄鉴于我目前的情况,提出了如下建议

师兄贴心的suggestion

我看着是挺好的,专心做好一个项目本就是当下最好的选择。两头做只会竹篮子打水——一场空。我不假思索地同意了,我灰太狼还会再回来的!

挥一挥衣袖,不带走一篇云彩

所以我的重心又重新回到了小程序大赛,更多的时间,更多的精力,更多的规划…我们RUSH 9 VANS小团队也会用心地把GDUT小市场做成做精,为广工学子的闲置交易提供一小程序式解决平台(手动滑稽

猜你喜欢

转载自blog.csdn.net/weixin_44950174/article/details/105705548