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