魔改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小市場做成做精,爲廣工學子的閒置交易提供一小程序式解決平臺(手動滑稽

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章