基本介紹
首次啟動 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 方法的主要邏輯為:
-
判斷博客是否已安裝,如果未安裝且當前并非測驗環境,那么由 failureHandler 處理 NotInstallException 例外并退出,否則繼續向下執行,
-
進行一次性 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("安裝完成!");
}
-
初始化博客的系統設定:也可以稱為初始化選項資訊,例如將安裝選項 is_installed 置為 true,將博客標題 blog_title 置為我們填寫的標題等,這些資訊會被保存到 options 表中,
-
保存用戶資訊:也就是我們填寫的姓名、email 等,在這些資訊存盤到 users 表之前,系統會將用戶的密碼進行加密處理,并為用戶分配一個頭像,
-
創建默認的分類:分類名稱為 "默認分類",
-
創建默認的文章:訪問博客首頁時看到的文章 "Hello Halo",
-
創建默認的頁面:訪問博客首頁時看到的頁面,標題為 "關于頁面",
-
創建默認的評論:評論的 postId 為文章 "Hello Halo" 的 id,即表示該評論是屬于 "Hello Halo" 的評論,
-
創建默認的選單:設定了 4 個一級選單、選單對應的 URL 以及選單在首頁排列的優先級,例如 "首頁" 的優先級為 0(最高優先級),因此排列在第一位,訪問的 URL 為 "/",因此點擊 "首頁" 時會觸發 "/" 請求,
-
發布 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 的回應進行封裝:

默認情況下是不開啟兩步驗證碼的(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);
}
- 如果博客未設定身份認證,那么將 users 表中的第一個用戶作為當前用戶,并存盤到 ThreadLocal 容器中,ThreadLocal 可用于在同一個執行緒內的多個函式或者組件之間傳遞公共資訊,如果開啟了身份認證,則繼續向下執行,
- 獲取 token,也就是從請求的 Query 引數中獲取 admin_token 或者從 Header 中獲取 Admin-Authorization,
- 根據 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!");
}
-
檢查 ThreadLocal 是否為空,為空表示用戶并未登陸,
-
獲取當前用戶并清除 cacheStore 中與用戶相關的 token,
-
記錄用戶登出日志,
博客首頁
上文介紹的登錄和登出指的是在管理員界面上的操作,實際上 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 = "https://www.cnblogs.com/johnlearning/p/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");
}
- 查看博客每頁顯示的文章數量,默認是 10,
- 查詢出所有已發布的文章并對其排序,默認按照發布時間降序排列,
- 將文章以及相關屬性存入到 model 中,Halo 中使用的是 FreeMaker 模板引擎,將資訊存入到 model 后前端可通過 EL 運算式獲取到這些內容,
- 回傳 "index" 路徑,該路徑指向已激活主題(默認主題為
caicai_anatole)的 index.ftl 檔案,該檔案可生成我們看到的博客主頁,
博客首頁:

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/464005.html
標籤:Java
上一篇:go學習第一課--語法基礎
