共计 13530 个字符,预计需要花费 34 分钟才能阅读完成。
1. 根本介绍
首次启动 Halo 我的项目时须要装置博客并注册用户信息,当博客装置实现后用户就能够依据注册的信息登录到管理员界面,上面咱们剖析一下整个过程中代码是如何执行的。
2. 博客装置
我的项目启动胜利后,咱们能够拜访 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 事件:记录 “ 博客已胜利初始化 ” 的系统日志。
3. 用户登录
上文中提到,当用户拜访 /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
申请,该申请的解决逻辑为:
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 中的拦截器会对 Controller 的响应进行封装(响应的封装与异样的解决都是通过拦截器实现的,后续会开一个专题讲讲 Halo 中拦截器的应用):
默认状况下是不开启两步验证码的(MFAType 的默认值为 0),因而响应中的 needMFACode 为 false。如果须要,那么可在管理员页面的 “ 用户 ” -> “ 个人资料 ” -> “ 两步验证 ” 处开启。浏览器收到上图中的响应后,会主动发送 /api/admin/login
申请(由 js 文件设置),但如果开启了两步验证码,那么还须要输出验证码能力持续拜访 /api/admin/login
。
/api/admin/login
会向用户返回一个 AuthToken 对象:
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 以及 accessToken 的过期工夫。其中 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:
浏览器中 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 中,身份认证通过。
以上便是用户输出账号密码来登录管理员页面的过程。
4. 用户登出
用户退出登录时,触发 /api/admin/logout
申请,申请的解决逻辑是革除掉用户的 token:
public void logout() {adminService.clearToken();
}
clearToken 办法如下:
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。
- 记录用户登出日志。
5. 博客首页
上文介绍的登录和登出指的是在管理员界面上的操作,实际上 127.0.0.1:8090
才是博客的首页。当咱们拜访 /
时,ContentIndexController 中的 index 办法会解决申请:
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 文件,该文件可生成咱们看到的博客主页。
博客首页: