Halo 開源項目學習(三):註冊與登錄

基本介紹

首次啟動 Halo 項目時需要安裝博客並註冊用戶信息,當博客安裝完成後用戶就可以根據註冊的信息登錄到管理員界面,下面我們分析一下整個過程中代碼是如何執行的。

博客安裝

項目啟動成功後,我們可以訪問 //127.0.0.1:8090 進入到博客首頁,或者訪問 //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 的過程:

可見,不同的過濾器之間攔截的請求並沒有交集,因此一個請求最多會被一個過濾器處理。當我們訪問 //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 表單,也就是上文中展示的安裝頁面。

值得注意的是,當我們訪問 //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 文件 //cdn.jsdelivr.net/npm/[email protected]/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 文件,該文件可生成我們看到的博客主頁。

博客首頁: