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