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.commons.text.StringEscapeUtils; 022import org.apache.log4j.Logger; 023import org.apache.wiki.api.core.Engine; 024import org.apache.wiki.api.exceptions.NoRequiredPropertyException; 025import org.apache.wiki.auth.NoSuchPrincipalException; 026import org.apache.wiki.auth.WikiPrincipal; 027import org.apache.wiki.auth.WikiSecurityException; 028import org.apache.wiki.util.TextUtil; 029import org.w3c.dom.Document; 030import org.w3c.dom.Element; 031import org.w3c.dom.NodeList; 032import org.xml.sax.SAXException; 033 034import javax.xml.parsers.DocumentBuilderFactory; 035import javax.xml.parsers.ParserConfigurationException; 036import java.io.BufferedWriter; 037import java.io.File; 038import java.io.FileNotFoundException; 039import java.io.FileOutputStream; 040import java.io.IOException; 041import java.io.OutputStreamWriter; 042import java.nio.charset.StandardCharsets; 043import java.security.Principal; 044import java.text.DateFormat; 045import java.text.ParseException; 046import java.text.SimpleDateFormat; 047import java.util.Collection; 048import java.util.Date; 049import java.util.Map; 050import java.util.Properties; 051import java.util.concurrent.ConcurrentHashMap; 052 053 054/** 055 * <p> 056 * GroupDatabase implementation for loading, persisting and storing wiki groups, 057 * using an XML file for persistence. Group entries are simple 058 * <code><group></code> elements under the root. Each group member is 059 * representated by a <code><member></code> element. For example: 060 * </p> 061 * <blockquote><code> 062 * <groups><br/> 063 * <group name="TV" created="Jun 20, 2006 2:50:54 PM" lastModified="Jan 21, 2006 2:50:54 PM"><br/> 064 * <member principal="Archie Bunker" /><br/> 065 * <member principal="BullwinkleMoose" /><br/> 066 * <member principal="Fred Friendly" /><br/> 067 * </group><br/> 068 * <group name="Literature" created="Jun 22, 2006 2:50:54 PM" lastModified="Jan 23, 2006 2:50:54 PM"><br/> 069 * <member principal="Charles Dickens" /><br/> 070 * <member principal="Homer" /><br/> 071 * </group><br/> 072 * </groups> 073 * </code></blockquote> 074 * @since 2.4.17 075 */ 076public class XMLGroupDatabase implements GroupDatabase { 077 078 private static final Logger log = Logger.getLogger( XMLGroupDatabase.class ); 079 080 /** The jspwiki.properties property specifying the file system location of the group database. */ 081 public static final String PROP_DATABASE = "jspwiki.xmlGroupDatabaseFile"; 082 083 private static final String DEFAULT_DATABASE = "groupdatabase.xml"; 084 085 private static final String CREATED = "created"; 086 087 private static final String CREATOR = "creator"; 088 089 private static final String GROUP_TAG = "group"; 090 091 private static final String GROUP_NAME = "name"; 092 093 private static final String LAST_MODIFIED = "lastModified"; 094 095 private static final String MODIFIER = "modifier"; 096 097 private static final String MEMBER_TAG = "member"; 098 099 private static final String PRINCIPAL = "principal"; 100 101 private static final String DATE_FORMAT = "yyyy.MM.dd 'at' HH:mm:ss:SSS z"; 102 103 private Document m_dom = null; 104 105 private DateFormat m_defaultFormat = DateFormat.getDateTimeInstance(); 106 107 private File m_file = null; 108 109 private Engine m_engine = null; 110 111 private Map<String, Group> m_groups = new ConcurrentHashMap<>(); 112 113 /** 114 * Looks up and deletes a {@link Group} from the group database. If the 115 * group database does not contain the supplied Group. this method throws a 116 * {@link NoSuchPrincipalException}. The method commits the results 117 * of the delete to persistent storage. 118 * @param group the group to remove 119 * @throws WikiSecurityException if the database does not contain the 120 * supplied group (thrown as {@link NoSuchPrincipalException}) or if 121 * the commit did not succeed 122 */ 123 @Override 124 public void delete( final Group group ) throws WikiSecurityException 125 { 126 final String index = group.getName(); 127 final boolean exists = m_groups.containsKey( index ); 128 129 if ( !exists ) 130 { 131 throw new NoSuchPrincipalException( "Not in database: " + group.getName() ); 132 } 133 134 m_groups.remove( index ); 135 136 // Commit to disk 137 saveDOM(); 138 } 139 140 /** 141 * Returns all wiki groups that are stored in the GroupDatabase as an array 142 * of Group objects. If the database does not contain any groups, this 143 * method will return a zero-length array. This method causes back-end 144 * storage to load the entire set of group; thus, it should be called 145 * infrequently (e.g., at initialization time). 146 * @return the wiki groups 147 * @throws WikiSecurityException if the groups cannot be returned by the back-end 148 */ 149 @Override 150 public Group[] groups() throws WikiSecurityException 151 { 152 buildDOM(); 153 final Collection<Group> groups = m_groups.values(); 154 return groups.toArray( new Group[groups.size()] ); 155 } 156 157 /** 158 * Initializes the group database based on values from a Properties object. 159 * The properties object must contain a file path to the XML database file 160 * whose key is {@link #PROP_DATABASE}. 161 * @param engine the wiki engine 162 * @param props the properties used to initialize the group database 163 * @throws NoRequiredPropertyException if the user database cannot be 164 * located, parsed, or opened 165 * @throws WikiSecurityException if the database could not be initialized successfully 166 */ 167 @Override 168 public void initialize( final Engine engine, final Properties props ) throws NoRequiredPropertyException, WikiSecurityException 169 { 170 m_engine = engine; 171 172 File defaultFile = null; 173 if ( engine.getRootPath() == null ) 174 { 175 log.warn( "Cannot identify JSPWiki root path" ); 176 defaultFile = new File( "WEB-INF/" + DEFAULT_DATABASE ).getAbsoluteFile(); 177 } 178 else 179 { 180 defaultFile = new File( engine.getRootPath() + "/WEB-INF/" + DEFAULT_DATABASE ); 181 } 182 183 // Get database file location 184 final String file = TextUtil.getStringProperty(props, PROP_DATABASE , defaultFile.getAbsolutePath()); 185 if ( file == null ) 186 { 187 log.warn( "XML group database property " + PROP_DATABASE + " not found; trying " + defaultFile ); 188 m_file = defaultFile; 189 } 190 else 191 { 192 m_file = new File( file ); 193 } 194 195 log.info( "XML group database at " + m_file.getAbsolutePath() ); 196 197 // Read DOM 198 buildDOM(); 199 } 200 201 /** 202 * Saves a Group to the group database. Note that this method <em>must</em> 203 * fail, and throw an <code>IllegalArgumentException</code>, if the 204 * proposed group is the same name as one of the built-in Roles: e.g., 205 * Admin, Authenticated, etc. The database is responsible for setting 206 * create/modify timestamps, upon a successful save, to the Group. 207 * The method commits the results of the delete to persistent storage. 208 * @param group the Group to save 209 * @param modifier the user who saved the Group 210 * @throws WikiSecurityException if the Group could not be saved successfully 211 */ 212 @Override 213 public void save( final Group group, final Principal modifier ) throws WikiSecurityException { 214 if ( group == null || modifier == null ) { 215 throw new IllegalArgumentException( "Group or modifier cannot be null." ); 216 } 217 218 checkForRefresh(); 219 220 final String index = group.getName(); 221 final boolean isNew = !( m_groups.containsKey( index ) ); 222 final Date modDate = new Date( System.currentTimeMillis() ); 223 if ( isNew ) 224 { 225 // If new, set created info 226 group.setCreated( modDate ); 227 group.setCreator( modifier.getName() ); 228 } 229 group.setModifier( modifier.getName() ); 230 group.setLastModified( modDate ); 231 232 // Add the group to the 'saved' list 233 m_groups.put( index, group ); 234 235 // Commit to disk 236 saveDOM(); 237 } 238 239 private void buildDOM() { 240 final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); 241 factory.setValidating( false ); 242 factory.setExpandEntityReferences( false ); 243 factory.setIgnoringComments( true ); 244 factory.setNamespaceAware( false ); 245 try { 246 m_dom = factory.newDocumentBuilder().parse( m_file ); 247 log.debug( "Database successfully initialized" ); 248 m_lastModified = m_file.lastModified(); 249 m_lastCheck = System.currentTimeMillis(); 250 } catch( final ParserConfigurationException e ) { 251 log.error( "Configuration error: " + e.getMessage() ); 252 } catch( final SAXException e ) { 253 log.error( "SAX error: " + e.getMessage() ); 254 } catch( final FileNotFoundException e ) { 255 log.info( "Group database not found; creating from scratch..." ); 256 } catch( final IOException e ) { 257 log.error( "IO error: " + e.getMessage() ); 258 } 259 if ( m_dom == null ) { 260 try { 261 // 262 // Create the DOM from scratch 263 // 264 m_dom = factory.newDocumentBuilder().newDocument(); 265 m_dom.appendChild( m_dom.createElement( "groups" ) ); 266 } catch( final ParserConfigurationException e ) { 267 log.fatal( "Could not create in-memory DOM" ); 268 } 269 } 270 271 // Ok, now go and read this sucker in 272 if ( m_dom != null ) { 273 final NodeList groupNodes = m_dom.getElementsByTagName( GROUP_TAG ); 274 for( int i = 0; i < groupNodes.getLength(); i++ ) { 275 final Element groupNode = (Element) groupNodes.item( i ); 276 final String groupName = groupNode.getAttribute( GROUP_NAME ); 277 if ( groupName == null ) { 278 log.warn( "Detected null group name in XMLGroupDataBase. Check your group database." ); 279 } 280 else 281 { 282 final Group group = buildGroup( groupNode, groupName ); 283 m_groups.put( groupName, group ); 284 } 285 } 286 } 287 } 288 289 private long m_lastCheck = 0; 290 private long m_lastModified = 0; 291 292 private void checkForRefresh() { 293 final long time = System.currentTimeMillis(); 294 if( time - m_lastCheck > 60*1000L ) { 295 final long lastModified = m_file.lastModified(); 296 if( lastModified > m_lastModified ) { 297 buildDOM(); 298 } 299 } 300 } 301 /** 302 * Constructs a Group based on a DOM group node. 303 * @param groupNode the node in the DOM containing the node 304 * @param name the name of the group 305 */ 306 private Group buildGroup( final Element groupNode, final String name ) { 307 // It's an error if either param is null (very odd) 308 if ( groupNode == null || name == null ) { 309 throw new IllegalArgumentException( "DOM element or name cannot be null." ); 310 } 311 312 // Construct a new group 313 final Group group = new Group( name, m_engine.getApplicationName() ); 314 315 // Get the users for this group, and add them 316 final NodeList members = groupNode.getElementsByTagName( MEMBER_TAG ); 317 for( int i = 0; i < members.getLength(); i++ ) { 318 final Element memberNode = (Element) members.item( i ); 319 final String principalName = memberNode.getAttribute( PRINCIPAL ); 320 final Principal member = new WikiPrincipal( principalName ); 321 group.add( member ); 322 } 323 324 // Add the created/last-modified info 325 final String creator = groupNode.getAttribute( CREATOR ); 326 final String created = groupNode.getAttribute( CREATED ); 327 final String modifier = groupNode.getAttribute( MODIFIER ); 328 final String modified = groupNode.getAttribute( LAST_MODIFIED ); 329 try { 330 group.setCreated( new SimpleDateFormat( DATE_FORMAT ).parse( created ) ); 331 group.setLastModified( new SimpleDateFormat( DATE_FORMAT ).parse( modified ) ); 332 } catch ( final ParseException e ) { 333 // If parsing failed, use the platform default 334 try { 335 group.setCreated( m_defaultFormat.parse( created ) ); 336 group.setLastModified( m_defaultFormat.parse( modified ) ); 337 } catch ( final ParseException e2 ) { 338 log.warn( "Could not parse 'created' or 'lastModified' " + "attribute for " + " group'" 339 + group.getName() + "'." + " It may have been tampered with." ); 340 } 341 } 342 group.setCreator( creator ); 343 group.setModifier( modifier ); 344 return group; 345 } 346 347 private void saveDOM() throws WikiSecurityException { 348 if ( m_dom == null ) { 349 log.fatal( "Group database doesn't exist in memory." ); 350 } 351 352 final File newFile = new File( m_file.getAbsolutePath() + ".new" ); 353 try( final BufferedWriter io = new BufferedWriter( new OutputStreamWriter( new FileOutputStream( newFile ), StandardCharsets.UTF_8 ) ) ) { 354 // Write the file header and document root 355 io.write( "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" ); 356 io.write( "<groups>\n" ); 357 358 // Write each profile as a <group> node 359 for( final Group group : m_groups.values() ) { 360 io.write( " <" + GROUP_TAG + " " ); 361 io.write( GROUP_NAME ); 362 io.write( "=\"" + StringEscapeUtils.escapeXml11( group.getName() )+ "\" " ); 363 io.write( CREATOR ); 364 io.write( "=\"" + StringEscapeUtils.escapeXml11( group.getCreator() ) + "\" " ); 365 io.write( CREATED ); 366 io.write( "=\"" + new SimpleDateFormat( DATE_FORMAT ).format( group.getCreated() ) + "\" " ); 367 io.write( MODIFIER ); 368 io.write( "=\"" + group.getModifier() + "\" " ); 369 io.write( LAST_MODIFIED ); 370 io.write( "=\"" + new SimpleDateFormat( DATE_FORMAT ).format( group.getLastModified() ) + "\"" ); 371 io.write( ">\n" ); 372 373 // Write each member as a <member> node 374 for( final Principal member : group.members() ) { 375 io.write( " <" + MEMBER_TAG + " " ); 376 io.write( PRINCIPAL ); 377 io.write( "=\"" + StringEscapeUtils.escapeXml11(member.getName()) + "\" " ); 378 io.write( "/>\n" ); 379 } 380 381 // Close tag 382 io.write( " </" + GROUP_TAG + ">\n" ); 383 } 384 io.write( "</groups>" ); 385 } catch( final IOException e ) { 386 throw new WikiSecurityException( e.getLocalizedMessage(), e ); 387 } 388 389 // Copy new file over old version 390 final File backup = new File( m_file.getAbsolutePath() + ".old" ); 391 if ( backup.exists() && !backup.delete()) { 392 log.error( "Could not delete old group database backup: " + backup ); 393 } 394 if ( !m_file.renameTo( backup ) ) { 395 log.error( "Could not create group database backup: " + backup ); 396 } 397 if ( !newFile.renameTo( m_file ) ) { 398 log.error( "Could not save database: " + backup + " restoring backup." ); 399 if ( !backup.renameTo( m_file ) ) { 400 log.error( "Restore failed. Check the file permissions." ); 401 } 402 log.error( "Could not save database: " + m_file + ". Check the file permissions" ); 403 } 404 } 405 406}