/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.catalina.realm;
import java.security.Principal;
import java.security.cert.X509Certificate;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.catalina.LifecycleException;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.ietf.jgss.GSSContext;
import org.ietf.jgss.GSSException;
import org.ietf.jgss.GSSName;
/**
* This class extends the CombinedRealm (hence it can wrap other Realms) to
* provide a user lock out mechanism if there are too many failed
* authentication attempts in a given period of time. To ensure correct
* operation, there is a reasonable degree of synchronisation in this Realm.
* This Realm does not require modification to the underlying Realms or the
* associated user storage mechanisms. It achieves this by recording all failed
* logins, including those for users that do not exist. To prevent a DOS by
* deliberating making requests with invalid users (and hence causing this cache
* to grow) the size of the list of users that have failed authentication is
* limited.
*/
public class LockOutRealm extends CombinedRealm {
private static final Log log = LogFactory.getLog(LockOutRealm.class);
/**
* The number of times in a row a user has to fail authentication to be
* locked out. Defaults to 5.
*/
protected int failureCount = 5;
/**
* The time (in seconds) a user is locked out for after too many
* authentication failures. Defaults to 300 (5 minutes).
*/
protected int lockOutTime = 300;
/**
* Number of users that have failed authentication to keep in cache. Over
* time the cache will grow to this size and may not shrink. Defaults to
* 1000.
*/
protected int cacheSize = 1000;
/**
* If a failed user is removed from the cache because the cache is too big
* before it has been in the cache for at least this period of time (in
* seconds) a warning message will be logged. Defaults to 3600 (1 hour).
*/
protected int cacheRemovalWarningTime = 3600;
/**
* Users whose last authentication attempt failed. Entries will be ordered
* in access order from least recent to most recent.
*/
protected Map<String,LockRecord> failedUsers = null;
/**
* Prepare for the beginning of active use of the public methods of this
* component and implement the requirements of
* {@link org.apache.catalina.util.LifecycleBase#startInternal()}.
*
* @exception LifecycleException if this component detects a fatal error
* that prevents this component from being used
*/
@Override
protected void startInternal() throws LifecycleException {
// Configure the list of failed users to delete the oldest entry once it
// exceeds the specified size
failedUsers = new LinkedHashMap<String, LockRecord>(cacheSize, 0.75f,
true) {
private static final long serialVersionUID = 1L;
@Override
protected boolean removeEldestEntry(
Map.Entry<String, LockRecord> eldest) {
if (size() > cacheSize) {
// Check to see if this element has been removed too quickly
long timeInCache = (System.currentTimeMillis() -
eldest.getValue().getLastFailureTime())/1000;
if (timeInCache < cacheRemovalWarningTime) {
log.warn(sm.getString("lockOutRealm.removeWarning",
eldest.getKey(), Long.valueOf(timeInCache)));
}
return true;
}
return false;
}
};
super.startInternal();
}
/**
* Return the Principal associated with the specified username, which
* matches the digest calculated using the given parameters using the
* method described in RFC 2069; otherwise return <code>null</code>.
*
* @param username Username of the Principal to look up
* @param clientDigest Digest which has been submitted by the client
* @param nonce Unique (or supposedly unique) token which has been used
* for this request
* @param realmName Realm name
* @param md5a2 Second MD5 digest used to calculate the digest :
* MD5(Method + ":" + uri)
*/
@Override
public Principal authenticate(String username, String clientDigest,
String nonce, String nc, String cnonce, String qop,
String realmName, String md5a2) {
Principal authenticatedUser = super.authenticate(username, clientDigest, nonce, nc, cnonce,
qop, realmName, md5a2);
return filterLockedAccounts(username, authenticatedUser);
}
/**
* Return the Principal associated with the specified username and
* credentials, if there is one; otherwise return <code>null</code>.
*
* @param username Username of the Principal to look up
* @param credentials Password or other credentials to use in
* authenticating this username
*/
@Override
public Principal authenticate(String username, String credentials) {
Principal authenticatedUser = super.authenticate(username, credentials);
return filterLockedAccounts(username, authenticatedUser);
}
/**
* Return the Principal associated with the specified chain of X509
* client certificates. If there is none, return <code>null</code>.
*
* @param certs Array of client certificates, with the first one in
* the array being the certificate of the client itself.
*/
@Override
public Principal authenticate(X509Certificate[] certs) {
String username = null;
if (certs != null && certs.length >0) {
username = certs[0].getSubjectDN().getName();
}
Principal authenticatedUser = super.authenticate(certs);
return filterLockedAccounts(username, authenticatedUser);
}
/**
* {@inheritDoc}
*/
@Override
public Principal authenticate(GSSContext gssContext, boolean storeCreds) {
if (gssContext.isEstablished()) {
String username = null;
GSSName name = null;
try {
name = gssContext.getSrcName();
} catch (GSSException e) {
log.warn(sm.getString("realmBase.gssNameFail"), e);
return null;
}
username = name.toString();
Principal authenticatedUser = super.authenticate(gssContext, storeCreds);
return filterLockedAccounts(username, authenticatedUser);
}
// Fail in all other cases
return null;
}
/*
* Filters authenticated principals to ensure that <code>null</code> is
* returned for any user that is currently locked out.
*/
private Principal filterLockedAccounts(String username, Principal authenticatedUser) {
// Register all failed authentications
if (authenticatedUser == null && isAvailable()) {
registerAuthFailure(username);
}
if (isLocked(username)) {
// If the user is currently locked, authentication will always fail
log.warn(sm.getString("lockOutRealm.authLockedUser", username));
return null;
}
if (authenticatedUser != null) {
registerAuthSuccess(username);
}
return authenticatedUser;
}
/**
* Unlock the specified username. This will remove all records of
* authentication failures for this user.
*
* @param username The user to unlock
*/
public void unlock(String username) {
// Auth success clears the lock record so...
registerAuthSuccess(username);
}
/*
* Checks to see if the current user is locked. If this is associated with
* a login attempt, then the last access time will be recorded and any
* attempt to authenticated a locked user will log a warning.
*/
private boolean isLocked(String username) {
LockRecord lockRecord = null;
synchronized (this) {
lockRecord = failedUsers.get(username);
}
// No lock record means user can't be locked
if (lockRecord == null) {
return false;
}
// Check to see if user is locked
if (lockRecord.getFailures() >= failureCount &&
(System.currentTimeMillis() -
lockRecord.getLastFailureTime())/1000 < lockOutTime) {
return true;
}
// User has not, yet, exceeded lock thresholds
return false;
}
/*
* After successful authentication, any record of previous authentication
* failure is removed.
*/
private synchronized void registerAuthSuccess(String username) {
// Successful authentication means removal from the list of failed users
failedUsers.remove(username);
}
/*
* After a failed authentication, add the record of the failed
* authentication.
*/
private void registerAuthFailure(String username) {
LockRecord lockRecord = null;
synchronized (this) {
if (!failedUsers.containsKey(username)) {
lockRecord = new LockRecord();
failedUsers.put(username, lockRecord);
} else {
lockRecord = failedUsers.get(username);
if (lockRecord.getFailures() >= failureCount &&
((System.currentTimeMillis() -
lockRecord.getLastFailureTime())/1000)
> lockOutTime) {
// User was previously locked out but lockout has now
// expired so reset failure count
lockRecord.setFailures(0);
}
}
}
lockRecord.registerFailure();
}
/**
* Get the number of failed authentication attempts required to lock the
* user account.
* @return the failureCount
*/
public int getFailureCount() {
return failureCount;
}
/**
* Set the number of failed authentication attempts required to lock the
* user account.
* @param failureCount the failureCount to set
*/
public void setFailureCount(int failureCount) {
this.failureCount = failureCount;
}
/**
* Get the period for which an account will be locked.
* @return the lockOutTime
*/
public int getLockOutTime() {
return lockOutTime;
}
/**
* Set the period for which an account will be locked.
* @param lockOutTime the lockOutTime to set
*/
public void setLockOutTime(int lockOutTime) {
this.lockOutTime = lockOutTime;
}
/**
* Get the maximum number of users for which authentication failure will be
* kept in the cache.
* @return the cacheSize
*/
public int getCacheSize() {
return cacheSize;
}
/**
* Set the maximum number of users for which authentication failure will be
* kept in the cache.
* @param cacheSize the cacheSize to set
*/
public void setCacheSize(int cacheSize) {
this.cacheSize = cacheSize;
}
/**
* Get the minimum period a failed authentication must remain in the cache
* to avoid generating a warning if it is removed from the cache to make
* space for a new entry.
* @return the cacheRemovalWarningTime
*/
public int getCacheRemovalWarningTime() {
return cacheRemovalWarningTime;
}
/**
* Set the minimum period a failed authentication must remain in the cache
* to avoid generating a warning if it is removed from the cache to make
* space for a new entry.
* @param cacheRemovalWarningTime the cacheRemovalWarningTime to set
*/
public void setCacheRemovalWarningTime(int cacheRemovalWarningTime) {
this.cacheRemovalWarningTime = cacheRemovalWarningTime;
}
protected static class LockRecord {
private final AtomicInteger failures = new AtomicInteger(0);
private long lastFailureTime = 0;
public int getFailures() {
return failures.get();
}
public void setFailures(int theFailures) {
failures.set(theFailures);
}
public long getLastFailureTime() {
return lastFailureTime;
}
public void registerFailure() {
failures.incrementAndGet();
lastFailureTime = System.currentTimeMillis();
}
}
}