天天看點

Halo 開源項目學習(三):注冊與登入

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

基本介紹

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

部落格安裝

項目啟動成功後,我們可以通路

http://127.0.0.1:8090

進入到部落格首頁,或者通路

http://127.0.0.1:8090/admin

進入到管理者頁面。但如果部落格未安裝,那麼頁面會被重定向到安裝頁面:

Halo 開源項目學習(三):注冊與登入

這是因為 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 的過程:

Halo 開源項目學習(三):注冊與登入

可見,不同的過濾器之間攔截的請求并沒有交集,是以一個請求最多會被一個過濾器處理。當我們通路

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

Halo 開源項目學習(三):注冊與登入

/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);
}
           
Halo 開源項目學習(三):注冊與登入

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 攔截,之後又是兩個重定向,最終讓我們看到安裝頁面:

Halo 開源項目學習(三):注冊與登入

在安裝頁面填寫完資訊後,點選 "安裝" 按鈕,觸發

/api/admin/installations

請求,請求中攜帶着我們填寫的部落格資訊:

Halo 開源項目學習(三):注冊與登入

/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/[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 的響應進行封裝:

Halo 開源項目學習(三):注冊與登入

預設情況下是不開啟兩步驗證碼的(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 作為容器。

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

Halo 開源項目學習(三):注冊與登入

浏覽器将 token 儲存在了 Local Storate:

Halo 開源項目學習(三):注冊與登入

當浏覽器下次請求資源時,會将 accessToken 存入到 Request Headers 中

Admin-Authorization

頭域:

Halo 開源項目學習(三):注冊與登入

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

<img src

Halo 開源項目學習(三):注冊與登入

浏覽器中 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 檔案,該檔案可生成我們看到的部落格首頁。

部落格首頁:

Halo 開源項目學習(三):注冊與登入