天天看點

最火背景管理系統 RuoYi 項目探秘,之二

最火背景管理系統 RuoYi 項目探秘,之二

上篇中,我們初步觀察了 RuoYi 的項目結構,并在最後實際運作起了項目。我們也發現了作者不好的代碼習慣,作為反例,我們應該要養成良好的編碼習慣。本篇開始,我們會按照 Web 界面逐一對具體子項目的實作的功能進行探秘。

常見又不常見的登入

在上一篇中,我們知道兩個很重要的資訊,一是 RuoYi 項目沒有使用前後端分離,用的是 Thymeleaf 模闆;二是權限架構選用的 Shiro。

沒有前後端分離,說明登入以及其他業務的 API 響應,必定有一部分是針對 html 的響應,而非全部是 Restful API,落在具體的登入功能上,說明登入表單送出資料的 API,有可能響應的就直接一個 html 頁面了。

而 Shiro 架構,說明我們對相關 RBAC 代碼分析時,需要從 Shiro 架構的特點進行。

我們再次運作項目,并通路 http://localhost,進入登入界面後,按 F12 打開浏覽器的開發人員工具,然後輸入驗證碼,點選登入。接着我們觀察工具的中的網絡中的 XHR,然後發現調用了登入 API:http://localhost/login,如圖:

最火背景管理系統 RuoYi 項目探秘,之二

我們再看請求體,如圖:

最火背景管理系統 RuoYi 項目探秘,之二

這裡存在一個非常嚴重的問題!!密碼明文傳輸,安全性很差!!如果我們要用 RuoYi 或者自己搭架構來做一些安全評級較高的項目,比如等保項目,很可能由于這個密碼問題就直接被否定了。

解決方法:密碼加密後傳輸,加密算法選用非對稱的,如 RSA。進一步,傳輸協定調整為 https,順便還對防止重播攻擊。

另外,我們還發現這裡傳輸了驗證碼,那驗證碼如何與目前登入綁定的?我們先驗證一下是否可以進行驗證碼替換攻擊。

我們在浏覽器同時打開兩個登入界面,兩個登入界面分别有兩個驗證碼,如圖:

最火背景管理系統 RuoYi 項目探秘,之二
最火背景管理系統 RuoYi 項目探秘,之二

我們将第二個驗證碼答案填入第一個界面,看能否登入。直接登入成功了。說明驗證碼與目前登入操作可能隻有會話綁定關系,隻要是同一會話,就認為是同一個操作。我們再用兩個不同的浏覽器驗證一下,這次就無法替換驗證碼登入了,說明驗證碼确實是隻與會話綁定,後面我們再從代碼中确認一次。

然後我們看一下登入成功後的前端邏輯,如圖:

最火背景管理系統 RuoYi 項目探秘,之二

可以看到 /login 接口響應 200 後,前端進入首頁 index,說明前端是判定登入響應為 200 後,再次通路 index,由于背景會話已記錄登入狀态,是以鑒權通過,通路傳回首頁的 html 内容。

為了解答驗證碼的問題以及更深入了解 RuoYi 項目的登入實作,我們進入代碼進行探秘。

神秘的驗證碼

上面,我們已經知道登入入口 API 為 /login,那麼隻要找到該 API ,就可以找到入口深入 RuoYi。

Sping Boot 項目的話,推薦一個 IDEA 插件 RestfulToolkit,它可以很友善的搜尋 API,要是沒有工具的話,我們就隻能用全局搜尋法,來找我們想看的 API。

我們先找 /login API,如圖:

最火背景管理系統 RuoYi 項目探秘,之二

同時,我們也順便展開的 RuoYi 的包結構,如圖:

最火背景管理系統 RuoYi 項目探秘,之二

真的是辣眼睛。

最火背景管理系統 RuoYi 項目探秘,之二

一般工程實踐中,我們會盡量用業務去劃分包的結構,即同業務的在一個包裡,更細的劃分在用子包展現。而不會像 RuoYi 這樣将一大堆可能業務相近的 Controller 扔同一個包裡就完了,區分度再用類名去區分。這樣其實很讓人頭疼。

我們繼續分析 /login。

首先,我們看到這個 Controller 有比較多的 JavaDoc 注釋和代碼注釋,這個很好,能夠友善别人了解代碼。但是 API 所對應的 Controller 函數卻沒有注釋,直接懵逼,算了,我們接着分析代碼。

GET /login 所對應的函數參數有三個: HttpServletRequest request、HttpServletResponse response 和 ModelMap mmap,這個 mmap 是個啥玩意?我們點進他的定義 ModelMap 裡,原來這個是用的 Spring Context。下載下傳源碼,看下官方類的說明寫的啥,如圖9:

最火背景管理系統 RuoYi 項目探秘,之二

原文如下:

/**
 * Implementation of {@link java.util.Map} for use when building model data for use
 * with UI tools. Supports chained calls and generation of model attribute names.
 *
 * <p>This class serves as generic model holder for Servlet MVC but is not tied to it.
 * Check out the {@link Model} interface for an interface variant.
 *
 * @author Rob Harrop
 * @author Juergen Hoeller
 * @since 2.0
 * @see Conventions#getVariableName
 * @see org.springframework.web.servlet.ModelAndView
 */
複制代碼           

可以看到,意思是 ModelMap 是标準庫中 Map 的一個實作,用于使用 UI 工具構造 model 資料。也就是說這個是結合 html 網頁的模型資料傳輸所使用的。

我們再看 POST /login函數,如圖:

最火背景管理系統 RuoYi 項目探秘,之二

函數有三個參數 String username、String password 和 Boolean rememberMe,分别對應的是:使用者名、使用者密碼和是否記住我。并沒有驗證碼相關的參數。是否說明驗證碼沒有通過背景校驗,或者它在哪裡被校驗的呢?

我們試驗一下。

第一步,我們先故意輸錯驗證碼登入,然後觀察響應體,再通過響應體的關鍵資料搜尋代碼中的實作。

驗證碼錯誤時,響應體如圖:

最火背景管理系統 RuoYi 項目探秘,之二

我們可以看到關鍵報錯資訊為“驗證碼錯誤”,通過這幾個漢字我們找到定義,如圖:

最火背景管理系統 RuoYi 項目探秘,之二

然後我們搜尋對應的常量定義,找到使用處,如圖:

最火背景管理系統 RuoYi 項目探秘,之二

圖中,我們就可以看到判斷代碼:

if (ShiroConstants.CAPTCHA_ERROR.equals(ServletUtils.getRequest().getAttribute(ShiroConstants.CURRENT_CAPTCHA)))
複制代碼           

這裡判定的是如果 request 的 attribute 裡 鍵值為 captcha(對應常量為 CURRENT_CAPTCHA)的值,是否為 captchaError(常量為 CAPTCHA_ERROR)。如果是,則說明驗證碼有問題。

看一下此方法被調用的地方,如圖:

最火背景管理系統 RuoYi 項目探秘,之二

證明确實是登入才判斷的驗證碼是否校驗通過。那麼 captcha 是在什麼時候被設定的值呢?我們再搜尋一下它的常量定義。

然後我們找到了類 CaptchaValidateFilter,然後看到了實作:

@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception
{
    request.setAttribute(ShiroConstants.CURRENT_CAPTCHA, ShiroConstants.CAPTCHA_ERROR);
    return true;
}
複制代碼           

再觀察,發現此類是繼承自 Shiro 的 AccessControlFilter,用于驗證碼校驗的過濾器。

然後我們查找校驗代碼,發現如下:

public boolean validateResponse(HttpServletRequest request, String validateCode)
{
    Object obj = ShiroUtils.getSession().getAttribute(Constants.KAPTCHA_SESSION_KEY);
    String code = String.valueOf(obj != null ? obj : "");
    // 驗證碼清除,防止多次使用。
    request.getSession().removeAttribute(Constants.KAPTCHA_SESSION_KEY);
    if (StringUtils.isEmpty(validateCode) || !validateCode.equalsIgnoreCase(code))
    {
        return false;
    }
    return true;
}
複制代碼           

這裡我們可以看到,對驗證碼的判斷,原始資料是從 attribute 裡擷取的,鍵值對應的常量為 KAPTCHA_SESSION_KEY,我們再查找一下它生成的地方,找到類 SysCaptchaController,然後看到代碼如圖:

最火背景管理系統 RuoYi 項目探秘,之二

可以看到該函數對應的 API 為 /captchaImage,正好就是界面上生成驗證碼圖檔所調用的 API。我們觀察一下此 API。哎……

代碼中使用了大量明文字元串,而非常量字元串,非常……非常……難受。但凡有一點代碼潔癖的,還是最好把這些常見的字元串定義為常量,或者使用工具元件中已定義好的枚舉、常量。要不然别人會說你的代碼寫得好土。

我們可以看到此處驗證碼為數學模式式時,生成校驗碼的方法為 String capText = captchaProducerMath.createText();,然後後面的代碼會将代碼分割為公式部分和結果部分,公式部分生成圖檔響應前端,結果部分存儲到 attribute 中用于判斷是否正确。

以上代碼分析後,整個驗證碼的生成和比較就比較清晰了。整理如下: 1.使用者未登入狀态,調用 /captchaImage,生成驗證碼圖檔,并将驗證碼正确結果放入 request 的 attribute 中,鍵值為 KAPTCHA_SESSION_KEY 2.使用者點選登入,調用 /login 傳遞資料,包括使用者名、密碼、驗證碼、是否記住使用者 3.攔截器 CaptchaValidateFilter 會先讀取 /login 傳入的驗證碼,并與 attribute 中的 KAPTCHA_SESSION_KEY 進行比較,如果相同,驗證碼正确,否則驗證碼錯誤,并同時清理已記錄的 attribute KAPTCHA_SESSION_KEY;然後會在 attribute 中添加一個頭 captcha,用于記錄驗證碼校驗結果 4./loign 對應的方法會調用登入代碼 subject.login(token);,會觸發 UserRealm 中的認證方法 doGetAuthenticationInfo(),會調用 SysLoginService 的方法 login(String username, String password) 進行使用者認證,而其中就會先進行驗證碼的判定 5.從 attribute 裡讀取驗證碼校驗結果的鍵值 captcha,如果對應的值為 captchaError,則說明驗證碼不對,就不會再進行使用者名、密碼的判斷了

我們最開始有疑問的驗證碼校驗,到此得到了答案。

奇怪的邏輯

從上面我們知道,其實最終對使用者認證相關資訊的判斷都落在 UserRealm,這是 Shiro 架構中認證相關的類,暫不詳細展開,在這個類裡面,各種認證情況都以異常形式抛出,代碼如下:

try
{
    user = loginService.login(username, password);
}
catch (CaptchaException e)
{
    throw new AuthenticationException(e.getMessage(), e);
}
catch (UserNotExistsException e)
{
    throw new UnknownAccountException(e.getMessage(), e);
}
catch (UserPasswordNotMatchException e)
{
    throw new IncorrectCredentialsException(e.getMessage(), e);
}
catch (UserPasswordRetryLimitExceedException e)
{
    throw new ExcessiveAttemptsException(e.getMessage(), e);
}
catch (UserBlockedException e)
{
    throw new LockedAccountException(e.getMessage(), e);
}
catch (RoleBlockedException e)
{
    throw new LockedAccountException(e.getMessage(), e);
}
catch (Exception e)
{
    log.info("對使用者[" + username + "]進行登入驗證..驗證未通過{}", e.getMessage());
    throw new AuthenticationException(e.getMessage(), e);
}
複制代碼           

但是最奇怪的是,RuoYi 的作者在 login() 方法中針對各種認證失敗的情況抛出了各種異常,然後又在 UserRealm 捕獲這些異常後,再抛出其他的異常,最後又依據這些異常共同的父類來決定響應的錯誤資料。

這就像給一個孩子穿了件紅色外套,然後走到門外,又給孩子加了件綠色外套,最後判斷孩子的情況,又是通過外套是毛衣來判斷。真讓人摸不着頭腦。

一般情況下,我們有兩種實作政策。第一種,針對沒的認證失敗情況,以不同的異常抛出,那麼就會有一個處理異常的頂層設計,通過不同的異常,傳回不同的響應資訊,在 Spring Boot 架構中,這個異常處理的頂層設計就是 @ControllerAdvice,所有異常都可以在這裡處理。

第二種,我們直接在 Controller 中捕獲異常,直接傳回不同的響應資訊即可。

像 RuoYi 作者這種,把以上兩種情況結合起來,又在中間多包裝一層異常的設計,就顯得既臃腫又複雜,不可取。

原材料從哪裡來

在驗證碼探秘的過程中,我們也基本理清了登入的邏輯,但我們還沒有看到使用者和密碼是如何校驗的,我們在上面邏輯中找一下相關邏輯。

我們先在 SysLoginService 中的 login() 中看到以下代碼:

// 密碼如果不在指定範圍内 錯誤
if (password.length() < UserConstants.PASSWORD_MIN_LENGTH
    || password.length() > UserConstants.PASSWORD_MAX_LENGTH)
{
    AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
    throw new UserPasswordNotMatchException();
}
// 使用者名不在指定範圍内 錯誤
if (username.length() < UserConstants.USERNAME_MIN_LENGTH
    || username.length() > UserConstants.USERNAME_MAX_LENGTH)
{
    AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
    throw new UserPasswordNotMatchException();
}
複制代碼           

這段代碼,判斷了密碼長度,不在長度範圍内的密碼,直接判定無效。但一般情況下,密碼相關的複雜度判斷,我們一般會采用密碼政策的設計,提供一個使用者可配置的政策,其中就會有密碼長度的配置項。然後在這種判斷密碼長度時,讀取密碼長度配置項,再進行判斷即可。使用者名也類似的邏輯。

像作者這種直接将邏輯寫死的情況,靈活性非常差,後期如果使用者想自定義密碼長度時,又需要修改代碼,不建議像 RuoYi 作者這樣實作。

接着,代碼查詢使用者資訊:

// 查詢使用者資訊
SysUser user = userService.selectUserByLoginName(username);
複制代碼           

然後對使用者的可用性狀态判斷,接着進行了密碼校驗,如下:

passwordService.validate(user, password);
複制代碼           

跟蹤到這個實作裡,忽略其他邏輯,發現密碼校驗函數為 matches(SysUser user, String newPassword),其實作為如下:

return user.getPassword().equals(encryptPassword(user.getLoginName(), newPassword, user.getSalt()));
複制代碼           

這段代碼,可以明确的看出來就是将登入的資料,加鹽後,再加密,然後與記錄中的使用者密碼資料進行比較。我們再看一下 encryptPassword 的實作:

return new Md5Hash(loginName + password + salt).toHex();
複制代碼           

也就是存儲的密碼資料不是加密資料,而是使用者名加上密碼再加上鹽,最後用 MD5 得到哈希值。并且我們也知道,使用者建立時,存儲的資料中,除了基本的使用者名、使用者密碼等資訊,還包括給使用者的鹽值。

我們大概找一下這個鹽值的生成方式,代碼如下:

/**
 * 生成随機鹽
 */
public static String randomSalt()
{
    // 一個Byte占兩個位元組,此處生成的3位元組,字元串長度為6
    SecureRandomNumberGenerator secureRandom = new SecureRandomNumberGenerator();
    String hex = secureRandom.nextBytes(3).toHex();
    return hex;
}
複制代碼           

小結

本篇分析了使用者的基本登入流程,了解到 RuoYi 作者沒有考慮密碼傳輸的安全性,對異常的處理不是很清爽,也知道了作者沒有設計密碼政策相關配置,相關的配置非常不靈活。而且可以複用的的字元串也應該抽離為常量,這些在我們做項目時都應該盡量避免。

另外,我們也看到,作者對使用者密碼的存儲,使用了比較安全的算法,不僅加了鹽,還使用 MD5 進行哈希,這點可以提升安全性,值得學習。

作者:阿嗚的邊城

連結:https://juejin.cn/post/7160955143188381727

繼續閱讀