Halo 2.17 为什么重构 RememberMe 机制的实现方式

137

前言

SCR-20241205-qarm.png

Halo 是一个强大易用的开源建站工具,配合上丰富的主题和插件,帮助用户快速搭建个人博客、企业站点、知识库、文档站等多种类型的网站。具备可插拔架构、主题套用、富文本编辑器等多重特性,支持用户根据自己的喜好选择不同类型的插件及主题模板来定制化自己的站点功能及外观。让内容创作和发布更加便捷生动。

截至今日,Halo 已经在 GitHub 上拥有 32.5 k+ Star,Docker Hub 获得了超过 220 万次下载,并拥有一百多名社区贡献者。

背景介绍

在 Halo 2.16 版本中,我为 Halo 添加了一个记住我(Remember Me)的功能,用户可以选择在登录时勾选 "Remember Me",这样下次访问时就不需要重新登录。

该功能是通过以下方式生成 Token,并将其作为 cookie 发送回客户端:

 username + ":" + expiryTime + ":" + algorithmName + ":"
   + algorithmHex(username + ":" + expiryTime + ":" + password + ":" + key)

这样实现的优点在于无需在后端存储 Token 就可以进行验证,并且用户密码的更改会自动使 Token 失效而无需做任何额外的处理。然而,它的主要缺点是缺乏管理能力,例如无法手动撤销 Token。

在 2.17 之前由于没有需要手动撤销 Token 的需求,因此这个实现是足够的。但是在 2.17 中,我引入了设备管理功能,基于原有的 RememberMe Token 机制,撤销设备的登录状态时无法撤销勾选了 RememberMe 的设备登录状态,因为会自动登录,因此需要一个可管理的 RememberMe Token 机制。

基于这样的原因,决定采用了持久化 Token 的方式,并通过随机生成 TokenValue 的方法来提高安全性,而不将用户名和密码直接签名在 Token 中。实现机制采用 Barry Jaspan 提出的改进的持久登录 Cookie 最佳实践

基于签名 Token 的实现

在原有的 Token 机制中,用户勾选 "Remember Me" 后,系统会生成一个包含用户名和 Token 的 cookie。服务器端不需要存储 Token,只需在用户访问时验证 Token 的有效性即可。系统会解密 Token 并验证用户名和密码是否匹配,若匹配则设置登录状态。

实现方式和原理

关键代码如下:

autoLogin 方法会在用户访问且没有登录的状态下执行一次,用于尝试自动登录。

  1. 首先从 ServerWebExchange 中获取名为 remember-me 的 Cookie 的值,这件事由 RememberMeCookieResolver 完成。
  2. 如果没有获取到 Cookie,则返回一个空的 Mono,表示没有自动登录。
  3. 如果获取到了 Cookie,则解码 Cookie 的值,获取到 cookieTokens,然后调用 processAutoLoginCookie 方法进行处理。cookieTokens 由以下部分组成,使用 : 分隔:
    • 用户名
    • 过期时间
    • 算法名称
    • 签名 algorithmHex(username + ":" + expiryTime + ":" + password + ":" + key)
public Mono<Authentication> autoLogin(ServerWebExchange exchange) {
    var rememberMeCookie = rememberMeCookieResolver.resolveRememberMeCookie(exchange);
    if (rememberMeCookie == null) {
        return Mono.empty();
    }
    log.debug("Remember-me cookie detected");
    return Mono.defer(
            () -> {
                String[] cookieTokens = decodeCookie(rememberMeCookie.getValue());
                return processAutoLoginCookie(cookieTokens, exchange);
            })
        .flatMap(user -> {
            this.userDetailsChecker.check(user);
            log.debug("Remember-me cookie accepted");
            return createSuccessfulAuthentication(exchange, user);
        })
        .onErrorResume(ex -> handleError(exchange, ex));
}

protected String[] decodeCookie(String cookieValue) throws InvalidCookieException {
    int paddingCount = 4 - (cookieValue.length() % 4);
    if (paddingCount < 4) {
        char[] padding = new char[paddingCount];
        Arrays.fill(padding, '=');
        cookieValue += new String(padding);
    }
    String cookieAsPlainText;
    try {
        cookieAsPlainText = new String(Base64.getDecoder().decode(cookieValue.getBytes()));
    } catch (IllegalArgumentException ex) {
        throw new InvalidCookieException(
            "Cookie token was not Base64 encoded; value was '" + cookieValue + "'");
    }
    String[] tokens = StringUtils.delimitedListToStringArray(cookieAsPlainText, DELIMITER);
    for (int i = 0; i < tokens.length; i++) {
        tokens[i] = URLDecoder.decode(tokens[i], StandardCharsets.UTF_8);
    }
    return tokens;
}

需要注意的是 Cookie 的值是经过 Base64 编码的,但是 Cookie 值不允许出现 =,因此在编码时会移除 =,而解码时需要补全 =,补全机制根据 Base64 编码后的长度必须是 4 的倍数不足的部分用 = 补全的特点作为依据。

得到 cookieTokens 后调用 processAutoLoginCookie 方法进行自动登录的校验和处理逻辑。

  1. 首先校验 cookieTokens 的长度是否为 4,不是则抛出异常。
  2. 校验 cookieTokens 的第二个元素的过期时间是否在当前时间之后,不是则抛出异常。
  3. 获取 cookieTokens 的第一个元素作为用户名,根据用户名从数据库中获取用户信息, 如果用户不存在则抛出异常。
  4. cookieTokens 中获取算法名称和签名,根据用户名、过期时间、密码和密钥计算签名,如果生成的签名与 cookieTokens 中的签名不一致则抛出异常。
  5. 最终返回 UserDetailscreateSuccessfulAuthentication 方法创建 Authentication
protected Mono<UserDetails> processAutoLoginCookie(String[] cookieTokens,
    ServerWebExchange exchange) {
    if (!isValidCookieTokensLength(cookieTokens)) {
        throw new InvalidCookieException(
            "Cookie token did not contain 3 or 4 tokens, but contained '" + Arrays.asList(
                cookieTokens) + "'");
    }

    long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
    if (isTokenExpired(tokenExpiryTime)) {
        throw new InvalidCookieException(
            "Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime)
                + "'; current time is '" + new Date() + "')");
    }

    // Check the user exists. Defer lookup until after expiry time checked, to
    // possibly avoid expensive database call.
    return getUserDetailsService().findByUsername(cookieTokens[0])
        .switchIfEmpty(Mono.error(new UsernameNotFoundException("User '" + cookieTokens[0]
            + "' not found")))
        .flatMap(userDetails -> {
            // Check signature of token matches remaining details. Must do this after user
            // lookup, as we need the DAO-derived password. If efficiency was a major issue,
            // just add in a UserCache implementation, but recall that this method is usually
            // only called once per HttpSession - if the token is valid, it will cause
            // SecurityContextHolder population, whilst if invalid, will cause the cookie to
            // be cancelled.
            String actualTokenSignature;
            String actualAlgorithm = DEFAULT_ALGORITHM;
            // If the cookie value contains the algorithm, we use that algorithm to check the
            // signature
            if (cookieTokens.length == 4) {
                actualTokenSignature = cookieTokens[3];
                actualAlgorithm = cookieTokens[2];
            } else {
                actualTokenSignature = cookieTokens[2];
            }
            return makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
                userDetails.getPassword(), actualAlgorithm)
                .doOnNext(expectedTokenSignature -> {
                    if (!equals(expectedTokenSignature, actualTokenSignature)) {
                        throw new InvalidCookieException(
                            "Cookie contained signature '" + actualTokenSignature
                                + "' but expected '"
                                + expectedTokenSignature + "'");
                    }
                })
                .thenReturn(userDetails);
        });
}

protected Mono<String> makeTokenSignature(long tokenExpiryTime, String username, String password, String algorithm) {
    return getKey()
        .handle((key, sink) -> {
            String data = username + ":" + tokenExpiryTime + ":" + password + ":" + key;
            try {
                MessageDigest digest = MessageDigest.getInstance(algorithm);
                sink.next(new String(Hex.encode(digest.digest(data.getBytes()))));
            } catch (NoSuchAlgorithmException ex) {
                sink.error(
                    new IllegalStateException("No " + algorithm + " algorithm available!"));
            }
        });
}

private long getTokenExpiryTime(String[] cookieTokens) {
    try {
        return Long.parseLong(cookieTokens[1]);
    } catch (NumberFormatException nfe) {
        throw new InvalidCookieException(
            "Cookie token[1] did not contain a valid number (contained '" + cookieTokens[1]
                + "')");
    }
}

protected long calculateExpireTime(ServerWebExchange exchange,
  Authentication authentication) {
  var tokenLifetime = rememberMeCookieResolver.getCookieMaxAge().toSeconds();
  return Instant.now().plusSeconds(tokenLifetime).toEpochMilli();
}

详情请查看 GitHub Halo PR #6131

问题

  1. 难以管理:Token 只能通过过期失效,无法手动让其失效,导致难以实现精细化的设备管理。
  2. 安全隐患:一旦 Token 被盗,攻击者可以持续访问直到 Token 过期,而用户可能未察觉到账户被盗用。

新的持久化 Token 机制

Barry Jaspan 提出的持久化 Token 机制改进了上述不足,其核心思想是通过 "系列标识符" 和 "Token" 的组合来实现更安全和可管理的持久化登录机制。

实现方式和原理

登录时生成系列标识符和 Token

  • 用户成功登录并勾选 "Remember Me" 后,系统会生成一个包含用户名、系列标识符(series)和 Token 的 cookie。
  • 系列标识符和 Token 都是随机生成且难以猜测的字符串,服务器会将这三个元素存储在数据库中。

以下是持久化 Token 的自定义模型设计:

apiVersion: security.halo.run/v1alpha1
kind: RememberMeToken
metadata:
  name: token-TrpNE
  creationTimestamp: 2024-07-08T10:00:41.796765067Z
spec:
  username: guqing
  series: 12dFjDr4Ugspgk0oMugxap==
  tokenValue: tF269aPzYCnQmKL/rX5uJo==
  lastUsed: 2024-07-08T10:01:45.520Z

series 作为唯一标识符,用于查询 tokenValue

验证过程

  • 当用户携带该 cookie 访问时,系统会比对用户名、seriestokenValue
  • 若匹配,用户通过验证,旧 Token 被移除并生成一个新的 Token,更新数据库和 cookie。
  • 若系列标识符(series)匹配但 Token 不匹配,系统假定发生了盗用行为,用户所有的持久化登录会被注销,并提示安全警告。
  • 若用户名和系列标识符都不匹配,忽略该 cookie。

Token 的生成和验证
基于持久化 Token 的机制,Cookie 的值由 seriestokenValue 组成,并使用 : 分隔后进行 Base64 编码。

setCookie(new String[] {token.getSeries(), token.getTokenValue()}, exchange);

void setCookie(String[] cookieTokens, ServerWebExchange exchange) {
  String cookieValue = encodeCookie(cookieTokens);
  rememberMeCookieResolver.setRememberMeCookie(exchange, cookieValue);
}

因此,在验证时需要解码 Cookie 的值,获取 seriestokenValue,然后根据 series 从数据库中获取 tokenValue 进行比对。

protected Mono<UserDetails> processAutoLoginCookie(String[] cookieTokens,
    ServerWebExchange exchange) {
    if (cookieTokens.length != 2) {
        throw new InvalidCookieException(
            "Cookie token did not contain " + 2 + " tokens, but contained '"
                + Arrays.asList(cookieTokens) + "'");
    }
    String presentedSeries = cookieTokens[0];
    String presentedToken = cookieTokens[1];
    return this.tokenRepository.getTokenForSeries(presentedSeries)
        // No series match, so we can't authenticate using this cookie
        .switchIfEmpty(Mono.error(new RememberMeAuthenticationException(
            "No persistent token found for series id: " + presentedSeries))
        )
        .flatMap(token -> {
            // We have a match for this user/series combination
            if (!presentedToken.equals(token.getTokenValue())) {
                // Token doesn't match series value. Delete all logins for this user and throw
                // an exception to warn them.
                return this.tokenRepository.removeUserTokens(token.getUsername())
                    .then(Mono.error(new CookieTheftException(
                        "Invalid remember-me token (Series/token) mismatch. Implies previous "
                            + "cookie theft"
                            + " attack.")));
            }

            if (isTokenExpired(token)) {
                return Mono.error(
                    new RememberMeAuthenticationException("Remember-me login has expired"));
            }

            // Token also matches, so login is valid. Update the token value, keeping the
            // *same* series number.
            log.debug("Refreshing persistent login token for user '{}', series '{}'",
                token.getUsername(), token.getSeries());
            var newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(),
                generateTokenData(), new Date());
            return Mono.just(newToken);
        })
        .flatMap(newToken -> updateToken(newToken)
            .doOnSuccess(unused -> addCookie(newToken, exchange))
            .onErrorMap(ex -> {
                log.error("Failed to update token: ", ex);
                return new RememberMeAuthenticationException(
                    "Autologin failed due to data access problem");
            })
            .then(getUserDetailsService().findByUsername(newToken.getUsername()))
        );
}

protected String generateSeriesData() {
    byte[] newSeries = new byte[this.seriesLength];
    this.random.nextBytes(newSeries);
    return new String(Base64.getEncoder().encode(newSeries));
}

protected String generateTokenData() {
    byte[] newToken = new byte[this.tokenLength];
    this.random.nextBytes(newToken);
    return new String(Base64.getEncoder().encode(newToken));
}

关于上述实现的具体细节参考 GitHub Halo PR #6131

遇到的问题:

经过使用后发现,部分用户反馈记住我功能失效的问题,经过排查发现是由于每次使用后都会更新 Token,但这个更新的过程中可能由于并发问题或者用户关闭浏览器导致 Cookie 的值未被写入浏览器导致用户下次访问时无法自动登录,并且判定为盗用行为。因此不再每次使用后都更新 Token,只更新最后使用时间。
参考 GitHub Halo PR #6329

新机制的优点

  1. 提升安全性

    • 可以直观的查看 Token 最后用于登录的时间,并且可以随时可以撤销 Token。
    • 引入系列标识符,有效检测并应对 cookie 盗用行为。
  2. 改进用户体验

    • 用户无需频繁重新登录,持久化登录体验更流畅。
    • 支持设备管理,用户可以查看和管理所有登录设备,提高透明度和控制力。

结论

通过这次重构,Halo 不仅增强了系统的安全性和可靠性,还大幅提升了用户体验,进一步巩固了其作为开源建站工具的竞争力。这一改进将为广大用户带来更加安全和便捷的使用体验。

同时,这次重构也让我更加深入理解了持久化 Token 的机制,对于后续的系统设计和开发工作也有了更多的启发和借鉴,功能的完善和优化是一个不断迭代的过程,不会在一开始就完美,就像本次重构一样,只有真正适合当前使用场景的实现方式才是最好的,过度设计和不必要的复杂性只会增加系统的维护成本,当实现方式不再适用时,及时调整和优化才是最重要的。