001/* 
002    Licensed to the Apache Software Foundation (ASF) under one
003    or more contributor license agreements.  See the NOTICE file
004    distributed with this work for additional information
005    regarding copyright ownership.  The ASF licenses this file
006    to you under the Apache License, Version 2.0 (the
007    "License"); you may not use this file except in compliance
008    with the License.  You may obtain a copy of the License at
009
010       http://www.apache.org/licenses/LICENSE-2.0
011
012    Unless required by applicable law or agreed to in writing,
013    software distributed under the License is distributed on an
014    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015    KIND, either express or implied.  See the License for the
016    specific language governing permissions and limitations
017    under the License.  
018 */
019package org.apache.wiki.auth.user;
020
021import java.io.IOException;
022import java.io.Serializable;
023import java.security.Principal;
024import java.sql.Connection;
025import java.sql.DatabaseMetaData;
026import java.sql.PreparedStatement;
027import java.sql.ResultSet;
028import java.sql.SQLException;
029import java.sql.Timestamp;
030import java.util.Date;
031import java.util.HashSet;
032import java.util.Map;
033import java.util.Properties;
034import java.util.Set;
035
036import javax.naming.Context;
037import javax.naming.InitialContext;
038import javax.naming.NamingException;
039import javax.sql.DataSource;
040
041import org.apache.commons.lang.StringUtils;
042import org.apache.wiki.WikiEngine;
043import org.apache.wiki.api.exceptions.NoRequiredPropertyException;
044import org.apache.wiki.auth.NoSuchPrincipalException;
045import org.apache.wiki.auth.WikiPrincipal;
046import org.apache.wiki.auth.WikiSecurityException;
047import org.apache.wiki.util.Serializer;
048
049/**
050 * <p>
051 * Implementation of UserDatabase that persists {@link DefaultUserProfile}
052 * objects to a JDBC DataSource, as might typically be provided by a web
053 * container. This implementation looks up the JDBC DataSource using JNDI. The
054 * JNDI name of the datasource, backing table and mapped columns used by this 
055 * class can be overridden by adding settings in <code>jspwiki.properties</code>.
056 * </p>
057 * <p>
058 * Configurable properties are these:
059 * </p>
060 * <table>
061 * <tr> <thead>
062 * <th>Property</th>
063 * <th>Default</th>
064 * <th>Definition</th>
065 * <thead> </tr>
066 * <tr>
067 * <td><code>jspwiki.userdatabase.datasource</code></td>
068 * <td><code>jdbc/UserDatabase</code></td>
069 * <td>The JNDI name of the DataSource</td>
070 * </tr>
071 * <tr>
072 * <td><code>jspwiki.userdatabase.table</code></td>
073 * <td><code>users</code></td>
074 * <td>The table that stores the user profiles</td>
075 * </tr>
076 * <tr>
077 * <td><code>jspwiki.userdatabase.attributes</code></td>
078 * <td><code>attributes</code></td>
079 * <td>The CLOB column containing the profile's custom attributes, stored as key/value strings, each separated by newline.</td>
080 * </tr>
081 * <tr>
082 * <td><code>jspwiki.userdatabase.created</code></td>
083 * <td><code>created</code></td>
084 * <td>The column containing the profile's creation timestamp</td>
085 * </tr>
086 * <tr>
087 * <td><code>jspwiki.userdatabase.email</code></td>
088 * <td><code>email</code></td>
089 * <td>The column containing the user's e-mail address</td>
090 * </tr>
091 * <tr>
092 * <td><code>jspwiki.userdatabase.fullName</code></td>
093 * <td><code>full_name</code></td>
094 * <td>The column containing the user's full name</td>
095 * </tr>
096 * <tr>
097 * <td><code>jspwiki.userdatabase.loginName</code></td>
098 * <td><code>login_name</code></td>
099 * <td>The column containing the user's login id</td>
100 * </tr>
101 * <tr>
102 * <td><code>jspwiki.userdatabase.password</code></td>
103 * <td><code>password</code></td>
104 * <td>The column containing the user's password</td>
105 * </tr>
106 * <tr>
107 * <td><code>jspwiki.userdatabase.modified</code></td>
108 * <td><code>modified</code></td>
109 * <td>The column containing the profile's last-modified timestamp</td>
110 * </tr>
111 * <tr>
112 * <td><code>jspwiki.userdatabase.uid</code></td>
113 * <td><code>uid</code></td>
114 * <td>The column containing the profile's unique identifier, as a long integer</td>
115 * </tr>
116 * <tr>
117 * <td><code>jspwiki.userdatabase.wikiName</code></td>
118 * <td><code>wiki_name</code></td>
119 * <td>The column containing the user's wiki name</td>
120 * </tr>
121 * <tr>
122 * <td><code>jspwiki.userdatabase.lockExpiry</code></td>
123 * <td><code>lock_expiry</code></td>
124 * <td>The column containing the date/time when the profile, if locked, should be unlocked.</td>
125 * </tr>
126 * <tr>
127 * <td><code>jspwiki.userdatabase.roleTable</code></td>
128 * <td><code>roles</code></td>
129 * <td>The table that stores user roles. When a new user is created, a new
130 * record is inserted containing user's initial role. The table will have an ID
131 * column whose name and values correspond to the contents of the user table's
132 * login name column. It will also contain a role column (see next row).</td>
133 * </tr>
134 * <tr>
135 * <td><code>jspwiki.userdatabase.role</code></td>
136 * <td><code>role</code></td>
137 * <td>The column in the role table that stores user roles. When a new user is
138 * created, this column will be populated with the value
139 * <code>Authenticated</code>. Once created, JDBCUserDatabase does not use
140 * this column again; it is provided strictly for the convenience of
141 * container-managed authentication services.</td>
142 * </tr>
143 * </table>
144 * <p>
145 * This class hashes passwords using SHA-1. All of the underying SQL commands
146 * used by this class are implemented using prepared statements, so it is immune
147 * to SQL injection attacks.
148 * </p>
149 * <p>
150 * This class is typically used in conjunction with a web container's JNDI
151 * resource factory. For example, Tomcat provides a basic
152 * JNDI factory for registering DataSources. To give JSPWiki access to the JNDI
153 * resource named by <code></code>, you would declare the datasource resource
154 * similar to this:
155 * </p>
156 * <blockquote><code>&lt;Context ...&gt;<br/>
157 *  &nbsp;&nbsp;...<br/>
158 *  &nbsp;&nbsp;&lt;Resource name="jdbc/UserDatabase" auth="Container"<br/>
159 *  &nbsp;&nbsp;&nbsp;&nbsp;type="javax.sql.DataSource" username="dbusername" password="dbpassword"<br/>
160 *  &nbsp;&nbsp;&nbsp;&nbsp;driverClassName="org.hsql.jdbcDriver" url="jdbc:HypersonicSQL:database"<br/>
161 *  &nbsp;&nbsp;&nbsp;&nbsp;maxActive="8" maxIdle="4"/&gt;<br/>
162 *  &nbsp;...<br/>
163 * &lt;/Context&gt;</code></blockquote>
164 * <p>
165 * To configure JSPWiki to use JDBC support, first create a database 
166 * with a structure similar to that provided by the HSQL and PostgreSQL 
167 * scripts in src/main/config/db.  If you have different table or column 
168 * names you can either alias them with a database view and have JSPWiki
169 * use the views, or alter the WEB-INF/jspwiki.properties file: the 
170 * jspwiki.userdatabase.* and jspwiki.groupdatabase.* properties change the
171 * names of the tables and columns that JSPWiki uses.
172 * </p>
173 * <p>
174 * A JNDI datasource (named jdbc/UserDatabase by default but can be configured 
175 * in the jspwiki.properties file) will need to be created in your servlet container.
176 * JDBC driver JARs should be added, e.g. in Tomcat's <code>lib</code>
177 * directory. For more Tomcat JNDI configuration examples, see <a
178 * href="http://tomcat.apache.org/tomcat-7.0-doc/jndi-resources-howto.html">
179 * http://tomcat.apache.org/tomcat-7.0-doc/jndi-resources-howto.html</a>.
180 * Once done, restart JSPWiki in the servlet container for it to read the 
181 * new properties and switch to JDBC authentication.
182 * </p>
183 * <p>
184 * JDBCUserDatabase commits changes as transactions if the back-end database
185 * supports them. If the database supports transactions, user profile changes
186 * are saved to permanent storage only when the {@link #commit()} method is
187 * called. If the database does <em>not</em> support transactions, then
188 * changes are made immediately (during the {@link #save(UserProfile)} method),
189 * and the {@linkplain #commit()} method no-ops. Thus, callers should always
190 * call the {@linkplain #commit()} method after saving a profile to guarantee
191 * that changes are applied.
192 * </p>
193 * 
194 * @since 2.3
195 */
196public class JDBCUserDatabase extends AbstractUserDatabase
197{
198
199    private static final String NOTHING = "";
200
201    public static final String DEFAULT_DB_ATTRIBUTES = "attributes";
202
203    public static final String DEFAULT_DB_CREATED = "created";
204
205    public static final String DEFAULT_DB_EMAIL = "email";
206
207    public static final String DEFAULT_DB_FULL_NAME = "full_name";
208
209    public static final String DEFAULT_DB_JNDI_NAME = "jdbc/UserDatabase";
210
211    public static final String DEFAULT_DB_LOCK_EXPIRY = "lock_expiry";
212
213    public static final String DEFAULT_DB_MODIFIED = "modified";
214
215    public static final String DEFAULT_DB_ROLE = "role";
216
217    public static final String DEFAULT_DB_ROLE_TABLE = "roles";
218
219    public static final String DEFAULT_DB_TABLE = "users";
220
221    public static final String DEFAULT_DB_LOGIN_NAME = "login_name";
222
223    public static final String DEFAULT_DB_PASSWORD = "password";
224
225    public static final String DEFAULT_DB_UID = "uid";
226
227    public static final String DEFAULT_DB_WIKI_NAME = "wiki_name";
228
229    public static final String PROP_DB_ATTRIBUTES = "jspwiki.userdatabase.attributes";
230
231    public static final String PROP_DB_CREATED = "jspwiki.userdatabase.created";
232
233    public static final String PROP_DB_EMAIL = "jspwiki.userdatabase.email";
234
235    public static final String PROP_DB_FULL_NAME = "jspwiki.userdatabase.fullName";
236
237    public static final String PROP_DB_DATASOURCE = "jspwiki.userdatabase.datasource";
238
239    public static final String PROP_DB_LOCK_EXPIRY = "jspwiki.userdatabase.lockExpiry";
240
241    public static final String PROP_DB_LOGIN_NAME = "jspwiki.userdatabase.loginName";
242
243    public static final String PROP_DB_MODIFIED = "jspwiki.userdatabase.modified";
244
245    public static final String PROP_DB_PASSWORD = "jspwiki.userdatabase.password";
246
247    public static final String PROP_DB_UID = "jspwiki.userdatabase.uid";
248
249    public static final String PROP_DB_ROLE = "jspwiki.userdatabase.role";
250
251    public static final String PROP_DB_ROLE_TABLE = "jspwiki.userdatabase.roleTable";
252
253    public static final String PROP_DB_TABLE = "jspwiki.userdatabase.table";
254
255    public static final String PROP_DB_WIKI_NAME = "jspwiki.userdatabase.wikiName";
256
257    private DataSource m_ds = null;
258
259    private String m_deleteUserByLoginName = null;
260
261    private String m_deleteRoleByLoginName = null;
262
263    private String m_findByEmail = null;
264
265    private String m_findByFullName = null;
266
267    private String m_findByLoginName = null;
268
269    private String m_findByUid = null;
270
271    private String m_findByWikiName = null;
272
273    private String m_renameProfile = null;
274
275    private String m_renameRoles = null;
276
277    private String m_updateProfile = null;
278
279    private String m_findAll = null;
280
281    private String m_findRoles = null;
282
283    private String m_insertProfile = null;
284
285    private String m_insertRole = null;
286
287    private String m_attributes = null;
288
289    private String m_email = null;
290
291    private String m_fullName = null;
292
293    private String m_lockExpiry = null;
294
295    private String m_loginName = null;
296
297    private String m_password = null;
298
299    private String m_uid = null;
300    
301    private String m_wikiName = null;
302
303    private String m_created = null;
304
305    private String m_modified = null;
306
307    private boolean m_supportsCommits = false;
308
309    /**
310     * Looks up and deletes the first {@link UserProfile} in the user database
311     * that matches a profile having a given login name. If the user database
312     * does not contain a user with a matching attribute, throws a
313     * {@link NoSuchPrincipalException}. This method is intended to be atomic;
314     * results cannot be partially committed. If the commit fails, it should
315     * roll back its state appropriately. Implementing classes that persist to
316     * the file system may wish to make this method <code>synchronized</code>.
317     * 
318     * @param loginName the login name of the user profile that shall be deleted
319     */
320    public void deleteByLoginName( String loginName ) throws NoSuchPrincipalException, WikiSecurityException
321    {
322        // Get the existing user; if not found, throws NoSuchPrincipalException
323        findByLoginName( loginName );
324        Connection conn = null;
325
326        try
327        {
328            // Open the database connection
329            conn = m_ds.getConnection();
330            if( m_supportsCommits )
331            {
332                conn.setAutoCommit( false );
333            }
334
335            PreparedStatement ps;
336            // Delete user record
337            ps = conn.prepareStatement( m_deleteUserByLoginName );
338            ps.setString( 1, loginName );
339            ps.execute();
340            ps.close();
341
342            // Delete role record
343            ps = conn.prepareStatement( m_deleteRoleByLoginName );
344            ps.setString( 1, loginName );
345            ps.execute();
346            ps.close();
347
348            // Commit and close connection
349            if( m_supportsCommits )
350            {
351                conn.commit();
352            }
353        }
354        catch( SQLException e )
355        {
356            throw new WikiSecurityException( e.getMessage(), e );
357        }
358        finally
359        {
360            try
361            {
362                if( conn != null ) conn.close();
363            }
364            catch( Exception e )
365            {
366            }
367        }
368    }
369
370    /**
371     * @see org.apache.wiki.auth.user.UserDatabase#findByEmail(java.lang.String)
372     */
373    public UserProfile findByEmail( String index ) throws NoSuchPrincipalException
374    {
375        return findByPreparedStatement( m_findByEmail, index );
376    }
377
378    /**
379     * @see org.apache.wiki.auth.user.UserDatabase#findByFullName(java.lang.String)
380     */
381    public UserProfile findByFullName( String index ) throws NoSuchPrincipalException
382    {
383        return findByPreparedStatement( m_findByFullName, index );
384    }
385
386    /**
387     * @see org.apache.wiki.auth.user.UserDatabase#findByLoginName(java.lang.String)
388     */
389    public UserProfile findByLoginName( String index ) throws NoSuchPrincipalException
390    {
391        return findByPreparedStatement( m_findByLoginName, index );
392    }
393
394    /**
395     * @see org.apache.wiki.auth.user.UserDatabase#findByWikiName(String)
396     */
397    public UserProfile findByUid( String uid ) throws NoSuchPrincipalException
398    {
399        return findByPreparedStatement( m_findByUid, uid );
400    }
401
402    /**
403     * @see org.apache.wiki.auth.user.UserDatabase#findByWikiName(String)
404     */
405    public UserProfile findByWikiName( String index ) throws NoSuchPrincipalException
406    {
407        return findByPreparedStatement( m_findByWikiName, index );
408    }
409
410    /**
411     * Returns all WikiNames that are stored in the UserDatabase as an array of
412     * WikiPrincipal objects. If the database does not contain any profiles,
413     * this method will return a zero-length array.
414     * 
415     * @return the WikiNames
416     */
417    public Principal[] getWikiNames() throws WikiSecurityException
418    {
419        Set<Principal> principals = new HashSet<Principal>();
420        Connection conn = null;
421        try
422        {
423            conn = m_ds.getConnection();
424            PreparedStatement ps = conn.prepareStatement( m_findAll );
425            ResultSet rs = ps.executeQuery();
426            while ( rs.next() )
427            {
428                String wikiName = rs.getString( m_wikiName );
429                if( wikiName == null )
430                {
431                    log.warn( "Detected null wiki name in XMLUserDataBase. Check your user database." );
432                }
433                else
434                {
435                    Principal principal = new WikiPrincipal( wikiName, WikiPrincipal.WIKI_NAME );
436                    principals.add( principal );
437                }
438            }
439            ps.close();
440        }
441        catch( SQLException e )
442        {
443            throw new WikiSecurityException( e.getMessage(), e );
444        }
445        finally
446        {
447            try
448            {
449                if( conn != null ) conn.close();
450            }
451            catch( Exception e )
452            {
453            }
454        }
455
456        return principals.toArray( new Principal[principals.size()] );
457    }
458
459    /**
460     * @see org.apache.wiki.auth.user.UserDatabase#initialize(org.apache.wiki.WikiEngine,
461     *      java.util.Properties)
462     */
463    public void initialize( WikiEngine engine, Properties props ) throws NoRequiredPropertyException, WikiSecurityException
464    {
465        String userTable;
466        String role;
467        String roleTable;
468
469        String jndiName = props.getProperty( PROP_DB_DATASOURCE, DEFAULT_DB_JNDI_NAME );
470        try
471        {
472            Context initCtx = new InitialContext();
473            Context ctx = (Context) initCtx.lookup( "java:comp/env" );
474            m_ds = (DataSource) ctx.lookup( jndiName );
475
476            // Prepare the SQL selectors
477            userTable = props.getProperty( PROP_DB_TABLE, DEFAULT_DB_TABLE );
478            m_email = props.getProperty( PROP_DB_EMAIL, DEFAULT_DB_EMAIL );
479            m_fullName = props.getProperty( PROP_DB_FULL_NAME, DEFAULT_DB_FULL_NAME );
480            m_lockExpiry = props.getProperty( PROP_DB_LOCK_EXPIRY, DEFAULT_DB_LOCK_EXPIRY );
481            m_loginName = props.getProperty( PROP_DB_LOGIN_NAME, DEFAULT_DB_LOGIN_NAME );
482            m_password = props.getProperty( PROP_DB_PASSWORD, DEFAULT_DB_PASSWORD );
483            m_uid = props.getProperty( PROP_DB_UID, DEFAULT_DB_UID );
484            m_wikiName = props.getProperty( PROP_DB_WIKI_NAME, DEFAULT_DB_WIKI_NAME );
485            m_created = props.getProperty( PROP_DB_CREATED, DEFAULT_DB_CREATED );
486            m_modified = props.getProperty( PROP_DB_MODIFIED, DEFAULT_DB_MODIFIED );
487            m_attributes = props.getProperty( PROP_DB_ATTRIBUTES, DEFAULT_DB_ATTRIBUTES );
488
489            m_findAll = "SELECT * FROM " + userTable;
490            m_findByEmail = "SELECT * FROM " + userTable + " WHERE " + m_email + "=?";
491            m_findByFullName = "SELECT * FROM " + userTable + " WHERE " + m_fullName + "=?";
492            m_findByLoginName = "SELECT * FROM " + userTable + " WHERE " + m_loginName + "=?";
493            m_findByUid = "SELECT * FROM " + userTable + " WHERE " + m_uid + "=?";
494            m_findByWikiName = "SELECT * FROM " + userTable + " WHERE " + m_wikiName + "=?";
495
496            // The user insert SQL prepared statement
497            m_insertProfile = "INSERT INTO " + userTable + " ("
498                              + m_uid + ","
499                              + m_email + ","
500                              + m_fullName + ","
501                              + m_password + ","
502                              + m_wikiName + ","
503                              + m_modified + ","
504                              + m_loginName + ","
505                              + m_attributes + ","
506                              + m_created
507                              + ") VALUES (?,?,?,?,?,?,?,?,?)";
508            
509            // The user update SQL prepared statement
510            m_updateProfile = "UPDATE " + userTable + " SET "
511                              + m_uid + "=?,"
512                              + m_email + "=?,"
513                              + m_fullName + "=?,"
514                              + m_password + "=?,"
515                              + m_wikiName + "=?,"
516                              + m_modified + "=?,"
517                              + m_loginName + "=?,"
518                              + m_attributes + "=?,"
519                              + m_lockExpiry + "=? "
520                              + "WHERE " + m_loginName + "=?";
521
522            // Prepare the role insert SQL
523            roleTable = props.getProperty( PROP_DB_ROLE_TABLE, DEFAULT_DB_ROLE_TABLE );
524            role = props.getProperty( PROP_DB_ROLE, DEFAULT_DB_ROLE );
525            m_insertRole = "INSERT INTO " + roleTable + " (" + m_loginName + "," + role + ") VALUES (?,?)";
526            m_findRoles = "SELECT * FROM " + roleTable + " WHERE " + m_loginName + "=?";
527
528            // Prepare the user delete SQL
529            m_deleteUserByLoginName = "DELETE FROM " + userTable + " WHERE " + m_loginName + "=?";
530
531            // Prepare the role delete SQL
532            m_deleteRoleByLoginName = "DELETE FROM " + roleTable + " WHERE " + m_loginName + "=?";
533
534            // Prepare the rename user/roles SQL
535            m_renameProfile = "UPDATE " + userTable + " SET " + m_loginName + "=?," + m_modified + "=? WHERE " + m_loginName
536                              + "=?";
537            m_renameRoles = "UPDATE " + roleTable + " SET " + m_loginName + "=? WHERE " + m_loginName + "=?";
538        }
539        catch( NamingException e )
540        {
541            log.error( "JDBCUserDatabase initialization error: " + e.getMessage() );
542            throw new NoRequiredPropertyException( PROP_DB_DATASOURCE, "JDBCUserDatabase initialization error: " + e.getMessage() );
543        }
544
545        // Test connection by doing a quickie select
546        Connection conn = null;
547        try
548        {
549            conn = m_ds.getConnection();
550            PreparedStatement ps = conn.prepareStatement( m_findAll );
551            ps.close();
552        }
553        catch( SQLException e )
554        {
555            log.error( "DB connectivity error: " + e.getMessage() );
556            throw new WikiSecurityException("DB connectivity error: " + e.getMessage(), e );
557        }
558        finally
559        {
560            try
561            {
562                if( conn != null ) conn.close();
563            }
564            catch( Exception e )
565            {
566            }
567        }
568        log.info( "JDBCUserDatabase initialized from JNDI DataSource: " + jndiName );
569
570        // Determine if the datasource supports commits
571        try
572        {
573            conn = m_ds.getConnection();
574            DatabaseMetaData dmd = conn.getMetaData();
575            if( dmd.supportsTransactions() )
576            {
577                m_supportsCommits = true;
578                conn.setAutoCommit( false );
579                log.info( "JDBCUserDatabase supports transactions. Good; we will use them." );
580            }
581        }
582        catch( SQLException e )
583        {
584            log.warn( "JDBCUserDatabase warning: user database doesn't seem to support transactions. Reason: " + e.getMessage() );
585        }
586        finally
587        {
588            try
589            {
590                if( conn != null ) conn.close();
591            }
592            catch( Exception e )
593            {
594            }
595        }
596    }
597
598    /**
599     * @see org.apache.wiki.auth.user.UserDatabase#rename(String, String)
600     */
601    public void rename( String loginName, String newName )
602                                                          throws NoSuchPrincipalException,
603                                                              DuplicateUserException,
604                                                              WikiSecurityException
605    {
606        // Get the existing user; if not found, throws NoSuchPrincipalException
607        UserProfile profile = findByLoginName( loginName );
608
609        // Get user with the proposed name; if found, it's a collision
610        try
611        {
612            UserProfile otherProfile = findByLoginName( newName );
613            if( otherProfile != null )
614            {
615                throw new DuplicateUserException( "security.error.cannot.rename", newName );
616            }
617        }
618        catch( NoSuchPrincipalException e )
619        {
620            // Good! That means it's safe to save using the new name
621        }
622
623        Connection conn = null;
624        try
625        {
626            // Open the database connection
627            conn = m_ds.getConnection();
628            if( m_supportsCommits )
629            {
630                conn.setAutoCommit( false );
631            }
632
633            Timestamp ts = new Timestamp( System.currentTimeMillis() );
634            Date modDate = new Date( ts.getTime() );
635
636            // Change the login ID for the user record
637            PreparedStatement ps = conn.prepareStatement( m_renameProfile );
638            ps.setString( 1, newName );
639            ps.setTimestamp( 2, ts );
640            ps.setString( 3, loginName );
641            ps.execute();
642            ps.close();
643
644            // Change the login ID for the role records
645            ps = conn.prepareStatement( m_renameRoles );
646            ps.setString( 1, newName );
647            ps.setString( 2, loginName );
648            ps.execute();
649            ps.close();
650
651            // Set the profile name and mod time
652            profile.setLoginName( newName );
653            profile.setLastModified( modDate );
654
655            // Commit and close connection
656            if( m_supportsCommits )
657            {
658                conn.commit();
659            }
660        }
661        catch( SQLException e )
662        {
663            throw new WikiSecurityException( e.getMessage(), e );
664        }
665        finally
666        {
667            try
668            {
669                if( conn != null ) conn.close();
670            }
671            catch( Exception e )
672            {
673            }
674        }
675    }
676
677    /**
678     * @see org.apache.wiki.auth.user.UserDatabase#save(org.apache.wiki.auth.user.UserProfile)
679     */
680    public void save( UserProfile profile ) throws WikiSecurityException
681    {
682        String initialRole = "Authenticated";
683
684        // Figure out which prepared statement to use & execute it
685        String loginName = profile.getLoginName();
686        PreparedStatement ps = null;
687        UserProfile existingProfile = null;
688
689        try
690        {
691            existingProfile = findByLoginName( loginName );
692        }
693        catch( NoSuchPrincipalException e )
694        {
695            // Existing profile will be null
696        }
697
698        // Get a clean password from the passed profile.
699        // Blank password is the same as null, which means we re-use the
700        // existing one.
701        String password = profile.getPassword();
702        String existingPassword = (existingProfile == null) ? null : existingProfile.getPassword();
703        if( NOTHING.equals( password ) )
704        {
705            password = null;
706        }
707        if( password == null )
708        {
709            password = existingPassword;
710        }
711
712        // If password changed, hash it before we save
713        if( !StringUtils.equals( password, existingPassword ) )
714        {
715            password = getHash( password );
716        }
717
718        Connection conn = null;
719        try
720        {
721            // Open the database connection
722            conn = m_ds.getConnection();
723            if( m_supportsCommits )
724            {
725                conn.setAutoCommit( false );
726            }
727
728            Timestamp ts = new Timestamp( System.currentTimeMillis() );
729            Date modDate = new Date( ts.getTime() );
730            java.sql.Date lockExpiry = profile.getLockExpiry() == null ? null : new java.sql.Date( profile.getLockExpiry().getTime() );
731            if( existingProfile == null )
732            {
733                // User is new: insert new user record
734                ps = conn.prepareStatement( m_insertProfile );
735                ps.setString( 1, profile.getUid() );
736                ps.setString( 2, profile.getEmail() );
737                ps.setString( 3, profile.getFullname() );
738                ps.setString( 4, password );
739                ps.setString( 5, profile.getWikiName() );
740                ps.setTimestamp( 6, ts );
741                ps.setString( 7, profile.getLoginName() );
742                try
743                {
744                    ps.setString( 8, Serializer.serializeToBase64( profile.getAttributes() ) );
745                }
746                catch ( IOException e )
747                {
748                    throw new WikiSecurityException( "Could not save user profile attribute. Reason: " + e.getMessage(), e );
749                }
750                ps.setTimestamp( 9, ts );
751                ps.execute();
752                ps.close();
753
754                // Insert new role record
755                ps = conn.prepareStatement( m_findRoles );
756                ps.setString( 1, profile.getLoginName() );
757                ResultSet rs = ps.executeQuery();
758                int roles = 0;
759                while ( rs.next() )
760                {
761                    roles++;
762                }
763                ps.close();
764                if( roles == 0 )
765                {
766                    ps = conn.prepareStatement( m_insertRole );
767                    ps.setString( 1, profile.getLoginName() );
768                    ps.setString( 2, initialRole );
769                    ps.execute();
770                    ps.close();
771                }
772
773                // Set the profile creation time
774                profile.setCreated( modDate );
775            }
776            else
777            {
778                // User exists: modify existing record
779                ps = conn.prepareStatement( m_updateProfile );
780                ps.setString( 1, profile.getUid() );
781                ps.setString( 2, profile.getEmail() );
782                ps.setString( 3, profile.getFullname() );
783                ps.setString( 4, password );
784                ps.setString( 5, profile.getWikiName() );
785                ps.setTimestamp( 6, ts );
786                ps.setString( 7, profile.getLoginName() );
787                try
788                {
789                    ps.setString( 8, Serializer.serializeToBase64( profile.getAttributes() ) );
790                }
791                catch ( IOException e )
792                {
793                    throw new WikiSecurityException( "Could not save user profile attribute. Reason: " + e.getMessage(), e );
794                }
795                ps.setDate( 9, lockExpiry );
796                ps.setString( 10, profile.getLoginName() );
797                ps.execute();
798                ps.close();
799            }
800            // Set the profile mod time
801            profile.setLastModified( modDate );
802
803            // Commit and close connection
804            if( m_supportsCommits )
805            {
806                conn.commit();
807            }
808        }
809        catch( SQLException e )
810        {
811            throw new WikiSecurityException( e.getMessage(), e );
812        }
813        finally
814        {
815            try
816            {
817                if( conn != null ) conn.close();
818            }
819            catch( Exception e )
820            {
821            }
822        }
823    }
824
825    /**
826     * Private method that returns the first {@link UserProfile} matching a
827     * named column's value. This method will also set the UID if it has not yet been set.     
828     * @param sql the SQL statement that should be prepared; it must have one parameter
829     * to set (either a String or a Long)
830     * @param index the value to match
831     * @return the resolved UserProfile
832     * @throws SQLException
833     */
834    private UserProfile findByPreparedStatement( String sql, Object index ) throws NoSuchPrincipalException
835    {
836        UserProfile profile = null;
837        boolean found = false;
838        boolean unique = true;
839        Connection conn = null;
840        try
841        {
842            // Open the database connection
843            conn = m_ds.getConnection();
844            if( m_supportsCommits )
845            {
846                conn.setAutoCommit( false );
847            }
848
849            PreparedStatement ps = conn.prepareStatement( sql );
850            
851            // Set the parameter to search by
852            if ( index instanceof String )
853            {
854                ps.setString( 1, (String)index );
855            }
856            else if ( index instanceof Long )
857            {
858                ps.setLong( 1, ( (Long)index).longValue() );
859            }
860            else 
861            {
862                throw new IllegalArgumentException( "Index type not recognized!" );
863            }
864            
865            // Go and get the record!
866            ResultSet rs = ps.executeQuery();
867            while ( rs.next() )
868            {
869                if( profile != null )
870                {
871                    unique = false;
872                    break;
873                }
874                profile = newProfile();
875                
876                // Fetch the basic user attributes
877                profile.setUid( rs.getString( m_uid ) );
878                if ( profile.getUid() == null )
879                {
880                    profile.setUid( generateUid( this ) );
881                }
882                profile.setCreated( rs.getTimestamp( m_created ) );
883                profile.setEmail( rs.getString( m_email ) );
884                profile.setFullname( rs.getString( m_fullName ) );
885                profile.setLastModified( rs.getTimestamp( m_modified ) );
886                Date lockExpiry = rs.getDate( m_lockExpiry );
887                profile.setLockExpiry( rs.wasNull() ? null : lockExpiry );
888                profile.setLoginName( rs.getString( m_loginName ) );
889                profile.setPassword( rs.getString( m_password ) );
890                
891                // Fetch the user attributes
892                String rawAttributes = rs.getString( m_attributes );
893                if ( rawAttributes != null )
894                {
895                    try
896                    {
897                        Map<String,? extends Serializable> attributes = Serializer.deserializeFromBase64( rawAttributes );
898                        profile.getAttributes().putAll( attributes );
899                    }
900                    catch ( IOException e )
901                    {
902                        log.error( "Could not parse user profile attributes!", e );
903                    }
904                }
905                found = true;
906            }
907            ps.close();
908        }
909        catch( SQLException e )
910        {
911            throw new NoSuchPrincipalException( e.getMessage() );
912        }
913        finally
914        {
915            try
916            {
917                if( conn != null ) conn.close();
918            }
919            catch( Exception e )
920            {
921            }
922        }
923
924        if( !found )
925        {
926            throw new NoSuchPrincipalException( "Could not find profile in database!" );
927        }
928        if( !unique )
929        {
930            throw new NoSuchPrincipalException( "More than one profile in database!" );
931        }
932        return profile;
933
934    }
935
936}