我的网站上有以下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;
}
}
不幸的是,使用可同时发送数百个请求的工具可以攻击此代码。在数据库调用锁定用户成功执行之前,黑客可能会强行猜测数百个用户/密码组合。我面临的是同步请求问题。天真的事情是在登录方法上同步。当然,这可以防止执行任何重复的请求,但它也会使应用程序的速度降低到我们业务的不可接受的速度。相反,有什么好的实践可以同步?
以下方法与锁定用户相关,并且需要正确协作。
答案 0 :(得分:2)
为什么要允许多个同时登录尝试?
// 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请求中缓存锁状态 - 但是必须仔细实现它。
仅在内存缓存中不是一个选项 - 如果合法用户将自己锁定,调用支持行解锁,解锁状态从数据库中删除但用户仍然无法登录,因为内存缓存怎么办? / p>
因此,使用后台线程,缓存的内容应该偶尔与数据库状态同步。
答案 1 :(得分:1)
在尝试验证密码之前,您可以通过递增和检查尝试字段来确保用户被锁定 - 这将自动锁定任何试图充斥系统的用户
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;
}
答案 2 :(得分:1)
Zim-Zam O'Pootertoot的回答是好的,只是你在很短的时间内对数据库进行了大量的调用。这可能会成为一个问题。
基本上你需要的是某种形式的速率限制,例如用户每个时间单位(分钟)的登录尝试次数不应超过n次。这通常通过token bucket algorithm实现。
令牌桶是用于分组交换计算机网络和电信网络的算法。它可用于检查数据包形式的数据传输是否符合带宽和突发性的定义限制(衡量不均匀性或交通流量的变化)。它还可以用作调度算法,以确定符合为带宽和突发性设置的限制的传输时序:请参阅网络调度程序。
对于Java,https://github.com/bbeck/token-bucket有一个非常好的实现。每个用户名一个存储桶,每次尝试都会删除一个令牌。
答案 3 :(得分:1)
同步呈现的login()
方法有点笨拙,因为它序列化了所有登录请求的访问权限。似乎就每个用户序列化请求就足够了。此外,您的方法在某种程度上是一个软目标,因为它会使数据库的往返次数超出需要的范围。即使是单一的也是相当昂贵的 - 这可能是为什么同步该方法会带来如此沉重的代价。
我建议
跟踪在任何给定时间处理登录请求的用户,并按用户序列化这些请求。
通过将数据库往返次数减至最多两次来改善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
映射上同步,但只有足够长的时间才能在开头获取和/或更新特定于用户名的对象,并在最后更新并可能删除该对象。这将稍微延迟并发登录,但不会像关键区域执行数据库访问那么多。在两者之间,每个线程在与所请求的用户名相关联的对象上进行同步。此序列化相同用户名的登录尝试,但允许不同用户名的登录并行进行。显然,所有登录都需要通过该类的相同实例。