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><Context ...><br/> 109 * ...<br/> 110 * <Resource name="jdbc/GroupDatabase" auth="Container"<br/> 111 * type="javax.sql.DataSource" username="dbusername" password="dbpassword"<br/> 112 * driverClassName="org.hsql.jdbcDriver" url="jdbc:HypersonicSQL:database"<br/> 113 * maxActive="8" maxIdle="4"/><br/> 114 * ...<br/> 115 * </Context></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}