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