From 7d642803031fe7f5ff70f07326af4a90a4fd1a6e Mon Sep 17 00:00:00 2001 From: felmer <felmer> Date: Mon, 8 Sep 2008 12:53:50 +0000 Subject: [PATCH] LMS-445 introducing DefaultSessionManager in 'authentication' based on the code of ch.systemsx.cisd.lims.webservice.DefaultSessionManager SVN: 8227 --- .../authentication/DefaultSessionManager.java | 346 ++++++++++++++++++ .../ILogMessagePrefixGenerator.java | 37 ++ .../cisd/authentication/ISessionManager.java | 2 +- .../DefaultSessionManagerTest.java | 302 +++++++++++++++ 4 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 authentication/source/java/ch/systemsx/cisd/authentication/DefaultSessionManager.java create mode 100644 authentication/source/java/ch/systemsx/cisd/authentication/ILogMessagePrefixGenerator.java create mode 100644 authentication/sourceTest/java/ch/systemsx/cisd/authentication/DefaultSessionManagerTest.java diff --git a/authentication/source/java/ch/systemsx/cisd/authentication/DefaultSessionManager.java b/authentication/source/java/ch/systemsx/cisd/authentication/DefaultSessionManager.java new file mode 100644 index 00000000000..fcbeed50625 --- /dev/null +++ b/authentication/source/java/ch/systemsx/cisd/authentication/DefaultSessionManager.java @@ -0,0 +1,346 @@ +/* + * Copyright 2008 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; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang.time.DateUtils; +import org.apache.commons.lang.time.DurationFormatUtils; +import org.apache.log4j.Logger; + +import ch.systemsx.cisd.common.exceptions.EnvironmentFailureException; +import ch.systemsx.cisd.common.exceptions.InvalidSessionException; +import ch.systemsx.cisd.common.exceptions.UserFailureException; +import ch.systemsx.cisd.common.logging.LogCategory; +import ch.systemsx.cisd.common.logging.LogFactory; +import ch.systemsx.cisd.common.server.IRemoteHostProvider; +import ch.systemsx.cisd.common.utilities.TokenGenerator; + +/** + * Default session manager. Needs + * <ul><li>a {@link ISessionFactory} for creating new session objects, + * <li>a {@link ILogMessagePrefixGenerator} for generating log messages which are + * logged by a logger with category {@link LogCategory#AUTH}, + * <li>a {@link IAuthenticationService} for authenticating users, + * <li>a {@link IRemoteHostProvider} for providing the remote host of the user client. + * </ul> + * + * @author Franz-Josef Elmer + */ +public class DefaultSessionManager<T extends BasicSession> implements ISessionManager<T> +{ + private static final String LOGOUT_PREFIX = "LOGOUT: "; + private static final String LOGIN_PREFIX = "LOGIN: "; + + + private static final Logger authenticationLog = + LogFactory.getLogger(LogCategory.AUTH, DefaultSessionManager.class); + + private static final Logger operationLog = + LogFactory.getLogger(LogCategory.OPERATION, DefaultSessionManager.class); + + private static final TokenGenerator tokenGenerator = new TokenGenerator(); + + private static final class FullSession<S> + { + /** Session data. */ + private final S session; + + /** The time period of inactivity (in milliseconds) after which the session will expire. */ + private final long expirationPeriodMillis; + + /** The last time when this session has been used (in milliseconds since 1970-01-01). */ + private long lastActiveTime; + + FullSession(final S session, final long expirationPeriodMillis) + { + assert session != null : "Undefined session"; + assert expirationPeriodMillis >= 0; // == 0 is for unit tests + + this.session = session; + this.expirationPeriodMillis = expirationPeriodMillis; + touch(); + } + + /** + * Returns the session. + */ + public S getSession() + { + return session; + } + + /** + * Sets the time of last activity (used to determine whether the session + * {@link #hasExpired()}. + */ + void touch() + { + this.lastActiveTime = System.currentTimeMillis(); + } + + /** + * Returns <code>true</code> if the session has expired. + */ + boolean hasExpired() + { + return System.currentTimeMillis() - lastActiveTime > expirationPeriodMillis; + } + } + + private final ISessionFactory<T> sessionFactory; + + private final ILogMessagePrefixGenerator<T> prefixGenerator; + + /** + * The map of session tokens to sessions. Access to this data structure needs to be + * synchronized. + */ + private final Map<String, FullSession<T>> sessions = new LinkedHashMap<String, FullSession<T>>(); + + private final IAuthenticationService authenticationService; + + private final IRemoteHostProvider remoteHostProvider; + + /** The time after which an inactive session will be expired (in milliseconds). */ + private final long sessionExpirationPeriodMillis; + + public DefaultSessionManager(final ISessionFactory<T> sessionFactory, + final ILogMessagePrefixGenerator<T> prefixGenerator, + final IAuthenticationService authenticationService, + final IRemoteHostProvider remoteHostProvider, final int sessionExpirationPeriodMinutes) + { + assert sessionFactory != null : "Missing session factory."; + assert prefixGenerator != null : "Missing prefix generator"; + assert authenticationService != null : "Missing authentication service."; + assert remoteHostProvider != null : "Missing remote host provider."; + assert sessionExpirationPeriodMinutes >= 0 : "Session experation time has to be a positive value: " + + sessionExpirationPeriodMinutes; // == 0 is for unit test + + this.sessionFactory = sessionFactory; + this.prefixGenerator = prefixGenerator; + this.authenticationService = authenticationService; + this.remoteHostProvider = remoteHostProvider; + sessionExpirationPeriodMillis = + sessionExpirationPeriodMinutes * DateUtils.MILLIS_PER_MINUTE; + + operationLog.info(String.format("Authentication service: '%s'", authenticationService + .getClass().getName())); + operationLog.info(String.format("Session expiration period (ms): %d", + sessionExpirationPeriodMillis)); + authenticationService.check(); + } + + private final T createAndStoreSession(final String user, final Principal principal, + final long now) + { + final String sessionToken = user + "-" + tokenGenerator.getNewToken(now); + synchronized (sessions) + { + final T session = + sessionFactory.create(sessionToken, user, principal, getRemoteHost(), now); + final FullSession<T> createdSession = + new FullSession<T>(session, sessionExpirationPeriodMillis); + sessions.put(user, createdSession); + return session; + } + } + + private static void checkIfNotBlank(final String object, final String name) + throws UserFailureException + { + if (StringUtils.isBlank(object)) + { + throw UserFailureException.fromTemplate("No '%s' specified.", name); + } + } + + private boolean isSessionUnavailable(final FullSession<T> session) + { + return session == null || doSessionExpiration(session); + } + + private boolean doSessionExpiration(final FullSession<T> session) + { + return session != null && session.hasExpired(); + } + + private void logAuthenticed(T session) + { + if (operationLog.isInfoEnabled()) + { + operationLog.info(LOGIN_PREFIX + "User '" + session.getUserName() + + "' has been successfully authenticated from host '" + getRemoteHost() + + "'. Session token: '" + session.getSessionToken() + "'."); + } + String prefix = prefixGenerator.createPrefix(session); + authenticationLog.info(prefix + ": login"); + } + + private void logFailedAuthentication(String user) + { + operationLog.warn(LOGIN_PREFIX + "User '" + user + "' failed to authenticate from host '" + + getRemoteHost() + "'."); + logAuthenticationFailure(user); + } + + private void logSessionFailure(String user, RuntimeException ex) + { + logAuthenticationFailure(user); + operationLog.error(LOGIN_PREFIX + "Error when trying to authenticate user '" + user + "'.", + ex); + } + + private void logAuthenticationFailure(String user) + { + String prefix = prefixGenerator.createPrefix(user, getRemoteHost()); + authenticationLog.info(prefix + ": login ...FAILED"); + } + + private void logSessionExpired(FullSession<T> fullSession) + { + T session = fullSession.getSession(); + if (operationLog.isInfoEnabled()) + { + operationLog.info(String.format("%sExpiring session '%s' for user '%s' " + + "after %d minutes of inactivity.", LOGOUT_PREFIX, + session.getSessionToken(), session.getUserName(), + sessionExpirationPeriodMillis / DateUtils.MILLIS_PER_MINUTE)); + } + String prefix = prefixGenerator.createPrefix(session); + authenticationLog.info(prefix + ": session_expired [inactive " + + DurationFormatUtils.formatDurationHMS(sessionExpirationPeriodMillis) + "]"); + } + + private void logLogout(T session) + { + String prefix = prefixGenerator.createPrefix(session); + authenticationLog.info(prefix + ": logout"); + if (operationLog.isInfoEnabled()) + { + String user = session.getUserName(); + operationLog.info(LOGOUT_PREFIX + "Session '" + session.getSessionToken() + + "' of user '" + user + "' has been closed from host '" + getRemoteHost() + + "'."); + } + } + + public T getSession(String sessionToken) throws UserFailureException + { + checkIfNotBlank(sessionToken, "sessionToken"); + + synchronized (sessions) + { + final String user = StringUtils.split(sessionToken, '-')[0]; + final FullSession<T> session = sessions.get(user); + if (session == null) + { + final String msg = + "Session token '" + sessionToken + "' is invalid: user is not logged in."; + if (operationLog.isInfoEnabled()) + { + operationLog.info(msg); + } + throw new InvalidSessionException(msg); + } + if (sessionToken.equals(session.getSession().getSessionToken()) == false) + { + final String msg = "Session token '" + sessionToken + "' is invalid: wrong token."; + if (operationLog.isInfoEnabled()) + { + operationLog.info(msg); + } + throw new InvalidSessionException(msg); + } + if (doSessionExpiration(session)) + { + logSessionExpired(session); + sessions.remove(user); + } + if (isSessionUnavailable(session)) + { + throw new InvalidSessionException( + "Session no longer available. Please login again."); + } + // This is where we know for sure we have a session. + session.touch(); + return session.getSession(); + } + } + + public String tryToOpenSession(String user, String password) + { + checkIfNotBlank(user, "user"); + checkIfNotBlank(password, "password"); + try + { + final String applicationToken = authenticationService.authenticateApplication(); + if (applicationToken == null) + { + operationLog.error("User '" + user + + "' failed to authenticate: application not authenticated."); + return null; + } + String sessionToken = null; + final long now = System.currentTimeMillis(); + final boolean isAuthenticated = + authenticationService.authenticateUser(applicationToken, user, password); + if (isAuthenticated) + { + try + { + final Principal principal = + authenticationService.getPrincipal(applicationToken, user); + T session = createAndStoreSession(user, principal, now); + sessionToken = session.getSessionToken(); + logAuthenticed(session); + } catch (final IllegalArgumentException ex) + { + // getPrincipal() of an authenticated user should not fail, if it does, this + // is an environment failure. + throw new EnvironmentFailureException(ex.getMessage(), ex); + } + } else + { + logFailedAuthentication(user); + } + return sessionToken; + } catch (final RuntimeException ex) + { + logSessionFailure(user, ex); + throw ex; + } + } + + public void closeSession(String sessionToken) + { + synchronized (sessions) + { + final T session = getSession(sessionToken); + sessions.remove(session.getUserName()); + logLogout(session); + } + } + + public String getRemoteHost() + { + return remoteHostProvider.getRemoteHost(); + } + +} diff --git a/authentication/source/java/ch/systemsx/cisd/authentication/ILogMessagePrefixGenerator.java b/authentication/source/java/ch/systemsx/cisd/authentication/ILogMessagePrefixGenerator.java new file mode 100644 index 00000000000..1a0c6e12d50 --- /dev/null +++ b/authentication/source/java/ch/systemsx/cisd/authentication/ILogMessagePrefixGenerator.java @@ -0,0 +1,37 @@ +/* + * Copyright 2008 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; + +/** + * Generator of a prefix for authentication log messages. The prefix contains user information. + * Minimum information is user name or ID and remote host (i.e. IP address of the user client + * computer). + * + * @author Franz-Josef Elmer + */ +public interface ILogMessagePrefixGenerator<T extends BasicSession> +{ + /** + * Creates a prefix based on the specified session. + */ + public String createPrefix(T session); + + /** + * Creates a prefix for specified user and remote host. + */ + public String createPrefix(String user, String remoteHost); +} diff --git a/authentication/source/java/ch/systemsx/cisd/authentication/ISessionManager.java b/authentication/source/java/ch/systemsx/cisd/authentication/ISessionManager.java index b0c096643ed..8dda0ccc636 100644 --- a/authentication/source/java/ch/systemsx/cisd/authentication/ISessionManager.java +++ b/authentication/source/java/ch/systemsx/cisd/authentication/ISessionManager.java @@ -37,7 +37,7 @@ public interface ISessionManager<T extends BasicSession> extends IRemoteHostProv * @return A session token that is used afterwards to get the <code>Session</code> object, or * <code>null</code>, if the user could not be authenticated. */ - public String openSession(String user, String password); + public String tryToOpenSession(String user, String password); /** Closes session by removing given <code>sessionToken</code> from active sessions. */ public void closeSession(String sessionToken); diff --git a/authentication/sourceTest/java/ch/systemsx/cisd/authentication/DefaultSessionManagerTest.java b/authentication/sourceTest/java/ch/systemsx/cisd/authentication/DefaultSessionManagerTest.java new file mode 100644 index 00000000000..2f25c0e9045 --- /dev/null +++ b/authentication/sourceTest/java/ch/systemsx/cisd/authentication/DefaultSessionManagerTest.java @@ -0,0 +1,302 @@ +/* + * Copyright 2007 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; + +import static org.testng.AssertJUnit.assertEquals; +import static org.testng.AssertJUnit.assertTrue; +import static org.testng.AssertJUnit.fail; + +import org.apache.commons.lang.StringUtils; +import org.apache.log4j.Level; +import org.jmock.Expectations; +import org.jmock.Mockery; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import ch.systemsx.cisd.common.exceptions.EnvironmentFailureException; +import ch.systemsx.cisd.common.exceptions.UserFailureException; +import ch.systemsx.cisd.common.logging.BufferedAppender; +import ch.systemsx.cisd.common.server.IRemoteHostProvider; +import ch.systemsx.cisd.common.utilities.OSUtilities; + +/** + * Test cases for the {@link DefaultSessionManager}. + * + * @author Bernd Rinn + */ +public class DefaultSessionManagerTest +{ + + private static final String REMOTE_HOST = "remote-host"; + + /** Kind of dummy <code>Principal</code> to point out that the login was successful. */ + private static final Principal principal = + new Principal(StringUtils.EMPTY, StringUtils.EMPTY, StringUtils.EMPTY, + StringUtils.EMPTY); + + private static final int SESSION_EXPIRATION_PERIOD_MINUTES = 1; + + private Mockery context; + + private IAuthenticationService authenticationService; + + private ISessionFactory<BasicSession> sessionFactory; + + private ILogMessagePrefixGenerator<BasicSession> prefixGenerator; + + private IRemoteHostProvider remoteHostProvider; + + private ISessionManager<BasicSession> sessionManager; + + private BufferedAppender logRecorder; + + private void assertExceptionMessageForInvalidSessionToken(final UserFailureException ex) + { + final String message = ex.getMessage(); + assertTrue(message, message.indexOf("login again") > 0); + } + + @SuppressWarnings("unchecked") + @BeforeMethod + public void setUp() + { + context = new Mockery(); + sessionFactory = context.mock(ISessionFactory.class); + prefixGenerator = context.mock(ILogMessagePrefixGenerator.class); + authenticationService = context.mock(IAuthenticationService.class); + remoteHostProvider = context.mock(IRemoteHostProvider.class); + context.checking(new Expectations() + { + { + one(authenticationService).check(); + } + }); + sessionManager = createSessionManager(SESSION_EXPIRATION_PERIOD_MINUTES); + logRecorder = new BufferedAppender("%-5p %c - %m%n", Level.DEBUG); + } + + @SuppressWarnings("unchecked") + private ISessionManager<BasicSession> createSessionManager(int sessionExpiration) + { + return new DefaultSessionManager(sessionFactory, prefixGenerator, authenticationService, + remoteHostProvider, sessionExpiration); + } + + @AfterMethod + public void tearDown() + { + logRecorder.reset(); + // To following line of code should also be called at the end of each test method. + // Otherwise one does not known which test failed. + context.assertIsSatisfied(); + } + + private void prepareRemoteHostSessionFactoryAndPrefixGenerator(final String user) + { + context.checking(new Expectations() + { + { + allowing(remoteHostProvider).getRemoteHost(); + will(returnValue(REMOTE_HOST)); + + one(sessionFactory) + .create(with(any(String.class)), with(equal(user)), + with(equal(principal)), with(equal(REMOTE_HOST)), + with(any(Long.class))); + BasicSession session = + new BasicSession(user + "-1", user, principal, REMOTE_HOST, 42L); + will(returnValue(session)); + + atLeast(1).of(prefixGenerator).createPrefix(session); + will(returnValue("[USER:'bla', HOST:'remote-host']")); + } + }); + } + + @Test + public void testSuccessfulAuthentication() + { + final String applicationToken = "ole"; + final String user = "bla"; + prepareRemoteHostSessionFactoryAndPrefixGenerator(user); + context.checking(new Expectations() + { + { + one(authenticationService).authenticateUser(applicationToken, user, "blub"); + will(returnValue(true)); + + one(authenticationService).authenticateApplication(); + will(returnValue(applicationToken)); + + one(authenticationService).getPrincipal(applicationToken, user); + will(returnValue(principal)); + } + }); + + final String token = sessionManager.tryToOpenSession("bla", "blub"); + assertEquals("bla-1", token); + assertEquals( + "INFO OPERATION.DefaultSessionManager - " + + "LOGIN: User 'bla' has been successfully authenticated from host 'remote-host'. Session token: '" + + token + "'." + OSUtilities.LINE_SEPARATOR + + "INFO AUTH.DefaultSessionManager - [USER:'bla', HOST:'remote-host']: login", logRecorder + .getLogContent()); + + context.assertIsSatisfied(); + } + + @Test + public void testFailedAuthentication() + { + final String applicationToken = "ole"; + final String user = "bla"; + context.checking(new Expectations() + { + { + one(authenticationService).authenticateUser("ole", user, "blub"); + will(returnValue(false)); + + one(authenticationService).authenticateApplication(); + will(returnValue(applicationToken)); + + allowing(remoteHostProvider).getRemoteHost(); + will(returnValue(REMOTE_HOST)); + + one(prefixGenerator).createPrefix(user, REMOTE_HOST); + will(returnValue("[USER:'bla', HOST:'remote-host']")); + } + }); + assert null == sessionManager.tryToOpenSession(user, "blub"); + assertEquals("WARN OPERATION.DefaultSessionManager - " + + "LOGIN: User 'bla' failed to authenticate from host 'remote-host'." + + OSUtilities.LINE_SEPARATOR + "INFO AUTH.DefaultSessionManager - [USER:'bla', HOST:'remote-host']: login ...FAILED", + logRecorder.getLogContent()); + + context.assertIsSatisfied(); + } + + @Test + public void testAuthenticationForUnavailableAuthenticationService() + { + final String errorMsg = "I pretend to be not here!"; + context.checking(new Expectations() + { + { + one(authenticationService).check(); + will(throwException(new EnvironmentFailureException(errorMsg))); + } + }); + try + { + createSessionManager(1); + fail("Unavailable authentication service not expected"); + } catch (final EnvironmentFailureException e) + { + assertEquals(errorMsg, e.getMessage()); + } + context.assertIsSatisfied(); + } + + @Test + public void testExpirationOfSession() + { + final String applicationToken = "ole"; + final String user = "bla"; + prepareRemoteHostSessionFactoryAndPrefixGenerator(user); + context.checking(new Expectations() + { + { + one(authenticationService).check(); + + one(authenticationService).authenticateUser(applicationToken, user, "blub"); + will(returnValue(true)); + + one(authenticationService).getPrincipal(applicationToken, user); + will(returnValue(principal)); + + one(authenticationService).authenticateApplication(); + will(returnValue(applicationToken)); + } + }); + + + sessionManager = createSessionManager(0); + final String sessionToken = sessionManager.tryToOpenSession("bla", "blub"); + assert sessionToken.length() > 0; + try + { + Thread.sleep(100); + } catch (final InterruptedException ex) + { + // ignored + } + logRecorder.resetLogContent(); + try + { + sessionManager.getSession(sessionToken); + fail("UserFailureException expected because session has expired."); + } catch (final UserFailureException e) + { + assertExceptionMessageForInvalidSessionToken(e); + } + assertEquals("INFO OPERATION.DefaultSessionManager - " + "LOGOUT: Expiring session '" + + sessionToken + "' for user 'bla' after 0 minutes of inactivity." + + OSUtilities.LINE_SEPARATOR + "INFO AUTH.DefaultSessionManager - [USER:'bla', HOST:'remote-host']: session_expired [inactive 0:00:00.000]", logRecorder + .getLogContent()); + + context.assertIsSatisfied(); + } + + @Test + public void testSessionRemoval() + { + final String applicationToken = "ole"; + final String user = "bla"; + final String password = "blub"; + prepareRemoteHostSessionFactoryAndPrefixGenerator(user); + context.checking(new Expectations() + { + { + one(authenticationService).authenticateUser(applicationToken, user, password); + will(returnValue(true)); + + one(authenticationService).authenticateApplication(); + will(returnValue(applicationToken)); + + one(authenticationService).getPrincipal(applicationToken, user); + will(returnValue(principal)); + } + }); + + final String sessionToken = sessionManager.tryToOpenSession(user, password); + + sessionManager.closeSession(sessionToken); + try + { + sessionManager.getSession(user); + fail("UserFailureException expected because session token no longer valid."); + } catch (final UserFailureException ex) + { + final String message = ex.getMessage(); + assertTrue(message, message.indexOf("user is not logged in") > 0); + } + + context.assertIsSatisfied(); + } + +} -- GitLab