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    
006    package fc.web.servlet;
007    
008    import java.io.*;
009    import java.net.*;
010    import java.sql.*;
011    import java.util.*;
012    import java.security.*;
013    import javax.servlet.*;
014    import javax.servlet.http.*;
015    
016    import fc.jdbc.*;
017    import fc.io.*;
018    import fc.web.*;
019    import fc.util.*;
020    
021    /** 
022    Logs in/logs out the user. Works in conjunction with {@link JDBCAuthFilter} and
023    the login page of the application. (handles the submit from the HTML login
024    form). Moreover, the application can logout the user by invoking this servlet
025    with an additional <tt><b>act=logout</b></tt> query string. Uses {@link
026    JDBCSession} to create/delete session id's automatically on successful
027    logins/logouts.
028    <p>
029    Requires 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
041    before control is transferred to the login page via a server side
042    redirect.
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
052    the 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>
062    In addition, upon successful login, the {@link #onLogin} method is invoked.
063    This method can be overriden as necessary by subclasses. Similarly, In addition, upon 
064    successful logout, the {@link #onLogout} method is invoked. 
065    <p>
066    The servlet requires the following request parameters from the
067    login form:
068    <ul>
069      <li><tt>username</tt>
070      <li><tt>password</tt>
071    </ul>
072    The 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>
083    Requires the following database schema:
084    <blockquote>
085    A <b>Users</b> table must exist. (note: "user" is a reserved word in many
086    databases, the table must be called User<b>s</b>.
087    <ol>
088    The 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>
095    The 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
097    must exist in the classpath of this servlet.
098    </blockquote>
099    Since this class uses {@link JDBCSession}, the default database
100    tables required by {@link JDBCSession} also must exist.
101    <p>
102    For security reasons, for logging in, the username/password form must be
103    submitted via a POST (GET is fine when logging out).
104    
105    @author hursh jain
106    **/
107    public class LoginServlet extends FCBaseServlet
108    {
109    /** value = "sid" **/
110    public static final String SID_COOKIE_NAME = "sid";
111    
112    protected String      login_query;
113    protected String    loginPage;
114    protected String    welcomePage;
115    protected String    logoutWelcomePage;
116    protected JDBCSession session;
117    protected MessageDigest hash;
118    
119    public 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    /**
147    Returns the cookie corresponding to the "sid". (this cookie
148    has key = {@link #SID_COOKIE_NAME} and the value is the SID
149    created/set at login time). Returns <tt>null</tt> is no
150    sid cookie is found.
151    */
152    public 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    
169    boolean checkHasValidSID(Connection con, HttpServletRequest req) 
170    throws 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    
183    public void doGet(HttpServletRequest req, HttpServletResponse res)
184    throws ServletException, IOException
185      {
186      //to support logout only.
187      doPost(req, res);
188      }
189      
190    public void doPost(HttpServletRequest req, HttpServletResponse res)
191    throws 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    /**
245    This method validates the specified username/password. 
246    <p>
247    <b>
248    This class should be subclassed to override this method and validate the
249    supplied username/password against a database in a different fashion if
250    desired.
251    </b>
252    The default implmentation of this method works with the following
253    initialization parameters.
254    <ul>
255    <li><tt>login_query</tt> (required): the query string to validate if this
256    username/passwrod combination exists. This query string should in
257    <b>PreparedStatement</b> format with 2 question marks (the first one will
258    be set with the username and the 2nd with the password). </li>
259    <li><tt>password_hash</tt> (optional): if present, should contain the 
260    name of the java cryto hash function to hash the password before comparing
261    it with the database (this is for cases where the passwords are stored as
262    hashed values in the database). Examples include <tt>MD5</tt>, <tt>SHA-1</tt>
263    etc.
264    </li>
265    </ul>
266    This 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    */
283    public String validateUser( 
284      Connection con, String username, String password) 
285    throws 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    /**
314    For this method to be available from application code, this servlet
315    should be set to load on startup and code similar to the following
316    example invoked.
317    <blockquote>
318    <tt>
319    LoginServlet ls = (LoginServlet) WebApp.allServletsMap.get("fc.web.servlet.LoginServlet");
320    if (ls == null) { //can happen if servlet is not loaded yet
321      throw new Exception("Unexpected error: LoginServlet was null");
322      }
323    return ls.encodePassword(passwd);
324    </tt>
325    </blockquote>
326    */
327    public 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    /**
354    Returns true on a successful login, false otherwise.
355    */
356    boolean doLogin(
357     Connection con, HttpServletRequest req, HttpServletResponse res) 
358    throws 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    
392    boolean doLogout(Connection con, 
393             HttpServletRequest req,  HttpServletResponse res) 
394    throws 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    /**
421    This method is invoked upon successful login. By default, it does
422    nothing 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    */
429    public void onLogin(Connection con, String sid, String username,
430             HttpServletRequest req,  HttpServletResponse res) 
431    throws SQLException, IOException
432      {
433      /* override as necessary */
434      }
435    
436    /**
437    This method is invoked upon successful login. By default, it does
438    nothing 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    */
443    public void onLogout(Connection con, String sid,
444             HttpServletRequest req,  HttpServletResponse res) 
445    throws SQLException, IOException
446      {
447      /* override as necessary */
448      }
449    
450    void goToTargetPage(
451      HttpServletRequest req, HttpServletResponse res, String defaultPage)
452    throws 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    
485    void addFailureMessage(HttpServletRequest req)
486    throws 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      
504    final 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    
511    final 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