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