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.shiro.realm.text;
020    
021    import org.apache.shiro.ShiroException;
022    import org.apache.shiro.io.ResourceUtils;
023    import org.apache.shiro.util.Destroyable;
024    import org.slf4j.Logger;
025    import org.slf4j.LoggerFactory;
026    
027    import java.io.File;
028    import java.io.IOException;
029    import java.io.InputStream;
030    import java.util.Enumeration;
031    import java.util.Properties;
032    import java.util.concurrent.ExecutorService;
033    import java.util.concurrent.Executors;
034    import java.util.concurrent.ScheduledExecutorService;
035    import java.util.concurrent.TimeUnit;
036    
037    /**
038     * A {@link TextConfigurationRealm} that defers all logic to the parent class, but just enables
039     * {@link java.util.Properties Properties} based configuration in addition to the parent class's String configuration.
040     * <p/>
041     * This class allows processing of a single .properties file for user, role, and
042     * permission configuration.
043     * <p/>
044     * The {@link #setResourcePath resourcePath} <em>MUST</em> be set before this realm can be initialized.  You
045     * can specify any resource path supported by
046     * {@link ResourceUtils#getInputStreamForPath(String) ResourceUtils.getInputStreamForPath} method.
047     * <p/>
048     * The Properties format understood by this implementation must be written as follows:
049     * <p/>
050     * Each line's key/value pair represents either a user-to-role(s) mapping <em>or</em> a role-to-permission(s)
051     * mapping.
052     * <p/>
053     * The user-to-role(s) lines have this format:</p>
054     * <p/>
055     * <code><b>user.</b><em>username</em> = <em>password</em>,role1,role2,...</code></p>
056     * <p/>
057     * Note that each key is prefixed with the token <b>{@code user.}</b>  Each value must adhere to the
058     * the {@link #setUserDefinitions(String) setUserDefinitions(String)} JavaDoc.
059     * <p/>
060     * The role-to-permission(s) lines have this format:</p>
061     * <p/>
062     * <code><b>role.</b><em>rolename</em> = <em>permissionDefinition1</em>, <em>permissionDefinition2</em>, ...</code>
063     * <p/>
064     * where each key is prefixed with the token <b>{@code role.}</b> and the value adheres to the format specified in
065     * the {@link #setRoleDefinitions(String) setRoleDefinitions(String)} JavaDoc.
066     * <p/>
067     * Here is an example of a very simple properties definition that conforms to the above format rules and corresponding
068     * method JavaDocs:
069     * <p/>
070     * <code>user.root = <em>rootPassword</em>,administrator<br/>
071     * user.jsmith = <em>jsmithPassword</em>,manager,engineer,employee<br/>
072     * user.abrown = <em>abrownPassword</em>,qa,employee<br/>
073     * user.djones = <em>djonesPassword</em>,qa,contractor<br/>
074     * <br/>
075     * role.administrator = *<br/>
076     * role.manager = &quot;user:read,write&quot;, file:execute:/usr/local/emailManagers.sh<br/>
077     * role.engineer = &quot;file:read,execute:/usr/local/tomcat/bin/startup.sh&quot;<br/>
078     * role.employee = application:use:wiki<br/>
079     * role.qa = &quot;server:view,start,shutdown,restart:someQaServer&quot;, server:view:someProductionServer<br/>
080     * role.contractor = application:use:timesheet</code>
081     *
082     * @since 0.2
083     */
084    public class PropertiesRealm extends TextConfigurationRealm implements Destroyable, Runnable {
085    
086        //TODO - complete JavaDoc
087    
088        /*-------------------------------------------
089        |             C O N S T A N T S             |
090        ============================================*/
091        private static final int DEFAULT_RELOAD_INTERVAL_SECONDS = 10;
092        private static final String USERNAME_PREFIX = "user.";
093        private static final String ROLENAME_PREFIX = "role.";
094        private static final String DEFAULT_RESOURCE_PATH = "classpath:shiro-users.properties";
095    
096        /*-------------------------------------------
097        |    I N S T A N C E   V A R I A B L E S    |
098        ============================================*/
099        private static final Logger log = LoggerFactory.getLogger(PropertiesRealm.class);
100    
101        protected ExecutorService scheduler = null;
102        protected boolean useXmlFormat = false;
103        protected String resourcePath = DEFAULT_RESOURCE_PATH;
104        protected long fileLastModified;
105        protected int reloadIntervalSeconds = DEFAULT_RELOAD_INTERVAL_SECONDS;
106    
107        public PropertiesRealm() {
108            super();
109        }
110    
111        /*--------------------------------------------
112        |  A C C E S S O R S / M O D I F I E R S    |
113        ============================================*/
114    
115        /**
116         * Determines whether or not the properties XML format should be used.  For more information, see
117         * {@link Properties#loadFromXML(java.io.InputStream)}
118         *
119         * @param useXmlFormat true to use XML or false to use the normal format.  Defaults to false.
120         */
121        public void setUseXmlFormat(boolean useXmlFormat) {
122            this.useXmlFormat = useXmlFormat;
123        }
124    
125        /**
126         * Sets the path of the properties file to load user, role, and permission information from.  The properties
127         * file will be loaded using {@link ResourceUtils#getInputStreamForPath(String)} so any convention recongized
128         * by that method is accepted here.  For example, to load a file from the classpath use
129         * {@code classpath:myfile.properties}; to load a file from disk simply specify the full path; to load
130         * a file from a URL use {@code url:www.mysite.com/myfile.properties}.
131         *
132         * @param resourcePath the path to load the properties file from.  This is a required property.
133         */
134        public void setResourcePath(String resourcePath) {
135            this.resourcePath = resourcePath;
136        }
137    
138        /**
139         * Sets the interval in seconds at which the property file will be checked for changes and reloaded.  If this is
140         * set to zero or less, property file reloading will be disabled.  If it is set to 1 or greater, then a
141         * separate thread will be created to monitor the propery file for changes and reload the file if it is updated.
142         *
143         * @param reloadIntervalSeconds the interval in seconds at which the property file should be examined for changes.
144         *                              If set to zero or less, reloading is disabled.
145         */
146        public void setReloadIntervalSeconds(int reloadIntervalSeconds) {
147            this.reloadIntervalSeconds = reloadIntervalSeconds;
148        }
149    
150        /*--------------------------------------------
151        |               M E T H O D S               |
152        ============================================*/
153    
154        @Override
155        public void onInit() {
156            super.onInit();
157            //TODO - cleanup - this method shouldn't be necessary
158            afterRoleCacheSet();
159        }
160    
161        protected void afterRoleCacheSet() {
162            loadProperties();
163            //we can only determine if files have been modified at runtime (not classpath entries or urls), so only
164            //start the thread in this case:
165            if (this.resourcePath.startsWith(ResourceUtils.FILE_PREFIX) && scheduler == null) {
166                startReloadThread();
167            }
168        }
169    
170        /**
171         * Destroy reload scheduler if one exists.
172         */
173        public void destroy() {
174            try {
175                if (scheduler != null) {
176                    scheduler.shutdown();
177                }
178            } catch (Exception e) {
179                if (log.isInfoEnabled()) {
180                    log.info("Unable to cleanly shutdown Scheduler.  Ignoring (shutting down)...", e);
181                }
182            } finally {
183                scheduler = null;
184            }
185        }
186    
187        protected void startReloadThread() {
188            if (this.reloadIntervalSeconds > 0) {
189                this.scheduler = Executors.newSingleThreadScheduledExecutor();
190                ((ScheduledExecutorService) this.scheduler).scheduleAtFixedRate(this, reloadIntervalSeconds, reloadIntervalSeconds, TimeUnit.SECONDS);
191            }
192        }
193    
194        public void run() {
195            try {
196                reloadPropertiesIfNecessary();
197            } catch (Exception e) {
198                if (log.isErrorEnabled()) {
199                    log.error("Error while reloading property files for realm.", e);
200                }
201            }
202        }
203    
204        private void loadProperties() {
205            if (resourcePath == null || resourcePath.length() == 0) {
206                throw new IllegalStateException("The resourcePath property is not set.  " +
207                        "It must be set prior to this realm being initialized.");
208            }
209    
210            if (log.isDebugEnabled()) {
211                log.debug("Loading user security information from file [" + resourcePath + "]...");
212            }
213    
214            Properties properties = loadProperties(resourcePath);
215            createRealmEntitiesFromProperties(properties);
216        }
217    
218        private Properties loadProperties(String resourcePath) {
219            Properties props = new Properties();
220    
221            InputStream is = null;
222            try {
223    
224                if (log.isDebugEnabled()) {
225                    log.debug("Opening input stream for path [" + resourcePath + "]...");
226                }
227    
228                is = ResourceUtils.getInputStreamForPath(resourcePath);
229                if (useXmlFormat) {
230    
231                    if (log.isDebugEnabled()) {
232                        log.debug("Loading properties from path [" + resourcePath + "] in XML format...");
233                    }
234    
235                    props.loadFromXML(is);
236                } else {
237    
238                    if (log.isDebugEnabled()) {
239                        log.debug("Loading properties from path [" + resourcePath + "]...");
240                    }
241    
242                    props.load(is);
243                }
244    
245            } catch (IOException e) {
246                throw new ShiroException("Error reading properties path [" + resourcePath + "].  " +
247                        "Initializing of the realm from this file failed.", e);
248            } finally {
249                ResourceUtils.close(is);
250            }
251    
252            return props;
253        }
254    
255    
256        private void reloadPropertiesIfNecessary() {
257            if (isSourceModified()) {
258                restart();
259            }
260        }
261    
262        private boolean isSourceModified() {
263            //we can only check last modified times on files - classpath and URL entries can't tell us modification times
264            return this.resourcePath.startsWith(ResourceUtils.FILE_PREFIX) && isFileModified();
265        }
266    
267        private boolean isFileModified() {
268            //SHIRO-394: strip file prefix before constructing the File instance:
269            String fileNameWithoutPrefix = this.resourcePath.substring(this.resourcePath.indexOf(":") + 1);
270            File propertyFile = new File(fileNameWithoutPrefix);
271            long currentLastModified = propertyFile.lastModified();
272            if (currentLastModified > this.fileLastModified) {
273                this.fileLastModified = currentLastModified;
274                return true;
275            } else {
276                return false;
277            }
278        }
279    
280        @SuppressWarnings("unchecked")
281        private void restart() {
282            if (resourcePath == null || resourcePath.length() == 0) {
283                throw new IllegalStateException("The resourcePath property is not set.  " +
284                        "It must be set prior to this realm being initialized.");
285            }
286    
287            if (log.isDebugEnabled()) {
288                log.debug("Loading user security information from file [" + resourcePath + "]...");
289            }
290    
291            try {
292                destroy();
293            } catch (Exception e) {
294                //ignored
295            }
296            init();
297        }
298    
299        @SuppressWarnings("unchecked")
300        private void createRealmEntitiesFromProperties(Properties properties) {
301    
302            StringBuilder userDefs = new StringBuilder();
303            StringBuilder roleDefs = new StringBuilder();
304    
305            Enumeration<String> propNames = (Enumeration<String>) properties.propertyNames();
306    
307            while (propNames.hasMoreElements()) {
308    
309                String key = propNames.nextElement().trim();
310                String value = properties.getProperty(key).trim();
311                if (log.isTraceEnabled()) {
312                    log.trace("Processing properties line - key: [" + key + "], value: [" + value + "].");
313                }
314    
315                if (isUsername(key)) {
316                    String username = getUsername(key);
317                    userDefs.append(username).append(" = ").append(value).append("\n");
318                } else if (isRolename(key)) {
319                    String rolename = getRolename(key);
320                    roleDefs.append(rolename).append(" = ").append(value).append("\n");
321                } else {
322                    String msg = "Encountered unexpected key/value pair.  All keys must be prefixed with either '" +
323                            USERNAME_PREFIX + "' or '" + ROLENAME_PREFIX + "'.";
324                    throw new IllegalStateException(msg);
325                }
326            }
327    
328            setUserDefinitions(userDefs.toString());
329            setRoleDefinitions(roleDefs.toString());
330            processDefinitions();
331        }
332    
333        protected String getName(String key, String prefix) {
334            return key.substring(prefix.length(), key.length());
335        }
336    
337        protected boolean isUsername(String key) {
338            return key != null && key.startsWith(USERNAME_PREFIX);
339        }
340    
341        protected boolean isRolename(String key) {
342            return key != null && key.startsWith(ROLENAME_PREFIX);
343        }
344    
345        protected String getUsername(String key) {
346            return getName(key, USERNAME_PREFIX);
347        }
348    
349        protected String getRolename(String key) {
350            return getName(key, ROLENAME_PREFIX);
351        }
352    }