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    private static final String NOTHING = "";
199
200    public static final String DEFAULT_DB_ATTRIBUTES = "attributes";
201
202    public static final String DEFAULT_DB_CREATED = "created";
203
204    public static final String DEFAULT_DB_EMAIL = "email";
205
206    public static final String DEFAULT_DB_FULL_NAME = "full_name";
207
208    public static final String DEFAULT_DB_JNDI_NAME = "jdbc/UserDatabase";
209
210    public static final String DEFAULT_DB_LOCK_EXPIRY = "lock_expiry";
211
212    public static final String DEFAULT_DB_MODIFIED = "modified";
213
214    public static final String DEFAULT_DB_ROLE = "role";
215
216    public static final String DEFAULT_DB_ROLE_TABLE = "roles";
217
218    public static final String DEFAULT_DB_TABLE = "users";
219
220    public static final String DEFAULT_DB_LOGIN_NAME = "login_name";
221
222    public static final String DEFAULT_DB_PASSWORD = "password";
223
224    public static final String DEFAULT_DB_UID = "uid";
225
226    public static final String DEFAULT_DB_WIKI_NAME = "wiki_name";
227
228    public static final String PROP_DB_ATTRIBUTES = "jspwiki.userdatabase.attributes";
229
230    public static final String PROP_DB_CREATED = "jspwiki.userdatabase.created";
231
232    public static final String PROP_DB_EMAIL = "jspwiki.userdatabase.email";
233
234    public static final String PROP_DB_FULL_NAME = "jspwiki.userdatabase.fullName";
235
236    public static final String PROP_DB_DATASOURCE = "jspwiki.userdatabase.datasource";
237
238    public static final String PROP_DB_LOCK_EXPIRY = "jspwiki.userdatabase.lockExpiry";
239
240    public static final String PROP_DB_LOGIN_NAME = "jspwiki.userdatabase.loginName";
241
242    public static final String PROP_DB_MODIFIED = "jspwiki.userdatabase.modified";
243
244    public static final String PROP_DB_PASSWORD = "jspwiki.userdatabase.password";
245
246    public static final String PROP_DB_UID = "jspwiki.userdatabase.uid";
247
248    public static final String PROP_DB_ROLE = "jspwiki.userdatabase.role";
249
250    public static final String PROP_DB_ROLE_TABLE = "jspwiki.userdatabase.roleTable";
251
252    public static final String PROP_DB_TABLE = "jspwiki.userdatabase.table";
253
254    public static final String PROP_DB_WIKI_NAME = "jspwiki.userdatabase.wikiName";
255
256    private DataSource m_ds = null;
257
258    private String m_deleteUserByLoginName = null;
259
260    private String m_deleteRoleByLoginName = null;
261
262    private String m_findByEmail = null;
263
264    private String m_findByFullName = null;
265
266    private String m_findByLoginName = null;
267
268    private String m_findByUid = null;
269
270    private String m_findByWikiName = null;
271
272    private String m_renameProfile = null;
273
274    private String m_renameRoles = null;
275
276    private String m_updateProfile = null;
277
278    private String m_findAll = null;
279
280    private String m_findRoles = null;
281
282    private String m_insertProfile = null;
283
284    private String m_insertRole = null;
285
286    private String m_attributes = null;
287
288    private String m_email = null;
289
290    private String m_fullName = null;
291
292    private String m_lockExpiry = null;
293
294    private String m_loginName = null;
295
296    private String m_password = null;
297
298    private String m_uid = null;
299    
300    private String m_wikiName = null;
301
302    private String m_created = null;
303
304    private String m_modified = null;
305
306    private boolean m_supportsCommits = false;
307
308    /**
309     * Looks up and deletes the first {@link UserProfile} in the user database
310     * that matches a profile having a given login name. If the user database
311     * does not contain a user with a matching attribute, throws a
312     * {@link NoSuchPrincipalException}. This method is intended to be atomic;
313     * results cannot be partially committed. If the commit fails, it should
314     * roll back its state appropriately. Implementing classes that persist to
315     * the file system may wish to make this method <code>synchronized</code>.
316     * 
317     * @param loginName the login name of the user profile that shall be deleted
318     */
319    @Override
320    public void deleteByLoginName( String loginName ) throws NoSuchPrincipalException, WikiSecurityException {
321        // Get the existing user; if not found, throws NoSuchPrincipalException
322        findByLoginName( loginName );
323
324        try( Connection conn = m_ds.getConnection() ; 
325             PreparedStatement ps1 = conn.prepareStatement( m_deleteUserByLoginName ); 
326             PreparedStatement ps2 = conn.prepareStatement( m_deleteRoleByLoginName ) )
327        {
328            // Open the database connection
329            if( m_supportsCommits ) {
330                conn.setAutoCommit( false );
331            }
332
333            // Delete user record
334            ps1.setString( 1, loginName );
335            ps1.execute();
336
337            // Delete role record
338            ps2.setString( 1, loginName );
339            ps2.execute();
340
341            // Commit and close connection
342            if( m_supportsCommits ) {
343                conn.commit();
344            }
345        } catch( SQLException e ) {
346            throw new WikiSecurityException( e.getMessage(), e );
347        }
348    }
349
350    /**
351     * @see org.apache.wiki.auth.user.UserDatabase#findByEmail(java.lang.String)
352     */
353    @Override
354    public UserProfile findByEmail( String index ) throws NoSuchPrincipalException {
355        return findByPreparedStatement( m_findByEmail, index );
356    }
357
358    /**
359     * @see org.apache.wiki.auth.user.UserDatabase#findByFullName(java.lang.String)
360     */
361    @Override
362    public UserProfile findByFullName( String index ) throws NoSuchPrincipalException {
363        return findByPreparedStatement( m_findByFullName, index );
364    }
365
366    /**
367     * @see org.apache.wiki.auth.user.UserDatabase#findByLoginName(java.lang.String)
368     */
369    @Override
370    public UserProfile findByLoginName( String index ) throws NoSuchPrincipalException {
371        return findByPreparedStatement( m_findByLoginName, index );
372    }
373
374    /**
375     * @see org.apache.wiki.auth.user.UserDatabase#findByWikiName(String)
376     */
377    @Override
378    public UserProfile findByUid( String uid ) throws NoSuchPrincipalException {
379        return findByPreparedStatement( m_findByUid, uid );
380    }
381
382    /**
383     * @see org.apache.wiki.auth.user.UserDatabase#findByWikiName(String)
384     */
385    @Override
386    public UserProfile findByWikiName( String index ) throws NoSuchPrincipalException {
387        return findByPreparedStatement( m_findByWikiName, index );
388    }
389
390    /**
391     * Returns all WikiNames that are stored in the UserDatabase as an array of
392     * WikiPrincipal objects. If the database does not contain any profiles,
393     * this method will return a zero-length array.
394     * 
395     * @return the WikiNames
396     */
397    @Override
398    public Principal[] getWikiNames() throws WikiSecurityException {
399        Set<Principal> principals = new HashSet<>();
400        try( Connection conn = m_ds.getConnection();
401             PreparedStatement ps = conn.prepareStatement( m_findAll );
402             ResultSet rs = ps.executeQuery() )
403        {
404            while ( rs.next() ) {
405                String wikiName = rs.getString( m_wikiName );
406                if( wikiName == null ) {
407                    log.warn( "Detected null wiki name in XMLUserDataBase. Check your user database." );
408                } else {
409                    Principal principal = new WikiPrincipal( wikiName, WikiPrincipal.WIKI_NAME );
410                    principals.add( principal );
411                }
412            }
413        } catch( SQLException e ) {
414            throw new WikiSecurityException( e.getMessage(), e );
415        }
416
417        return principals.toArray( new Principal[principals.size()] );
418    }
419
420    /**
421     * @see org.apache.wiki.auth.user.UserDatabase#initialize(org.apache.wiki.WikiEngine,
422     *      java.util.Properties)
423     */
424    @Override
425    public void initialize( WikiEngine engine, Properties props ) throws NoRequiredPropertyException, WikiSecurityException {
426        String jndiName = props.getProperty( PROP_DB_DATASOURCE, DEFAULT_DB_JNDI_NAME );
427        try {
428            Context initCtx = new InitialContext();
429            Context ctx = (Context) initCtx.lookup( "java:comp/env" );
430            m_ds = (DataSource) ctx.lookup( jndiName );
431
432            // Prepare the SQL selectors
433            String userTable = props.getProperty( PROP_DB_TABLE, DEFAULT_DB_TABLE );
434            m_email = props.getProperty( PROP_DB_EMAIL, DEFAULT_DB_EMAIL );
435            m_fullName = props.getProperty( PROP_DB_FULL_NAME, DEFAULT_DB_FULL_NAME );
436            m_lockExpiry = props.getProperty( PROP_DB_LOCK_EXPIRY, DEFAULT_DB_LOCK_EXPIRY );
437            m_loginName = props.getProperty( PROP_DB_LOGIN_NAME, DEFAULT_DB_LOGIN_NAME );
438            m_password = props.getProperty( PROP_DB_PASSWORD, DEFAULT_DB_PASSWORD );
439            m_uid = props.getProperty( PROP_DB_UID, DEFAULT_DB_UID );
440            m_wikiName = props.getProperty( PROP_DB_WIKI_NAME, DEFAULT_DB_WIKI_NAME );
441            m_created = props.getProperty( PROP_DB_CREATED, DEFAULT_DB_CREATED );
442            m_modified = props.getProperty( PROP_DB_MODIFIED, DEFAULT_DB_MODIFIED );
443            m_attributes = props.getProperty( PROP_DB_ATTRIBUTES, DEFAULT_DB_ATTRIBUTES );
444
445            m_findAll = "SELECT * FROM " + userTable;
446            m_findByEmail = "SELECT * FROM " + userTable + " WHERE " + m_email + "=?";
447            m_findByFullName = "SELECT * FROM " + userTable + " WHERE " + m_fullName + "=?";
448            m_findByLoginName = "SELECT * FROM " + userTable + " WHERE " + m_loginName + "=?";
449            m_findByUid = "SELECT * FROM " + userTable + " WHERE " + m_uid + "=?";
450            m_findByWikiName = "SELECT * FROM " + userTable + " WHERE " + m_wikiName + "=?";
451
452            // The user insert SQL prepared statement
453            m_insertProfile = "INSERT INTO " + userTable + " ("
454                              + m_uid + ","
455                              + m_email + ","
456                              + m_fullName + ","
457                              + m_password + ","
458                              + m_wikiName + ","
459                              + m_modified + ","
460                              + m_loginName + ","
461                              + m_attributes + ","
462                              + m_created
463                              + ") VALUES (?,?,?,?,?,?,?,?,?)";
464            
465            // The user update SQL prepared statement
466            m_updateProfile = "UPDATE " + userTable + " SET "
467                              + m_uid + "=?,"
468                              + m_email + "=?,"
469                              + m_fullName + "=?,"
470                              + m_password + "=?,"
471                              + m_wikiName + "=?,"
472                              + m_modified + "=?,"
473                              + m_loginName + "=?,"
474                              + m_attributes + "=?,"
475                              + m_lockExpiry + "=? "
476                              + "WHERE " + m_loginName + "=?";
477
478            // Prepare the role insert SQL
479            String roleTable = props.getProperty( PROP_DB_ROLE_TABLE, DEFAULT_DB_ROLE_TABLE );
480            String role = props.getProperty( PROP_DB_ROLE, DEFAULT_DB_ROLE );
481            m_insertRole = "INSERT INTO " + roleTable + " (" + m_loginName + "," + role + ") VALUES (?,?)";
482            m_findRoles = "SELECT * FROM " + roleTable + " WHERE " + m_loginName + "=?";
483
484            // Prepare the user delete SQL
485            m_deleteUserByLoginName = "DELETE FROM " + userTable + " WHERE " + m_loginName + "=?";
486
487            // Prepare the role delete SQL
488            m_deleteRoleByLoginName = "DELETE FROM " + roleTable + " WHERE " + m_loginName + "=?";
489
490            // Prepare the rename user/roles SQL
491            m_renameProfile = "UPDATE " + userTable + " SET " + m_loginName + "=?," + m_modified + "=? WHERE " + m_loginName
492                              + "=?";
493            m_renameRoles = "UPDATE " + roleTable + " SET " + m_loginName + "=? WHERE " + m_loginName + "=?";
494        } catch( NamingException e ) {
495            log.error( "JDBCUserDatabase initialization error: " + e.getMessage() );
496            throw new NoRequiredPropertyException( PROP_DB_DATASOURCE, "JDBCUserDatabase initialization error: " + e.getMessage() );
497        }
498
499        // Test connection by doing a quickie select
500        try( Connection conn = m_ds.getConnection(); PreparedStatement ps = conn.prepareStatement( m_findAll ) ) {
501        } catch( SQLException e ) {
502            log.error( "DB connectivity error: " + e.getMessage() );
503            throw new WikiSecurityException("DB connectivity error: " + e.getMessage(), e );
504        }
505        log.info( "JDBCUserDatabase initialized from JNDI DataSource: " + jndiName );
506
507        // Determine if the datasource supports commits
508        try( Connection conn = m_ds.getConnection() ) {
509            DatabaseMetaData dmd = conn.getMetaData();
510            if( dmd.supportsTransactions() ) {
511                m_supportsCommits = true;
512                conn.setAutoCommit( false );
513                log.info( "JDBCUserDatabase supports transactions. Good; we will use them." );
514            }
515        } catch( SQLException e ) {
516            log.warn( "JDBCUserDatabase warning: user database doesn't seem to support transactions. Reason: " + e.getMessage() );
517        }
518    }
519
520    /**
521     * @see org.apache.wiki.auth.user.UserDatabase#rename(String, String)
522     */
523    @Override
524    public void rename( String loginName, String newName ) throws NoSuchPrincipalException, DuplicateUserException, WikiSecurityException {
525        // Get the existing user; if not found, throws NoSuchPrincipalException
526        UserProfile profile = findByLoginName( loginName );
527
528        // Get user with the proposed name; if found, it's a collision
529        try {
530            UserProfile otherProfile = findByLoginName( newName );
531            if( otherProfile != null ) {
532                throw new DuplicateUserException( "security.error.cannot.rename", newName );
533            }
534        } catch( NoSuchPrincipalException e ) {
535            // Good! That means it's safe to save using the new name
536        }
537
538        try( Connection conn = m_ds.getConnection(); 
539             PreparedStatement ps1 = conn.prepareStatement( m_renameProfile );
540             PreparedStatement ps2 = conn.prepareStatement( m_renameRoles ) )
541        {
542            if( m_supportsCommits ) {
543                conn.setAutoCommit( false );
544            }
545
546            Timestamp ts = new Timestamp( System.currentTimeMillis() );
547            Date modDate = new Date( ts.getTime() );
548
549            // Change the login ID for the user record
550            ps1.setString( 1, newName );
551            ps1.setTimestamp( 2, ts );
552            ps1.setString( 3, loginName );
553            ps1.execute();
554
555            // Change the login ID for the role records
556            ps2.setString( 1, newName );
557            ps2.setString( 2, loginName );
558            ps2.execute();
559
560            // Set the profile name and mod time
561            profile.setLoginName( newName );
562            profile.setLastModified( modDate );
563
564            // Commit and close connection
565            if( m_supportsCommits ) {
566                conn.commit();
567            }
568        } catch( SQLException e ) {
569            throw new WikiSecurityException( e.getMessage(), e );
570        }
571    }
572
573    /**
574     * @see org.apache.wiki.auth.user.UserDatabase#save(org.apache.wiki.auth.user.UserProfile)
575     */
576    @Override
577    public void save( UserProfile profile ) throws WikiSecurityException {
578        String initialRole = "Authenticated";
579
580        // Figure out which prepared statement to use & execute it
581        String loginName = profile.getLoginName();
582        UserProfile existingProfile = null;
583
584        try {
585            existingProfile = findByLoginName( loginName );
586        } catch( NoSuchPrincipalException e ) {
587            // Existing profile will be null
588        }
589
590        // Get a clean password from the passed profile.
591        // Blank password is the same as null, which means we re-use the existing one.
592        String password = profile.getPassword();
593        String existingPassword = (existingProfile == null) ? null : existingProfile.getPassword();
594        if( NOTHING.equals( password ) ) {
595            password = null;
596        }
597        if( password == null ) {
598            password = existingPassword;
599        }
600
601        // If password changed, hash it before we save
602        if( !StringUtils.equals( password, existingPassword ) ) {
603            password = getHash( password );
604        }
605
606        try( Connection conn = m_ds.getConnection();
607             PreparedStatement ps1 = conn.prepareStatement( m_insertProfile );
608             PreparedStatement ps2 = conn.prepareStatement( m_findRoles );
609             PreparedStatement ps3 = conn.prepareStatement( m_insertRole );
610             PreparedStatement ps4 = conn.prepareStatement( m_updateProfile ) )
611        {
612            if( m_supportsCommits ) {
613                conn.setAutoCommit( false );
614            }
615
616            Timestamp ts = new Timestamp( System.currentTimeMillis() );
617            Date modDate = new Date( ts.getTime() );
618            java.sql.Date lockExpiry = profile.getLockExpiry() == null ? null : new java.sql.Date( profile.getLockExpiry().getTime() );
619            if( existingProfile == null )
620            {
621                // User is new: insert new user record
622                ps1.setString( 1, profile.getUid() );
623                ps1.setString( 2, profile.getEmail() );
624                ps1.setString( 3, profile.getFullname() );
625                ps1.setString( 4, password );
626                ps1.setString( 5, profile.getWikiName() );
627                ps1.setTimestamp( 6, ts );
628                ps1.setString( 7, profile.getLoginName() );
629                try {
630                    ps1.setString( 8, Serializer.serializeToBase64( profile.getAttributes() ) );
631                } catch ( IOException e ) {
632                    throw new WikiSecurityException( "Could not save user profile attribute. Reason: " + e.getMessage(), e );
633                }
634                ps1.setTimestamp( 9, ts );
635                ps1.execute();
636
637                // Insert new role record
638                ps2.setString( 1, profile.getLoginName() );
639                int roles = 0;
640                try ( ResultSet rs = ps2.executeQuery() ) {
641                    while ( rs.next() ) {
642                        roles++;
643                    }
644                }
645                
646                if( roles == 0 ) {
647                    ps3.setString( 1, profile.getLoginName() );
648                    ps3.setString( 2, initialRole );
649                    ps3.execute();
650                }
651
652                // Set the profile creation time
653                profile.setCreated( modDate );
654            } else {
655                // User exists: modify existing record
656                ps4.setString( 1, profile.getUid() );
657                ps4.setString( 2, profile.getEmail() );
658                ps4.setString( 3, profile.getFullname() );
659                ps4.setString( 4, password );
660                ps4.setString( 5, profile.getWikiName() );
661                ps4.setTimestamp( 6, ts );
662                ps4.setString( 7, profile.getLoginName() );
663                try
664                {
665                    ps4.setString( 8, Serializer.serializeToBase64( profile.getAttributes() ) );
666                }
667                catch ( IOException e )
668                {
669                    throw new WikiSecurityException( "Could not save user profile attribute. Reason: " + e.getMessage(), e );
670                }
671                ps4.setDate( 9, lockExpiry );
672                ps4.setString( 10, profile.getLoginName() );
673                ps4.execute();
674            }
675            // Set the profile mod time
676            profile.setLastModified( modDate );
677
678            // Commit and close connection
679            if( m_supportsCommits ) {
680                conn.commit();
681            }
682        }
683        catch( SQLException e )
684        {
685            throw new WikiSecurityException( e.getMessage(), e );
686        }
687    }
688
689    /**
690     * Private method that returns the first {@link UserProfile} matching a
691     * named column's value. This method will also set the UID if it has not yet been set.     
692     * @param sql the SQL statement that should be prepared; it must have one parameter
693     * to set (either a String or a Long)
694     * @param index the value to match
695     * @return the resolved UserProfile
696     * @throws SQLException
697     */
698    private UserProfile findByPreparedStatement( String sql, Object index ) throws NoSuchPrincipalException
699    {
700        UserProfile profile = null;
701        boolean found = false;
702        boolean unique = true;
703        try( Connection conn = m_ds.getConnection(); PreparedStatement ps = conn.prepareStatement( sql ) ) {
704            if( m_supportsCommits ) {
705                conn.setAutoCommit( false );
706            }
707            
708            // Set the parameter to search by
709            if ( index instanceof String ) {
710                ps.setString( 1, (String)index );
711            } else if ( index instanceof Long ) {
712                ps.setLong( 1, ( (Long)index).longValue() );
713            } else {
714                throw new IllegalArgumentException( "Index type not recognized!" );
715            }
716            
717            // Go and get the record!
718            try ( ResultSet rs = ps.executeQuery() ) {
719                while ( rs.next() ) {
720                    if( profile != null ) {
721                        unique = false;
722                        break;
723                    }
724                    profile = newProfile();
725                    
726                    // Fetch the basic user attributes
727                    profile.setUid( rs.getString( m_uid ) );
728                    if ( profile.getUid() == null ) {
729                        profile.setUid( generateUid( this ) );
730                    }
731                    profile.setCreated( rs.getTimestamp( m_created ) );
732                    profile.setEmail( rs.getString( m_email ) );
733                    profile.setFullname( rs.getString( m_fullName ) );
734                    profile.setLastModified( rs.getTimestamp( m_modified ) );
735                    Date lockExpiry = rs.getDate( m_lockExpiry );
736                    profile.setLockExpiry( rs.wasNull() ? null : lockExpiry );
737                    profile.setLoginName( rs.getString( m_loginName ) );
738                    profile.setPassword( rs.getString( m_password ) );
739                    
740                    // Fetch the user attributes
741                    String rawAttributes = rs.getString( m_attributes );
742                    if ( rawAttributes != null ) {
743                        try {
744                            Map<String,? extends Serializable> attributes = Serializer.deserializeFromBase64( rawAttributes );
745                            profile.getAttributes().putAll( attributes );
746                        } catch ( IOException e ) {
747                            log.error( "Could not parse user profile attributes!", e );
748                        }
749                    }
750                    found = true;
751                }
752            }
753        } catch( SQLException e ) {
754            throw new NoSuchPrincipalException( e.getMessage() );
755        }
756
757        if( !found ) {
758            throw new NoSuchPrincipalException( "Could not find profile in database!" );
759        }
760        if( !unique ) {
761            throw new NoSuchPrincipalException( "More than one profile in database!" );
762        }
763        return profile;
764    }
765
766}