001: /*
002: JSPWiki - a JSP-based WikiWiki clone.
003:
004: Copyright (C) 2001-2005 Janne Jalkanen (Janne.Jalkanen@iki.fi)
005:
006: This program is free software; you can redistribute it and/or modify
007: it under the terms of the GNU Lesser General Public License as published by
008: the Free Software Foundation; either version 2.1 of the License, or
009: (at your option) any later version.
010:
011: This program is distributed in the hope that it will be useful,
012: but WITHOUT ANY WARRANTY; without even the implied warranty of
013: MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
014: GNU Lesser General Public License for more details.
015:
016: You should have received a copy of the GNU Lesser General Public License
017: along with this program; if not, write to the Free Software
018: Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
019: */
020: package com.ecyrd.jspwiki.auth.user;
021:
022: import java.io.*;
023: import java.security.Principal;
024: import java.text.DateFormat;
025: import java.text.ParseException;
026: import java.text.SimpleDateFormat;
027: import java.util.Date;
028: import java.util.HashSet;
029: import java.util.Properties;
030: import java.util.Set;
031:
032: import javax.xml.parsers.DocumentBuilderFactory;
033: import javax.xml.parsers.ParserConfigurationException;
034:
035: import org.w3c.dom.Document;
036: import org.w3c.dom.Element;
037: import org.w3c.dom.NodeList;
038: import org.xml.sax.SAXException;
039:
040: import com.ecyrd.jspwiki.NoRequiredPropertyException;
041: import com.ecyrd.jspwiki.WikiEngine;
042: import com.ecyrd.jspwiki.auth.NoSuchPrincipalException;
043: import com.ecyrd.jspwiki.auth.WikiPrincipal;
044: import com.ecyrd.jspwiki.auth.WikiSecurityException;
045:
046: /**
047: * <p>Manages {@link DefaultUserProfile} objects using XML files for persistence.
048: * Passwords are hashed using SHA1. User entries are simple <code><user></code>
049: * elements under the root. User profile properties are attributes of the
050: * element. For example:</p>
051: * <blockquote><code>
052: * <users><br/>
053: * <user loginName="janne" fullName="Janne Jalkanen"<br/>
054: * wikiName="JanneJalkanen" email="janne@ecyrd.com"<br/>
055: * password="{SHA}457b08e825da547c3b77fbc1ff906a1d00a7daee"/><br/>
056: * </users>
057: * </code></blockquote>
058: * <p>In this example, the un-hashed password is <code>myP@5sw0rd</code>. Passwords are hashed without salt.</p>
059: * @author Andrew Jaquith
060: * @since 2.3
061: */
062:
063: // FIXME: If the DB is shared across multiple systems, it's possible to lose accounts
064: // if two people add new accounts right after each other from different wikis.
065: public class XMLUserDatabase extends AbstractUserDatabase {
066:
067: /**
068: * The jspwiki.properties property specifying the file system location of
069: * the user database.
070: */
071: public static final String PROP_USERDATABASE = "jspwiki.xmlUserDatabaseFile";
072:
073: private static final String DEFAULT_USERDATABASE = "userdatabase.xml";
074:
075: private static final String CREATED = "created";
076:
077: private static final String EMAIL = "email";
078:
079: private static final String FULL_NAME = "fullName";
080:
081: private static final String LOGIN_NAME = "loginName";
082:
083: private static final String LAST_MODIFIED = "lastModified";
084:
085: private static final String PASSWORD = "password";
086:
087: private static final String USER_TAG = "user";
088:
089: private static final String WIKI_NAME = "wikiName";
090:
091: private Document c_dom = null;
092:
093: private DateFormat c_defaultFormat = DateFormat
094: .getDateTimeInstance();
095:
096: private DateFormat c_format = new SimpleDateFormat(
097: "yyyy.MM.dd 'at' HH:mm:ss:SSS z");
098:
099: private File c_file = null;
100:
101: /**
102: * Looks up and deletes the first {@link UserProfile} in the user database
103: * that matches a profile having a given login name. If the user database
104: * does not contain a user with a matching attribute, throws a
105: * {@link NoSuchPrincipalException}.
106: * @param loginName the login name of the user profile that shall be deleted
107: */
108: public synchronized void deleteByLoginName(String loginName)
109: throws NoSuchPrincipalException, WikiSecurityException {
110: if (c_dom == null) {
111: throw new WikiSecurityException(
112: "FATAL: database does not exist");
113: }
114:
115: NodeList users = c_dom.getDocumentElement()
116: .getElementsByTagName(USER_TAG);
117: for (int i = 0; i < users.getLength(); i++) {
118: Element user = (Element) users.item(i);
119: if (user.getAttribute(LOGIN_NAME).equals(loginName)) {
120: c_dom.getDocumentElement().removeChild(user);
121:
122: // Commit to disk
123: saveDOM();
124: return;
125: }
126: }
127: throw new NoSuchPrincipalException("Not in database: "
128: + loginName);
129: }
130:
131: /**
132: * Looks up and returns the first {@link UserProfile}in the user database
133: * that matches a profile having a given e-mail address. If the user
134: * database does not contain a user with a matching attribute, throws a
135: * {@link NoSuchPrincipalException}.
136: * @param index the e-mail address of the desired user profile
137: * @return the user profile
138: * @see com.ecyrd.jspwiki.auth.user.UserDatabase#findByEmail(String)
139: */
140: public UserProfile findByEmail(String index)
141: throws NoSuchPrincipalException {
142: UserProfile profile = findByAttribute(EMAIL, index);
143: if (profile != null) {
144: return profile;
145: }
146: throw new NoSuchPrincipalException("Not in database: " + index);
147: }
148:
149: /**
150: * Looks up and returns the first {@link UserProfile}in the user database
151: * that matches a profile having a given full name. If the user database
152: * does not contain a user with a matching attribute, throws a
153: * {@link NoSuchPrincipalException}.
154: * @param index the fill name of the desired user profile
155: * @return the user profile
156: * @see com.ecyrd.jspwiki.auth.user.UserDatabase#findByFullName(java.lang.String)
157: */
158: public UserProfile findByFullName(String index)
159: throws NoSuchPrincipalException {
160: UserProfile profile = findByAttribute(FULL_NAME, index);
161: if (profile != null) {
162: return profile;
163: }
164: throw new NoSuchPrincipalException("Not in database: " + index);
165: }
166:
167: /**
168: * Looks up and returns the first {@link UserProfile}in the user database
169: * that matches a profile having a given login name. If the user database
170: * does not contain a user with a matching attribute, throws a
171: * {@link NoSuchPrincipalException}.
172: * @param index the login name of the desired user profile
173: * @return the user profile
174: * @see com.ecyrd.jspwiki.auth.user.UserDatabase#findByLoginName(java.lang.String)
175: */
176: public UserProfile findByLoginName(String index)
177: throws NoSuchPrincipalException {
178: UserProfile profile = findByAttribute(LOGIN_NAME, index);
179: if (profile != null) {
180: return profile;
181: }
182: throw new NoSuchPrincipalException("Not in database: " + index);
183: }
184:
185: /**
186: * Looks up and returns the first {@link UserProfile}in the user database
187: * that matches a profile having a given wiki name. If the user database
188: * does not contain a user with a matching attribute, throws a
189: * {@link NoSuchPrincipalException}.
190: * @param index the wiki name of the desired user profile
191: * @return the user profile
192: * @see com.ecyrd.jspwiki.auth.user.UserDatabase#findByWikiName(java.lang.String)
193: */
194: public UserProfile findByWikiName(String index)
195: throws NoSuchPrincipalException {
196: UserProfile profile = findByAttribute(WIKI_NAME, index);
197: if (profile != null) {
198: return profile;
199: }
200: throw new NoSuchPrincipalException("Not in database: " + index);
201: }
202:
203: /**
204: * Returns all WikiNames that are stored in the UserDatabase
205: * as an array of WikiPrincipal objects. If the database does not
206: * contain any profiles, this method will return a zero-length
207: * array.
208: * @return the WikiNames
209: */
210: public Principal[] getWikiNames() throws WikiSecurityException {
211: if (c_dom == null) {
212: throw new IllegalStateException(
213: "FATAL: database does not exist");
214: }
215: Set principals = new HashSet();
216: NodeList users = c_dom.getElementsByTagName(USER_TAG);
217: for (int i = 0; i < users.getLength(); i++) {
218: Element user = (Element) users.item(i);
219: String wikiName = user.getAttribute(WIKI_NAME);
220: if (wikiName == null) {
221: log
222: .warn("Detected null wiki name in XMLUserDataBase. Check your user database.");
223: } else {
224: Principal principal = new WikiPrincipal(wikiName,
225: WikiPrincipal.WIKI_NAME);
226: principals.add(principal);
227: }
228: }
229: return (Principal[]) principals
230: .toArray(new Principal[principals.size()]);
231: }
232:
233: /**
234: * Initializes the user database based on values from a Properties object.
235: * The properties object must contain a file path to the XML database file
236: * whose key is {@link #PROP_USERDATABASE}.
237: * @see com.ecyrd.jspwiki.auth.user.UserDatabase#initialize(com.ecyrd.jspwiki.WikiEngine,
238: * java.util.Properties)
239: * @throws NoRequiredPropertyException if the user database cannot be located, parsed, or opened
240: */
241: public void initialize(WikiEngine engine, Properties props)
242: throws NoRequiredPropertyException {
243: File defaultFile = null;
244: if (engine.getRootPath() == null) {
245: log.warn("Cannot identify JSPWiki root path");
246: defaultFile = new File("WEB-INF/" + DEFAULT_USERDATABASE)
247: .getAbsoluteFile();
248: } else {
249: defaultFile = new File(engine.getRootPath() + "/WEB-INF/"
250: + DEFAULT_USERDATABASE);
251: }
252:
253: // Get database file location
254: String file = props.getProperty(PROP_USERDATABASE);
255: if (file == null) {
256: log.error("XML user database property " + PROP_USERDATABASE
257: + " not found; trying " + defaultFile);
258: c_file = defaultFile;
259: } else {
260: c_file = new File(file);
261: }
262:
263: log.info("XML user database at " + c_file.getAbsolutePath());
264:
265: buildDOM();
266: sanitizeDOM();
267: }
268:
269: private void buildDOM() {
270: // Read DOM
271: DocumentBuilderFactory factory = DocumentBuilderFactory
272: .newInstance();
273: factory.setValidating(false);
274: factory.setExpandEntityReferences(false);
275: factory.setIgnoringComments(true);
276: factory.setNamespaceAware(false);
277: try {
278: c_dom = factory.newDocumentBuilder().parse(c_file);
279: log.debug("Database successfully initialized");
280: c_lastModified = c_file.lastModified();
281: c_lastCheck = System.currentTimeMillis();
282: } catch (ParserConfigurationException e) {
283: log.error("Configuration error: " + e.getMessage());
284: } catch (SAXException e) {
285: log.error("SAX error: " + e.getMessage());
286: } catch (FileNotFoundException e) {
287: log
288: .info("User database not found; creating from scratch...");
289: } catch (IOException e) {
290: log.error("IO error: " + e.getMessage());
291: }
292: if (c_dom == null) {
293: try {
294: //
295: // Create the DOM from scratch
296: //
297: c_dom = factory.newDocumentBuilder().newDocument();
298: c_dom.appendChild(c_dom.createElement("users"));
299: } catch (ParserConfigurationException e) {
300: log.fatal("Could not create in-memory DOM");
301: }
302: }
303: }
304:
305: private void saveDOM() throws WikiSecurityException {
306: if (c_dom == null) {
307: log.fatal("User database doesn't exist in memory.");
308: }
309:
310: File newFile = new File(c_file.getAbsolutePath() + ".new");
311: try {
312: BufferedWriter io = new BufferedWriter(
313: new OutputStreamWriter(
314: new FileOutputStream(newFile), "UTF-8"));
315:
316: // Write the file header and document root
317: io.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
318: io.write("<users>\n");
319:
320: // Write each profile as a <user> node
321: Element root = c_dom.getDocumentElement();
322: NodeList nodes = root.getElementsByTagName(USER_TAG);
323: for (int i = 0; i < nodes.getLength(); i++) {
324: Element user = (Element) nodes.item(i);
325: io.write("<" + USER_TAG + " ");
326: io.write(LOGIN_NAME);
327: io.write("=\"" + user.getAttribute(LOGIN_NAME) + "\" ");
328: io.write(WIKI_NAME);
329: io.write("=\"" + user.getAttribute(WIKI_NAME) + "\" ");
330: io.write(FULL_NAME);
331: io.write("=\"" + user.getAttribute(FULL_NAME) + "\" ");
332: io.write(EMAIL);
333: io.write("=\"" + user.getAttribute(EMAIL) + "\" ");
334: io.write(PASSWORD);
335: io.write("=\"" + user.getAttribute(PASSWORD) + "\" ");
336: io.write(CREATED);
337: io.write("=\"" + user.getAttribute(CREATED) + "\" ");
338: io.write(LAST_MODIFIED);
339: io.write("=\"" + user.getAttribute(LAST_MODIFIED)
340: + "\" ");
341: io.write(" />\n");
342: }
343: io.write("</users>");
344: io.close();
345: } catch (IOException e) {
346: throw new WikiSecurityException(e.getLocalizedMessage());
347: }
348:
349: // Copy new file over old version
350: File backup = new File(c_file.getAbsolutePath() + ".old");
351: if (backup.exists()) {
352: if (!backup.delete()) {
353: log.error("Could not delete old user database backup: "
354: + backup);
355: }
356: }
357: if (!c_file.renameTo(backup)) {
358: log.error("Could not create user database backup: "
359: + backup);
360: }
361: if (!newFile.renameTo(c_file)) {
362: log.error("Could not save database: " + backup
363: + " restoring backup.");
364: if (!backup.renameTo(c_file)) {
365: log
366: .error("Restore failed. Check the file permissions.");
367: }
368: log.error("Could not save database: " + c_file
369: + ". Check the file permissions");
370: }
371: }
372:
373: private long c_lastCheck = 0;
374: private long c_lastModified = 0;
375:
376: private void checkForRefresh() {
377: long time = System.currentTimeMillis();
378:
379: if (time - c_lastCheck > 60 * 1000L) {
380: long lastModified = c_file.lastModified();
381:
382: if (lastModified > c_lastModified) {
383: buildDOM();
384: }
385: }
386: }
387:
388: /**
389: * Determines whether the user database shares user/password data with the
390: * web container; always returns <code>false</code>.
391: * @see com.ecyrd.jspwiki.auth.user.UserDatabase#isSharedWithContainer()
392: */
393: public boolean isSharedWithContainer() {
394: return false;
395: }
396:
397: /**
398: * @see com.ecyrd.jspwiki.auth.user.UserDatabase#rename(String, String)
399: */
400: public synchronized void rename(String loginName, String newName)
401: throws NoSuchPrincipalException, DuplicateUserException,
402: WikiSecurityException {
403: if (c_dom == null) {
404: log.fatal("Could not rename profile '" + loginName
405: + "'; database does not exist");
406: throw new IllegalStateException(
407: "FATAL: database does not exist");
408: }
409: checkForRefresh();
410:
411: // Get the existing user; if not found, throws NoSuchPrincipalException
412: UserProfile profile = findByLoginName(loginName);
413:
414: // Get user with the proposed name; if found, it's a collision
415: try {
416: UserProfile otherProfile = findByLoginName(newName);
417: if (otherProfile != null) {
418: throw (new DuplicateUserException(
419: "Cannot rename: the login name '" + newName
420: + "' is already taken."));
421: }
422: } catch (NoSuchPrincipalException e) {
423: // Good! That means it's safe to save using the new name
424: }
425:
426: // Find the user with the old login id attribute, and change it
427: NodeList users = c_dom.getElementsByTagName(USER_TAG);
428: for (int i = 0; i < users.getLength(); i++) {
429: Element user = (Element) users.item(i);
430: if (user.getAttribute(LOGIN_NAME).equals(loginName)) {
431: Date modDate = new Date(System.currentTimeMillis());
432: setAttribute(user, LOGIN_NAME, newName);
433: setAttribute(user, LAST_MODIFIED, c_format
434: .format(modDate));
435: profile.setLoginName(newName);
436: profile.setLastModified(modDate);
437: break;
438: }
439: }
440:
441: // Commit to disk
442: saveDOM();
443: }
444:
445: /**
446: * Saves a {@link UserProfile}to the user database, overwriting the
447: * existing profile if it exists. The user name under which the profile
448: * should be saved is returned by the supplied profile's
449: * {@link UserProfile#getLoginName()}method.
450: * @param profile the user profile to save
451: * @throws WikiSecurityException if the profile cannot be saved
452: */
453: public synchronized void save(UserProfile profile)
454: throws WikiSecurityException {
455: if (c_dom == null) {
456: log.fatal("Could not save profile " + profile
457: + " database does not exist");
458: throw new IllegalStateException(
459: "FATAL: database does not exist");
460: }
461:
462: checkForRefresh();
463:
464: String index = profile.getLoginName();
465: NodeList users = c_dom.getElementsByTagName(USER_TAG);
466: Element user = null;
467: boolean isNew = true;
468: for (int i = 0; i < users.getLength(); i++) {
469: Element currentUser = (Element) users.item(i);
470: if (currentUser.getAttribute(LOGIN_NAME).equals(index)) {
471: user = currentUser;
472: isNew = false;
473: break;
474: }
475: }
476: Date modDate = new Date(System.currentTimeMillis());
477: if (isNew) {
478: profile.setCreated(modDate);
479: log.info("Creating new user " + index);
480: user = c_dom.createElement(USER_TAG);
481: c_dom.getDocumentElement().appendChild(user);
482: setAttribute(user, CREATED, c_format.format(profile
483: .getCreated()));
484: }
485: setAttribute(user, LAST_MODIFIED, c_format.format(modDate));
486: setAttribute(user, LOGIN_NAME, profile.getLoginName());
487: setAttribute(user, FULL_NAME, profile.getFullname());
488: setAttribute(user, WIKI_NAME, profile.getWikiName());
489: setAttribute(user, EMAIL, profile.getEmail());
490:
491: // Hash and save the new password if it's different from old one
492: String newPassword = profile.getPassword();
493: if (newPassword != null && !newPassword.equals("")) {
494: String oldPassword = user.getAttribute(PASSWORD);
495: if (!oldPassword.equals(newPassword)) {
496: setAttribute(user, PASSWORD, SHA_PREFIX
497: + getHash(newPassword));
498: }
499: }
500:
501: // Set the profile timestamps
502: if (isNew) {
503: profile.setCreated(modDate);
504: }
505: profile.setLastModified(modDate);
506:
507: // Commit to disk
508: saveDOM();
509: }
510:
511: /**
512: * Private method that returns the first {@link UserProfile}matching a
513: * <user> element's supplied attribute.
514: * @param matchAttribute
515: * @param index
516: * @return the profile, or <code>null</code> if not found
517: */
518: private UserProfile findByAttribute(String matchAttribute,
519: String index) {
520: if (c_dom == null) {
521: throw new IllegalStateException(
522: "FATAL: database does not exist");
523: }
524:
525: checkForRefresh();
526:
527: NodeList users = c_dom.getElementsByTagName(USER_TAG);
528: for (int i = 0; i < users.getLength(); i++) {
529: Element user = (Element) users.item(i);
530: if (user.getAttribute(matchAttribute).equals(index)) {
531: UserProfile profile = new DefaultUserProfile();
532: profile.setLoginName(user.getAttribute(LOGIN_NAME));
533: profile.setFullname(user.getAttribute(FULL_NAME));
534: profile.setPassword(user.getAttribute(PASSWORD));
535: profile.setEmail(user.getAttribute(EMAIL));
536: String created = user.getAttribute(CREATED);
537: String modified = user.getAttribute(LAST_MODIFIED);
538:
539: profile.setCreated(parseDate(profile, created));
540: profile.setLastModified(parseDate(profile, modified));
541:
542: return profile;
543: }
544: }
545: return null;
546: }
547:
548: /**
549: * Tries to parse a date using the default format - then, for backwards
550: * compatibility reasons, tries the platform default.
551: *
552: * @param profile
553: * @param date
554: * @return A parsed date, or null, if both parse attempts fail.
555: */
556: private Date parseDate(UserProfile profile, String date) {
557: try {
558: return c_format.parse(date);
559: } catch (ParseException e) {
560: try {
561: return c_defaultFormat.parse(date);
562: } catch (ParseException e2) {
563: log.warn("Could not parse 'created' or 'lastModified' "
564: + "attribute for " + " profile '"
565: + profile.getLoginName() + "'."
566: + " It may have been tampered with.");
567: }
568: }
569: return null;
570: }
571:
572: /**
573: * After loading the DOM, this method sanity-checks the dates in the DOM and makes
574: * sure they are formatted properly. This is sort-of hacky, but it should work.
575: */
576: private void sanitizeDOM() {
577: if (c_dom == null) {
578: throw new IllegalStateException(
579: "FATAL: database does not exist");
580: }
581:
582: NodeList users = c_dom.getElementsByTagName(USER_TAG);
583: for (int i = 0; i < users.getLength(); i++) {
584: Element user = (Element) users.item(i);
585: String loginName = user.getAttribute(LOGIN_NAME);
586: String created = user.getAttribute(CREATED);
587: String modified = user.getAttribute(LAST_MODIFIED);
588: try {
589: created = c_format.format(c_format.parse(created));
590: modified = c_format.format(c_format.parse(modified));
591: user.setAttribute(CREATED, created);
592: user.setAttribute(LAST_MODIFIED, modified);
593: } catch (ParseException e) {
594: try {
595: created = c_format.format(c_defaultFormat
596: .parse(created));
597: modified = c_format.format(c_defaultFormat
598: .parse(modified));
599: user.setAttribute(CREATED, created);
600: user.setAttribute(LAST_MODIFIED, modified);
601: } catch (ParseException e2) {
602: log
603: .warn("Could not parse 'created' or 'lastModified' "
604: + "attribute for "
605: + " profile '"
606: + loginName
607: + "'."
608: + " It may have been tampered with.");
609: }
610: }
611: }
612: }
613:
614: /**
615: * Private method that sets an attibute value for a supplied DOM element.
616: * @param element the element whose attribute is to be set
617: * @param attribute the name of the attribute to set
618: * @param value the desired attribute value
619: */
620: private void setAttribute(Element element, String attribute,
621: String value) {
622: if (value != null) {
623: element.setAttribute(attribute, value);
624: }
625: }
626: }
|