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