diff --git a/authentication/source/java/ch/systemsx/cisd/authentication/file/CachingAuthenticationService.java b/authentication/source/java/ch/systemsx/cisd/authentication/file/CachingAuthenticationService.java new file mode 100644 index 0000000000000000000000000000000000000000..fea56bd82e64eee36818e770988f3dacc665e22e --- /dev/null +++ b/authentication/source/java/ch/systemsx/cisd/authentication/file/CachingAuthenticationService.java @@ -0,0 +1,578 @@ +/* + * Copyright 2013 ETH Zuerich, CISD + * + * Licensed 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 ch.systemsx.cisd.authentication.file; + +import java.io.File; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import org.apache.commons.lang.StringUtils; +import org.apache.log4j.Logger; + +import ch.systemsx.cisd.authentication.IAuthenticationService; +import ch.systemsx.cisd.authentication.Principal; +import ch.systemsx.cisd.authentication.file.LineBasedUserStore.IUserEntryFactory; +import ch.systemsx.cisd.common.exceptions.ConfigurationFailureException; +import ch.systemsx.cisd.common.exceptions.EnvironmentFailureException; +import ch.systemsx.cisd.common.logging.LogCategory; +import ch.systemsx.cisd.common.logging.LogFactory; +import ch.systemsx.cisd.common.utilities.ITimeProvider; +import ch.systemsx.cisd.common.utilities.SystemTimeProvider; + +/** + * An {@link IAuthenticationService} that delegates to another {@link IAuthenticationService} and + * keeps the returned value for user authentication in a local cache which is written out to a + * password cache file which will be used to populate the cache on restart, so the password cache + * survives restarts. Changing the password cache file will change the cache without restart, e.g. + * deleting a line from it will remove the cache entry with immediate effect. + * <p> + * In order to make caching as smooth as possible for regular users of the system, a authentication + * request which is served from the cache under some conditions triggers a re-validation request + * with the delegate authentication service. As re-validation is done asynchronously, it does not + * block the user from working. + * <p> + * Two configurable time periods (in milli-seconds) are relevant: + * <ul> + * <li><code>cacheTimeMillis</code> is the time period after caching that a cache entry is kept. + * Older cache entries are treated as invalid and ignored. The default is 28 hours, which means that + * any user who logs into the system once a day will never have to wait for the delegate + * authentication system to respond as all cache updates are performed in asynchronous + * re-validations.</li> + * <li><code>cacheTimeNoRevalidationMillis</code> is the time period after caching in which + * successful authentication requests do not trigger a re-validation request. The default is 1 hour. + * This feature is meant to reduce the load on the delegate authentication system. Set it to + * <code>cacheTimeMillis</code> to never re-validate or set to 0 to always re-validate.</li> + * </ul> + * + * @author Bernd Rinn + */ +public class CachingAuthenticationService implements IAuthenticationService +{ + public final static long ONE_MINUTE = 60 * 1000L; + + public final static long ONE_HOUR = 60 * ONE_MINUTE; + + private final static long CACHE_TIME_MILLIS_NO_REVALIDATION = ONE_HOUR; + + private final static long CACHE_TIME_MILLIS = 28 * ONE_HOUR; + + private static final Logger operationLog = + LogFactory.getLogger(LogCategory.OPERATION, CachingAuthenticationService.class); + + private enum CacheEntryStatus + { + /** Entry OK without validation. */ + OK, + /** Entry OK but should request re-validation. */ + OK_REVALIDATE, + /** No entry or entry expired. */ + NO_ENTRY, + } + + /** A request for validation of a user. */ + final class ValidationRequest + { + private final UserCacheEntry user; + + private final long queuedAt; + + private final String passwordOrNull; + + private final boolean authenticated; + + ValidationRequest(UserCacheEntry user, String passwordOrNull, boolean authenticated, + long now) + { + this.user = user; + this.passwordOrNull = passwordOrNull; + this.authenticated = authenticated; + this.queuedAt = now; + } + + /** + * Returns the user entry to re-validate. + */ + UserCacheEntry getUser() + { + return user; + } + + /** + * Returns the password the user supplied, or <code>null</code>, if this is a + * non-authenticating + * validation request. + */ + String tryGetPassword() + { + return passwordOrNull; + } + + /** + * Returns <code>true</code> if this request does not include authentication. + */ + boolean withoutAuthentication() + { + return (passwordOrNull == null); + } + + /** + * Returns <code>true</code>, if the user has been successfully authenticated from the + * cached entry with the given password. + */ + boolean isAuthenticated() + { + return authenticated; + } + + /** + * Returns <code>true</code> if this is a valid request at the time when this method is + * called. + */ + boolean isValid() + { + final UserCacheEntry current = userStore.tryGetUserById(user.getUserId()); + // OK, if there is no cache entry. + if (current == null) + { + return true; + } + // Not OK, if the request has no password but the cache entry has one. + if (passwordOrNull == null && current.hasPassword()) + { + return false; + } + // OK, if the request has a password but the cache entry has none. + if (passwordOrNull != null && current.hasPassword() == false) + { + return true; + } + // OK, if the time of request queuing is after the time of caching. + return (current.getCachedAt() < queuedAt); + } + } + + /** + * The class that performs re-validation of users and their passwords. + * + * @author Bernd Rinn + */ + final class RevalidationRunnable implements Runnable + { + void runOnce() throws InterruptedException + { + final ValidationRequest request = validationQueue.take(); + if (request.isValid() == false) + { + return; + } + final String userId = request.getUser().getUserId(); + final Principal p = + delegate.tryGetAndAuthenticateUser(userId, + request.tryGetPassword()); + // If a user got remove from the delegate system, remove the cache entry. + if (p == null) + { + userStore.removeUser(userId); + if (request.isAuthenticated()) + { + operationLog + .warn(String.format("User '%s' has been logged in which is no " + + "longer a valid user.", userId)); + } + return; + } + // Only update the cache if no authentication was requested or if the + // authentication was successful. + if (p.isAuthenticated() || request.withoutAuthentication()) + { + userStore.addOrUpdateUser(new UserCacheEntry(p, request + .tryGetPassword(), timeProvider.getTimeInMilliseconds())); + } + if (request.isAuthenticated() && p.isAuthenticated() == false) + { + operationLog.warn(String.format( + "User '%s' has been logged in with an outdated password.", + userId)); + } + } + + @Override + public void run() + { + while (true) + { + try + { + runOnce(); + } catch (Throwable th) + { + operationLog.error( + "Exception in " + CachingAuthenticationService.class.getSimpleName() + + " Revalidator: ", th); + } + } + } + } + + /** A simple interface to authenticate by one type of id. */ + interface IAuthenticator + { + Principal tryGetAndAuthenticate(String id, String passwordOrNull); + } + + private final IUserStore<UserCacheEntry> userStore; + + private final IAuthenticationService delegate; + + private final IAuthenticator userIdAuthenticator; + + private final IAuthenticator emailAuthenticator; + + private final long cacheTimeNoRevalidationMillis; + + private final long cacheTimeMillis; + + private final BlockingQueue<ValidationRequest> validationQueue; + + private final ITimeProvider timeProvider; + + CachingAuthenticationService(IAuthenticationService delegate, + String passwordCacheFileName) + { + this(delegate, createUserStore(passwordCacheFileName)); + } + + CachingAuthenticationService(IAuthenticationService authenticationService, + IUserStore<UserCacheEntry> store) + { + this(authenticationService, store, CACHE_TIME_MILLIS_NO_REVALIDATION, + CACHE_TIME_MILLIS); + } + + CachingAuthenticationService(IAuthenticationService delegate, + String passwordCacheFileName, + long cacheTimeNoRevalidationMillis, + long cacheTimeMillis) + { + this(delegate, createUserStore(passwordCacheFileName), + cacheTimeNoRevalidationMillis, cacheTimeMillis); + } + + CachingAuthenticationService(IAuthenticationService delegate, + IUserStore<UserCacheEntry> userStore, + long cacheTimeNoRevalidationMillis, + long cacheTimeMillis) + { + this(delegate, userStore, cacheTimeNoRevalidationMillis, cacheTimeMillis, true, + SystemTimeProvider.SYSTEM_TIME_PROVIDER); + } + + // For unit tests. + CachingAuthenticationService(IAuthenticationService delegate, + IUserStore<UserCacheEntry> userStore, + long cacheTimeNoRevalidationMillis, + long cacheTimeMillis, boolean startRevalidationThread, ITimeProvider timeProvider) + { + this.delegate = delegate; + this.userIdAuthenticator = new IAuthenticator() + { + @Override + public Principal tryGetAndAuthenticate(String userId, String passwordOrNull) + { + return CachingAuthenticationService.this.delegate.tryGetAndAuthenticateUser( + userId, + passwordOrNull); + } + }; + this.emailAuthenticator = new IAuthenticator() + { + @Override + public Principal tryGetAndAuthenticate(String email, String passwordOrNull) + { + return CachingAuthenticationService.this.delegate + .tryGetAndAuthenticateUserByEmail(email, passwordOrNull); + } + }; + this.userStore = userStore; + this.cacheTimeNoRevalidationMillis = + Math.min(cacheTimeNoRevalidationMillis, cacheTimeMillis); + this.cacheTimeMillis = cacheTimeMillis; + this.validationQueue = new LinkedBlockingQueue<ValidationRequest>(); + this.timeProvider = timeProvider; + if (startRevalidationThread) + { + final Thread t = new Thread(new RevalidationRunnable()); + t.setName(getClass().getSimpleName() + " - Validator"); + t.setDaemon(true); + t.start(); + } + } + + static IUserStore<UserCacheEntry> createUserStore( + final String passwordCacheFileName) + { + final ILineStore lineStore = + new FileBasedLineStore(new File(passwordCacheFileName), "Password cache file"); + return new LineBasedUserStore<UserCacheEntry>(lineStore, + new IUserEntryFactory<UserCacheEntry>() + { + @Override + public UserCacheEntry create(String line) + { + return new UserCacheEntry(line); + } + }); + } + + // For unit tests. + BlockingQueue<ValidationRequest> getValidationQueue() + { + return validationQueue; + } + + @Override + public boolean authenticateUser(String userId, String password) + { + return Principal.isAuthenticated(tryGetAndAuthenticateUser(userId, password, + userIdAuthenticator, userStore.tryGetUserById(userId))); + } + + private Principal tryGetAndAuthenticateUser(String id, + String passwordOrNull, IAuthenticator auth, UserCacheEntry entry) + { + // Note: getStatus() returns NO_ENTRY on entry == null + final boolean requiresAuthentication = StringUtils.isNotEmpty(passwordOrNull); + final long now = timeProvider.getTimeInMilliseconds(); + final CacheEntryStatus state = + getStatus(entry, requiresAuthentication, now); + switch (state) + { + case OK: + { + final boolean authenticated = entry.isPasswordCorrect(passwordOrNull); + if (authenticated == false && requiresAuthentication) + { + validationQueue.offer(new ValidationRequest(entry, passwordOrNull, false, now)); + } + return toPrincipal(entry, authenticated); + } + case OK_REVALIDATE: + { + final boolean authenticated = entry.isPasswordCorrect(passwordOrNull); + validationQueue.offer(new ValidationRequest(entry, passwordOrNull, authenticated, + now)); + return toPrincipal(entry, authenticated); + } + case NO_ENTRY: + { + final Principal p = auth.tryGetAndAuthenticate(id, passwordOrNull); + if (p == null) + { + return null; + } + final UserCacheEntry user = + new UserCacheEntry(p, passwordOrNull, timeProvider.getTimeInMilliseconds()); + userStore.addOrUpdateUser(user); + return p; + } + default: + throw new Error("Unknown cache entry state " + state); + } + } + + private static Principal toPrincipal(UserEntry userOrNull, boolean authenticated) + { + if (userOrNull == null) + { + return null; + } + final Principal principal = userOrNull.asPrincipal(); + principal.setAuthenticated(authenticated); + return principal; + } + + private CacheEntryStatus getStatus(UserCacheEntry entry, boolean requirePassword, long now) + { + if (entry == null) + { + return CacheEntryStatus.NO_ENTRY; + } + if (requirePassword && entry.hasPassword() == false) + { + return CacheEntryStatus.NO_ENTRY; + } + final long cachedAt = entry.getCachedAt(); + if (cachedAt + cacheTimeNoRevalidationMillis >= now) + { + return CacheEntryStatus.OK; + } else if (cachedAt + cacheTimeMillis >= now) + { + return CacheEntryStatus.OK_REVALIDATE; + } else + { + // Treat an expired entry like a missing entry. + return CacheEntryStatus.NO_ENTRY; + } + } + + @Override + @Deprecated + public boolean authenticateUser(String dummyToken, String userId, String password) + { + return authenticateUser(userId, password); + } + + @Override + public Principal tryGetAndAuthenticateUser(String userId, String passwordOrNull) + { + return tryGetAndAuthenticateUser(userId, passwordOrNull, userIdAuthenticator, + userStore.tryGetUserById(userId)); + } + + @Override + @Deprecated + public Principal tryGetAndAuthenticateUser(String dummyToken, String userId, + String passwordOrNull) + { + return tryGetAndAuthenticateUser(userId, passwordOrNull); + } + + @Override + public Principal getPrincipal(String userId) throws IllegalArgumentException + { + final Principal principalOrNull = + tryGetAndAuthenticateUser(userId, null, userIdAuthenticator, + userStore.tryGetUserById(userId)); + if (principalOrNull == null) + { + throw new IllegalArgumentException("Cannot find user '" + userId + "'."); + } + return principalOrNull; + } + + @Override + @Deprecated + public Principal getPrincipal(String dummyToken, String userId) throws IllegalArgumentException + { + return getPrincipal(userId); + } + + @Override + public Principal tryGetAndAuthenticateUserByEmail(String email, String passwordOrNull) + { + return tryGetAndAuthenticateUser(email, passwordOrNull, emailAuthenticator, + userStore.tryGetUserByEmail(email)); + } + + @Override + @Deprecated + public Principal tryGetAndAuthenticateUserByEmail(String dummyToken, String email, + String passwordOrNull) + { + return tryGetAndAuthenticateUserByEmail(email, passwordOrNull); + } + + @Override + public boolean supportsAuthenticatingByEmail() + { + return delegate.supportsAuthenticatingByEmail(); + } + + @Override + public boolean supportsListingByUserId() + { + return delegate.supportsListingByUserId(); + } + + @Override + public List<Principal> listPrincipalsByUserId(String userIdQuery) + throws IllegalArgumentException + { + return delegate.listPrincipalsByUserId(userIdQuery); + } + + @Override + public boolean supportsListingByEmail() + { + return delegate.supportsListingByEmail(); + } + + @Override + public List<Principal> listPrincipalsByEmail(String emailQuery) throws IllegalArgumentException + { + return delegate.listPrincipalsByEmail(emailQuery); + } + + @Override + public boolean supportsListingByLastName() + { + return delegate.supportsListingByLastName(); + } + + @Override + public List<Principal> listPrincipalsByLastName(String lastNameQuery) + throws IllegalArgumentException + { + return delegate.listPrincipalsByLastName(lastNameQuery); + } + + @Override + @Deprecated + public String authenticateApplication() + { + return delegate.authenticateApplication(); + } + + @Override + @Deprecated + public List<Principal> listPrincipalsByUserId(String dummyToken, String userIdQuery) + throws IllegalArgumentException + { + return delegate.listPrincipalsByUserId(dummyToken, userIdQuery); + } + + @Override + @Deprecated + public List<Principal> listPrincipalsByEmail(String dummyToken, String emailQuery) + throws IllegalArgumentException + { + return delegate.listPrincipalsByEmail(dummyToken, emailQuery); + } + + @Override + @Deprecated + public List<Principal> listPrincipalsByLastName(String dummyToken, String lastNameQuery) + throws IllegalArgumentException + { + return delegate.listPrincipalsByLastName(dummyToken, lastNameQuery); + } + + @Override + public boolean isRemote() + { + return delegate.isRemote(); + } + + @Override + public void check() throws EnvironmentFailureException, ConfigurationFailureException + { + userStore.check(); + delegate.check(); + } + +} diff --git a/authentication/source/java/ch/systemsx/cisd/authentication/file/UserCacheEntry.java b/authentication/source/java/ch/systemsx/cisd/authentication/file/UserCacheEntry.java new file mode 100644 index 0000000000000000000000000000000000000000..1de21351df6c8c7de7858717247c77b7391e05a2 --- /dev/null +++ b/authentication/source/java/ch/systemsx/cisd/authentication/file/UserCacheEntry.java @@ -0,0 +1,117 @@ +/* + * Copyright 2013 ETH Zuerich, CISD + * + * Licensed 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 ch.systemsx.cisd.authentication.file; + +import org.apache.commons.lang.StringUtils; + +import ch.systemsx.cisd.authentication.Principal; +import ch.systemsx.cisd.common.security.PasswordHasher; + +/** + * A class representing a user entry in the password cache file. + * + * @author Bernd Rinn + */ +public class UserCacheEntry extends UserEntry +{ + // For unit tests. + static final int NUMBER_OF_COLUMNS_IN_PASSWORD_CACHE_FILE = 6; + + private static final int CACHED_AT_IDX = 5; + + private final long cachedAt; + + UserCacheEntry(String passwordFileLine) throws IllegalArgumentException + { + super(split(passwordFileLine, NUMBER_OF_COLUMNS_IN_PASSWORD_CACHE_FILE)); + this.cachedAt = Long.parseLong(getElement(CACHED_AT_IDX)); + } + + UserCacheEntry(Principal p, long cachedAt) + { + this(p, null, cachedAt); + } + + UserCacheEntry(Principal p, String passwordOrNull, long cachedAt) + { + super(createPasswordFileEntry(p, passwordOrNull, cachedAt)); + this.cachedAt = cachedAt; + } + + private static String[] createPasswordFileEntry(Principal p, String passwordOrNull, + long cachedAt) + { + assert p != null; + + return new String[] + { + p.getUserId(), + p.getEmail(), + p.getFirstName(), + p.getLastName(), + (p.isAuthenticated() && StringUtils.isNotEmpty(passwordOrNull)) ? PasswordHasher + .computeSaltedHash(passwordOrNull) + : "", + Long.toString(cachedAt), + }; +} + + /** + * Returns the time stamp when this cache entry was put into the cache. + */ + long getCachedAt() + { + return cachedAt; + } + + /** + * @throw {@link UnsupportedOperationException} + */ + @Override + void setEmail(String email) throws UnsupportedOperationException + { + throw new UnsupportedOperationException(); + } + + /** + * @throw {@link UnsupportedOperationException} + */ + @Override + void setFirstName(String firstName) throws UnsupportedOperationException + { + throw new UnsupportedOperationException(); + } + + /** + * @throw {@link UnsupportedOperationException} + */ + @Override + void setLastName(String lastName) throws UnsupportedOperationException + { + throw new UnsupportedOperationException(); + } + + /** + * @throw {@link UnsupportedOperationException} + */ + @Override + void setPassword(String plainPassword) throws UnsupportedOperationException + { + throw new UnsupportedOperationException(); + } + +} diff --git a/authentication/sourceTest/java/ch/systemsx/cisd/authentication/file/CachingAuthenticationServiceInvalidLoginTests.java b/authentication/sourceTest/java/ch/systemsx/cisd/authentication/file/CachingAuthenticationServiceInvalidLoginTests.java new file mode 100644 index 0000000000000000000000000000000000000000..2cebf27827390c83f70869343df0b2764a39721d --- /dev/null +++ b/authentication/sourceTest/java/ch/systemsx/cisd/authentication/file/CachingAuthenticationServiceInvalidLoginTests.java @@ -0,0 +1,292 @@ +/* + * Copyright 2013 ETH Zuerich, CISD + * + * Licensed 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 ch.systemsx.cisd.authentication.file; + +import static org.testng.AssertJUnit.assertEquals; +import static org.testng.AssertJUnit.assertFalse; +import static org.testng.AssertJUnit.assertNotNull; +import static org.testng.AssertJUnit.assertNull; +import static org.testng.AssertJUnit.assertTrue; + +import java.io.File; +import java.util.List; +import java.util.regex.Pattern; + +import org.apache.commons.lang.StringUtils; +import org.jmock.Expectations; +import org.jmock.Mockery; +import org.testng.annotations.AfterClass; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import ch.systemsx.cisd.authentication.IAuthenticationService; +import ch.systemsx.cisd.authentication.Principal; +import ch.systemsx.cisd.common.filesystem.FileUtilities; +import ch.systemsx.cisd.common.utilities.ITimeProvider; + +/** + * Test cases for the {@link CachingAuthenticationService} with invalid login attempts. + * + * @author Bernd Rinn + */ +public class CachingAuthenticationServiceInvalidLoginTests +{ + private static final File workingDirectory = + new File("targets/unit-test-wd/CachingAuthenticationServiceInvalidLoginTests"); + + private static final File PASSWD_FILE = new File(workingDirectory, "passwd"); + + private static final String PASSWD_FILENAME = PASSWD_FILE.getPath(); + + private static final long CACHE_TIME_NO_REVAL_MILLIS = 1000L; + + private static final long CACHE_TIME_MILLIS = 2000L; + + private static final String user = "User"; + + private static final String firstName = "First Name"; + + private static final String lastName = "Last Name"; + + private static final String email = "e@mail"; + + private static final String invalidPassword = "passw0rd"; + + private static final String validPassword = "s@crit"; + + private static final long timeStart = 450L; + + private static final long timeCache = 500L; + + private static final long time2 = 550L; + + private static final long time2Cache = 600L; + + private static final long time3 = 650L; + + private static final long time4 = 700L; + + private static final long time5 = 800L; + + private Mockery context; + + private IAuthenticationService delegateService; + + private ITimeProvider timeProvider; + + private CachingAuthenticationService service; + + private CachingAuthenticationService.RevalidationRunnable revalidator; + + @BeforeClass + public void setUp() + { + FileUtilities.deleteRecursively(workingDirectory); + workingDirectory.mkdirs(); + context = new Mockery(); + delegateService = context.mock(IAuthenticationService.class); + timeProvider = context.mock(ITimeProvider.class); + service = + new CachingAuthenticationService(delegateService, + CachingAuthenticationService.createUserStore(PASSWD_FILENAME), + CACHE_TIME_NO_REVAL_MILLIS, CACHE_TIME_MILLIS, false, timeProvider); + revalidator = service.new RevalidationRunnable(); + } + + @AfterMethod + public void tearDown() + { + context.assertIsSatisfied(); + } + + @AfterClass + public void cleanUp() + { + FileUtilities.deleteRecursively(workingDirectory); + } + + @Test + public void testAuthenticateUserFirstTimeUserDoesntExist() + { + PASSWD_FILE.delete(); + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(timeStart)); + one(delegateService).tryGetAndAuthenticateUser(user, invalidPassword); + will(returnValue(null)); + } + }); + final Principal p = service.tryGetAndAuthenticateUser(user, invalidPassword); + assertNull(p); + checkCacheEmpty(); + assertTrue(service.getValidationQueue().isEmpty()); + context.assertIsSatisfied(); + } + + // Note: subsequent test methods depend on testAuthenticateUserFirstTimeUserDoesntExist() not + // being called after this method, so we force a dependency. + @Test(dependsOnMethods = "testAuthenticateUserFirstTimeUserDoesntExist") + public void testAuthenticateUserFirstTimeInvalidPassword() + { + PASSWD_FILE.delete(); + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(timeStart)); + one(delegateService).tryGetAndAuthenticateUser(user, invalidPassword); + will(returnValue(new Principal(user, firstName, lastName, email, false))); + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(timeCache)); + } + }); + final Principal p = service.tryGetAndAuthenticateUser(user, invalidPassword); + assertNotNull(p); + checkPrincipalUser(user, firstName, lastName, email, false, p); + checkCache(user, firstName, lastName, email, timeCache, false); + assertTrue(service.getValidationQueue().isEmpty()); + context.assertIsSatisfied(); + } + + @Test(dependsOnMethods = "testAuthenticateUserFirstTimeInvalidPassword") + public void testGetPrincipalAfterCacheWithoutPassword() + { + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(time2)); + } + }); + final Principal p = service.tryGetAndAuthenticateUser(user, null); + assertNotNull(p); + checkPrincipalUser(user, firstName, lastName, email, false, p); + checkCache(user, firstName, lastName, email, timeCache, false); + assertTrue(service.getValidationQueue().isEmpty()); + context.assertIsSatisfied(); + } + + @Test(dependsOnMethods = "testGetPrincipalAfterCacheWithoutPassword") + public void testAuthenticateUserSecondTimeValidPassword() + { + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(time3)); + // Cannot use the cached entry because it doesn't have a valid password hash. + one(delegateService).tryGetAndAuthenticateUser(user, validPassword); + will(returnValue(new Principal(user, firstName, lastName, email, true))); + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(time2Cache)); + } + }); + final Principal p = service.tryGetAndAuthenticateUser(user, validPassword); + assertNotNull(p); + checkPrincipalUser(user, firstName, lastName, email, true, p); + checkCache(user, firstName, lastName, email, time2Cache, true); + assertTrue(service.getValidationQueue().isEmpty()); + context.assertIsSatisfied(); + } + + @Test(dependsOnMethods = "testGetPrincipalAfterCacheWithoutPassword") + public void testAuthenticateUserThirdTimeInvalidPassword() throws InterruptedException + { + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(time4)); + } + }); + final Principal p = service.tryGetAndAuthenticateUser(user, invalidPassword); + assertNotNull(p); + checkPrincipalUser(user, firstName, lastName, email, false, p); + checkCache(user, firstName, lastName, email, time2Cache, true); + assertFalse(service.getValidationQueue().isEmpty()); + + // Run validator with request, but the password is invalid so the result will not be cached. + context.checking(new Expectations() + { + { + one(delegateService).tryGetAndAuthenticateUser(user, invalidPassword); + will(returnValue(new Principal(user, firstName, lastName, email, false))); + } + }); + revalidator.runOnce(); + checkCache(user, firstName, lastName, email, time2Cache, true); + + // A valid call is still served from the cache. + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(time5)); + } + }); + final Principal p2 = service.tryGetAndAuthenticateUser(user, validPassword); + assertNotNull(p2); + checkPrincipalUser(user, firstName, lastName, email, true, p2); + checkCache(user, firstName, lastName, email, time2Cache, true); + + context.assertIsSatisfied(); + } + + @SuppressWarnings("hiding") + private void checkPrincipalUser(final String user, final String firstName, + final String lastName, final String email, boolean authenticated, Principal p) + { + assertNotNull(p); + assertEquals(user, p.getUserId()); + assertEquals(firstName, p.getFirstName()); + assertEquals(lastName, p.getLastName()); + assertEquals(email, p.getEmail()); + assertEquals(authenticated, p.isAuthenticated()); + } + + private void checkCacheEmpty() + { + assertTrue(PASSWD_FILE.length() == 0); + } + + @SuppressWarnings("hiding") + private void checkCache(final String user, final String firstName, + final String lastName, final String email, final long timeCache, final boolean withHash) + { + assertTrue(PASSWD_FILE.exists()); + assertTrue(PASSWD_FILE.length() > 0); + final List<String> passwdLines = FileUtilities.loadToStringList(PASSWD_FILE, null); + assertEquals(1, passwdLines.size()); + final String[] split = StringUtils.splitPreserveAllTokens(passwdLines.get(0), ':'); + assertEquals(UserCacheEntry.NUMBER_OF_COLUMNS_IN_PASSWORD_CACHE_FILE, split.length); + assertEquals(user, split[0]); + assertEquals(email, split[1]); + assertEquals(firstName, split[2]); + assertEquals(lastName, split[3]); + if (withHash) + { + assertTrue(split[4], Pattern.matches("[\\w\\/,\\+]+", split[4])); + } else + { + assertEquals("", split[4]); + } + assertEquals(Long.toString(timeCache), split[5]); + } +} diff --git a/authentication/sourceTest/java/ch/systemsx/cisd/authentication/file/CachingAuthenticationServiceSuccessTests.java b/authentication/sourceTest/java/ch/systemsx/cisd/authentication/file/CachingAuthenticationServiceSuccessTests.java new file mode 100644 index 0000000000000000000000000000000000000000..1cbc9842f92c358335945a2da6365a01b4291636 --- /dev/null +++ b/authentication/sourceTest/java/ch/systemsx/cisd/authentication/file/CachingAuthenticationServiceSuccessTests.java @@ -0,0 +1,299 @@ +/* + * Copyright 2013 ETH Zuerich, CISD + * + * Licensed 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 ch.systemsx.cisd.authentication.file; + +import static org.testng.AssertJUnit.assertEquals; +import static org.testng.AssertJUnit.assertFalse; +import static org.testng.AssertJUnit.assertNotNull; +import static org.testng.AssertJUnit.assertNull; +import static org.testng.AssertJUnit.assertTrue; + +import java.io.File; +import java.util.List; +import java.util.regex.Pattern; + +import org.apache.commons.lang.StringUtils; +import org.jmock.Expectations; +import org.jmock.Mockery; +import org.testng.annotations.AfterClass; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import ch.systemsx.cisd.authentication.IAuthenticationService; +import ch.systemsx.cisd.authentication.Principal; +import ch.systemsx.cisd.common.filesystem.FileUtilities; +import ch.systemsx.cisd.common.utilities.ITimeProvider; + +/** + * Test cases for the {@link CachingAuthenticationService} with successful logins. + * + * @author Bernd Rinn + */ +public class CachingAuthenticationServiceSuccessTests +{ + private static final File workingDirectory = + new File("targets/unit-test-wd/CachingAuthenticationServiceSuccessTests"); + + private static final File PASSWD_FILE = new File(workingDirectory, "passwd"); + + private static final String PASSWD_FILENAME = PASSWD_FILE.getPath(); + + private static final long CACHE_TIME_NO_REVAL_MILLIS = 1000L; + + private static final long CACHE_TIME_MILLIS = 2000L; + + private static final String user = "User"; + + private static final String firstName = "First Name"; + + private static final String lastName = "Last Name"; + + private static final String email = "e@mail"; + + private static final String email2 = "e@mail2"; + + private static final String email3 = "e@mail3"; + + private static final String password = "passw0rd"; + + private static final long timeStart = 450L; + + private static final long timeCache = 500L; + + private static final long timeSecondRequest = 800L; + + private static final long timeThirdRequest = 1200L; + + private static final long timeForthRequest = 1750L; // Triggers re-validation + + private static final long time2Cache = 1800L; // In re-validation + + private static final long timeFifthRequest = 1900L; // That's after re-validation from the + // cache. + + private static final long timeSixthRequest = 9950L; // Triggers expiration + + private static final long time3Cache = 10000L; // New cache after expiration + + private Mockery context; + + private IAuthenticationService delegateService; + + private ITimeProvider timeProvider; + + private CachingAuthenticationService service; + + private CachingAuthenticationService.RevalidationRunnable revalidator; + + @BeforeClass + public void setUp() + { + FileUtilities.deleteRecursively(workingDirectory); + workingDirectory.mkdirs(); + context = new Mockery(); + delegateService = context.mock(IAuthenticationService.class); + timeProvider = context.mock(ITimeProvider.class); + service = + new CachingAuthenticationService(delegateService, + CachingAuthenticationService.createUserStore(PASSWD_FILENAME), + CACHE_TIME_NO_REVAL_MILLIS, CACHE_TIME_MILLIS, false, timeProvider); + revalidator = service.new RevalidationRunnable(); + } + + @AfterMethod + public void tearDown() + { + context.assertIsSatisfied(); + } + + @AfterClass + public void cleanUp() + { + FileUtilities.deleteRecursively(workingDirectory); + } + + @Test + public void testAuthenticateUserFirstTimeSuccess() + { + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(timeStart)); + one(delegateService).tryGetAndAuthenticateUser(user, password); + will(returnValue(new Principal(user, firstName, lastName, email, true))); + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(timeCache)); + } + }); + final Principal p = service.tryGetAndAuthenticateUser(user, password); + checkPrincipalHappyCase(user, firstName, lastName, email, p); + checkCacheHappyCase(user, firstName, lastName, email, timeCache); + assertTrue(service.getValidationQueue().isEmpty()); + context.assertIsSatisfied(); + } + + @Test(dependsOnMethods = "testAuthenticateUserFirstTimeSuccess") + public void testAuthenticateUserFromCacheStatusOKSuccess() + { + // Now it has to be answered from the cache. + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(timeSecondRequest)); + } + }); + final Principal p = service.tryGetAndAuthenticateUser(user, password); + checkPrincipalHappyCase(user, firstName, lastName, email, p); + checkCacheHappyCase(user, firstName, lastName, email, timeCache); + assertTrue(service.getValidationQueue().isEmpty()); + context.assertIsSatisfied(); + } + + @Test(dependsOnMethods = "testAuthenticateUserFromCacheStatusOKSuccess") + public void testAuthenticateUserFromCacheByEmailStatusOKSuccess() + { + // Now it has to be answered from the cache. + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(timeThirdRequest)); + } + }); + final Principal p = service.tryGetAndAuthenticateUserByEmail(email.toUpperCase(), password); + checkPrincipalHappyCase(user, firstName, lastName, email, p); + checkCacheHappyCase(user, firstName, lastName, email, timeCache); + assertTrue(service.getValidationQueue().isEmpty()); + context.assertIsSatisfied(); + } + + @Test(dependsOnMethods = "testAuthenticateUserFromCacheByEmailStatusOKSuccess") + public void testAuthenticateUserFromCacheStatusOKRevalSuccess() throws InterruptedException + { + // Now it has to be answered from the cache, but re-validation is required. + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(timeForthRequest)); + } + }); + final Principal p = service.tryGetAndAuthenticateUser(user, password); + checkPrincipalHappyCase(user, firstName, lastName, email, p); + checkCacheHappyCase(user, firstName, lastName, email, timeCache); + assertFalse(service.getValidationQueue().isEmpty()); + + // Perform revalidation + context.checking(new Expectations() + { + { + one(delegateService).tryGetAndAuthenticateUser(user, password); + will(returnValue(new Principal(user, firstName, lastName, email2, true))); + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(time2Cache)); + } + }); + revalidator.runOnce(); + assertTrue(service.getValidationQueue().isEmpty()); + checkCacheHappyCase(user, firstName, lastName, email2, time2Cache); + + // Now it has to be answered from the cache again. + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(timeFifthRequest)); + } + }); + final Principal p2 = + service.tryGetAndAuthenticateUserByEmail(email2.toUpperCase(), password); + checkPrincipalHappyCase(user, firstName, lastName, email2, p2); + checkCacheHappyCase(user, firstName, lastName, email2, time2Cache); + assertTrue(service.getValidationQueue().isEmpty()); + + // Check that email is gone from the cache. + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(timeFifthRequest)); + one(delegateService).tryGetAndAuthenticateUserByEmail(email.toUpperCase(), + password); + will(returnValue(null)); + } + }); + final Principal p3 = + service.tryGetAndAuthenticateUserByEmail(email.toUpperCase(), password); + assertNull(p3); + context.assertIsSatisfied(); + } + + @Test(dependsOnMethods = "testAuthenticateUserFromCacheStatusOKRevalSuccess") + public void testAuthenticateUserAfterCacheExpiratonSuccess() + { + context.checking(new Expectations() + { + { + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(timeSixthRequest)); + one(delegateService).tryGetAndAuthenticateUser(user, password); + will(returnValue(new Principal(user, firstName, lastName, email3, true))); + one(timeProvider).getTimeInMilliseconds(); + will(returnValue(time3Cache)); + } + }); + final Principal p = service.tryGetAndAuthenticateUser(user, password); + checkPrincipalHappyCase(user, firstName, lastName, email3, p); + checkCacheHappyCase(user, firstName, lastName, email3, time3Cache); + assertTrue(service.getValidationQueue().isEmpty()); + context.assertIsSatisfied(); + } + + @SuppressWarnings("hiding") + private void checkPrincipalHappyCase(final String user, final String firstName, + final String lastName, final String email, Principal p) + { + assertNotNull(p); + assertEquals(user, p.getUserId()); + assertEquals(firstName, p.getFirstName()); + assertEquals(lastName, p.getLastName()); + assertEquals(email, p.getEmail()); + assertTrue(p.isAuthenticated()); + } + + @SuppressWarnings("hiding") + private void checkCacheHappyCase(final String user, final String firstName, + final String lastName, + final String email, final long timeCache) + { + assertTrue(PASSWD_FILE.exists()); + assertTrue(PASSWD_FILE.length() > 0); + final List<String> passwdLines = FileUtilities.loadToStringList(PASSWD_FILE, null); + assertEquals(1, passwdLines.size()); + final String[] split = StringUtils.split(passwdLines.get(0), ':'); + assertEquals(UserCacheEntry.NUMBER_OF_COLUMNS_IN_PASSWORD_CACHE_FILE, split.length); + assertEquals(user, split[0]); + assertEquals(email, split[1]); + assertEquals(firstName, split[2]); + assertEquals(lastName, split[3]); + assertTrue(split[4], Pattern.matches("[\\w\\/,\\+]+", split[4])); + assertEquals(Long.toString(timeCache), split[5]); + } +}