登录模块
学习一下Halo登录认证是咋做的
登录流程
controller
run/halo/app/controller/admin/api/AdminController.java
@PostMapping("login")
@ApiOperation("Login")
@CacheLock(autoDelete = false, prefix = "login_auth")
public AuthToken auth(@RequestBody @Valid LoginParam loginParam) {
return adminService.authCodeCheck(loginParam);
}
PostMapping和ApiOperation我知道分别是springmvc和swagger的注解
这个CacheLock没见过,估计是个自定义的注解,点过去看一下,位于run.halo.app.cache.lock
,Target为ElementType.METHOD,idea全局搜索一下这个注解类,发现了一个切面类CacheLockInterceptor
,切入点为使用了该注解的方法。
切面类关键代码:
// Get method signature
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
// Get cache lock
CacheLock cacheLock = methodSignature.getMethod().getAnnotation(CacheLock.class);
//构建一个缓存key,用到了注解的prefix属性或方法签名,方法参数(有CacheParam注解的),请求ip(traceRequest==true时)
String cacheLockKey = buildCacheLockKey(cacheLock, joinPoint);
try {
// 往缓存中put值,如果此key已有值,返回false,否则true,这里缓存的实现底层用的是ConcurrentHashMap
Boolean cacheResult = cacheStore
.putIfAbsent(cacheLockKey, CACHE_LOCK_VALUE, cacheLock.expired(),
cacheLock.timeUnit());
if (cacheResult == null) {
throw new ServiceException("Unknown reason of cache " + cacheLockKey)
.setErrorData(cacheLockKey);
}
//当有值时,抛出异常
if (!cacheResult) {
throw new FrequentAccessException("访问过于频繁,请稍后再试!").setErrorData(cacheLockKey);
}
// Proceed the method
return joinPoint.proceed();
} finally {
// Delete the cache
if (cacheLock.autoDelete()) {
cacheStore.delete(cacheLockKey);
}
}
通过上面代码可以看到,往ConcurrentHashMap里put一个CACHE_LOCK_VALUE字符串常量,当有值时抛出一个自定义异常,所以这个CacheLock注解的功能是用来做限流的,在登录方法auth()上使用时用了2个属性:autoDelete = false, prefix = "login_auth"。这里autoDelete为false,所以方法完成后也不会删除缓存。cacheStore.putIfAbsent方法传入了expired和timeUnit,auth方法上没有覆盖默认值,这里为5秒。所以auth登录方法是5秒内只能调用一次。
后来f12看登录调用接口过程时发现会先调用login/precheck,验证用户名密码正确后查出用户信息,给前台判断是否需要两步验证,前台再在之前的登录参数上加上authcode(不需要两步验证值为null),再调用login完成登录。
Service
run/halo/app/service/impl/AdminServiceImpl.java
不管需不需要两步验证,都是调用authCodeCheck完成登录的
@Override
@NonNull
public AuthToken authCodeCheck(@NonNull final LoginParam loginParam) {
// get user
final User user = this.authenticate(loginParam);
// check authCode
if (MFAType.useMFA(user.getMfaType())) {
if (StrUtil.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
return buildAuthToken(user);
}
先调用this.authenticate来验证用户名密码
关键代码如下
先通过用户名查出数据库里的用户信息:
user = Validator.isEmail(username)
? userService.getByEmailOfNonNull(username) :
userService.getByUsernameOfNonNull(username);
判断密码是否正确:
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);
}
验证密码时用到了hutool工具包:BCrypt.checkpw(原文,密文)
密码验证失败后发布登录失败的LogEvent
然后分别是校验两步验证码,是否重复登录。通过后就是登录成功了,发布登录成功的LogEvent,LogEvent由run/halo/app/listener/logger/LogEventListener.java
监听,该监听类功能就是往数据库记录日志事件。
登录成功后调用buildAuthToken生成Token
AuthToken类有3个属性,分别是访问token,刷新token,过期时间(int 秒)
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,一个是放入的userId,先前不懂,明明只用以token为key,userId为值放入就可以了,cacheStore的实现可以定时遍历去清除过期的token,这样就已经完成了根据token查询用户的功能了。后面查看方法调用发现,这里以userId为key放入token是为了实现服务端控制token有效性的功能。用户主动logout时会通过userId去移除该用户的token。
等等,到这里好像就完了?没看到设置用户上下文呢?保存在哪儿的呢?
登录时有个校验有是否已登录:
SecurityContextHolder.getContext().isAuthenticated()
,看一下SecurityContextHolder类:
run/halo/app/security/context/SecurityContextHolder.java
public class SecurityContextHolder {
private static final ThreadLocal<SecurityContext> CONTEXT_HOLDER = new ThreadLocal<>();
private SecurityContextHolder() {
}
/**
* Gets context.
*
* @return security context
*/
@NonNull
public static SecurityContext getContext() {
// Get from thread local
SecurityContext context = CONTEXT_HOLDER.get();
if (context == null) {
// If no context is available now then create an empty context
context = createEmptyContext();
// Set to thread local
CONTEXT_HOLDER.set(context);
}
return context;
}
/**
* Sets security context.
*
* @param context security context
*/
public static void setContext(@Nullable SecurityContext context) {
CONTEXT_HOLDER.set(context);
}
/**
* Clears context.
*/
public static void clearContext() {
CONTEXT_HOLDER.remove();
}
/**
* Creates an empty security context.
*
* @return an empty security context
*/
@NonNull
private static SecurityContext createEmptyContext() {
return new SecurityContextImpl(null);
}
}
嗯,熟悉的ThreadLocal,就是这个类保存了在线用户信息
Filter
看setContext方法,找一下在那里调用的
run/halo/app/security/filter/AdminAuthenticationFilter.java
@Override
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;
}
// Get token from request
String token = getTokenFromRequest(request);
if (StringUtils.isBlank(token)) {
throw new AuthenticationException("未登录,请登录后访问");
}
// 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);
// Set security
SecurityContextHolder
.setContext(new SecurityContextImpl(new AuthenticationImpl(userDetail)));
// Do filter
filterChain.doFilter(request, response);
}
类最终继承于spring-web的OncePerRequestFilter
AdminAuthenticationFilter extends AbstractAuthenticationFilter extends OncePerRequestFilter
AbstractAuthenticationFilter 定义了两个Set
private Set<String> excludeUrlPatterns = new HashSet<>(16);
private Set<String> urlPatterns = new LinkedHashSet<>();
urlPatterns用LinkedHashSet可能是因为要保留加入时的顺序信息,在判断是否过滤时根据加入url的优先级可以提高效率。
//实现OncePerRequestFilter的shouldNotFilter
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
Assert.notNull(request, "Http servlet request must not be null");
// check white list
boolean result = excludeUrlPatterns.stream()
.anyMatch(p -> antPathMatcher.match(p, urlPathHelper.getRequestUri(request)));
return result || urlPatterns.stream()
.noneMatch(p -> antPathMatcher.match(p, urlPathHelper.getRequestUri(request)));
}
AdminAuthenticationFilter构造初始化值时加入了要过滤的url模式和要排除的url
addUrlPatterns("/api/admin/**", "/api/content/comments");
addExcludeUrlPatterns(
"/api/admin/login",
"/api/admin/refresh/*",
"/api/admin/installations",
"/api/admin/migrations/halo",
"/api/admin/is_installed",
"/api/admin/password/code",
"/api/admin/password/reset",
"/api/admin/login/precheck"
);
访问需要过滤的url时,执行doAuthenticate方法,从request中token,再从缓存里通过token得到userId,最后查询数据库得到User对象,包装成UserDetail,
最后设置用户上下文信息:
SecurityContextHolder.setContext(new SecurityContextImpl(new AuthenticationImpl(userDetail)));
Halo的用户登录和(保存用户上下文信息|用户鉴权)的实现就看完了。
总结:
- Halo登录认证使用的Token只是一个uuid字符串,并没有放信息到字符串,服务端不是无状态化的,是和session一样在服务端保存用户状态。但是在保存token上做了过期机制,和主动失效。因为Halo只是个个人Blog系统而已,也不存在什么大量用户在线并发,在服务端保存状态也可以,用不着使用jwt,而不用session-cookie认证机制是因为有CSRF攻击和移动端访问不便的问题。
- 登录接口还有限流,接口限流的功能使用了AOP,ConcurrentHashMap,ReentrantLock来实现
- 在Cache和Filter方面都贯彻了面向抽象编程的思想,Cache有两个实现,一个是基于ConcurrentHashMap的内存缓存实现(默认),一个是基于Google的嵌入式KV键值数据库LevelDB的文件缓存实现。Filter 认证方面定义了一个AbstractAuthenticationFilter类,有3个实现,其中AdminAuthenticationFilter是做blog后台用户认证的。
- 登录使用了spring event做日志记录
- ...两步验证的实现改天再看
Q.E.D.