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