/**
* Copyright (c) 2007, Markus Jevring <markus@jevring.net>
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. The names of the contributors may not be used to endorse or promote
* products derived from this software without specific prior written
* permission.
*
* THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*
*/
/**
* @author Markus Jevring <markus@jevring.net>
* @since 2007-maj-14 : 23:29:48
*/
package cu.ftpd.user.userbases.local;
import cu.authentication.AuthenticationRequest;
import cu.ftpd.Server;
import cu.ftpd.logging.Logging;
import cu.ftpd.user.User;
import cu.ftpd.user.groups.*;
import cu.ftpd.user.userbases.*;
import cu.ftpd.user.userbases.changetracking.ChangeTracker;
import cu.ftpd.user.userbases.changetracking.Modification;
import cu.ftpd.user.userbases.changetracking.UserbaseChange;
import java.io.*;
import java.util.*;
/**
* @author Markus Jevring <markus@jevring.net>
* @version $Id: LocalUserbase.java 286 2008-12-06 00:36:20Z jevring $
*/
public class LocalUserbase implements Userbase {
private final File userdir;
private final Random random = new Random();
private final Map<String, LocalUser> users = Collections.synchronizedMap(new HashMap<String, LocalUser>());
private final GroupManager groupManager;
private final Object groupLock = new Object();
private final Leechers leechers;
private final Allotments allotments;
private final ChangeTracker changeTracker;
public LocalUserbase(File dataDirectory, boolean allotment, ChangeTracker changeTracker) throws IOException {
this.changeTracker = changeTracker;
userdir = new File(dataDirectory, "users");
if (!userdir.exists()) {
throw new IOException("Directory '" + userdir.getAbsolutePath() + "' doesn't exist");
}
//long start = System.currentTimeMillis();
// NOTE: these need to be loaded before we load the users, since they keep track of the ratio for the user
leechers = new Leechers(new File(dataDirectory, "leechers.conf"));
allotments = new Allotments(new File(dataDirectory, "allotments.conf"));
loadUsers();
groupManager = new GroupManager(new File(dataDirectory, "groups"), changeTracker);
//System.out.println("Userbase initialization done.");
//System.out.println("USERBASE INITIALIZE took " + (System.currentTimeMillis() - start) + " milliseconds");
if (allotment) {
new Timer(true).schedule(new AllotmentTask(this), 5 * 1000, 86400 * 1000);
}
}
public LocalUser getUser(String username) throws NoSuchUserException {
LocalUser user = users.get(username);
if (user != null) {
return user;
} else {
throw new NoSuchUserException(username);
}
}
public LocalGroup getGroup(String name) throws NoSuchGroupException {
return groupManager.getGroup(name);
}
public Map<String, ? extends User> getUsers() {
return users;
}
public String createHashedPassword(String password) {
byte[] salt = new byte[4];
random.nextBytes(salt);
String hashedPassword = PasswordHasher.cuftpdHash(password.toCharArray(), salt);
return '$' + new String(Hex.bytesToHex(salt)) + '$' + hashedPassword;
}
public int authenticate(AuthenticationRequest auth) {
User user = users.get(auth.getUsername());
if (user == null) {
return AuthenticationResponses.NO_SUCH_USER;
} else if (user.isSuspended()) {
return AuthenticationResponses.USER_SUSPENDED;
} else {
boolean identOk = checkIdent(auth, user);
if (identOk) {
return checkPassword(user, auth);
} else {
return AuthenticationResponses.BAD_IDENT_OR_HOST;
// user connected from somewhere that wasn't allowed
// don't tell the user why it failed, just fail.
// if we would tell the user why it failed just after the USER command, then an attacker would know that it's a valid user
// if we tell the user after the password, but still say why it failed, the attacker will know that it's a valid user
}
}
}
private int checkPassword(User user, AuthenticationRequest auth) {
String[] data = user.getHashedPassword().split("\\$",3);
if (data.length == 3) {
String salt = data[1];
String hashedPasswordFromFile = data[2];
// Note: we can't hash the password before we get in to here, which is cool when we're in system memory, but is more critical when we are doing RMI.
// but since the RMI connection is protected by SSL, we get the same security as over SSH
String hashedSuppliedPassword = PasswordHasher.cuftpdHash(auth.getPassword().toCharArray(), Hex.hexToBytes(salt));
// note: something could go wrong with the hashing. where should we write about that? (if anywhere). debuglog/stderr maybe?
return (hashedPasswordFromFile.equals(hashedSuppliedPassword) ? AuthenticationResponses.OK : AuthenticationResponses.BAD_PASSWORD);
} else {
return AuthenticationResponses.BAD_PASSWORD;
}
// note: we have to create the hash here, since that's different for each userbase implementation
}
private boolean checkIdent(AuthenticationRequest auth, User user) {
String userHostNamePattern = auth.getIdent() + '@' + auth.getHost().getHostName();
String userHostAddressPattern = auth.getIdent() + '@' + auth.getHost().getHostAddress();
for (String ip : user.getIps()) {
// note the direction of the conversion of "." to "\\.", which is to make sure that "." isn't treated as a wild card
ip = ip.replaceAll("\\.", "\\\\.").replaceAll("\\*", ".*").replaceAll("\\?", ".?");
if (userHostAddressPattern.matches(ip) || userHostNamePattern.matches(ip)) {
return true;
}
}
return false;
}
@Override
public void deleteGroup(String groupname) throws NoSuchGroupException {
deleteGroup(groupname, true);
}
public void deleteGroup(String groupname, boolean propagate) throws NoSuchGroupException {
synchronized(groupLock) {
if (groupManager.getGroup(groupname) != null) {
synchronized(users) {
leechers.remoteAllLeechForGroup(groupname);
for (User u : users.values()) {
u.setLeech(leechers.hasLeech(u.getUsername()));
}
}
allotments.removeAllAllotmentsForGroup(groupname);
groupManager.deleteGroup(groupname);
for (User user : users.values()) {
if (user.isMemberOfGroup(groupname)) {
user.removeGroup(groupname);
}
}
}
if (propagate) {
changeTracker.addChange(new UserbaseChange(UserbaseChange.Property.Group, Modification.Remove, groupname));
}
}
}
@Override
public void deleteUser(String username) throws NoSuchUserException {
deleteUser(username, true);
}
public void deleteUser(String username, boolean propagate) throws NoSuchUserException {
synchronized(users) {
User user = users.remove(username);
if (user == null) {
throw new NoSuchUserException(username);
}
Server.getInstance().kick(username, "deleted");
File userfile = new File(userdir, username);
@SuppressWarnings("unused")
boolean deleted = userfile.delete();
// no need to terminate here, the site-command handles that
leechers.removeAllLeechForUser(username);
allotments.removeAllAllotmentsForUser(username);
if (propagate) {
changeTracker.addChange(new UserbaseChange(UserbaseChange.Property.User, Modification.Remove, username));
}
}
}
public void store(User user) {
//System.out.println("storing user: " + user.getUsername());
// we would like to assert this here, but since my IDE complains about not knowing which METHOD assert is, even though it highlights it, we're skipping it
//assert(user instanceof LocalUser);
BufferedWriter out = null;
try {
// we should be the only one writing to these files, so there's no use getting a lock
out = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(new File(userdir, user.getUsername()))));
out.write(user.toString());
out.flush();
//System.out.println("stored userfile for " + user.getUsername());
} catch (IOException e) {
Logging.getErrorLog().reportCritical("Failed to store user to disk: " + e.getMessage());
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
Logging.getErrorLog().reportException("Failed to close file", e);
//e.printStackTrace();
}
}
}
}
/**
* Creates a user and adds it to the specified group.
*
* @param groupname the name of the group in which to add the user.
* @param username the username of the user to add.
* @param password the (cleartext) password of the user.
* @param checkSpace if set to <tt>true</tt> the space in the group will be checked before adding.
* @return the newly created user, or <tt>null</tt> if <tt>checkSpace</tt> was set to <tt>true</tt> and the group was already full.
* @throws cu.ftpd.user.groups.NoSuchGroupException thrown if the specified group doesn't exist.
* @throws UserExistsException thrown if there is already a user with the specified username.
*/
public User gAddUser(String groupname, String username, String password, boolean checkSpace) throws NoSuchGroupException, UserExistsException, IllegalArgumentException, GroupLimitReachedException {
synchronized(groupLock) {
Group group = groupManager.getGroup(groupname);
if (checkSpace) {
int numberOfUsersAlreadyInGroup = 0;
synchronized(users) {
for (User user : users.values()) {
if (user.isMemberOfGroup(groupname)) {
numberOfUsersAlreadyInGroup++;
}
}
}
if (group.getSlots() <= numberOfUsersAlreadyInGroup) {
User user = addUser(username, password);
user.addGroup(groupname);
user.setPrimaryGroup(groupname);
return user;
} else {
throw new GroupLimitReachedException("slots", group.getName());
}
} else {
User user = addUser(username, password);
user.addGroup(groupname);
user.setPrimaryGroup(groupname);
return user;
}
}
}
@Override
public User addUser(String username, String password) throws UserExistsException {
return addUser(username, password, true);
}
public LocalUser addUser(String username, String password, boolean propagate) throws UserExistsException {
synchronized(users) {
if (!users.containsKey(username)) {
LocalUser user = new LocalUser(username, createHashedPassword(password), this, changeTracker);
users.put(username, user);
store(user);
if (propagate) {
changeTracker.addChange(new UserbaseChange(UserbaseChange.Property.User, Modification.Add, username, password));
}
return user;
} else {
throw new UserExistsException(username);
}
}
}
public void renameGroup(String oldName, String newName) throws NoSuchGroupException, GroupExistsException {
throw new IllegalStateException("Group renames are not allowed at this time!");
/*
synchronized (groupLock) {
groupManager.renameGroup(oldName, newName);
synchronized(users) { // NOTE: nested locks, watch for deadlock/race
for (User user : users.values()) {
if (user.isMemberOfGroup(oldName)) {
user.removeGroup(oldName);
user.addGroup(newName);
// renaming groups?
// if we translate groups to numbers, then we have to re-read the permissions each time a group name changes or a group is added, so we can re-resolve groupnames that didn't resolve previously for rules
// maybe we should just not allow a group to be renamed
// we must change the values in leechers and allotments as well
// now that we have so much to change for each thing, wouldn't it be better to address groups by their number?
// the troubles are that userfiels become harder to read
// we must convert group numbers to group names for the permissions EACH TIME
// OR we will modify the permissions to resolve the groupnames to IDs when the parsing is done
// think on this
if (oldName.equals(user.getPrimaryGroup())) {
user.setPrimaryGroup(newName);
}
}
}
}
}
*/
}
@Override
public Group createGroup(String name, String description) throws GroupExistsException {
return createGroup(name, description, true);
}
public Group createGroup(String name, String description, boolean propagate) throws GroupExistsException {
final Group group = groupManager.createGroup(0, 0, 0, 0, name, description);
if (propagate) {
changeTracker.addChange(new UserbaseChange(UserbaseChange.Property.Group, Modification.Add, name, description));
}
return group;
}
/**
* Sets the ratio for the specified user in the specified group.
* If a group is provided and <code>checkAvailability</code> is set to <code>true</code> and <code>ratio</code> is set to <code>0</code> then a check is performed.
* This check counts the number of "ratio 0" users currently in the group, and compares this to the max number of allowed "ratio 0" users.
* If the current number is less than the allowed, the ratio for the user is changed.
* If not, then this will return false.
* <p/>
* NOTE: This function MUST lock on some object to prevent simultaneous update of the users (which could be exploited to grant more leech slots than intended)
*
* @param leech true if the user is supposed to have leech in the specified group, false otherwise
* @param username the user to set the ratio for.
* @param groupname the group from which to use possible leech slots.
* @param checkAvailability true if we check if there are any leech slots available in the group, if applicable. @return true if the ratio was successfully set for the user, false otherwise. (See above) @throws cuftpd.user.authentication.userbases.NoSuchUserException
* thrown if the specified user can't be found
* @throws cu.ftpd.user.groups.NoSuchGroupException
* thrown if the specified group can't be found
* @return true if the operation was succesful, false otherwise
*/
public boolean setLeechForUser(boolean leech, String username, String groupname, boolean checkAvailability) throws NoSuchUserException, NoSuchGroupException {
/**
PROBLEMS:
- the number of leech slots for a group changes (who gets removed, does anybody get removed)
- files get changed manually when the system is down, which causes discrepancies in the data (this could be fixable with a database with constraints)
- possible changes: slots change to too few, users change to too many
: this can be fixed by keeping the names of the leechers/alloters in add-order, so that if the number of people of number of slots changes, they people who overflow will be the last people to get leech/allotment, and they will be removed
[Have a special group in the group file that represents the generic users.]
-1 for each slot type means that it is unlimited, which the case will be for all the slot types in the generic group (unless the siteop sets it differently)(this can also be specified for allotment size)
This CAN be doable by having a list for each group of who is in their leech slots.
once a person is in the leech slot for 0 groups, he reverts back to $DEFAULT_RATIO (which is the no ratio, a.k.a the ratio of the section).
This might be confusing for the gadmins, so we need to add extra documentation about it.
Also, have a pool of unlimited leech accounts that people with USEREDIT can set. If a user has leech, he needs to be registered in the generic group of leech users
reply: 200 User $username no longer uses a leech slot for group $group
on every ratio change for a user, check to see if he is still in a leech slot for any group
if he is, leave the ratio as 0
if he's not, change the ratio to N
if a USEREDIT user changes the ratio, then this overrides all group ratios
if a USEREDIT user sets ratio to 0, nothing in the groups change, the user gets ratio 0, and is added to the generic group of leechers.
if a USEREDIT user sets ratio to N>0, the user is removed from leech spots for all the groups he was currently in.
NOTE: if someone alters the list of leech users for groups or general, and doesn't change the ratio in the userfile, this all goes to hell.
This can be fixed by not having a ratio in the userfile, but deriving it from the other files on startup, but this is cumbersome.
actually, it's quite simple:
for each user {
for each group in {user.getGroups + $GENERIC} {
if user.hasLeechIn(group) {
user.setRatio(0)
}
}
}
(Default ratio is the ratio of the section)
Thus, users cannot use leech slots for groups they are not a member of.
CHECK AVAILABILITY
When ratio 0 is set for a user by a gadmin, the group for which the setting applies needs to be specified.
This way we don't have to check availability in all the groups, just the one that is specified.
Do all this in the userbase, so that there can't be any funny business with changed from 2 places at the same time (locking)
can we have a change trigger on group and user files, so we can detect when it has been changed?
yes we can, by checking .lastModified() on the file with a timer, but why would we need it?
the only one we'd be interested in watching is the permissions-file, but that is changed as soon as someone logs on, so that's not needed.
*/
synchronized(groupLock) {
User user = getUser(username);
if (groupname == null) {
groupname = "default";
}
if (checkAvailability) {
if (leech) {
Group group = getGroup(groupname);
List<LeechEntry> l = leechers.getLeechersForGroup(groupname);
if (l.size() < group.getLeechSlots()) {
// we can add without a problem
leechers.addLeecher(new LeechEntry(username, groupname));
} else {
// we can't add
return false;
// we could have checked if the user was in the list, and returned true then, but it doesn't matter, so we just return false.
}
} else {
leechers.removeLeecher(new LeechEntry(username, groupname));
return true;
}
} else {
// this also implies that we are using the general or whole site 'group'
if (leech) {
leechers.addLeecher(new LeechEntry(username, groupname));
} else {
// remove the user from the leech users for the general group
leechers.removeLeecher(new LeechEntry(username, groupname));
}
}
user.setLeech(leechers.hasLeech(username));
leechers.save();
return true;
}
}
/**
* Sets the allotment for the specified user in the specified group.
* If a group is provided and <code>checkAvailability</code> is set to <code>true</code> then a check is performed.
* This check counts the number of allotment users already present in the group, and if that is more than the number of allowed allotment users, it will return <code>false</code>.
* It also checks that the allotment is within the accpetable range set for the group in question.
* <p/>
* NOTE: This function MUST lock on some object to prevent simultaneous update of the users (which could be exploited to grant more allotment slots than intended)
*
* @param allotment the amount of credits to allot every week.
* @param username the username to which to allot the credits.
* @param groupname the groupname for which we are checking the slots.
* @param checkAvailability true if we are doing checks as per above, false otherwise.
* @return true if the allotment was set to the user properly, false otherwise.
* @throws cu.ftpd.user.userbases.NoSuchUserException
* thrown if the specified user can't be found
* @throws cu.ftpd.user.groups.NoSuchGroupException
* thrown if the specified group can't be found
*/
public boolean setAllotmentForUser(long allotment, String username, String groupname, boolean checkAvailability) throws NoSuchUserException, NoSuchGroupException {
synchronized(groupLock) {
if (allotment == 0) {
allotments.removeAllotment(username, groupname);
allotments.save();
} else {
User user = getUser(username);
if (checkAvailability) {
// check the size of the allotment first
// then check if there are any allotment slots left in this group
Group group = getGroup(groupname);
if (allotment <= group.getMaxAllotment()) {
List<Allotment> l = allotments.getAllotmentsForGroup(groupname);
if (l.size() < group.getAllotmentSlots()) {
allotments.addAllotment(new Allotment(user.getUsername(), groupname, allotment));
allotments.save();
} else {
return false;
}
} else {
return false;
}
} else {
// if we're not checking availability, we are implicitly saying that this is in the general group
// no, an admin can chose to add a user to one of the leech slots for a certain group
allotments.addAllotment(new Allotment(user.getUsername(), groupname, allotment));
allotments.save();
}
}
}
return true;
}
public Map<String, ? extends Group> getGroups() {
return groupManager.getGroups();
}
/**
* Checks the Leechers object to see if this user uses a leech slot for the specified group.
*/
public boolean userUsesLeechSlotForGroup(String username, String groupname) {
return leechers.userUsesLeechSlotForGroup(username, groupname);
}
/**
* Checks the Allotments object to see if this user users an allotment slot for the specified group.
*/
public boolean userUsesAllotmentSlotForGroup(String username, String groupname) {
return allotments.userUsesAllotmentSlotForGroup(username, groupname);
}
public List<Allotment> getAllotments(String groupname) {
if (groupname == null) {
return allotments.getAllotments();
} else {
return allotments.getAllotmentsForGroup(groupname);
}
}
public void saveGroups() {
groupManager.save();
}
public List<LeechEntry> getLeechers(String groupname) {
if (groupname == null) {
return leechers.getLeechers();
} else {
return leechers.getLeechersForGroup(groupname);
}
}
public void runAllotment() {
//System.err.println("running allotment");
long time = System.currentTimeMillis();
synchronized(users) {
Calendar now = Calendar.getInstance();
Calendar lastUpdate = Calendar.getInstance();
lastUpdate.setTimeInMillis(allotments.getLastAllotment());
if (lastUpdate.get(Calendar.WEEK_OF_YEAR) == now.get(Calendar.WEEK_OF_YEAR)) {
// this means that we have already run the allotment for this week
//System.out.println("we've already done allotments this week");
return;
}
Iterator<Allotment> i = allotments.getAllotments().iterator();
// we use the iterator, since we might need to remove allotments if users have disappeared.
while (i.hasNext()) {
Allotment a = i.next();
try {
User user = getUser(a.getUsername());
// there is a problem here, and that is that if a person has gotten alloted, then gets a new allotment, then the user will get that allotment next
// time the allotment is run, so some gadmin can just add and remove allotments to pump up someone's credits
// thus, we should keep a list of when users were alloted, rather than when a particular allotment happened (userfile or something general)
// AND it should be run something like every 6 hours or something. it could really be run every week, but since we don't know if people will be online all the time...
// we should probably put this value in the userfile. it's a simple place to put it. OR we put it in the allotment object that keeps track of everything, that's much better
// _todo: put the LAST_ALLOTED value at the top of allotments.conf, and remove the individual allotment times
// done
if (user.getCredits() < a.getAllotment()) {
user.setCredits(a.getAllotment());
//System.err.println("gave " + a.getAllotment() + " to user " + a.getUsername() + " in group " + a.getGroupname());
} else {
//System.err.println("skipping " + a.getUsername() + " since he already got credits this time around");
}
} catch (NoSuchUserException e) {
// this user might have been removed, but still has allotments, remove it and carry on.
i.remove();
}
}
// only update if we actually run the allotments
allotments.setLastAllotment(time);
}
allotments.save();
// this will check the user for allotment-information, and when it was last sent to the users.
// when it does, it goes over all users, and if they have less than the alloted credits, sets the credits to the alloted amount
// if they have more, it leaves them
// it then sets the time (now) of when it last alloted people credits.
// this is done so that if it goes down when it is about to perform allotments, it will know this, because the date isn't updated.
// make this a command too, so that it can be run from the site to run again, according to the same rules above (so users don't have to wait another week for their stuff)
// normally, run this once per day, at midnight.
// have data for when each of the users was updated, so that if we were stopped in mid stream, we can continue from there
// also, if new users are added after the run, the allotment will give them their credits, since their update time will be 0
}
private void loadUsers() {
for (File userfile : userdir.listFiles()) {
if (userfile.isFile()) {
// the password, here null, will get filled in by user.fill() after.
LocalUser user = new LocalUser(userfile.getName(), null, this, changeTracker);
user.fill(userfile);
users.put(user.getUsername(), user);
}
}
}
public boolean hasLeech(String username) {
return leechers.hasLeech(username);
}
}