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