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 方法的主要邏輯為:
-
判斷博客是否已安裝,如果未安裝且當前並非測試環境,那麼由 failureHandler 處理 NotInstallException 異常並退出,否則繼續向下執行。
-
進行一次性 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("安裝完成!");
}
-
初始化博客的系統設置:也可以稱為初始化選項信息,例如將安裝選項 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 文件 //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 = "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 文件,該文件可生成我們看到的博客主頁。
博客首頁: