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