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 java.security.Principal;
022import java.sql.*;
023import java.util.*;
024import java.util.Date;
025
026import javax.naming.Context;
027import javax.naming.InitialContext;
028import javax.naming.NamingException;
029import javax.sql.DataSource;
030
031import org.apache.log4j.Logger;
032import org.apache.wiki.WikiEngine;
033import org.apache.wiki.api.exceptions.NoRequiredPropertyException;
034import org.apache.wiki.auth.NoSuchPrincipalException;
035import org.apache.wiki.auth.WikiPrincipal;
036import org.apache.wiki.auth.WikiSecurityException;
037
038/**
039 * <p>
040 * Implementation of GroupDatabase that persists {@link Group} objects to a JDBC
041 * DataSource, as might typically be provided by a web container. This
042 * implementation looks up the JDBC DataSource using JNDI. The JNDI name of the
043 * datasource, backing table and mapped columns used by this class can be
044 * overridden by adding settings in <code>jspwiki.properties</code>.
045 * </p>
046 * <p>
047 * Configurable properties are these:
048 * </p>
049 * <table>
050 * <tr> <thead>
051 * <th>Property</th>
052 * <th>Default</th>
053 * <th>Definition</th>
054 * <thead> </tr>
055 * <tr>
056 * <td><code>jspwiki.groupdatabase.datasource</code></td>
057 * <td><code>jdbc/GroupDatabase</code></td>
058 * <td>The JNDI name of the DataSource</td>
059 * </tr>
060 * <tr>
061 * <td><code>jspwiki.groupdatabase.table</code></td>
062 * <td><code>groups</code></td>
063 * <td>The table that stores the groups</td>
064 * </tr>
065 * <tr>
066 * <td><code>jspwiki.groupdatabase.membertable</code></td>
067 * <td><code>group_members</code></td>
068 * <td>The table that stores the names of group members</td>
069 * </tr>
070 * <tr>
071 * <td><code>jspwiki.groupdatabase.created</code></td>
072 * <td><code>created</code></td>
073 * <td>The column containing the group's creation timestamp</td>
074 * </tr>
075 * <tr>
076 * <td><code>jspwiki.groupdatabase.creator</code></td>
077 * <td><code>creator</code></td>
078 * <td>The column containing the group creator's name</td>
079 * </tr>
080 * <tr>
081 * <td><code>jspwiki.groupdatabase.name</code></td>
082 * <td><code>name</code></td>
083 * <td>The column containing the group's name</td>
084 * </tr>
085 * <tr>
086 * <td><code>jspwiki.groupdatabase.member</code></td>
087 * <td><code>member</code></td>
088 * <td>The column containing the group member's name</td>
089 * </tr>
090 * <tr>
091 * <td><code>jspwiki.groupdatabase.modified</code></td>
092 * <td><code>modified</code></td>
093 * <td>The column containing the group's last-modified timestamp</td>
094 * </tr>
095 * <tr>
096 * <td><code>jspwiki.groupdatabase.modifier</code></td>
097 * <td><code>modifier</code></td>
098 * <td>The column containing the name of the user who last modified the group</td>
099 * </tr>
100 * </table>
101 * <p>
102 * This class is typically used in conjunction with a web container's JNDI
103 * resource factory. For example, Tomcat versions 4 and higher provide a basic
104 * JNDI factory for registering DataSources. To give JSPWiki access to the JNDI
105 * resource named by <code>jdbc/GroupDatabase</code>, you would declare the
106 * datasource resource similar to this:
107 * </p>
108 * <blockquote><code>&lt;Context ...&gt;<br/>
109 *  &nbsp;&nbsp;...<br/>
110 *  &nbsp;&nbsp;&lt;Resource name="jdbc/GroupDatabase" auth="Container"<br/>
111 *  &nbsp;&nbsp;&nbsp;&nbsp;type="javax.sql.DataSource" username="dbusername" password="dbpassword"<br/>
112 *  &nbsp;&nbsp;&nbsp;&nbsp;driverClassName="org.hsql.jdbcDriver" url="jdbc:HypersonicSQL:database"<br/>
113 *  &nbsp;&nbsp;&nbsp;&nbsp;maxActive="8" maxIdle="4"/&gt;<br/>
114 *  &nbsp;...<br/>
115 * &lt;/Context&gt;</code></blockquote>
116 * <p>
117 * JDBC driver JARs should be added to Tomcat's <code>common/lib</code>
118 * directory. For more Tomcat 5.5 JNDI configuration examples, see <a
119 * href="http://tomcat.apache.org/tomcat-5.5-doc/jndi-resources-howto.html">
120 * http://tomcat.apache.org/tomcat-5.5-doc/jndi-resources-howto.html</a>.
121 * </p>
122 * <p>
123 * JDBCGroupDatabase commits changes as transactions if the back-end database
124 * supports them. If the database supports transactions, group changes are saved
125 * to permanent storage only when the {@link #commit()} method is called. If the
126 * database does <em>not</em> support transactions, then changes are made
127 * immediately (during the {@link #save(Group, Principal)} method), and the
128 * {@linkplain #commit()} method no-ops. Thus, callers should always call the
129 * {@linkplain #commit()} method after saving a profile to guarantee that
130 * changes are applied.
131 * </p>
132 * 
133 * @since 2.3
134 */
135public class JDBCGroupDatabase implements GroupDatabase {
136    
137    /** Default column name that stores the JNDI name of the DataSource. */
138    public static final String DEFAULT_GROUPDB_DATASOURCE = "jdbc/GroupDatabase";
139
140    /** Default table name for the table that stores groups. */
141    public static final String DEFAULT_GROUPDB_TABLE = "groups";
142
143    /** Default column name that stores the names of group members. */
144    public static final String DEFAULT_GROUPDB_MEMBER_TABLE = "group_members";
145
146    /** Default column name that stores the the group creation timestamps. */
147    public static final String DEFAULT_GROUPDB_CREATED = "created";
148
149    /** Default column name that stores group creator names. */
150    public static final String DEFAULT_GROUPDB_CREATOR = "creator";
151
152    /** Default column name that stores the group names. */
153    public static final String DEFAULT_GROUPDB_NAME = "name";
154
155    /** Default column name that stores group member names. */
156    public static final String DEFAULT_GROUPDB_MEMBER = "member";
157
158    /** Default column name that stores group last-modified timestamps. */
159    public static final String DEFAULT_GROUPDB_MODIFIED = "modified";
160
161    /** Default column name that stores names of users who last modified groups. */
162    public static final String DEFAULT_GROUPDB_MODIFIER = "modifier";
163
164    /** The JNDI name of the DataSource. */
165    public static final String PROP_GROUPDB_DATASOURCE = "jspwiki.groupdatabase.datasource";
166
167    /** The table that stores the groups. */
168    public static final String PROP_GROUPDB_TABLE = "jspwiki.groupdatabase.table";
169
170    /** The table that stores the names of group members. */
171    public static final String PROP_GROUPDB_MEMBER_TABLE = "jspwiki.groupdatabase.membertable";
172
173    /** The column containing the group's creation timestamp. */
174    public static final String PROP_GROUPDB_CREATED = "jspwiki.groupdatabase.created";
175
176    /** The column containing the group creator's name. */
177    public static final String PROP_GROUPDB_CREATOR = "jspwiki.groupdatabase.creator";
178
179    /** The column containing the group's name. */
180    public static final String PROP_GROUPDB_NAME = "jspwiki.groupdatabase.name";
181
182    /** The column containing the group member's name. */
183    public static final String PROP_GROUPDB_MEMBER = "jspwiki.groupdatabase.member";
184
185    /** The column containing the group's last-modified timestamp. */
186    public static final String PROP_GROUPDB_MODIFIED = "jspwiki.groupdatabase.modified";
187
188    /** The column containing the name of the user who last modified the group. */
189    public static final String PROP_GROUPDB_MODIFIER = "jspwiki.groupdatabase.modifier";
190
191    protected static final Logger log = Logger.getLogger( JDBCGroupDatabase.class );
192
193    private DataSource m_ds = null;
194
195    private String m_created = null;
196
197    private String m_creator = null;
198
199    private String m_name = null;
200
201    private String m_member = null;
202
203    private String m_modified = null;
204
205    private String m_modifier = null;
206
207    private String m_findAll = null;
208
209    private String m_findGroup = null;
210
211    private String m_findMembers = null;
212
213    private String m_insertGroup = null;
214
215    private String m_insertGroupMembers = null;
216
217    private String m_updateGroup = null;
218
219    private String m_deleteGroup = null;
220
221    private String m_deleteGroupMembers = null;
222
223    private boolean m_supportsCommits = false;
224
225    private WikiEngine m_engine = null;
226
227    /**
228     * Looks up and deletes a {@link Group} from the group database. If the
229     * group database does not contain the supplied Group. this method throws a
230     * {@link NoSuchPrincipalException}. The method commits the results of the
231     * delete to persistent storage.
232     * 
233     * @param group the group to remove
234     * @throws WikiSecurityException if the database does not contain the
235     *             supplied group (thrown as {@link NoSuchPrincipalException})
236     *             or if the commit did not succeed
237     */
238    public void delete( Group group ) throws WikiSecurityException
239    {
240        if( !exists( group ) )
241        {
242            throw new NoSuchPrincipalException( "Not in database: " + group.getName() );
243        }
244
245        String groupName = group.getName();
246        Connection conn = null;
247        PreparedStatement ps = null;
248        try
249        {
250            // Open the database connection
251            conn = m_ds.getConnection();
252            if( m_supportsCommits )
253            {
254                conn.setAutoCommit( false );
255            }
256
257            ps = conn.prepareStatement( m_deleteGroup );
258            ps.setString( 1, groupName );
259            ps.execute();
260            ps.close();
261
262            ps = conn.prepareStatement( m_deleteGroupMembers );
263            ps.setString( 1, groupName );
264            ps.execute();
265
266            // Commit and close connection
267            if( m_supportsCommits )
268            {
269                conn.commit();
270            }
271        }
272        catch( SQLException e )
273        {
274            closeQuietly( conn, ps, null );
275            throw new WikiSecurityException( "Could not delete group " + groupName + ": " + e.getMessage(), e );
276        }
277        finally
278        {
279            closeQuietly( conn, ps, null );
280        }
281    }
282
283    /**
284     * Returns all wiki groups that are stored in the GroupDatabase as an array
285     * of Group objects. If the database does not contain any groups, this
286     * method will return a zero-length array. This method causes back-end
287     * storage to load the entire set of group; thus, it should be called
288     * infrequently (e.g., at initialization time).
289     * 
290     * @return the wiki groups
291     * @throws WikiSecurityException if the groups cannot be returned by the
292     *             back-end
293     */
294    public Group[] groups() throws WikiSecurityException
295    {
296        Set<Group> groups = new HashSet<Group>();
297        Connection conn = null;
298        PreparedStatement ps = null;
299        ResultSet rs = null;
300        try
301        {
302            // Open the database connection
303            conn = m_ds.getConnection();
304
305            ps = conn.prepareStatement( m_findAll );
306            rs = ps.executeQuery();
307            while ( rs.next() )
308            {
309                String groupName = rs.getString( m_name );
310                if( groupName == null )
311                {
312                    log.warn( "Detected null group name in JDBCGroupDataBase. Check your group database." );
313                }
314                else
315                {
316                    Group group = new Group( groupName, m_engine.getApplicationName() );
317                    group.setCreated( rs.getTimestamp( m_created ) );
318                    group.setCreator( rs.getString( m_creator ) );
319                    group.setLastModified( rs.getTimestamp( m_modified ) );
320                    group.setModifier( rs.getString( m_modifier ) );
321                    populateGroup( group );
322                    groups.add( group );
323                }
324            }
325        }
326        catch( SQLException e )
327        {
328            closeQuietly( conn, ps, rs );
329            throw new WikiSecurityException( e.getMessage(), e );
330        }
331        finally
332        {
333            closeQuietly( conn, ps, rs );
334        }
335
336        return groups.toArray( new Group[groups.size()] );
337    }
338
339    /**
340     * Saves a Group to the group database. Note that this method <em>must</em>
341     * fail, and throw an <code>IllegalArgumentException</code>, if the
342     * proposed group is the same name as one of the built-in Roles: e.g.,
343     * Admin, Authenticated, etc. The database is responsible for setting
344     * create/modify timestamps, upon a successful save, to the Group. The
345     * method commits the results of the delete to persistent storage.
346     * 
347     * @param group the Group to save
348     * @param modifier the user who saved the Group
349     * @throws WikiSecurityException if the Group could not be saved
350     *             successfully
351     */
352    public void save( Group group, 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        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            Timestamp ts = new Timestamp( System.currentTimeMillis() );
372            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            Principal[] members = group.members();
414            for( int i = 0; i < members.length; i++ )
415            {
416                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( 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    public void initialize( WikiEngine engine, Properties props ) throws NoRequiredPropertyException, WikiSecurityException
449    {
450        String table;
451        String memberTable;
452
453        m_engine = engine;
454
455        String jndiName = props.getProperty( PROP_GROUPDB_DATASOURCE, DEFAULT_GROUPDB_DATASOURCE );
456        try
457        {
458            Context initCtx = new InitialContext();
459            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( 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( 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            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( 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( Group group )
546    {
547        String index = group.getName();
548        try
549        {
550            findGroup( index );
551            return true;
552        }
553        catch( 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( 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( 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( 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                String memberName = rs.getString( m_member );
643                if( memberName != null )
644                {
645                    WikiPrincipal principal = new WikiPrincipal( memberName, WikiPrincipal.UNSPECIFIED );
646                    group.add( principal );
647                }
648            }
649        }
650        catch( 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( Connection conn, PreparedStatement ps, ResultSet rs ) {
662        if( conn != null ) {
663            try {
664                conn.close();
665            } catch( Exception e ) {
666            }
667        }
668        if( ps != null )  {
669            try {
670                ps.close();
671            } catch( Exception e ) {
672            }
673        }
674        if( rs != null )  {
675            try {
676                rs.close();
677            } catch( Exception e ) {
678            }
679        }
680    }
681
682}