在登录方法上同步代码

Synchronize code on login method

我的网站中有以下 Java 代码片段

public boolean login(String username, string password){
    if(isUserLocked(username)){
        return false;
    }
    if(isPasswordCorrect(username, password)){
        return true;
    }
    else{
        increaseFailedAttempts(username);
        if(getFailedAttempts(username) > MAXIMUM_FAILED_ATTEMPTS)
        {
            lockUser(username);
        }

        return false;
    }
}

不幸的是,可以使用同时发送数百个请求的工具来破解此代码。在成功执行锁定用户的数据库调用之前,黑客可以暴力猜测数百个 user/password 组合。我面临的是一个同步请求问题。天真的事情是在登录方法上同步。当然,这可以防止任何重复的请求被执行,但它也会将应用程序的速度降低到我们业务无法接受的程度。有哪些值得同步的好做法?

以下方法与锁定用户相关,它们需要正确协同工作。

您可以通过在尝试验证密码之前递增和检查尝试字段来确保用户被锁定 - 这将自动锁定任何试图淹没系统的用户

public boolean login(String username, string password){
    if(isUserLocked(username)){
        return false;
    }
    increaseAttempts(username);
    if(getAttempts(username) > (MAXIMUM_FAILED_ATTEMPTS + 1) {
        lockUser(username);
    } else if(isPasswordCorrect(username, password) {
        resetAttempts(username);
        unlockUser(username);
        return true;
    }
    return false;
}

Zim-Zam O'Pootertoot 的回答还不错,只是您在很短的时间内对数据库进行了大量调用。这可能会成为一个问题。

基本上你需要的是某种形式的速率限制,例如用户每时间单位(分钟)的登录尝试次数不应超过 n 次。这通常通过 token bucket algorithm.

来实现

The token bucket is an algorithm used in packet switched computer networks and telecommunications networks. It can be used to check that data transmissions, in the form of packets, conform to defined limits on bandwidth and burstiness (a measure of the unevenness or variations in the traffic flow). It can also be used as a scheduling algorithm to determine the timing of transmissions that will comply with the limits set for the bandwidth and burstiness: see network scheduler.

Java https://github.com/bbeck/token-bucket 有一个非常好的实现。每个用户名一个桶,每次尝试删除一个令牌。

同步呈现的 login() 方法有点笨手笨脚,因为这会序列化对 所有 登录请求的访问。似乎在每个用户的基础上序列化请求就足够了。此外,你的方法有点像一个软目标,因为它比它需要做的更多的往返数据库。即使是一个也相当昂贵——这可能就是为什么同步方法会造成如此沉重的损失。

我建议

  1. 跟踪在任何给定时间处理其登录请求的用户,并按用户序列化这些用户。

  2. 通过将数据库往返次数减少到最多两次来改进 login() 的整体行为——一次读取指定用户所有需要的当前数据,一次更新它。您甚至可以考虑缓存这些数据,如果您使用 JPA 访问您的用户数据,您几乎可以免费获得这些数据。

关于 (1),这是您可以按用户名序列化登录的一种方法:

public class UserLoginSerializer {
    private Map<String, Counter> pendingCounts = new HashMap<>();

    public boolean login(String username, String password) {
        Counter numPending;
        boolean result;

        synchronized (pendingCounts) {
            numPending = pendingCounts.get(username);
            if (numPending == null) {
                numPending = new Counter(1);
                pendingCounts.put(username, numPending);
            } else {
                numPending.increment();
            }
        }

        try {
            // username-scoped synchronization:
            synchronized (numPending) {
                result = doLogin(username, password);
            }
        } finally {
            synchronized (pendingCounts) {
                if (numPending.decrement() <= 0) {
                    pendingCounts.remove(username);
                }
            }
        }

        return result;
    }

    /** performs the actual login check */
    private boolean doLogin(String username, String password) {
        // ...
    }
}

class Counter {
    private int value;

    public Counter(int i) {
        value = i;
    }

    /** increments this counter and returns the new value */
    public int increment() {
        return ++value;
    }

    /** decrements this counter and returns the new value */
    public int decrement() {
        return --value;
    }
}

每个线程都在 pendingCounts 映射上同步,但只够在开始时获取和/或更新特定于用户名的对象,并在结束时更新并可能删除该对象。这会稍微延迟并发登录,但不会像关键区域执行数据库访问那样延迟。在这两者之间,每个线程在与请求的用户名关联的对象上同步。这会序列化同一用户名的登录尝试,但允许不同用户名的登录并行进行。显然,所有登录都需要通过 class.

的同一个实例

为什么允许同时登录尝试不止一次?

// resizing is expensive, try to estimate the right size up front
private Map<String, Boolean> attempts = new ConcurrentHashMap<>(1024);

public void login(String username, String password) {
  // putIfAbsent returns previous value or null if there was no mapping for the key
  // not null => login for the username in progress
  // null => new user, proceed
  if (attempts.putIfAbsent(username, Boolean.TRUE) != null) {
    throw new RuntimeException("Login attempt in progress, request should be  discarded");
  }
  try {
    // this part remains unchanged
    // if the user locked, return false
    // if the password ok, reset failed attempts, return true
    // otherwise increase failed attempts
    //    if too many failed attempts, lock the user
    // return false
  } finally {
    attempts.remove(username);
  }
}

ConcurrentHashMap不需要额外的同步,上面使用的操作是原子的。

当然,为了加快 isUserLocked,您可以在 HashMap 或 HTTP 请求中缓存锁定状态——但必须谨慎实施。

单独在内存缓存中不是一个选项 – 如果合法用户将自己锁定在外,致电支持热线以解锁,解锁状态已从数据库中删除,但由于内存缓存,用户仍然无法登录怎么办?

所以缓存的内容应该使用后台线程偶尔与数据库状态同步。