/* * RHQ Management Platform * Copyright (C) 2005-2010 Red Hat, Inc. * All rights reserved. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2, as * published by the Free Software Foundation, and/or the GNU Lesser * General Public License, version 2.1, also as published by the Free * Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License and the GNU Lesser General Public License * for more details. * * You should have received a copy of the GNU General Public License * and the GNU Lesser General Public License along with this program; * if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ package org.rhq.coregui.client; import com.google.gwt.http.client.Request; import com.google.gwt.http.client.RequestBuilder; import com.google.gwt.http.client.RequestCallback; import com.google.gwt.http.client.RequestException; import com.google.gwt.http.client.Response; import com.google.gwt.user.client.Cookies; import com.google.gwt.user.client.History; import com.google.gwt.user.client.Timer; import com.google.gwt.user.client.rpc.AsyncCallback; import org.rhq.core.domain.auth.Subject; import org.rhq.core.domain.criteria.SubjectCriteria; import org.rhq.core.domain.util.PageList; import org.rhq.coregui.client.gwt.GWTServiceLookup; import org.rhq.coregui.client.inventory.common.graph.CustomDateRangeState; import org.rhq.coregui.client.util.Log; import org.rhq.coregui.client.util.preferences.UserPreferences; /** * Upon application load, if already loggedIn on the server-side, local loggedIn bit will be set to true. * * If login successful, the local loggedIn bit will be set to true. * If user clicks logout explicitly, LoginView will be shown, which sets local loggedIn bit to false. * If count down timer expires, LoginView will be shown, which sets local loggedIn bit to false. * * If error during GWT RPC Service, check local loggedIn status * If loggedIn bit false, display LoginView * Else check server-side logged in state * If logged out on server-side, LoginView will be shown, which sets local loggedIn bit to false. * * Additionally, all login checks go through checkLoginStatus where the following happens * * 1)HTTP POST request sent to portal.war SessionAccessServlet, which, when logged in: * yes)logged in returns (Subject.id):(Server side session id):(Last Accessed time) * no )empty text and Login screen displayed * * 2)If logged in there are two flavors of Subject logins that will occur * a)Regular RHQ logins and case sensitive LDAP users with established RHQ accounts: Subject.id > 0 * b)LDAP users i)registering new user ii)Existing but case-insensitive ldap username used : Subject.id == 0 * * Case a) is trivial and well understood. With case b) the credentials are used to retrieve or create RHQ Subject * instances by terminating previous sessions and creating new ones to be used with all future client * side UI requests. In case b) the WebUser is updated appropriately. * * @author Joseph Marques * @author Jay Shaughnessy * @author Simeon Pinder */ public class UserSessionManager { private static final Messages MSG = CoreGUI.getMessages(); private static int SESSION_TIMEOUT_MINIMUM = 60 * 1000; // The length of CoreGUI inactivity (no call to refresh()) before a CoreGUI session timeout // Initially: 1 hour private static int sessionTimeout = SESSION_TIMEOUT_MINIMUM * 60; // After CoreGUI logout, the delay before the server-side logout. This is the period in which we allow in-flight // async requests to complete. // Currently: 5 seconds private static int LOGOUT_DELAY = 5 * 1000; // 5 seconds // We want the CoreGUI session timeout to rule them all. The rhq subject session is refreshed on each SLSB // call. But the HTTP session will die after 30 minutes. Keep it alive until logout by pinging SessionAccessServlet // occasionally, as specified by this interval (which must be less than the 30 minutes). // Currently: 20 minutes private static int SESSION_ACCESS_REFRESH = 20 * 60 * 1000; // The web session public static final String SESSION_NAME = "RHQ-Session"; // The web session scheduled to be logged out on the server side private static final String DOOMED_SESSION_NAME = "RHQ-DoomedSession"; // HTTP Header indicating to SessionAccessServlet to update the portal war webUser private static final String HEADER_WEB_USER_UPDATE = "rhq-webuser-update"; // HTTP Header indicating to SessionAccessServlet to update the HTTP session access time private static final String HEADER_LAST_ACCESS_UPDATE = "rhq-last-access-update"; private static Subject sessionSubject; private static UserPreferences userPreferences; private static Timer logoutTimer = new Timer() { @Override public void run() { logoutServerSide(); } }; private static Timer coreGuiSessionTimer = new Timer() { @Override public void run() { Log.info("Session timer expired."); new LoginView().showLoginDialog(true); } }; private static Timer httpSessionTimer = new Timer() { @Override public void run() { Log.info("HTTP Session refresh timer expired."); refreshHttpSession(); } }; enum State { IS_LOGGED_IN, // IS_REGISTERING, // IS_LOGGED_OUT, // IS_UNKNOWN; } // At entry or browser refresh set state IS_UNKNOWN private static State sessionState = State.IS_UNKNOWN; private UserSessionManager() { // static access only } public static void checkLoginStatus(final String user, final String password, final AsyncCallback<Subject> callback) { //initiate request to portal.war(SessionAccessServlet) to retrieve existing session info if exists //session has valid user then <subjectId>:<sessionId>:<lastAccess> else "" final RequestBuilder b = createSessionAccessRequestBuilder(); try { b.setCallback(new RequestCallback() { public void onResponseReceived(final Request request, final Response response) { Log.info("response text = " + response.getText()); String sessionIdString = response.getText(); if (sessionIdString.startsWith("booting")) { // "booting" is the string we get back from SessionAccessServlet if StartupBean hasn't finished new LoginView().showLoginDialog(MSG.view_core_serverInitializing()); return; } // If a session is active it will return valid session strings if (sessionIdString.length() > 0) { String[] parts = sessionIdString.split(":"); final int subjectId = Integer.parseInt(parts[0]); final String sessionId = parts[1]; // not null final long lastAccess = Long.parseLong(parts[2]); Log.info("sessionAccess-subjectId: " + subjectId); Log.info("sessionAccess-sessionId: " + sessionId); Log.info("sessionAccess-lastAccess: " + lastAccess); // There is a window of LOGOUT_DELAY ms where the coreGui session is logged out but the // server session is valid (to allow in-flight requests to process successfully). During // this window prevent a browser refresh (F5) from being able to bypass the // loginView and hijack the still-valid server session. We need to allow: // 1) a browser refresh when coreGui is logged in (no doomedSession) // 2) a valid, quick re-login (sessionState loggedOut, not unknown) // Being careful of these scenarios, catch the bad refresh situation and // redirect back to loginView if (State.IS_UNKNOWN == sessionState && sessionId.equals(getDoomedSessionId())) { // a browser refresh kills any existing logoutTimer. Reschedule the logout. sessionState = State.IS_LOGGED_OUT; scheduleLogoutServerSide(sessionId); new LoginView().showLoginDialog(true); return; } String previousSessionId = getPreviousSessionId(); // may be null Log.info("sessionAccess-previousSessionId: " + previousSessionId); if (previousSessionId == null || previousSessionId.equals(sessionId) == false) { // persist sessionId if different from previously saved sessionId Log.info("sessionAccess-savingSessionId: " + sessionId); saveSessionId(sessionId); // new sessions get the full SESSION_TIMEOUT period prior to expire Log.info("sessionAccess-schedulingSessionTimeout: " + sessionTimeout); coreGuiSessionTimer.schedule(sessionTimeout); } else { // existing sessions should expire SESSION_TIMEOUT minutes from the previous access time long expiryTime = lastAccess + sessionTimeout; long expiryMillis = expiryTime - System.currentTimeMillis(); // can not schedule a time with millis less than or equal to 0 if (expiryMillis < 1) { expiryMillis = 1; // expire VERY quickly } else if (expiryMillis > sessionTimeout) { expiryMillis = sessionTimeout; // guarantees maximum } Log.info("sessionAccess-reschedulingSessionTimeout: " + expiryMillis); coreGuiSessionTimer.schedule((int) expiryMillis); } // Certain logins may not follow a "LogOut" history item. Specifically, if the session timer // causes a logout the History token will be the user's current view. If the same user // logs in again his view should be maintained, but if the subsequent login is for a // different user we want him to start fresh, so in this case ensure a proper // History token is set. if (!History.getToken().equals("LogOut")) { if (null != sessionSubject && sessionSubject.getId() != subjectId) { // on user change register the logout History.newItem("LogOut", false); } // TODO else { // We don't currently capture enough state info to solve this scenario: // 1) session expires // 2) browser refresh // 3) log in as different user. // In this case the previous user's path will be the initial view for the new user. To // solve this we'd need to somehow flag that a browser refresh has occurred. This may // be doable by looking for state transitions from UNKNOWN to other states. // } } // set the session subject, so the fetch to load the configuration works final Subject subject = new Subject(); subject.setId(subjectId); subject.setSessionId(Integer.valueOf(sessionId)); // populate the username for the subject for isUserWithPrincipal check in ldap processing subject.setName(user); sessionSubject = subject; if (subject.getId() == 0) {//either i)ldap new user registration ii)ldap case sensitive match if ((subject.getName() == null) || (subject.getName().trim().isEmpty())) { //we've lost crucial information, probably in a browser refresh. Send them back through login Log.trace("Unable to locate information critical to ldap registration/account lookup. Log back in."); sessionState = State.IS_LOGGED_OUT; new LoginView().showLoginDialog(true); return; } Log.error("Proceeding with case insensitive login of ldap user '" + user + "'."); GWTServiceLookup.getSubjectService().processSubjectForLdap(subject, password, new AsyncCallback<Subject>() { public void onFailure(Throwable caught) { // this means either: a) we mapped the username to a previously registered LDAP // user but login via LDAP failed, or b) we were not able to map the username // to any LDAP users, previously registered or not. Log.debug("Failed to complete ldap processing for subject: " + caught.getMessage()); new LoginView().showLoginDialog(MSG.view_login_noUser()); return; } public void onSuccess(final Subject processedSubject) { //Then found case insensitive and returned that logged in user //Figure out of this is new user registration boolean isNewUser = false; if (processedSubject.getUserConfiguration() != null) { isNewUser = Boolean.valueOf(processedSubject.getUserConfiguration() .getSimpleValue("isNewUser", "false")); } if (!isNewUser) { // otherwise, we successfully logged in as an existing LDAP user case insensitively. Log.trace("Logged in case insensitively as ldap user '" + processedSubject.getName() + "'"); callback.onSuccess(processedSubject); } else {// if account is still active assume new LDAP user registration. Log.trace("Proceeding with registration for ldap user '" + user + "'."); sessionState = State.IS_REGISTERING; sessionSubject = processedSubject; new LoginView().showRegistrationDialog(subject.getName(), String.valueOf(processedSubject.getSessionId()), password, callback); } return; } });//end processSubjectForLdap call } else {//else send through regular session check SubjectCriteria criteria = new SubjectCriteria(); criteria.fetchConfiguration(true); criteria.addFilterId(subjectId); GWTServiceLookup.getSubjectService().findSubjectsByCriteria(criteria, new AsyncCallback<PageList<Subject>>() { public void onFailure(Throwable caught) { CoreGUI.getErrorHandler().handleError(MSG.util_userSession_loadFailSubject(), caught); Log.info("Failed to load user's subject"); //TODO: pass message to login ui. new LoginView().showLoginDialog(true); return; } public void onSuccess(PageList<Subject> results) { final Subject validSessionSubject = results.get(0); //update the returned subject with current session id validSessionSubject.setSessionId(Integer.valueOf(sessionId)); Log.trace("Completed session check for subject '" + validSessionSubject + "'."); //initiate ldap check for ldap authz update(wrt roles) of subject with silent update //as the subject.id > 0 then only group authorization updates will occur if ldap configured. GWTServiceLookup.getSubjectService().processSubjectForLdap(validSessionSubject, "", new AsyncCallback<Subject>() { public void onFailure(Throwable caught) { Log.warn("Errors occurred processing subject for LDAP." + caught.getMessage()); //TODO: pass informative message to Login UI. callback.onSuccess(validSessionSubject); return; } public void onSuccess(Subject result) { Log.trace("Successfully processed subject '" + validSessionSubject.getName() + "' for LDAP."); callback.onSuccess(validSessionSubject); return; } }); } }); }//end of server side session check; } else { //invalid client session. Back to login sessionState = State.IS_LOGGED_OUT; new LoginView().showLoginDialog(true); return; } } public void onError(Request request, Throwable exception) { callback.onFailure(exception); } }); b.send(); } catch (RequestException e) { callback.onFailure(e); } } /** Takes an updated Subject and signals SessionAccessServlet in portal.war to update the associated WebUser * because for this specific authenticated user. Currently assumes Subject instances returned from SubjectManagerBean.processSubjectForLdap. * This should only ever be called by UI logic in LDAP logins(case insensitive/new registration) after RHQ sessions have been renewed server side * correctly. * * @param loggedInSubject Subject with updated session */ private static void scheduleWebUserUpdate(final Subject loggedInSubject) { final RequestBuilder b = createSessionAccessRequestBuilder(); //add header to signal SessionAccessServlet to update the WebUser for the successfully logged in user b.setHeader(HEADER_WEB_USER_UPDATE, String.valueOf(loggedInSubject.getSessionId())); try { b.setCallback(new RequestCallback() { public void onResponseReceived(final Request request, final Response response) { Log.trace("Successfully submitted request to update server side WebUser for subject '" + loggedInSubject.getName() + "'."); } @Override public void onError(Request request, Throwable exception) { Log.trace("Failed to submit request to update server side WebUser for subject '" + loggedInSubject.getName() + "'." + ((exception != null ? exception.getMessage() : " Exception ref null."))); } }); b.send(); } catch (RequestException e) { Log.trace("Failure submitting update request for WebUser '" + loggedInSubject.getName() + "'." + (e != null ? e.getMessage() : "RequestException reference is null.")); } } public static void login() { login(null, null); } /**Same as login, but passes in credentials optionally needed during ldap[i)new user registration or ii)case insensitive] logins. * * @param user * @param password */ public static void login(String user, String password) { checkLoginStatus(user, password, new AsyncCallback<Subject>() { public void onSuccess(final Subject loggedInSubject) { // will build UI if necessary, then fires history event sessionState = State.IS_LOGGED_IN; int storedSessionSubjectId = -1; if (sessionSubject != null) { sessionSubject.getId(); } //update the sessionSubject appropriately sessionSubject = loggedInSubject; sessionState = State.IS_LOGGED_IN; userPreferences = new UserPreferences(loggedInSubject); userPreferences.setAutomaticPersistence(true); GWTServiceLookup.getSystemService().getSessionTimeout(new AsyncCallback<String>() { @Override public void onSuccess(String result) { try { final long millis = Long.parseLong(result); // let's be safe here sessionTimeout = (int) millis; sessionTimeout = (millis != (long) sessionTimeout) ? Integer.MAX_VALUE : sessionTimeout; sessionTimeout = (sessionTimeout < SESSION_TIMEOUT_MINIMUM) ? SESSION_TIMEOUT_MINIMUM : sessionTimeout; } catch (NumberFormatException e) { sessionTimeout = SESSION_TIMEOUT_MINIMUM; } LoginView.redirectTo(""); // redirect back to the "root" path (coregui/) } @Override public void onFailure(Throwable caught) { CoreGUI.getErrorHandler().handleError(MSG.view_admin_systemSettings_cannotLoadSettings(), caught); } }); httpSessionTimer.schedule(SESSION_ACCESS_REFRESH); //conditionally update session information and server side WebUser when updated. if ((sessionSubject != null) && (loggedInSubject.getId() != storedSessionSubjectId)) { //update the sessionSubject appropriately sessionSubject = loggedInSubject; //update the sessionId saveSessionId(String.valueOf(loggedInSubject.getSessionId().intValue())); //Update the portal war WebUser now that we've updated subject+session scheduleWebUserUpdate(loggedInSubject); } // invalidate the CustomDateRangeState instance CustomDateRangeState.invalidateInstance(); CoreGUI.get().init(); } public void onFailure(Throwable caught) { Log.error("Unable to determine login status - check Server status."); } public String toString() {//attempt to identify call back return super.toString() + " UserSessionManager.checkLoginStatus()"; } }); } private static void saveSessionId(String sessionId) { Cookies.setCookie(SESSION_NAME, sessionId); } private static String getPreviousSessionId() { return Cookies.getCookie(SESSION_NAME); } private static void saveDoomedSessionId(String doomedSessionId) { Cookies.setCookie(DOOMED_SESSION_NAME, doomedSessionId); } private static String getDoomedSessionId() { return Cookies.getCookie(DOOMED_SESSION_NAME); } private static void removeDoomedSessionId() { Cookies.removeCookie(DOOMED_SESSION_NAME); } public static void refresh() { refresh(sessionTimeout); } private static void refresh(int millis) { // now continue with the rest of the login logic sessionState = State.IS_LOGGED_IN; Log.info("Refreshing session timer..."); coreGuiSessionTimer.schedule(millis); } public static void logout() { if (isLoggedOut()) { return; // nothing to do, already called } sessionState = State.IS_LOGGED_OUT; Log.info("Destroying session timer..."); coreGuiSessionTimer.cancel(); Log.info("Destroying HTTP session refresh timer..."); httpSessionTimer.cancel(); //ResourceTypeRepository.Cache.getInstance().clear(); CoreGUI.get().reset(); // log out the web session on the server-side in a delayed fashion, // allowing enough time to pass to let in-flight requests complete scheduleLogoutServerSide(String.valueOf(sessionSubject.getSessionId())); } private static void scheduleLogoutServerSide(String sessionId) { if (null == sessionId) { return; } String doomedSessionId = getDoomedSessionId(); // if we are requesting an already doomed sessionId be logged out then cancel any existing timer if (sessionId.equals(doomedSessionId)) { logoutTimer.cancel(); } saveDoomedSessionId(sessionId); logoutTimer.schedule(LOGOUT_DELAY); } // only call this from the logoutTimer, all logout requests should be scheduled via logoutTimer(String) private static void logoutServerSide() { final Integer doomedSessionId = Integer.valueOf(getDoomedSessionId()); removeDoomedSessionId(); if (null == doomedSessionId) { return; } // if the subject scheduled for logout is now logged in again, don't log him out. Unlikely but // possible for a quick re-login of the same user. if (State.IS_LOGGED_IN == sessionState && null != sessionSubject && doomedSessionId.equals(sessionSubject.getSessionId())) { return; } try { // The logout() method should be called against the dying session. This makes sense and // also protects against the fact that sessionSubject may be null at the time of this call. // The sessionSubject is used to build the server request, so it must be valid. Subject tempSubject = sessionSubject; sessionSubject = new Subject(); sessionSubject.setSessionId(doomedSessionId); GWTServiceLookup.getSubjectService().logout(doomedSessionId, new AsyncCallback<Void>() { public void onFailure(Throwable caught) { CoreGUI.getErrorHandler().handleError(MSG.util_userSession_logoutFail(), caught); } public void onSuccess(Void result) { if (Log.isTraceEnabled()) { Log.trace("Logged out: " + doomedSessionId); } } }); // return sessionSubject to its actual value sessionSubject = tempSubject; } catch (Throwable caught) { CoreGUI.getErrorHandler().handleError(MSG.util_userSession_logoutFail(), caught); } } public static boolean isLoggedIn() { if (Log.isTraceEnabled()) { Log.trace("isLoggedIn = " + sessionState); } return sessionState == State.IS_LOGGED_IN; } public static boolean isLoggedOut() { return sessionState == State.IS_LOGGED_OUT; } public static boolean isRegistering() { return sessionState == State.IS_REGISTERING; } public static Subject getSessionSubject() { return sessionSubject; } public static String getSessionId() { if (sessionSubject == null) { Log.error("UserSessionManager: sessionSubject is null"); return null; } Integer sessionId = sessionSubject.getSessionId(); if (sessionId == null) { Log.error("UserSessionManager: sessionId is null"); return null; } return sessionId.toString(); } /** * Obtain an object that you can use to add/modify/remove/retrieve user preferences. * You can optionally wrap the returned object with a * {@link org.rhq.coregui.client.util.preferences.MeasurementUserPreferences} * object to work with measurement preferences. * * @return user preferences object */ public static UserPreferences getUserPreferences() { return userPreferences; } private static void refreshHttpSession() { final RequestBuilder b = createSessionAccessRequestBuilder(); // add header to signal SessionAccessServlet to refresh the http lastAccess time (basically a no-op as the // request will make that happen). b.setHeader(HEADER_LAST_ACCESS_UPDATE, "dummy"); try { b.setCallback(new RequestCallback() { public void onResponseReceived(final Request request, final Response response) { Log.trace("Successfully submitted request to update HTTP accessTime"); } @Override public void onError(Request request, Throwable t) { Log.trace("Error updating HTTP accessTime", t); } }); b.send(); } catch (RequestException e) { Log.trace("Error requesting update of HTTP accessTime", e); } finally { httpSessionTimer.schedule(SESSION_ACCESS_REFRESH); } } private static RequestBuilder createSessionAccessRequestBuilder() { final RequestBuilder b = new RequestBuilder(RequestBuilder.POST, "/portal/sessionAccess"); b.setHeader("Accept", "text/plain"); return b; } }