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