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     * No-op method that in previous versions of JSPWiki was intended to
229     * atomically commit changes to the user database. Now, the
230     * {@link #save(Group, Principal)} and {@link #delete(Group)} methods are
231     * atomic themselves.
232     * 
233     * @throws WikiSecurityException never...
234     * @deprecated there is no need to call this method because the save and
235     *             delete methods contain their own commit logic
236     */
237    @Deprecated
238    public void commit() throws WikiSecurityException
239    {
240    }
241
242    /**
243     * Looks up and deletes a {@link Group} from the group database. If the
244     * group database does not contain the supplied Group. this method throws a
245     * {@link NoSuchPrincipalException}. The method commits the results of the
246     * delete to persistent storage.
247     * 
248     * @param group the group to remove
249     * @throws WikiSecurityException if the database does not contain the
250     *             supplied group (thrown as {@link NoSuchPrincipalException})
251     *             or if the commit did not succeed
252     */
253    public void delete( Group group ) throws WikiSecurityException
254    {
255        if( !exists( group ) )
256        {
257            throw new NoSuchPrincipalException( "Not in database: " + group.getName() );
258        }
259
260        String groupName = group.getName();
261        Connection conn = null;
262        PreparedStatement ps = null;
263        try
264        {
265            // Open the database connection
266            conn = m_ds.getConnection();
267            if( m_supportsCommits )
268            {
269                conn.setAutoCommit( false );
270            }
271
272            ps = conn.prepareStatement( m_deleteGroup );
273            ps.setString( 1, groupName );
274            ps.execute();
275            ps.close();
276
277            ps = conn.prepareStatement( m_deleteGroupMembers );
278            ps.setString( 1, groupName );
279            ps.execute();
280
281            // Commit and close connection
282            if( m_supportsCommits )
283            {
284                conn.commit();
285            }
286        }
287        catch( SQLException e )
288        {
289            closeQuietly( conn, ps, null );
290            throw new WikiSecurityException( "Could not delete group " + groupName + ": " + e.getMessage(), e );
291        }
292        finally
293        {
294            closeQuietly( conn, ps, null );
295        }
296    }
297
298    /**
299     * Returns all wiki groups that are stored in the GroupDatabase as an array
300     * of Group objects. If the database does not contain any groups, this
301     * method will return a zero-length array. This method causes back-end
302     * storage to load the entire set of group; thus, it should be called
303     * infrequently (e.g., at initialization time).
304     * 
305     * @return the wiki groups
306     * @throws WikiSecurityException if the groups cannot be returned by the
307     *             back-end
308     */
309    public Group[] groups() throws WikiSecurityException
310    {
311        Set<Group> groups = new HashSet<Group>();
312        Connection conn = null;
313        PreparedStatement ps = null;
314        ResultSet rs = null;
315        try
316        {
317            // Open the database connection
318            conn = m_ds.getConnection();
319
320            ps = conn.prepareStatement( m_findAll );
321            rs = ps.executeQuery();
322            while ( rs.next() )
323            {
324                String groupName = rs.getString( m_name );
325                if( groupName == null )
326                {
327                    log.warn( "Detected null group name in JDBCGroupDataBase. Check your group database." );
328                }
329                else
330                {
331                    Group group = new Group( groupName, m_engine.getApplicationName() );
332                    group.setCreated( rs.getTimestamp( m_created ) );
333                    group.setCreator( rs.getString( m_creator ) );
334                    group.setLastModified( rs.getTimestamp( m_modified ) );
335                    group.setModifier( rs.getString( m_modifier ) );
336                    populateGroup( group );
337                    groups.add( group );
338                }
339            }
340        }
341        catch( SQLException e )
342        {
343            closeQuietly( conn, ps, rs );
344            throw new WikiSecurityException( e.getMessage(), e );
345        }
346        finally
347        {
348            closeQuietly( conn, ps, rs );
349        }
350
351        return groups.toArray( new Group[groups.size()] );
352    }
353
354    /**
355     * Saves a Group to the group database. Note that this method <em>must</em>
356     * fail, and throw an <code>IllegalArgumentException</code>, if the
357     * proposed group is the same name as one of the built-in Roles: e.g.,
358     * Admin, Authenticated, etc. The database is responsible for setting
359     * create/modify timestamps, upon a successful save, to the Group. The
360     * method commits the results of the delete to persistent storage.
361     * 
362     * @param group the Group to save
363     * @param modifier the user who saved the Group
364     * @throws WikiSecurityException if the Group could not be saved
365     *             successfully
366     */
367    public void save( Group group, Principal modifier ) throws WikiSecurityException
368    {
369        if( group == null || modifier == null )
370        {
371            throw new IllegalArgumentException( "Group or modifier cannot be null." );
372        }
373
374        boolean exists = exists( group );
375        Connection conn = null;
376        PreparedStatement ps = null;
377        try
378        {
379            // Open the database connection
380            conn = m_ds.getConnection();
381            if( m_supportsCommits )
382            {
383                conn.setAutoCommit( false );
384            }
385            
386            Timestamp ts = new Timestamp( System.currentTimeMillis() );
387            Date modDate = new Date( ts.getTime() );
388            if( !exists )
389            {
390                // Group is new: insert new group record
391                ps = conn.prepareStatement( m_insertGroup );
392                ps.setString( 1, group.getName() );
393                ps.setTimestamp( 2, ts );
394                ps.setString( 3, modifier.getName() );
395                ps.setTimestamp( 4, ts );
396                ps.setString( 5, modifier.getName() );
397                ps.execute();
398
399                // Set the group creation time
400                group.setCreated( modDate );
401                group.setCreator( modifier.getName() );
402                ps.close();
403            }
404            else
405            {
406                // Modify existing group record
407                ps = conn.prepareStatement( m_updateGroup );
408                ps.setTimestamp( 1, ts );
409                ps.setString( 2, modifier.getName() );
410                ps.setString( 3, group.getName() );
411                ps.execute();
412                ps.close();
413            }
414            // Set the group modified time
415            group.setLastModified( modDate );
416            group.setModifier( modifier.getName() );
417
418            // Now, update the group member list
419
420            // First, delete all existing member records
421            ps = conn.prepareStatement( m_deleteGroupMembers );
422            ps.setString( 1, group.getName() );
423            ps.execute();
424            ps.close();
425
426            // Insert group member records
427            ps = conn.prepareStatement( m_insertGroupMembers );
428            Principal[] members = group.members();
429            for( int i = 0; i < members.length; i++ )
430            {
431                Principal member = members[i];
432                ps.setString( 1, group.getName() );
433                ps.setString( 2, member.getName() );
434                ps.execute();
435            }
436
437            // Commit and close connection
438            if( m_supportsCommits )
439            {
440                conn.commit();
441            }
442        }
443        catch( SQLException e )
444        {
445            closeQuietly(conn, ps, null );
446            throw new WikiSecurityException( e.getMessage(), e );
447        }
448        finally
449        {
450            closeQuietly(conn, ps, null );
451        }
452    }
453
454    /**
455     * Initializes the group database based on values from a Properties object.
456     * 
457     * @param engine the wiki engine
458     * @param props the properties used to initialize the group database
459     * @throws WikiSecurityException if the database could not be initialized
460     *             successfully
461     * @throws NoRequiredPropertyException if a required property is not present
462     */
463    public void initialize( WikiEngine engine, Properties props ) throws NoRequiredPropertyException, WikiSecurityException
464    {
465        String table;
466        String memberTable;
467
468        m_engine = engine;
469
470        String jndiName = props.getProperty( PROP_GROUPDB_DATASOURCE, DEFAULT_GROUPDB_DATASOURCE );
471        try
472        {
473            Context initCtx = new InitialContext();
474            Context ctx = (Context) initCtx.lookup( "java:comp/env" );
475            m_ds = (DataSource) ctx.lookup( jndiName );
476
477            // Prepare the SQL selectors
478            table = props.getProperty( PROP_GROUPDB_TABLE, DEFAULT_GROUPDB_TABLE );
479            memberTable = props.getProperty( PROP_GROUPDB_MEMBER_TABLE, DEFAULT_GROUPDB_MEMBER_TABLE );
480            m_name = props.getProperty( PROP_GROUPDB_NAME, DEFAULT_GROUPDB_NAME );
481            m_created = props.getProperty( PROP_GROUPDB_CREATED, DEFAULT_GROUPDB_CREATED );
482            m_creator = props.getProperty( PROP_GROUPDB_CREATOR, DEFAULT_GROUPDB_CREATOR );
483            m_modifier = props.getProperty( PROP_GROUPDB_MODIFIER, DEFAULT_GROUPDB_MODIFIER );
484            m_modified = props.getProperty( PROP_GROUPDB_MODIFIED, DEFAULT_GROUPDB_MODIFIED );
485            m_member = props.getProperty( PROP_GROUPDB_MEMBER, DEFAULT_GROUPDB_MEMBER );
486
487            m_findAll = "SELECT DISTINCT * FROM " + table;
488            m_findGroup = "SELECT DISTINCT * FROM " + table + " WHERE " + m_name + "=?";
489            m_findMembers = "SELECT * FROM " + memberTable + " WHERE " + m_name + "=?";
490
491            // Prepare the group insert/update SQL
492            m_insertGroup = "INSERT INTO " + table + " (" + m_name + "," + m_modified + "," + m_modifier + "," + m_created + ","
493                            + m_creator + ") VALUES (?,?,?,?,?)";
494            m_updateGroup = "UPDATE " + table + " SET " + m_modified + "=?," + m_modifier + "=? WHERE " + m_name + "=?";
495
496            // Prepare the group member insert SQL
497            m_insertGroupMembers = "INSERT INTO " + memberTable + " (" + m_name + "," + m_member + ") VALUES (?,?)";
498
499            // Prepare the group delete SQL
500            m_deleteGroup = "DELETE FROM " + table + " WHERE " + m_name + "=?";
501            m_deleteGroupMembers = "DELETE FROM " + memberTable + " WHERE " + m_name + "=?";
502        }
503        catch( NamingException e )
504        {
505            log.error( "JDBCGroupDatabase initialization error: " + e );
506            throw new NoRequiredPropertyException( PROP_GROUPDB_DATASOURCE, "JDBCGroupDatabase initialization error: " + e);
507        }
508
509        // Test connection by doing a quickie select
510        Connection conn = null;
511        PreparedStatement ps = null;
512        try
513        {
514            conn = m_ds.getConnection();
515            ps = conn.prepareStatement( m_findAll );
516            ps.executeQuery();
517            ps.close();
518        }
519        catch( SQLException e )
520        {
521            closeQuietly( conn, ps, null );
522            log.error( "DB connectivity error: " + e.getMessage() );
523            throw new WikiSecurityException("DB connectivity error: " + e.getMessage(), e );
524        }
525        finally
526        {
527            closeQuietly( conn, ps, null );
528        }
529        log.info( "JDBCGroupDatabase initialized from JNDI DataSource: " + jndiName );
530
531        // Determine if the datasource supports commits
532        try
533        {
534            conn = m_ds.getConnection();
535            DatabaseMetaData dmd = conn.getMetaData();
536            if( dmd.supportsTransactions() )
537            {
538                m_supportsCommits = true;
539                conn.setAutoCommit( false );
540                log.info( "JDBCGroupDatabase supports transactions. Good; we will use them." );
541            }
542        }
543        catch( SQLException e )
544        {
545            closeQuietly( conn, null, null );
546            log.warn( "JDBCGroupDatabase warning: user database doesn't seem to support transactions. Reason: " + e);
547        }
548        finally
549        {
550            closeQuietly( conn, null, null );
551        }
552    }
553
554    /**
555     * Returns <code>true</code> if the Group exists in back-end storage.
556     * 
557     * @param group the Group to look for
558     * @return the result of the search
559     */
560    private boolean exists( Group group )
561    {
562        String index = group.getName();
563        try
564        {
565            findGroup( index );
566            return true;
567        }
568        catch( NoSuchPrincipalException e )
569        {
570            return false;
571        }
572    }
573
574    /**
575     * Loads and returns a Group from the back-end database matching a supplied
576     * name.
577     * 
578     * @param index the name of the Group to find
579     * @return the populated Group
580     * @throws NoSuchPrincipalException if the Group cannot be found
581     * @throws SQLException if the database query returns an error
582     */
583    private Group findGroup( String index ) throws NoSuchPrincipalException
584    {
585        Group group = null;
586        boolean found = false;
587        boolean unique = true;
588        ResultSet rs = null;
589        PreparedStatement ps = null;
590        Connection conn = null;
591        try
592        {
593            // Open the database connection
594            conn = m_ds.getConnection();
595
596            ps = conn.prepareStatement( m_findGroup );
597            ps.setString( 1, index );
598            rs = ps.executeQuery();
599            while ( rs.next() )
600            {
601                if( group != null )
602                {
603                    unique = false;
604                    break;
605                }
606                group = new Group( index, m_engine.getApplicationName() );
607                group.setCreated( rs.getTimestamp( m_created ) );
608                group.setCreator( rs.getString( m_creator ) );
609                group.setLastModified( rs.getTimestamp( m_modified ) );
610                group.setModifier( rs.getString( m_modifier ) );
611                populateGroup( group );
612                found = true;
613            }
614        }
615        catch( SQLException e )
616        {
617            closeQuietly( conn, ps, rs );
618            throw new NoSuchPrincipalException( e.getMessage() );
619        }
620        finally
621        {
622            closeQuietly( conn, ps, rs );
623        }
624
625        if( !found )
626        {
627            throw new NoSuchPrincipalException( "Could not find group in database!" );
628        }
629        if( !unique )
630        {
631            throw new NoSuchPrincipalException( "More than one group in database!" );
632        }
633        return group;
634    }
635
636    /**
637     * Fills a Group with members.
638     * 
639     * @param group the group to populate
640     * @return the populated Group
641     */
642    private Group populateGroup( Group group )
643    {
644        ResultSet rs = null;
645        PreparedStatement ps = null;
646        Connection conn = null;
647        try
648        {
649            // Open the database connection
650            conn = m_ds.getConnection();
651
652            ps = conn.prepareStatement( m_findMembers );
653            ps.setString( 1, group.getName() );
654            rs = ps.executeQuery();
655            while ( rs.next() )
656            {
657                String memberName = rs.getString( m_member );
658                if( memberName != null )
659                {
660                    WikiPrincipal principal = new WikiPrincipal( memberName, WikiPrincipal.UNSPECIFIED );
661                    group.add( principal );
662                }
663            }
664        }
665        catch( SQLException e )
666        {
667            // I guess that means there aren't any principals...
668        }
669        finally
670        {
671            closeQuietly( conn, ps, rs );
672        }
673        return group;
674    }
675    
676    void closeQuietly( Connection conn, PreparedStatement ps, ResultSet rs ) {
677        if( conn != null ) {
678            try {
679                conn.close();
680            } catch( Exception e ) {
681            }
682        }
683        if( ps != null )  {
684            try {
685                ps.close();
686            } catch( Exception e ) {
687            }
688        }
689        if( rs != null )  {
690            try {
691                rs.close();
692            } catch( Exception e ) {
693            }
694        }
695    }
696
697}