Thursday, 7 August 2008

JDev/ADF: How to log user login/logout/timeout to the database

I recently had a requirement to record when users log-in and out of my ADF application, as well as time-out of the application after an inactive period. As part of the requirement the user's name, login datetime, and logout/timeout datetime needed to be written to the database.

After much head scratching, staring at ADF BC help documentation to see if there was a solution, a case of Java brain freeze, I finally posted on the JDev OTN forum for assistance. Luckily John and Pako came to my rescue (thanks gents!) and gave me the inspiration for a solution. I thought I'd share my solution here for all to use.

My problem in looking for a solution was I was focusing on ADF BC and ADF Faces/JSF too closely to find a solution and ignoring the rest of the J2EE stack. Let's explain:

While ADF BC is ideal for working with the database, it is not ideal for capturing the start and end of user sessions. (This isn't such a surprise if you consider ADF BC works as an ORM, not a session management tool) The ADF BC application modules are only instantiated when you have a web page that reference's the AM and the VOs of the ADF BC project. If the user first navigates to a web page in your application such as a splash screen that shows no database derived information, the AM user session isn't started, missing the opportunity to record when the user session's started.

What about JavaServer Faces? Do JSF session level beans provide a solution? Unfortunately the same issue for ADF BC above applies to JSF session level beans. The JSF beans are only instantiated when an EL expression references them. If the first page your user navigates to doesn't include the appropriate EL, the JSF session bean is not instantiated missing the beginning of the user's session.

As such we need to step back and see what the J2EE stack can provide to us to solve this problem. The solution is 2 classes, a custom HttpSessionListener class and a custom Filter class.

J2EE applications can support creating a custom implementation of javax.servlet.http.HttpSessionListener interface. When configured within the project's web.xml file, on the start and end of any session, the custom listener's methods sessionCreated() and sessionDestroyed() are guaranteed to be called:

package view;

import // relevant imports

public class HttpSessionListener
implements javax.servlet.http.HttpSessionListener {
public HttpSessionListener() { }

public void sessionCreated(
HttpSessionEvent httpSessionEvent) { }

public void sessionDestroyed(
HttpSessionEvent httpSessionEvent) { }
}


And the web.xml entry looks like:

<listener>
<listener-class>view.HttpSessionListener</listener-class>
</listener>


What constitutes the start of a session? Whenever a new user accesses a web page within our application.

What constitutes the end of a session? Either when the user logs out of the session through a facility supplied by the programmer that calls the session.invalidate() method. In JSF this would be:

FacesContext fc = FacesContext.getCurrentInstance();
HttpServletRequest request =
(HttpServletRequest)fc.getExternalContext().getRequest();
request.getSession().invalidate();


....or alternatively the session times-out, configured by the web.xml <session-timeout> entry (expressed in number of minutes).

With the sessionCreated() and sessionDestroyed() methods within the HttpSessionListener, we now have an ideal place to write-and-read values to-and-from the implicit J2EE session scope to capture the user logging in and out. For example:

public void sessionCreated(HttpSessionEvent httpSessionEvent) {
HttpSession session = httpSessionEvent.getSession();

String sessionScopeUser =
(String)session.getAttribute("User");
if (sessionScopeUser == null ||
sessionScopeUser.equals("")) {
session.setAttribute("User", "?????");
}

Long sessionScopeLoginDateTime =
(Long)session.getAttribute("LoginDateTime");
if (sessionScopeLoginDateTime == null) {
session.setAttribute("LoginDateTime",
System.currentTimeMillis());
}
}

public void sessionDestroyed(
HttpSessionEvent httpSessionEvent) {

HttpSession session = httpSessionEvent.getSession();

String user = (String)session.getAttribute("User");
Long loginDateTime =
(Long)session.getAttribute("LoginDateTime");

Date loginDateTime = new Date(loginDateTime);
Date logoutDateTime =
new Date(System.currentTimeMillis());

SimpleDateFormat format =
new SimpleDateFormat("dd/MM/yyyy hh:mm:ss");
String strLoginDateTime =
format.format(loginDateTime);
String strLogoutDateTime =
format.format(logoutDateTime);

// JDBC to write the values to the database
}


For example you see in the sessionCreated() method that we can set a session variable LoginDateTime = System.currentTimeMillis, then in the sessionDestroyed() method we can retrieve the LoginDateTime and later write that to the database via a JDBC call.

However note an issue with the above solution. Within HttpSessionListener, we have no way to get at the authenticated user's details (essentially their account name). Unfortunately the HttpSessionListener doesn't have access to the Http request or response which does have access to the user's credentials, thus the limitation.

To get around this we introduce another J2EE construct called a javax.servlet.Filter. A class implementing this interface has a method called doFilter which is called on each Http request/response in our application. Within the Filter we are allowed to write to session scope, and in addition it has access to the user's credentials.

As such we can do something like this:

package view;

import // relevant imports

public class SessionFilter
implements javax.servlet.Filter {
public SessionFilter() { }

public void init(FilterConfig filterConfig)
throws ServletException { }

public void doFilter(
ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain filterChain)
throws IOException,
ServletException {

if (servletRequest != null &&
servletRequest instanceof HttpServletRequest) {
HttpServletRequest request = (
HttpServletRequest)servletRequest;
HttpSession session = request.getSession();

String sessionScopeUser =
(String)session.getAttribute("User");
if (sessionScopeUser == null ||
sessionScopeUser.equals("")) {
String user = request. (cont..)
getUserPrincipal().toString();

if (user != null) {
session.setAttribute("User", user);
}
}
}
filterChain.doFilter(
servletRequest, servletResponse);
}

public void destroy() { }
}


Note if the current session scope User variable is null, we grab the principal user from the request and write this to the session scope. Thus when the sessionDestroyed() method is called it has an actual User to work with.

To configure the Filter in the web.xml file you include the following entries:

<filter>
<filter-name>SessionFilter</filter-name>
<filter-class>view.SessionFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>SessionFilter</filter-name>
<url-pattern>/*</url-pattern>
<dispatcher>FORWARD</dispatcher>
<dispatcher>REQUEST</dispatcher>
</filter-mapping>


Now the listener can grab the user name from the session scope supplied via the filter. This completes the solution.

There are a couple of caveats with this solution:

The user's credentials (account name) can only be retrieved by the Filter if the user has or is visiting a J2EE container based protected page of the application. If the user has yet to visit a protected page and thus hasn't been forced to login, the user's credentials will not yet be initialised. As such in creating an application where the user's details are recorded on entry and exit to the application, the user must be forced to log in regardless of which application web page they access. This is simply at the programmer's discretion depending on how they developer sets up the container based security.

(Note however at some point if the user does login, the filter will correctly pickup the user's name at that point, and that will be used by the end listener.sessionDestroyed() method thereafter defeating the caveat above. Also note there appears to be a difference between what the call to request.getUserPrincipal() returns in a number of cases when the user isn't authenticated. I believe if the app isn't configured against an LDAP security provider, it'll return null, but otherwise "anonymous" in the case of an LDAP security provider. Please ensure to test this functionality in your environment).

In testing this facility, you might notice if you force the user to logout via the invalidate() method described above that the HttpSessionListener sessionDestroyed() method is called twice, once when they logout, and a second time after some delayed time period. This occurs if after the logout you redirect the user back to a web page within your application. What you're essentially doing is starting another session (which may not be immediately obvious if you haven't added security/authentication requirements to all your web pages), and the delayed second call of the sessionDestroyed() method is a timeout occurring. The simple solution, on logout redirect the user to a web page outside of your application.

The solution above assumes you'll write the session information to the database via a JDBC call creating its own database connection. Ideally in an ADF BC project, you'd make use of the connection pool provided by ADF BC rather than creating wasteful dedicated database sessions.

A final note is the above solution is just that, and not necessarily the best way to do this. This code has not been tested, has not been run in a production environment, and therefore your mileage may vary. You have been warned! However hopefully this will be helpful to someone, or alternatively somebody will see the error in my ways and tell me a much better way to do this!

8 comments:

Simon Haslam said...

Hmmm, interesting problem. I immediately thought prepareSession() (although of course that would only tackle half of the requirement!), but as you say, it wouldn't be any good if you've not invoked the AM. That said, in most ADF BC apps you don't get far without doing so surely?

I'm not terribly keen on the "// JDBC to write the values to the database" bit ;-) . Yes, you're right you don't want to create wasteful connections, but also as an App Server administrator you don't want some other place to have to set up database credentials either.

Anyway, thanks Chris - this is a very useful post... it will be going on my easy/tricky ADF things list!

Simon Haslam said...

Couldn't a custom login module in the app server be used instead? If you were using database tables, rather than LDAP, for authentication you'd certainly already have the db connection when logging on.

...just thinking aloud really ;-)

Chris Muir said...

@Simon re dedicated JDBC calls: I've been pondering this for some while, wondering how from the JEE side to actually get at an AM in the ADF BC side. I know the code to actually create an AM myself, but I'm unsure if this would result in 2 instances of the same ADF BC AM (thus 2 application module + 2 connection pools), or would the AMs be part of the same AM/connection pool, if you get my drift?

Re custom login module, not that I've looked at them, but I would have thought the same problem applies that if the user isn't forced to authenticate as soon as they hit the site, the custom login module will not fire, in turn not capture the start date/time of the user's session. It would be interesting to verify this though.

Lastly there may be a number of different ways to mutilate a cat here, and part of the goal of this post is to show ADF programmers to look further into the JEE stack for solutions.

CM.

Amir said...

I have also posted the following in OTN Forum but still waiting for the reply, so I thought I'll post on your blog to see if you can suggest something.
--
I am working on an JSF, ADF BC Application developing in JDeveloper 10.1.3.3.0 and Oracle 10g.

I have successfully used DBProcLoginModule by Frank Nimphius with an authentication stored procedure to authenticate from database tables storing user credentials and roles information. The logs show successfull authentication and redirect to the secured page.

Now I want to display the user name and some other user information on my pages. But I am unable to access this information.

I have seen "Implementing Authorization Programmatically" (http://download-uk.oracle.com/docs/html/B25947_01/adding_security008.htm), but I am not sure if implementing UserInfo.java class as mentioned in the article will work in this scenario or not. I want to test it, but I am not sure if getUserRole() and other functions will work since authentication is performed by DBProcLoginModule class and getters and setters are also present in this.

Instead of UserInfo, I tried creating a managed bean using DBProcLoginModule class to access username and logonsucceeded properties. But the username displayed is null and logonSucceeded is false in this case despite the authentication is successfull.

I have also looked at some solutions by Chris Muir (http://one-size-doesnt-fit-all.blogspot.com/2008/08/jdevadf-how-to-log-user.html) and Matthew Wilson (http://my.opera.com/dominionspy/blog/show.dml/575983), which use HttpSession and custom Filter classes.

Being new to ADF, I am not sure which direction to proceed to acheive the goal and I am confused by these many scenarios. Can someone please tell me which one of these solutions or some other solution will be useful in my scenario as stated above.

Thanks,

Amir

Chris Muir said...

Hi Amir

Looks like you solved the issue on the forums:

http://forums.oracle.com/forums/thread.jspa?threadID=837974&tstart=0

CM.

Pavel said...

Hi Chris Muir,

Many thanks for a very useful post.
I’m using the DBTransaction of Application Module to write Log-out Date into Database. Do you have any Idea how can I access to DBTransaction from HttpSessionListener?

Chris Muir said...

At the time I don't believe I discovered a solution to what you want. Under WLS probably the easiest way to ensure that the HttpSessionListener shares the same connection pool as the AM DBTransaction, is to ensure your AM is using a jndi data source, and in your HttpSessionListenera grab the same data source through the same jndi. Unfortunately I can't remember if the same is possible under OC4J.

Regards,

CM.

kumar said...

Very usefull Thanks you