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