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