001// Copyright (c) 2001 Hursh Jain (http://www.mollypages.org) 
002// The Molly framework is freely distributable under the terms of an
003// MIT-style license. For details, see the molly pages web site at:
004// http://www.mollypages.org/. Use, modify, have fun !
005
006package fc.web.servlet;
007
008import java.io.*;
009import java.net.*;
010import java.sql.*;
011import java.util.*;
012import java.security.*;
013import javax.servlet.*;
014import javax.servlet.http.*;
015
016import fc.jdbc.*;
017import fc.io.*;
018import fc.web.*;
019import fc.util.*;
020
021/** 
022Logs in/logs out the user. Works in conjunction with {@link JDBCAuthFilter} and
023the login page of the application. (handles the submit from the HTML login
024form). Moreover, the application can logout the user by invoking this servlet
025with an additional <tt><b>act=logout</b></tt> query string. Uses {@link
026JDBCSession} to create/delete session id's automatically on successful
027logins/logouts.
028<p>
029Requires the following servlet initialization parameters
030<ul>
031  <li><tt>welcome_page</tt> the <i>webapp relative</i> path to the
032  welcome page to be shown after a successful login.
033  <li><tt>login_page</tt> the <i>webapp relative</i> path to the
034  login page.
035  <li><tt>logout_welcome_page</tt> the <i>webapp relative</i> path
036  to the welcome page to be shown after a successful logout. (this
037  parameter is <b>optional</b> and if not specified, the welcome_page
038  will be used).
039</ul>
040<u>On login failure</u>, the following attributes are set in the request
041before control is transferred to the login page via a server side
042redirect.
043<ol>
044  <li><tt>retrycount</tt>, value is a Integer object representing the
045  number of times login has been unsuccessfuly tried. Note: <u>the login
046  page should read this attribute if present and store it in the form
047  as a hidden parameter. When the login form is submitted, this variable
048  will be sent back to this servlet in the request as a parameter and
049  upon login failure, be appropriately incremented.</u>
050</ol>
051<u>On login success</u>, the following attributes are set as a cookie on
052the client. This cookie is removed on logout.
053<ol>
054  <li><tt>{@link #SID_COOKIE_NAME}</tt> the session ID assigned to the
055  user. After logging in, a session will exist in the database. {@link
056  JDBCSession} can thereafter be used to store any information for that
057  session in the database via the session ID.
058  <li><tt>user.name</tt>, the name of the user that was succefully
059  used to login to the system. (this is useful for displaying the
060  username in the front end page without hitting the database everytime).
061</ol>
062In addition, upon successful login, the {@link #onLogin} method is invoked.
063This method can be overriden as necessary by subclasses. Similarly, In addition, upon 
064successful logout, the {@link #onLogout} method is invoked. 
065<p>
066The servlet requires the following request parameters from the
067login form:
068<ul>
069  <li><tt>username</tt>
070  <li><tt>password</tt>
071</ul>
072The following request parameters are optional. 
073<ul>
074  <li><tt>target</tt> if present (either as a cookie or in the URL),
075  the client is redirected to the URL specified by the target
076  (otherwise the client is redirected to the welcome_page after
077  login/logout). The {@link AuthFilter} automatically stores the
078  original target page as a parameter (URLEncoded) so that users are
079  seamlessly redirected to their original target after a successful
080  login or logout.
081</ul>
082<p>
083Requires the following database schema:
084<blockquote>
085A <b>Users</b> table must exist. (note: "user" is a reserved word in many
086databases, the table must be called User<b>s</b>.
087<ol>
088The following columns must exist in the <tt>Users</tt> table.
089  <li> <b>user_id</b> the user id
090  <li> <b>username</b> the name of the user (corresponds to
091  the username parameter in the login form)
092  <li> <b>password</b> the password for the user (corresponds
093  to the password parameter in the login form).
094</ol>
095The class will use the {@link fc.jdbc.dbo.DBOMgr} framework so a
096<b>UserMgr</b> class corresponding to the aforementioned <b>User</b> table
097must exist in the classpath of this servlet.
098</blockquote>
099Since this class uses {@link JDBCSession}, the default database
100tables required by {@link JDBCSession} also must exist.
101<p>
102For security reasons, for logging in, the username/password form must be
103submitted via a POST (GET is fine when logging out).
104
105@author hursh jain
106**/
107public class LoginServlet extends FCBaseServlet
108{
109/** value = "sid" **/
110public static final String SID_COOKIE_NAME = "sid";
111
112protected String      login_query;
113protected String    loginPage;
114protected String    welcomePage;
115protected String    logoutWelcomePage;
116protected JDBCSession session;
117protected MessageDigest hash;
118
119public void init(ServletConfig conf) throws ServletException
120  {
121  super.init(conf);
122
123  try {
124    loginPage       = WebUtil.getRequiredParam(this,  "login_page");
125    welcomePage     = WebUtil.getRequiredParam(this,"welcome_page");
126    logoutWelcomePage   = WebUtil.getParam(
127                  this, "logout_welcome_page", welcomePage);
128    login_query     = WebUtil.getRequiredParam(this, "login_query");
129    String hash_name  = WebUtil.getParam(this, "password_hash", null);
130    
131    log.bug("login page=", loginPage);
132    log.bug("welcome page=", welcomePage);
133    log.bug("logout welcome page=", logoutWelcomePage);
134    log.bug("login_query=", login_query);
135    log.bug("password_hash=", hash_name);
136
137    hash  = (MessageDigest) MessageDigest.getInstance(hash_name);
138    session = JDBCSession.getInstance();
139    }
140  catch (Exception e) {
141    log.error(IOUtil.throwableToString(e));
142    throw new ServletException(IOUtil.throwableToString(e));
143    }
144  }
145
146/**
147Returns the cookie corresponding to the "sid". (this cookie
148has key = {@link #SID_COOKIE_NAME} and the value is the SID
149created/set at login time). Returns <tt>null</tt> is no
150sid cookie is found.
151*/
152public static Cookie getSIDCookie(HttpServletRequest req)
153  {
154  Cookie[] cookies = req.getCookies();
155  
156  if (cookies == null)
157    return null;
158    
159  for (int n = 0; n < cookies.length; n++) 
160    {
161    if (cookies[n].getName().equals(SID_COOKIE_NAME))
162      {
163      return cookies[n];
164      }
165    }
166  return null;
167  }
168
169boolean checkHasValidSID(Connection con, HttpServletRequest req) 
170throws SQLException
171  {
172  Cookie c = getSIDCookie(req);
173  if (c == null)
174    return false;
175    
176  String sid = c.getValue();
177  if (session.exists(con, sid))
178    return true;
179    
180  return false;
181  }
182
183public void doGet(HttpServletRequest req, HttpServletResponse res)
184throws ServletException, IOException
185  {
186  //to support logout only.
187  doPost(req, res);
188  }
189  
190public void doPost(HttpServletRequest req, HttpServletResponse res)
191throws ServletException, IOException
192  {
193  Connection con = null;
194  
195  try {
196    con = getConnection();
197    
198    String action = req.getParameter("act");
199
200    if (action != null) {
201      if (action.equals("logout")) 
202        {
203        if (doLogout(con, req, res)) {
204          goToTargetPage(req, res, logoutWelcomePage);
205          return;
206          }
207        return;
208        }
209      }
210      
211    //at this point we are logging in.
212    
213    //may have valid SID if login page accessed directly
214    //via browser's history or whatever.
215    if (checkHasValidSID(con, req)) {
216      goToTargetPage(req, res, welcomePage);
217      return;
218      }
219
220    if (! doLogin(con, req, res)) {
221      addFailureMessage(req);
222      WebUtil.forward(req, res, loginPage);//server side redirect
223      return;
224      }
225    else{
226      goToTargetPage(req, res, welcomePage);
227      return;
228      }
229    }
230  catch (Exception e) {
231    throw new ServletException(e);
232    }
233  finally {
234    try {
235      con.close();
236      }
237    catch (SQLException e) {
238      throw new ServletException(e);
239      }
240    }
241    
242  } //~doPost
243
244/**
245This method validates the specified username/password. 
246<p>
247<b>
248This class should be subclassed to override this method and validate the
249supplied username/password against a database in a different fashion if
250desired.
251</b>
252The default implmentation of this method works with the following
253initialization parameters.
254<ul>
255<li><tt>login_query</tt> (required): the query string to validate if this
256username/passwrod combination exists. This query string should in
257<b>PreparedStatement</b> format with 2 question marks (the first one will
258be set with the username and the 2nd with the password). </li>
259<li><tt>password_hash</tt> (optional): if present, should contain the 
260name of the java cryto hash function to hash the password before comparing
261it with the database (this is for cases where the passwords are stored as
262hashed values in the database). Examples include <tt>MD5</tt>, <tt>SHA-1</tt>
263etc.
264</li>
265</ul>
266This method should return the following values:
267<ul>
268<li>If authentication failed: <tt>null</tt></li>
269<li>If authentication succeeded: 
270  <ol>
271  <li>If the returned string is non-null and non-empty, then it should
272  contain the userid for the authenticated user and this
273  <tt>userid</tt> is stored in the {@link JDBCSession}.</li>
274  <li>If the user authenticated succeeds, a non-null string containing
275  a unique username or userid should be returned. (the username/userid
276  should be the Primary key field used in the database to uniquely 
277  identify a user).
278  </li> 
279  </ol>
280</li>
281</ul>
282*/
283public String validateUser( 
284  Connection con, String username, String password) 
285throws SQLException, IOException
286  {
287  PreparedStatement pstmt = null;
288
289  if (! (con instanceof fc.jdbc.PooledConnection) ) { 
290    pstmt = con.prepareStatement(login_query);
291    }
292  else{
293    PooledConnection pc = (PooledConnection) con;
294    pstmt = pc.getCachedPreparedStatement(login_query);
295    }
296    
297  pstmt.setString(1, username);
298  pstmt.setString(2, encodePassword(password));
299
300  log.bug("Validating logon using: ", pstmt);
301    
302  ResultSet rs = pstmt.executeQuery();
303  String uid = null;
304  
305  if (rs.next()) {
306    log.bug("Successfully validated username=", username, ". Got uid=[", uid + "]");
307    uid = rs.getString(1);
308    }
309    
310  return uid; 
311  }
312
313/**
314For this method to be available from application code, this servlet
315should be set to load on startup and code similar to the following
316example invoked.
317<blockquote>
318<tt>
319LoginServlet ls = (LoginServlet) WebApp.allServletsMap.get("fc.web.servlet.LoginServlet");
320if (ls == null) { //can happen if servlet is not loaded yet
321  throw new Exception("Unexpected error: LoginServlet was null");
322  }
323return ls.encodePassword(passwd);
324</tt>
325</blockquote>
326*/
327public String encodePassword(String password) throws IOException
328  {
329  if (hash == null || password == null)
330    return password;
331
332  try {
333    //MessageDigest getInstance not thread safe, alternative is to
334    //use that in a sychronized block.
335    final MessageDigest hash2 = (MessageDigest) hash.clone();
336    final byte[] digest_buf = hash2.digest (password.getBytes("UTF-8"));
337    
338    //postgres, mysql have md5() which returns the md5 hash as a 
339    //hexadecimal string. they don't really have base64 that can work
340    //seamlessly as:  string->md5()->base64-->password    
341    //   password = new sun.misc.BASE64Encoder().encode(digest_buf);
342    //So we do this:
343    
344    password = HexOutputStream.toHex(digest_buf);
345    }
346  catch (Exception e) {
347    throw new IOException(IOUtil.throwableToString(e));
348    }
349  
350  return password;
351  }
352
353/**
354Returns true on a successful login, false otherwise.
355*/
356boolean doLogin(
357 Connection con, HttpServletRequest req, HttpServletResponse res) 
358throws SQLException, IOException
359  {
360  String username = req.getParameter("username");
361  String password = req.getParameter("password"); 
362
363  if (username == null || password == null) {
364    log.error("username not recieved in request (not present at all)");
365    return false;
366    }
367
368  if (password == null) {
369    log.error("password not recieved in request (not present at all)");
370    return false;
371    }
372
373  String sid = SessionUtil.newSessionID();
374  String uid = validateUser(con, username, password);
375  boolean suxez = (uid != null);
376  
377  if (suxez) 
378    {
379    session.create(con, sid, uid);
380      
381    addCookie(res, SID_COOKIE_NAME, sid);
382    addCookie(res, "user.name", username);
383    onLogin(con, sid, username, req, res);
384    }
385  else{
386    log.info("login FAILED for username=[", username, "]");
387    }
388    
389  return suxez;
390  }
391
392boolean doLogout(Connection con, 
393         HttpServletRequest req,  HttpServletResponse res) 
394throws SQLException, IOException
395  {
396  Cookie c = getSIDCookie(req);
397
398  if (c == null)
399    return false;
400
401  String sid = c.getValue();
402  log.bug("logging out: ", sid);
403
404  boolean suxez = false;  
405  if (session.exists(con, sid)) {
406    session.expire(con, sid);
407    suxez = true;
408    }
409  else{
410    log.bug("session:", sid, " does not exist, ignoring logout");
411    }
412    
413  deleteCookie(res, "user.name");
414  deleteCookie(res, SID_COOKIE_NAME);
415  onLogout(con, sid, req, res);
416  
417  return suxez;
418  }
419
420/**
421This method is invoked upon successful login. By default, it does
422nothing but subclasses can override this method as needed.
423
424@param  con     a connection to the database
425@param  username  the username for this user (that was used to login the
426          user via the login query)
427@param  sid     the session id for this user
428*/
429public void onLogin(Connection con, String sid, String username,
430         HttpServletRequest req,  HttpServletResponse res) 
431throws SQLException, IOException
432  {
433  /* override as necessary */
434  }
435
436/**
437This method is invoked upon successful login. By default, it does
438nothing but subclasses can override this method as needed.
439
440@param  con     a connection to the database
441@param  sid     the session id for this user
442*/
443public void onLogout(Connection con, String sid,
444         HttpServletRequest req,  HttpServletResponse res) 
445throws SQLException, IOException
446  {
447  /* override as necessary */
448  }
449
450void goToTargetPage(
451  HttpServletRequest req, HttpServletResponse res, String defaultPage)
452throws IOException, ServletException
453  {
454  String target = null;
455  
456  final Cookie cookie = WebUtil.getCookie(req, "target");
457  if (cookie != null) {
458    target = cookie.getValue();
459    cookie.setPath("/");
460    cookie.setMaxAge(0); //delete
461    res.addCookie(cookie);
462    }
463  else  
464    target = req.getParameter("target");
465
466  if (target == null) 
467    {
468    //we want browser's url to change so client side
469    WebUtil.clientRedirect(req, res, defaultPage);
470    }
471  else {
472    /*
473    we don't use a server side redirect because the target
474    is the full original URL (like: http://....) and
475    server side redirect would try /http://.... (the
476    leading / because server side redirects are context
477    relative) we also want the browser's url to change
478    */
479    target = URLDecoder.decode(target);
480    
481    WebUtil.clientRedirect(req, res, target);
482    }
483  }
484
485void addFailureMessage(HttpServletRequest req)
486throws ServletException 
487  {
488  String retrycount = req.getParameter("retrycount");
489  if (retrycount == null)
490    retrycount = "0";
491  
492  Integer rint = null;
493  try { 
494    rint = new Integer(Integer.parseInt(retrycount) + 1);
495    }
496  catch (NumberFormatException e) {
497    rint = new Integer(1);
498    log.warn(e);
499    }
500
501  req.setAttribute("retrycount", rint);
502  } 
503  
504final void addCookie(HttpServletResponse res, String key, String val) 
505  {
506  Cookie cookie = new Cookie(key, val);
507  cookie.setMaxAge(session.getExpireTime()); 
508  res.addCookie(cookie);
509  }
510
511final void deleteCookie( HttpServletResponse res, String key) 
512  {
513  //the value is irrelevant since we are deleting the cookie
514  Cookie cookie = new Cookie(key, ""); 
515  cookie.setMaxAge(0); 
516  res.addCookie(cookie);
517  }
518
519}  //~class
520