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.authorize;
020
021import org.apache.log4j.Logger;
022import org.apache.wiki.api.core.Engine;
023import org.apache.wiki.api.exceptions.NoRequiredPropertyException;
024import org.apache.wiki.auth.NoSuchPrincipalException;
025import org.apache.wiki.auth.WikiPrincipal;
026import org.apache.wiki.auth.WikiSecurityException;
027
028import javax.naming.Context;
029import javax.naming.InitialContext;
030import javax.naming.NamingException;
031import javax.sql.DataSource;
032import java.security.Principal;
033import java.sql.Connection;
034import java.sql.DatabaseMetaData;
035import java.sql.PreparedStatement;
036import java.sql.ResultSet;
037import java.sql.SQLException;
038import java.sql.Timestamp;
039import java.util.Date;
040import java.util.HashSet;
041import java.util.Properties;
042import java.util.Set;
043
044/**
045 * <p>
046 * Implementation of GroupDatabase that persists {@link Group} objects to a JDBC
047 * DataSource, as might typically be provided by a web container. This
048 * implementation looks up the JDBC DataSource using JNDI. The JNDI name of the
049 * datasource, backing table and mapped columns used by this class can be
050 * overridden by adding settings in <code>jspwiki.properties</code>.
051 * </p>
052 * <p>
053 * Configurable properties are these:
054 * </p>
055 * <table>
056 * <tr> <thead>
057 * <th>Property</th>
058 * <th>Default</th>
059 * <th>Definition</th>
060 * <thead> </tr>
061 * <tr>
062 * <td><code>jspwiki.groupdatabase.datasource</code></td>
063 * <td><code>jdbc/GroupDatabase</code></td>
064 * <td>The JNDI name of the DataSource</td>
065 * </tr>
066 * <tr>
067 * <td><code>jspwiki.groupdatabase.table</code></td>
068 * <td><code>groups</code></td>
069 * <td>The table that stores the groups</td>
070 * </tr>
071 * <tr>
072 * <td><code>jspwiki.groupdatabase.membertable</code></td>
073 * <td><code>group_members</code></td>
074 * <td>The table that stores the names of group members</td>
075 * </tr>
076 * <tr>
077 * <td><code>jspwiki.groupdatabase.created</code></td>
078 * <td><code>created</code></td>
079 * <td>The column containing the group's creation timestamp</td>
080 * </tr>
081 * <tr>
082 * <td><code>jspwiki.groupdatabase.creator</code></td>
083 * <td><code>creator</code></td>
084 * <td>The column containing the group creator's name</td>
085 * </tr>
086 * <tr>
087 * <td><code>jspwiki.groupdatabase.name</code></td>
088 * <td><code>name</code></td>
089 * <td>The column containing the group's name</td>
090 * </tr>
091 * <tr>
092 * <td><code>jspwiki.groupdatabase.member</code></td>
093 * <td><code>member</code></td>
094 * <td>The column containing the group member's name</td>
095 * </tr>
096 * <tr>
097 * <td><code>jspwiki.groupdatabase.modified</code></td>
098 * <td><code>modified</code></td>
099 * <td>The column containing the group's last-modified timestamp</td>
100 * </tr>
101 * <tr>
102 * <td><code>jspwiki.groupdatabase.modifier</code></td>
103 * <td><code>modifier</code></td>
104 * <td>The column containing the name of the user who last modified the group</td>
105 * </tr>
106 * </table>
107 * <p>
108 * This class is typically used in conjunction with a web container's JNDI
109 * resource factory. For example, Tomcat versions 4 and higher provide a basic
110 * JNDI factory for registering DataSources. To give JSPWiki access to the JNDI
111 * resource named by <code>jdbc/GroupDatabase</code>, you would declare the
112 * datasource resource similar to this:
113 * </p>
114 * <blockquote><code>&lt;Context ...&gt;<br/>
115 *  &nbsp;&nbsp;...<br/>
116 *  &nbsp;&nbsp;&lt;Resource name="jdbc/GroupDatabase" auth="Container"<br/>
117 *  &nbsp;&nbsp;&nbsp;&nbsp;type="javax.sql.DataSource" username="dbusername" password="dbpassword"<br/>
118 *  &nbsp;&nbsp;&nbsp;&nbsp;driverClassName="org.hsql.jdbcDriver" url="jdbc:HypersonicSQL:database"<br/>
119 *  &nbsp;&nbsp;&nbsp;&nbsp;maxActive="8" maxIdle="4"/&gt;<br/>
120 *  &nbsp;...<br/>
121 * &lt;/Context&gt;</code></blockquote>
122 * <p>
123 * JDBC driver JARs should be added to Tomcat's <code>common/lib</code>
124 * directory. For more Tomcat 5.5 JNDI configuration examples, see <a
125 * href="http://tomcat.apache.org/tomcat-5.5-doc/jndi-resources-howto.html">
126 * http://tomcat.apache.org/tomcat-5.5-doc/jndi-resources-howto.html</a>.
127 * </p>
128 * <p>
129 * JDBCGroupDatabase commits changes as transactions if the back-end database
130 * supports them. Changes are made
131 * immediately (during the {@link #save(Group, Principal)} method).
132 * </p>
133 * 
134 * @since 2.3
135 */
136public class JDBCGroupDatabase implements GroupDatabase {
137    
138    /** Default column name that stores the JNDI name of the DataSource. */
139    public static final String DEFAULT_GROUPDB_DATASOURCE = "jdbc/GroupDatabase";
140
141    /** Default table name for the table that stores groups. */
142    public static final String DEFAULT_GROUPDB_TABLE = "groups";
143
144    /** Default column name that stores the names of group members. */
145    public static final String DEFAULT_GROUPDB_MEMBER_TABLE = "group_members";
146
147    /** Default column name that stores the the group creation timestamps. */
148    public static final String DEFAULT_GROUPDB_CREATED = "created";
149
150    /** Default column name that stores group creator names. */
151    public static final String DEFAULT_GROUPDB_CREATOR = "creator";
152
153    /** Default column name that stores the group names. */
154    public static final String DEFAULT_GROUPDB_NAME = "name";
155
156    /** Default column name that stores group member names. */
157    public static final String DEFAULT_GROUPDB_MEMBER = "member";
158
159    /** Default column name that stores group last-modified timestamps. */
160    public static final String DEFAULT_GROUPDB_MODIFIED = "modified";
161
162    /** Default column name that stores names of users who last modified groups. */
163    public static final String DEFAULT_GROUPDB_MODIFIER = "modifier";
164
165    /** The JNDI name of the DataSource. */
166    public static final String PROP_GROUPDB_DATASOURCE = "jspwiki.groupdatabase.datasource";
167
168    /** The table that stores the groups. */
169    public static final String PROP_GROUPDB_TABLE = "jspwiki.groupdatabase.table";
170
171    /** The table that stores the names of group members. */
172    public static final String PROP_GROUPDB_MEMBER_TABLE = "jspwiki.groupdatabase.membertable";
173
174    /** The column containing the group's creation timestamp. */
175    public static final String PROP_GROUPDB_CREATED = "jspwiki.groupdatabase.created";
176
177    /** The column containing the group creator's name. */
178    public static final String PROP_GROUPDB_CREATOR = "jspwiki.groupdatabase.creator";
179
180    /** The column containing the group's name. */
181    public static final String PROP_GROUPDB_NAME = "jspwiki.groupdatabase.name";
182
183    /** The column containing the group member's name. */
184    public static final String PROP_GROUPDB_MEMBER = "jspwiki.groupdatabase.member";
185
186    /** The column containing the group's last-modified timestamp. */
187    public static final String PROP_GROUPDB_MODIFIED = "jspwiki.groupdatabase.modified";
188
189    /** The column containing the name of the user who last modified the group. */
190    public static final String PROP_GROUPDB_MODIFIER = "jspwiki.groupdatabase.modifier";
191
192    protected static final Logger log = Logger.getLogger( JDBCGroupDatabase.class );
193
194    private DataSource m_ds = null;
195
196    private String m_created = null;
197
198    private String m_creator = null;
199
200    private String m_name = null;
201
202    private String m_member = null;
203
204    private String m_modified = null;
205
206    private String m_modifier = null;
207
208    private String m_findAll = null;
209
210    private String m_findGroup = null;
211
212    private String m_findMembers = null;
213
214    private String m_insertGroup = null;
215
216    private String m_insertGroupMembers = null;
217
218    private String m_updateGroup = null;
219
220    private String m_deleteGroup = null;
221
222    private String m_deleteGroupMembers = null;
223
224    private boolean m_supportsCommits = false;
225
226    private Engine m_engine = null;
227
228    /**
229     * Looks up and deletes a {@link Group} from the group database. If the
230     * group database does not contain the supplied Group. this method throws a
231     * {@link NoSuchPrincipalException}. The method commits the results of the
232     * delete to persistent storage.
233     * 
234     * @param group the group to remove
235     * @throws WikiSecurityException if the database does not contain the
236     *             supplied group (thrown as {@link NoSuchPrincipalException})
237     *             or if the commit did not succeed
238     */
239    @Override public void delete( final Group group ) throws WikiSecurityException
240    {
241        if( !exists( group ) )
242        {
243            throw new NoSuchPrincipalException( "Not in database: " + group.getName() );
244        }
245
246        final String groupName = group.getName();
247        Connection conn = null;
248        PreparedStatement ps = null;
249        try
250        {
251            // Open the database connection
252            conn = m_ds.getConnection();
253            if( m_supportsCommits )
254            {
255                conn.setAutoCommit( false );
256            }
257
258            ps = conn.prepareStatement( m_deleteGroup );
259            ps.setString( 1, groupName );
260            ps.execute();
261            ps.close();
262
263            ps = conn.prepareStatement( m_deleteGroupMembers );
264            ps.setString( 1, groupName );
265            ps.execute();
266
267            // Commit and close connection
268            if( m_supportsCommits )
269            {
270                conn.commit();
271            }
272        }
273        catch( final SQLException e )
274        {
275            closeQuietly( conn, ps, null );
276            throw new WikiSecurityException( "Could not delete group " + groupName + ": " + e.getMessage(), e );
277        }
278        finally
279        {
280            closeQuietly( conn, ps, null );
281        }
282    }
283
284    /**
285     * Returns all wiki groups that are stored in the GroupDatabase as an array
286     * of Group objects. If the database does not contain any groups, this
287     * method will return a zero-length array. This method causes back-end
288     * storage to load the entire set of group; thus, it should be called
289     * infrequently (e.g., at initialization time).
290     * 
291     * @return the wiki groups
292     * @throws WikiSecurityException if the groups cannot be returned by the
293     *             back-end
294     */
295    @Override public Group[] groups() throws WikiSecurityException
296    {
297        final Set<Group> groups = new HashSet<>();
298        Connection conn = null;
299        PreparedStatement ps = null;
300        ResultSet rs = null;
301        try
302        {
303            // Open the database connection
304            conn = m_ds.getConnection();
305
306            ps = conn.prepareStatement( m_findAll );
307            rs = ps.executeQuery();
308            while ( rs.next() )
309            {
310                final String groupName = rs.getString( m_name );
311                if( groupName == null )
312                {
313                    log.warn( "Detected null group name in JDBCGroupDataBase. Check your group database." );
314                }
315                else
316                {
317                    final Group group = new Group( groupName, m_engine.getApplicationName() );
318                    group.setCreated( rs.getTimestamp( m_created ) );
319                    group.setCreator( rs.getString( m_creator ) );
320                    group.setLastModified( rs.getTimestamp( m_modified ) );
321                    group.setModifier( rs.getString( m_modifier ) );
322                    populateGroup( group );
323                    groups.add( group );
324                }
325            }
326        }
327        catch( final SQLException e )
328        {
329            closeQuietly( conn, ps, rs );
330            throw new WikiSecurityException( e.getMessage(), e );
331        }
332        finally
333        {
334            closeQuietly( conn, ps, rs );
335        }
336
337        return groups.toArray( new Group[groups.size()] );
338    }
339
340    /**
341     * Saves a Group to the group database. Note that this method <em>must</em>
342     * fail, and throw an <code>IllegalArgumentException</code>, if the
343     * proposed group is the same name as one of the built-in Roles: e.g.,
344     * Admin, Authenticated, etc. The database is responsible for setting
345     * create/modify timestamps, upon a successful save, to the Group. The
346     * method commits the results of the delete to persistent storage.
347     * 
348     * @param group the Group to save
349     * @param modifier the user who saved the Group
350     * @throws WikiSecurityException if the Group could not be saved successfully
351     */
352    @Override public void save( final Group group, final Principal modifier ) throws WikiSecurityException
353    {
354        if( group == null || modifier == null )
355        {
356            throw new IllegalArgumentException( "Group or modifier cannot be null." );
357        }
358
359        final boolean exists = exists( group );
360        Connection conn = null;
361        PreparedStatement ps = null;
362        try
363        {
364            // Open the database connection
365            conn = m_ds.getConnection();
366            if( m_supportsCommits )
367            {
368                conn.setAutoCommit( false );
369            }
370            
371            final Timestamp ts = new Timestamp( System.currentTimeMillis() );
372            final Date modDate = new Date( ts.getTime() );
373            if( !exists )
374            {
375                // Group is new: insert new group record
376                ps = conn.prepareStatement( m_insertGroup );
377                ps.setString( 1, group.getName() );
378                ps.setTimestamp( 2, ts );
379                ps.setString( 3, modifier.getName() );
380                ps.setTimestamp( 4, ts );
381                ps.setString( 5, modifier.getName() );
382                ps.execute();
383
384                // Set the group creation time
385                group.setCreated( modDate );
386                group.setCreator( modifier.getName() );
387                ps.close();
388            }
389            else
390            {
391                // Modify existing group record
392                ps = conn.prepareStatement( m_updateGroup );
393                ps.setTimestamp( 1, ts );
394                ps.setString( 2, modifier.getName() );
395                ps.setString( 3, group.getName() );
396                ps.execute();
397                ps.close();
398            }
399            // Set the group modified time
400            group.setLastModified( modDate );
401            group.setModifier( modifier.getName() );
402
403            // Now, update the group member list
404
405            // First, delete all existing member records
406            ps = conn.prepareStatement( m_deleteGroupMembers );
407            ps.setString( 1, group.getName() );
408            ps.execute();
409            ps.close();
410
411            // Insert group member records
412            ps = conn.prepareStatement( m_insertGroupMembers );
413            final Principal[] members = group.members();
414            for( int i = 0; i < members.length; i++ )
415            {
416                final Principal member = members[i];
417                ps.setString( 1, group.getName() );
418                ps.setString( 2, member.getName() );
419                ps.execute();
420            }
421
422            // Commit and close connection
423            if( m_supportsCommits )
424            {
425                conn.commit();
426            }
427        }
428        catch( final SQLException e )
429        {
430            closeQuietly(conn, ps, null );
431            throw new WikiSecurityException( e.getMessage(), e );
432        }
433        finally
434        {
435            closeQuietly(conn, ps, null );
436        }
437    }
438
439    /**
440     * Initializes the group database based on values from a Properties object.
441     * 
442     * @param engine the wiki engine
443     * @param props the properties used to initialize the group database
444     * @throws WikiSecurityException if the database could not be initialized
445     *             successfully
446     * @throws NoRequiredPropertyException if a required property is not present
447     */
448    @Override public void initialize( final Engine engine, final Properties props ) throws NoRequiredPropertyException, WikiSecurityException
449    {
450        final String table;
451        final String memberTable;
452
453        m_engine = engine;
454
455        final String jndiName = props.getProperty( PROP_GROUPDB_DATASOURCE, DEFAULT_GROUPDB_DATASOURCE );
456        try
457        {
458            final Context initCtx = new InitialContext();
459            final Context ctx = (Context) initCtx.lookup( "java:comp/env" );
460            m_ds = (DataSource) ctx.lookup( jndiName );
461
462            // Prepare the SQL selectors
463            table = props.getProperty( PROP_GROUPDB_TABLE, DEFAULT_GROUPDB_TABLE );
464            memberTable = props.getProperty( PROP_GROUPDB_MEMBER_TABLE, DEFAULT_GROUPDB_MEMBER_TABLE );
465            m_name = props.getProperty( PROP_GROUPDB_NAME, DEFAULT_GROUPDB_NAME );
466            m_created = props.getProperty( PROP_GROUPDB_CREATED, DEFAULT_GROUPDB_CREATED );
467            m_creator = props.getProperty( PROP_GROUPDB_CREATOR, DEFAULT_GROUPDB_CREATOR );
468            m_modifier = props.getProperty( PROP_GROUPDB_MODIFIER, DEFAULT_GROUPDB_MODIFIER );
469            m_modified = props.getProperty( PROP_GROUPDB_MODIFIED, DEFAULT_GROUPDB_MODIFIED );
470            m_member = props.getProperty( PROP_GROUPDB_MEMBER, DEFAULT_GROUPDB_MEMBER );
471
472            m_findAll = "SELECT DISTINCT * FROM " + table;
473            m_findGroup = "SELECT DISTINCT * FROM " + table + " WHERE " + m_name + "=?";
474            m_findMembers = "SELECT * FROM " + memberTable + " WHERE " + m_name + "=?";
475
476            // Prepare the group insert/update SQL
477            m_insertGroup = "INSERT INTO " + table + " (" + m_name + "," + m_modified + "," + m_modifier + "," + m_created + ","
478                            + m_creator + ") VALUES (?,?,?,?,?)";
479            m_updateGroup = "UPDATE " + table + " SET " + m_modified + "=?," + m_modifier + "=? WHERE " + m_name + "=?";
480
481            // Prepare the group member insert SQL
482            m_insertGroupMembers = "INSERT INTO " + memberTable + " (" + m_name + "," + m_member + ") VALUES (?,?)";
483
484            // Prepare the group delete SQL
485            m_deleteGroup = "DELETE FROM " + table + " WHERE " + m_name + "=?";
486            m_deleteGroupMembers = "DELETE FROM " + memberTable + " WHERE " + m_name + "=?";
487        }
488        catch( final NamingException e )
489        {
490            log.error( "JDBCGroupDatabase initialization error: " + e );
491            throw new NoRequiredPropertyException( PROP_GROUPDB_DATASOURCE, "JDBCGroupDatabase initialization error: " + e);
492        }
493
494        // Test connection by doing a quickie select
495        Connection conn = null;
496        PreparedStatement ps = null;
497        try
498        {
499            conn = m_ds.getConnection();
500            ps = conn.prepareStatement( m_findAll );
501            ps.executeQuery();
502            ps.close();
503        }
504        catch( final SQLException e )
505        {
506            closeQuietly( conn, ps, null );
507            log.error( "DB connectivity error: " + e.getMessage() );
508            throw new WikiSecurityException("DB connectivity error: " + e.getMessage(), e );
509        }
510        finally
511        {
512            closeQuietly( conn, ps, null );
513        }
514        log.info( "JDBCGroupDatabase initialized from JNDI DataSource: " + jndiName );
515
516        // Determine if the datasource supports commits
517        try
518        {
519            conn = m_ds.getConnection();
520            final DatabaseMetaData dmd = conn.getMetaData();
521            if( dmd.supportsTransactions() )
522            {
523                m_supportsCommits = true;
524                conn.setAutoCommit( false );
525                log.info( "JDBCGroupDatabase supports transactions. Good; we will use them." );
526            }
527        }
528        catch( final SQLException e )
529        {
530            closeQuietly( conn, null, null );
531            log.warn( "JDBCGroupDatabase warning: user database doesn't seem to support transactions. Reason: " + e);
532        }
533        finally
534        {
535            closeQuietly( conn, null, null );
536        }
537    }
538
539    /**
540     * Returns <code>true</code> if the Group exists in back-end storage.
541     * 
542     * @param group the Group to look for
543     * @return the result of the search
544     */
545    private boolean exists( final Group group )
546    {
547        final String index = group.getName();
548        try
549        {
550            findGroup( index );
551            return true;
552        }
553        catch( final NoSuchPrincipalException e )
554        {
555            return false;
556        }
557    }
558
559    /**
560     * Loads and returns a Group from the back-end database matching a supplied
561     * name.
562     * 
563     * @param index the name of the Group to find
564     * @return the populated Group
565     * @throws NoSuchPrincipalException if the Group cannot be found
566     * @throws SQLException if the database query returns an error
567     */
568    private Group findGroup( final String index ) throws NoSuchPrincipalException
569    {
570        Group group = null;
571        boolean found = false;
572        boolean unique = true;
573        ResultSet rs = null;
574        PreparedStatement ps = null;
575        Connection conn = null;
576        try
577        {
578            // Open the database connection
579            conn = m_ds.getConnection();
580
581            ps = conn.prepareStatement( m_findGroup );
582            ps.setString( 1, index );
583            rs = ps.executeQuery();
584            while ( rs.next() )
585            {
586                if( group != null )
587                {
588                    unique = false;
589                    break;
590                }
591                group = new Group( index, m_engine.getApplicationName() );
592                group.setCreated( rs.getTimestamp( m_created ) );
593                group.setCreator( rs.getString( m_creator ) );
594                group.setLastModified( rs.getTimestamp( m_modified ) );
595                group.setModifier( rs.getString( m_modifier ) );
596                populateGroup( group );
597                found = true;
598            }
599        }
600        catch( final SQLException e )
601        {
602            closeQuietly( conn, ps, rs );
603            throw new NoSuchPrincipalException( e.getMessage() );
604        }
605        finally
606        {
607            closeQuietly( conn, ps, rs );
608        }
609
610        if( !found )
611        {
612            throw new NoSuchPrincipalException( "Could not find group in database!" );
613        }
614        if( !unique )
615        {
616            throw new NoSuchPrincipalException( "More than one group in database!" );
617        }
618        return group;
619    }
620
621    /**
622     * Fills a Group with members.
623     * 
624     * @param group the group to populate
625     * @return the populated Group
626     */
627    private Group populateGroup( final Group group )
628    {
629        ResultSet rs = null;
630        PreparedStatement ps = null;
631        Connection conn = null;
632        try
633        {
634            // Open the database connection
635            conn = m_ds.getConnection();
636
637            ps = conn.prepareStatement( m_findMembers );
638            ps.setString( 1, group.getName() );
639            rs = ps.executeQuery();
640            while ( rs.next() )
641            {
642                final String memberName = rs.getString( m_member );
643                if( memberName != null )
644                {
645                    final WikiPrincipal principal = new WikiPrincipal( memberName, WikiPrincipal.UNSPECIFIED );
646                    group.add( principal );
647                }
648            }
649        }
650        catch( final SQLException e )
651        {
652            // I guess that means there aren't any principals...
653        }
654        finally
655        {
656            closeQuietly( conn, ps, rs );
657        }
658        return group;
659    }
660    
661    void closeQuietly( final Connection conn, final PreparedStatement ps, final ResultSet rs ) {
662        if( conn != null ) {
663            try {
664                conn.close();
665            } catch( final Exception e ) {
666            }
667        }
668        if( ps != null )  {
669            try {
670                ps.close();
671            } catch( final Exception e ) {
672            }
673        }
674        if( rs != null )  {
675            try {
676                rs.close();
677            } catch( final Exception e ) {
678            }
679        }
680    }
681
682}