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