Halo 開源專案學習(三):註冊與登入

John同學發表於2022-04-25

基本介紹

首次啟動 Halo 專案時需要安裝部落格並註冊使用者資訊,當部落格安裝完成後使用者就可以根據註冊的資訊登入到管理員介面,下面我們分析一下整個過程中程式碼是如何執行的。

部落格安裝

專案啟動成功後,我們可以訪問 http://127.0.0.1:8090 進入到部落格首頁,或者訪問 http://127.0.0.1:8090/admin 進入到管理員頁面。但如果部落格未安裝,那麼頁面會被重定向到安裝頁面:

這是因為 Halo 中定義了幾個過濾器,分別為 ContentFilter、ApiAuthenticationFilter 和 AdminAuthenticationFilter。這三個過濾器均為 AbstractAuthenticationFilter 的子類,而 AbstractAuthenticationFilter 又繼承自 OncePerRequestFilter,其重寫的 doFilterInternal 方法如下:

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
    FilterChain filterChain) throws ServletException, IOException {
    // Check whether the blog is installed or not
    Boolean isInstalled =
        optionService
            .getByPropertyOrDefault(PrimaryProperties.IS_INSTALLED, Boolean.class, false);

    // 如果部落格未安裝且當前並不是測試環境
    if (!isInstalled && !Mode.TEST.equals(haloProperties.getMode())) {
        // If not installed
        getFailureHandler().onFailure(request, response, new NotInstallException("當前部落格還沒有初始化"));
        return;
    }

    try {
        // Check the one-time-token
        // 進行一次性 token 檢查
        if (isSufficientOneTimeToken(request)) {
            filterChain.doFilter(request, response);
            return;
        }

        // 一次性 token 驗證失敗則需要做身份認證
        // Do authenticate
        doAuthenticate(request, response, filterChain);
    } catch (AbstractHaloException e) {
        getFailureHandler().onFailure(request, response, e);
    } finally {
        SecurityContextHolder.clearContext();
    }
}

doFilterInternal 方法的主要邏輯為:

  1. 判斷部落格是否已安裝,如果未安裝且當前並非測試環境,那麼由 failureHandler 處理 NotInstallException 異常並退出,否則繼續向下執行。

  2. 進行一次性 token 檢查(本文並未使用到),如果一次性 token 驗證成功則將該請求交付給下一個過濾器;如果失敗則執行 doAuthenticate 方法對使用者進行身份認證。若在發生異常,那麼由 failureHandler 的 onFailure 方法處理該請求。

繼承了 AbstractAuthenticationFilter 的子類都會根據上述邏輯處理使用者的請求,只不過在不同的子類過濾器中,身份認證邏輯和 failureHandler 會有一定差異。下圖展示了一個請求經過 Filter 的過程:

可見,不同的過濾器之間攔截的請求並沒有交集,因此一個請求最多會被一個過濾器處理。當我們訪問 http://127.0.0.1:8090 時,該請求會被 ContentFilter 攔截,然後執行 doFilterInternal 方法,由於部落格未安裝,所以由 failureHandler 處理 NotInstallException 異常。ContentFilter 中定義的 failureHandler 屬於 ContentAuthenticationFailureHandler 類,該類中 onFailure 方法定義如下:

public void onFailure(HttpServletRequest request, HttpServletResponse response,
    AbstractHaloException exception) throws IOException, ServletException {
    if (exception instanceof NotInstallException) {
        // 重定向到 /install
        response.sendRedirect(request.getContextPath() + "/install");
        return;
    }

    // Forward to error
    request.getRequestDispatcher(request.getContextPath() + "/error")
        .forward(request, response);
}

上述程式碼表示,當異常為 NotInstallException,就將請求重定向到 /install

/install 請求在 MainController 中定義,且該請求又會被重定向到 /admin/index.html#install

@GetMapping("install")
public void installation(HttpServletResponse response) throws IOException {
    String installRedirectUri =
        StringUtils.appendIfMissing(this.haloProperties.getAdminPath(), "/") + INSTALL_REDIRECT_URI;
    // /admin/index.html#install
    response.sendRedirect(installRedirectUri);
}

index.html 檔案位於 /resource/admin 目錄下,#install 表示定位到 index.html 頁面的 install 表單,也就是上文中展示的安裝頁面。

值得注意的是,當我們訪問 http://127.0.0.1:8090/admin 時,請求並不會被過濾器處理(三個過濾器均放行了 /admin),但頁面還是被重定向到了安裝頁面,這是因為 MainController 中也定義了 /admin 請求的重定向規則:

@GetMapping("${halo.admin-path:admin}")
public void admin(HttpServletResponse response) throws IOException {
    String adminIndexRedirectUri =
        HaloUtils.ensureBoth(haloProperties.getAdminPath(), HaloUtils.URL_SEPARATOR)
            + INDEX_REDIRECT_URI;
    // /admin/index.html
    response.sendRedirect(adminIndexRedirectUri);
}

可見,訪問 /admin 時,請求會被重定向到 /admin/index.html,但直接訪問 index.html 還並不能顯示安裝頁面,因為 URL 中並沒有新增定位標識 #install。檢視 index.html 中的程式碼後可以發現,當該頁面開啟時,瀏覽器會自動訪問 /favicon.ico/api/admin/is_installed/api/admin/is_installed 會被過濾器放行,但 /favicon.ico 卻會被 ContentFilter 攔截,之後又是兩個重定向,最終讓我們看到安裝頁面:

在安裝頁面填寫完資訊後,點選 "安裝" 按鈕,觸發 /api/admin/installations 請求,請求中攜帶著我們填寫的部落格資訊:

/api/admin/installations 在 InstallController 中定義,主要處理邏輯為:

public BaseResponse<String> installBlog(@RequestBody InstallParam installParam) {
    // Validate manually
    ValidationUtils.validate(installParam, CreateCheck.class);

    // Check is installed
    boolean isInstalled = optionService
        .getByPropertyOrDefault(PrimaryProperties.IS_INSTALLED, Boolean.class, false);

    if (isInstalled) {
        throw new BadRequestException("該部落格已初始化,不能再次安裝!");
    }

    // Initialize settings
    initSettings(installParam);

    // Create default user
    User user = createUser(installParam);

    // Create default category
    Category category = createDefaultCategoryIfAbsent();

    // Create default post
    PostDetailVO post = createDefaultPostIfAbsent(category);

    // Create default sheet
    createDefaultSheet();

    // Create default postComment
    createDefaultComment(post);

    // Create default menu
    createDefaultMenu();

    eventPublisher.publishEvent(
        new LogEvent(this, user.getId().toString(), LogType.BLOG_INITIALIZED, "部落格已成功初始化")
    );

    return BaseResponse.ok("安裝完成!");
}
  1. 初始化部落格的系統設定:也可以稱為初始化選項資訊,例如將安裝選項 is_installed 置為 true,將部落格標題 blog_title 置為我們填寫的標題等,這些資訊會被儲存到 options 表中。

  2. 儲存使用者資訊:也就是我們填寫的姓名、email 等,在這些資訊儲存到 users 表之前,系統會將使用者的密碼進行加密處理,併為使用者分配一個頭像。

  3. 建立預設的分類:分類名稱為 "預設分類"。

  4. 建立預設的文章:訪問部落格首頁時看到的文章 "Hello Halo"。

  5. 建立預設的頁面:訪問部落格首頁時看到的頁面,標題為 "關於頁面"。

  6. 建立預設的評論:評論的 postId 為文章 "Hello Halo" 的 id,即表示該評論是屬於 "Hello Halo" 的評論。

  7. 建立預設的選單:設定了 4 個一級選單、選單對應的 URL 以及選單在首頁排列的優先順序,例如 "首頁" 的優先順序為 0(最高優先順序),因此排列在第一位,訪問的 URL 為 "/",因此點選 "首頁" 時會觸發 "/" 請求。

  8. 釋出 LogEvent 事件:記錄 "部落格已成功初始化" 的系統日誌。

使用者登入

上文中提到,當使用者訪問 /admin 時,請求會被重定向到 /admin/index.html,而訪問 index.html 時,預設顯示的是登入表單,此時瀏覽器中的 URL 為 admin/index.html#/login?redirect=%2Fdashboard,這是由 index.html 引入的的 js 檔案 https://cdn.jsdelivr.net/npm/halo-admin@1.4.13/dist/js/app.22ce7788.js(後文中將其簡稱為 js 檔案)設定的,表示登入成功後重定向到 "Halo Dashboard" 介面(與定位 install 一樣,這裡是定位到 dashboard)。使用者可填寫 "使用者名稱/郵箱" 和 "密碼" 進行登入,登入按鈕會觸發 /api/admin/precheck 請求,該請求的處理邏輯為:

@PostMapping("login/precheck")
@ApiOperation("Login")
@CacheLock(autoDelete = false, prefix = "login_precheck")
public LoginPreCheckDTO authPreCheck(@RequestBody @Valid LoginParam loginParam) {
    final User user = adminService.authenticate(loginParam);
    return new LoginPreCheckDTO(MFAType.useMFA(user.getMfaType()));
}

上述方法首先呼叫 authenticate 方法驗證使用者的登入引數,然後告知前端登入引數是否正確以及是否需要輸入兩步驗證碼(預設關閉)。authenticate 方法會根據使用者名稱/郵箱從 users 表中獲取使用者的資訊,並判斷當前使用者賬號是否有效,如果有效則繼續判斷登入的密碼與設定的密碼是否相同,如果密碼正確則返回 User 物件:

public User authenticate(@NonNull LoginParam loginParam) {
    Assert.notNull(loginParam, "Login param must not be null");

    String username = loginParam.getUsername();

    String mismatchTip = "使用者名稱或者密碼不正確";

    final User user;

    try {
    // Get user by username or email
    // userName 是使用者名稱還是郵箱
    user = ValidationUtils.isEmail(username)
    ? userService.getByEmailOfNonNull(username) :
    userService.getByUsernameOfNonNull(username);
    } catch (NotFoundException e) {
    log.error("Failed to find user by name: " + username);
    // 記錄登入失敗的日誌
    eventPublisher.publishEvent(
    new LogEvent(this, loginParam.getUsername(), LogType.LOGIN_FAILED,
    loginParam.getUsername()));

    throw new BadRequestException(mismatchTip);
    }

    // 使用者賬號的有效時間 expireTime 必須小於當前時間, 否則無法正常登入,這個東西就很奇怪
    userService.mustNotExpire(user);

    // 檢查登入密碼是否正確
    if (!userService.passwordMatch(user, loginParam.getPassword())) {
    // If the password is mismatch
    eventPublisher.publishEvent(
    new LogEvent(this, loginParam.getUsername(), LogType.LOGIN_FAILED,
    loginParam.getUsername()));

    throw new BadRequestException(mismatchTip);
    }

    return user;
}

雖然 /api/login/precheck 返回的是一個 LoginPreCheckDTO 物件,但實際上前端收到的是一個 BaseResponse 物件,這是因為 Halo 中會使用 AOP 對 Controller 的響應進行封裝:

預設情況下是不開啟兩步驗證碼的(MFAType 的預設值為 0),因此響應中的 needMFACode 為 false。如果需要,那麼可在管理員頁面的 "使用者" -> "個人資料" -> "兩步驗證" 處開啟。瀏覽器收到上圖中的響應後,會自動傳送 /api/admin/login 請求(由 js 檔案設定),但如果開啟了兩步驗證碼,那麼還需要輸入驗證碼才能繼續訪問 /api/admin/login

/api/admin/login 會向使用者返回一個 AuthToken 物件:

@PostMapping("login")
@ApiOperation("Login")
@CacheLock(autoDelete = false, prefix = "login_auth")
public AuthToken auth(@RequestBody @Valid LoginParam loginParam) {
	return adminService.authCodeCheck(loginParam);
}

authCodeCheck 方法的處理邏輯為:

public AuthToken authCodeCheck(@NonNull final LoginParam loginParam) {
    // get user
    final User user = this.authenticate(loginParam);

    // check authCode
    // 檢查兩步驗證碼
    if (MFAType.useMFA(user.getMfaType())) {
        if (StringUtils.isBlank(loginParam.getAuthcode())) {
        throw new BadRequestException("請輸入兩步驗證碼");
    }
    TwoFactorAuthUtils.validateTFACode(user.getMfaKey(), loginParam.getAuthcode());
    }

    if (SecurityContextHolder.getContext().isAuthenticated()) {
        // If the user has been logged in
        throw new BadRequestException("您已登入,請不要重複登入");
    }

    // Log it then login successful
    // 記錄登入成功的日誌
    eventPublisher.publishEvent(
    new LogEvent(this, user.getUsername(), LogType.LOGGED_IN, user.getNickname()));

    // Generate new token
    // 為使用者生成 token
    return buildAuthToken(user);
}

上述方法首先呼叫 authenticate 方法獲取使用者,然後檢查兩步驗證碼(如果設定的話),接著記錄登入成功的日誌,最後為使用者生成一個 token,token 可作為使用者的身份標識,伺服器可以根據 token 驗證使用者的身份,而無需使用者名稱和密碼。token 的生成邏輯如下:

private AuthToken buildAuthToken(@NonNull User user) {
    Assert.notNull(user, "User must not be null");

    // Generate new token
    AuthToken token = new AuthToken();

    token.setAccessToken(HaloUtils.randomUUIDWithoutDash());
    token.setExpiredIn(ACCESS_TOKEN_EXPIRED_SECONDS);
    token.setRefreshToken(HaloUtils.randomUUIDWithoutDash());

    // Cache those tokens, just for clearing
    cacheStore.putAny(SecurityUtils.buildAccessTokenKey(user), token.getAccessToken(),
                      ACCESS_TOKEN_EXPIRED_SECONDS, TimeUnit.SECONDS);
    cacheStore.putAny(SecurityUtils.buildRefreshTokenKey(user), token.getRefreshToken(),
                      REFRESH_TOKEN_EXPIRED_DAYS, TimeUnit.DAYS);

    // Cache those tokens with user id
    cacheStore.putAny(SecurityUtils.buildTokenAccessKey(token.getAccessToken()), user.getId(),
                      ACCESS_TOKEN_EXPIRED_SECONDS, TimeUnit.SECONDS);
    cacheStore.putAny(SecurityUtils.buildTokenRefreshKey(token.getRefreshToken()), user.getId(),
                      REFRESH_TOKEN_EXPIRED_DAYS, TimeUnit.DAYS);

    return token;
}

可以發現,token 中包含了 accessToken(隨機生成的 UUID)、refreshToken(隨機生成的 UUID)以及 accessToken 和 refreshToken 的過期時間。其中 accessToken 是用來做身份認證的,而 refreshToken 的作用是實現 token 的 "無痛重新整理"。具體來講,後端返回 token 資訊後,瀏覽器會同時儲存 accessToken 和 refreshToken,如果 accessToken 過期,那麼當瀏覽器傳送請求時,伺服器會返回 "Token 已過期或不存在" 的失敗響應,此時瀏覽器可以傳送 /api/admin/refresh/{refreshToken} 請求,通過 refreshToken 向伺服器申請一個新的 token(包括 accessToken 和 refreshToken),然後使用新的 accessToken 重新傳送之前未處理成功的請求。因此,accessToken 和 refreshToken 是繫結在一起的,且 refreshToken 的過期時間(Halo 中設定的是 30 天)要大於 accessToken(1 天)。上述程式碼中,伺服器使用 cacheStore 儲存使用者 id 和 token ,cacheStore 是專案中的內部快取,它使用 ConcurrentHashMap 作為容器。

使用者登入成功後瀏覽器獲得的響應:

瀏覽器將 token 儲存在了 Local Storate:

當瀏覽器下次請求資源時,會將 accessToken 存入到 Request Headers 中 Admin-Authorization 頭域:

accessToken 過期後,瀏覽器使用 refreshToken 申請新的 token:

<img src

瀏覽器中 token 的儲存、token 過期後的重新申請以及 Header 中 token 的新增都是由 js 檔案設定的。另外,前文中提到,過濾器攔截請求後首先要進行一次性 token 檢查,如果失敗則需要驗證使用者的身份,而 Admin-Authorization 頭域就是用於身份認證的,例如上圖中的請求 api/admin/users/profiles 會被 AdminAuthenticationFilter 攔截,因為並未設定一次性 token,因此需要進行身份認證,而 AdminAuthenticationFilter 的身份認證邏輯為:

protected void doAuthenticate(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {

    // 如果未設定認證
    if (!haloProperties.isAuthEnabled()) {
        // Set security
        userService.getCurrentUser().ifPresent(user ->
        SecurityContextHolder.setContext(
        new SecurityContextImpl(new AuthenticationImpl(new UserDetail(user)))));

        // Do filter
        filterChain.doFilter(request, response);
    return;
    }

    // 獲取 token, 從請求的 Query 引數中獲取 admin_token 或者從 Header 中獲取 Admin-Authorization
    // Get token from request
    String token = getTokenFromRequest(request);

    if (StringUtils.isBlank(token)) {
    throw new AuthenticationException("未登入,請登入後訪問");
    }

    // 根據 token 從 cacheStore 快取中獲取使用者 id
    // Get user id from cache
    Optional<Integer> optionalUserId =
    cacheStore.getAny(SecurityUtils.buildTokenAccessKey(token), Integer.class);

    if (!optionalUserId.isPresent()) {
    	throw new AuthenticationException("Token 已過期或不存在").setErrorData(token);
    }

    // 獲取使用者
    // Get the user
    User user = userService.getById(optionalUserId.get());

    // Build user detail
    UserDetail userDetail = new UserDetail(user);

    // 將使用者資訊儲存到 ThreadLocal 中
    // Set security
    SecurityContextHolder
    .setContext(new SecurityContextImpl(new AuthenticationImpl(userDetail)));

    // Do filter
    filterChain.doFilter(request, response);
}
  1. 如果部落格未設定身份認證,那麼將 users 表中的第一個使用者作為當前使用者,並儲存到 ThreadLocal 容器中,ThreadLocal 可用於在同一個執行緒內的多個函式或者元件之間傳遞公共資訊。如果開啟了身份認證,則繼續向下執行。
  2. 獲取 token,也就是從請求的 Query 引數中獲取 admin_token 或者從 Header 中獲取 Admin-Authorization。
  3. 根據 token 從 cacheStore 快取中獲取使用者 id,查詢出使用者後將使用者儲存到 ThreadLocal 中,身份認證通過。

以上便是使用者輸入賬號密碼來登入管理員頁面的過程。

使用者登出

使用者退出登入時,觸發 /api/admin/logout 請求,請求的處理邏輯是清除掉使用者的 token:

public void logout() {
	adminService.clearToken();
}

clearToken 方法如下:

@PostMapping("logout")
@ApiOperation("Logs out (Clear session)")
@CacheLock(autoDelete = false)
public void clearToken() {
    // 檢查 ThreadLocal 是否為空
    // Check if the current is logging in
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    if (authentication == null) {
    	throw new BadRequestException("您尚未登入,因此無法登出");
    }

    // 獲取當前使用者
    // Get current user
    User user = authentication.getDetail().getUser();

    // 清除 accessToken
    // Clear access token
    cacheStore.getAny(SecurityUtils.buildAccessTokenKey(user), String.class)
    .ifPresent(accessToken -> {
    	// Delete token
    	cacheStore.delete(SecurityUtils.buildTokenAccessKey(accessToken));
    	cacheStore.delete(SecurityUtils.buildAccessTokenKey(user));
    });

    // 清除 refreshToken
    // Clear refresh token
    cacheStore.getAny(SecurityUtils.buildRefreshTokenKey(user), String.class)
    .ifPresent(refreshToken -> {
        cacheStore.delete(SecurityUtils.buildTokenRefreshKey(refreshToken));
        cacheStore.delete(SecurityUtils.buildRefreshTokenKey(user));
    });

    eventPublisher.publishEvent(
    new LogEvent(this, user.getUsername(), LogType.LOGGED_OUT, user.getNickname()));

    log.info("You have been logged out, looking forward to your next visit!");
}
  1. 檢查 ThreadLocal 是否為空,為空表示使用者並未登陸。

  2. 獲取當前使用者並清除 cacheStore 中與使用者相關的 token。

  3. 記錄使用者登出日誌。

部落格首頁

上文介紹的登入和登出指的是在管理員介面上的操作,實際上 127.0.0.1:8090 才是部落格的首頁。當我們訪問 / 時,ContentIndexController 中的 index 方法會處理請求:

@GetMapping
public String index(Integer p, String token, Model model) {

    PostPermalinkType permalinkType = optionService.getPostPermalinkType();

    if (PostPermalinkType.ID.equals(permalinkType) && !Objects.isNull(p)) {
        Post post = postService.getById(p);
        return postModel.content(post, token, model);
    }

    return this.index(model, 1);
}

index(model, 1) 指的是顯示部落格的第一頁:

public String index(Model model,
        @PathVariable(value = "page") Integer page) {
    return postModel.list(page, model);
}

postModel.list 方法的邏輯如下:

public String list(Integer page, Model model) {
    // 獲取每頁顯示的文章數量
    int pageSize = optionService.getPostPageSize();
    Pageable pageable = PageRequest
        .of(page >= 1 ? page - 1 : page, pageSize, postService.getPostDefaultSort());

    // 查詢出所有已釋出的文章, 預設按照發布時間降序排列
    Page<Post> postPage = postService.pageBy(PostStatus.PUBLISHED, pageable);
    Page<PostListVO> posts = postService.convertToListVo(postPage);

    // 將文章以及相關屬性存入到 model 中
    model.addAttribute("is_index", true);
    model.addAttribute("posts", posts);
    model.addAttribute("meta_keywords", optionService.getSeoKeywords());
    model.addAttribute("meta_description", optionService.getSeoDescription());
    // 返回已啟用主題檔案中的 index.ftl
    return themeService.render("index");
}
  1. 檢視部落格每頁顯示的文章數量,預設是 10。
  2. 查詢出所有已釋出的文章並對其排序,預設按照發布時間降序排列。
  3. 將文章以及相關屬性存入到 model 中,Halo 中使用的是 FreeMaker 模板引擎,將資訊存入到 model 後前端可通過 EL 表示式獲取到這些內容。
  4. 返回 "index" 路徑,該路徑指向已啟用主題(預設主題為 caicai_anatole)的 index.ftl 檔案,該檔案可生成我們看到的部落格主頁。

部落格首頁:

相關文章