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