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>&lt;group&gt;</code> elements under the root. Each group member is
066 * representated by a <code>&lt;member&gt;</code> element. For example:
067 * </p>
068 * <blockquote><code>
069 * &lt;groups&gt;<br/>
070 * &nbsp;&nbsp;&lt;group name="TV" created="Jun 20, 2006 2:50:54 PM" lastModified="Jan 21, 2006 2:50:54 PM"&gt;<br/>
071 * &nbsp;&nbsp;&nbsp;&nbsp;&lt;member principal="Archie Bunker" /&gt;<br/>
072 * &nbsp;&nbsp;&nbsp;&nbsp;&lt;member principal="BullwinkleMoose" /&gt;<br/>
073 * &nbsp;&nbsp;&nbsp;&nbsp;&lt;member principal="Fred Friendly" /&gt;<br/>
074 * &nbsp;&nbsp;&lt;/group&gt;<br/>
075 * &nbsp;&nbsp;&lt;group name="Literature" created="Jun 22, 2006 2:50:54 PM" lastModified="Jan 23, 2006 2:50:54 PM"&gt;<br/>
076 * &nbsp;&nbsp;&nbsp;&nbsp;&lt;member principal="Charles Dickens" /&gt;<br/>
077 * &nbsp;&nbsp;&nbsp;&nbsp;&lt;member principal="Homer" /&gt;<br/>
078 * &nbsp;&nbsp;&lt;/group&gt;<br/>
079 * &lt;/groups&gt;
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}