登录模块

学习一下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的用户登录和(保存用户上下文信息|用户鉴权)的实现就看完了。


总结:

  1. Halo登录认证使用的Token只是一个uuid字符串,并没有放信息到字符串,服务端不是无状态化的,是和session一样在服务端保存用户状态。但是在保存token上做了过期机制,和主动失效。因为Halo只是个个人Blog系统而已,也不存在什么大量用户在线并发,在服务端保存状态也可以,用不着使用jwt,而不用session-cookie认证机制是因为有CSRF攻击和移动端访问不便的问题。
  2. 登录接口还有限流,接口限流的功能使用了AOP,ConcurrentHashMap,ReentrantLock来实现
  3. 在Cache和Filter方面都贯彻了面向抽象编程的思想,Cache有两个实现,一个是基于ConcurrentHashMap的内存缓存实现(默认),一个是基于Google的嵌入式KV键值数据库LevelDB的文件缓存实现。Filter 认证方面定义了一个AbstractAuthenticationFilter类,有3个实现,其中AdminAuthenticationFilter是做blog后台用户认证的。
  4. 登录使用了spring event做日志记录
  5. ...两步验证的实现改天再看

Q.E.D.


我并不是什么都知道,我只是知道我所知道的。