diff --git a/PUP/Authentication.cs b/PUP/Authentication.cs
new file mode 100644
index 0000000..ff02ac4
--- /dev/null
+++ b/PUP/Authentication.cs
@@ -0,0 +1,300 @@
+using IFS.Logging;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace IFS
+{
+
+
+ ///
+ /// Provides very (very) rudimentary security.
+ /// Exposes facilities for user authentication (via password) and
+ /// access control.
+ ///
+ /// Since all IFS transactions are done in plaintext, I don't want to expose real NTLM passwords,
+ /// (or deal with the security issues that would entail)
+ /// so IFS usernames/passwords are completely separate entities from Windows auth
+ /// and access is controlled very coarsely. (More fine-grained ACLs are really overkill for the
+ /// use-cases we need for IFS).
+ ///
+ /// Accounts are split into two categories: Users and Administrators.
+ /// Users can read any file, but can only write files in their home directory.
+ /// Administrators can read/write files in any directory.
+ ///
+ public static class Authentication
+ {
+ static Authentication()
+ {
+ ReadAccountDatabase();
+ }
+
+ public static List EnumerateUsers()
+ {
+ return _accounts.Values.ToList();
+ }
+
+ public static UserToken Authenticate(string userName, string password)
+ {
+ //
+ // Look up the user
+ //
+ UserToken token = null;
+
+ if (_accounts.ContainsKey(userName))
+ {
+ UserToken accountToken = _accounts[userName];
+
+ //
+ // Account exists; compare password hash against the hash of the password provided.
+ // (If there is no hash then no password is set and we do no check.)
+ //
+ if (!string.IsNullOrEmpty(accountToken.PasswordHash))
+ {
+ // Convert hash to base64 string and compare with actual password hash
+ if (ValidatePassword(accountToken, password))
+ {
+ // Yay!
+ token = accountToken;
+ }
+ else
+ {
+ // No match, password is incorrect.
+ token = null;
+ }
+ }
+ else if (string.IsNullOrEmpty(password))
+ {
+ // Just ensure both passwords are empty.
+ token = accountToken;
+ }
+ }
+
+ return token;
+ }
+
+ public static bool AddUser(string userName, string password, string fullName, string homeDirectory, IFSPrivileges privileges)
+ {
+ bool bSuccess = false;
+
+ if (!_accounts.ContainsKey(userName))
+ {
+ // Add the user to the database
+ UserToken newUser = new UserToken(userName, String.Empty, fullName, homeDirectory, privileges);
+ _accounts.Add(userName, newUser);
+
+ // Set password (which has the side-effect of committing the database)
+ bSuccess = SetPassword(userName, password);
+ }
+
+ return bSuccess;
+ }
+
+ public static bool RemoveUser(string userName)
+ {
+ if (_accounts.ContainsKey(userName))
+ {
+ _accounts.Remove(userName);
+ WriteAccountDatabase();
+ return true;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Changes the user's password. This is intended to be executed from the IFS console or by
+ /// an authenticated administrator.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool SetPassword(string userName, string newPassword)
+ {
+ bool bSuccess = false;
+
+ //
+ // Look up the user
+ //
+ if (_accounts.ContainsKey(userName))
+ {
+ UserToken accountToken = _accounts[userName];
+
+ if (!string.IsNullOrEmpty(newPassword))
+ {
+ // Calculate hash of new password
+ SHA1 sha = new SHA1CryptoServiceProvider();
+
+ byte[] passwordHash = sha.ComputeHash(Encoding.UTF8.GetBytes(newPassword));
+
+ // Convert hash to base64 string and compare with actual password hash
+ accountToken.PasswordHash = Convert.ToBase64String(passwordHash);
+ }
+ else
+ {
+ // Just set an empty password.
+ accountToken.PasswordHash = String.Empty;
+ }
+
+ // Commit to accounts file
+ WriteAccountDatabase();
+
+ bSuccess = true;
+ }
+
+ return bSuccess;
+ }
+
+ private static bool ValidatePassword(UserToken accountToken, string password)
+ {
+ // Convert to UTF-8 byte array
+ byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
+
+ // And hash
+ SHA1 sha = new SHA1CryptoServiceProvider();
+
+ byte[] passwordHash = sha.ComputeHash(passwordBytes);
+
+ // Convert hash to base64 string and compare with actual password hash
+ return accountToken.PasswordHash == Convert.ToBase64String(passwordHash);
+ }
+
+ private static void WriteAccountDatabase()
+ {
+ using (StreamWriter accountStream = new StreamWriter(Path.Combine("Conf", "accounts.txt")))
+ {
+ accountStream.WriteLine("# accounts.txt:");
+ accountStream.WriteLine("#");
+ accountStream.WriteLine("# The format for a user account is:");
+ accountStream.WriteLine("# ::[Admin|User]::");
+ accountStream.WriteLine("#");
+
+ foreach(UserToken account in _accounts.Values)
+ {
+ accountStream.WriteLine(account.ToString());
+ }
+ }
+ }
+
+ private static void ReadAccountDatabase()
+ {
+ _accounts = new Dictionary();
+
+ using (StreamReader accountStream = new StreamReader(Path.Combine("Conf", "accounts.txt")))
+ {
+ int lineNumber = 0;
+ while (!accountStream.EndOfStream)
+ {
+ lineNumber++;
+ string line = accountStream.ReadLine().Trim();
+
+ if (string.IsNullOrEmpty(line))
+ {
+ // Empty line, ignore.
+ continue;
+ }
+
+ if (line.StartsWith("#"))
+ {
+ // Comment to EOL, ignore.
+ continue;
+ }
+
+ // Each entry is of the format:
+ // ::[Admin|User]::
+ //
+ // Find the ':' separating tokens and ensure there are exactly five
+ string[] tokens = line.Split(new char[] { ':' }, StringSplitOptions.None);
+
+ if (tokens.Length != 5)
+ {
+ Log.Write(LogType.Warning, LogComponent.Configuration,
+ "accounts.txt line {0}: Invalid syntax.", lineNumber);
+ }
+
+ IFSPrivileges privs;
+
+ switch(tokens[2].ToLowerInvariant())
+ {
+ case "admin":
+ privs = IFSPrivileges.ReadWrite;
+ break;
+
+ case "user":
+ privs = IFSPrivileges.ReadOnly;
+ break;
+
+ default:
+ Log.Write(LogType.Warning, LogComponent.Configuration,
+ "accounts.txt line {0}: Invalid account type '{1}'.", lineNumber, tokens[2]);
+ continue;
+ }
+
+ UserToken token =
+ new UserToken(
+ tokens[0],
+ tokens[1],
+ tokens[3],
+ tokens[4],
+ privs);
+
+ if (_accounts.ContainsKey(tokens[0].ToLowerInvariant()))
+ {
+ Log.Write(LogType.Warning, LogComponent.Configuration,
+ "accounts.txt line {0}: Duplicate user entry for '{1}'.", lineNumber, tokens[0]);
+ continue;
+ }
+ else
+ {
+ _accounts.Add(tokens[0].ToLowerInvariant(), token);
+ }
+ }
+ }
+ }
+
+ private static Dictionary _accounts;
+ }
+
+ public enum IFSPrivileges
+ {
+ ReadOnly = 0, // Read-only except home directory
+ ReadWrite // Read/write everywhere
+ }
+
+ public class UserToken
+ {
+ public UserToken(string userName, string passwordHash, string fullName, string homeDirectory, IFSPrivileges privileges)
+ {
+ UserName = userName;
+ PasswordHash = passwordHash;
+ FullName = fullName;
+ HomeDirectory = homeDirectory;
+ Privileges = privileges;
+ }
+
+ public override string ToString()
+ {
+ return String.Format("{0}:{1}:{2}:{3}:{4}",
+ UserName,
+ PasswordHash,
+ Privileges == IFSPrivileges.ReadOnly ? "User" : "Admin",
+ FullName,
+ HomeDirectory);
+ }
+
+ public static UserToken Guest = new UserToken("guest", String.Empty, "No one", String.Empty, IFSPrivileges.ReadOnly);
+
+ public string UserName;
+ public string PasswordHash;
+ public string FullName;
+ public string HomeDirectory;
+ public IFSPrivileges Privileges;
+ }
+}
diff --git a/PUP/Conf/accounts.txt b/PUP/Conf/accounts.txt
new file mode 100644
index 0000000..8f27008
--- /dev/null
+++ b/PUP/Conf/accounts.txt
@@ -0,0 +1,10 @@
+# accounts.txt:
+#
+#
+# The format for a user account is:
+# ::[Admin|User]::
+#
+
+jdersch::Admin:Josh Dersch:jdersch
+luser::User:Joe Luser:luser
+
diff --git a/PUP/Entrypoint.cs b/PUP/Entrypoint.cs
index 20bc8b9..be7ed01 100644
--- a/PUP/Entrypoint.cs
+++ b/PUP/Entrypoint.cs
@@ -97,6 +97,12 @@ namespace IFS
private static void RunCommandPrompt()
{
+ List users = Authentication.EnumerateUsers();
+
+ Authentication.SetPassword(users[0].UserName, "hamdinger");
+
+ UserToken user = Authentication.Authenticate(users[0].UserName, "hamdinger");
+
while (true)
{
Console.Write(">>>");
diff --git a/PUP/FTP/FTPServer.cs b/PUP/FTP/FTPServer.cs
index e5f0f26..9f3ef62 100644
--- a/PUP/FTP/FTPServer.cs
+++ b/PUP/FTP/FTPServer.cs
@@ -310,6 +310,11 @@ namespace IFS.FTP
return;
}
+ if (AuthenticateUser(fileSpec) == null)
+ {
+ return;
+ }
+
List files = EnumerateFiles(fullPath);
@@ -355,6 +360,11 @@ namespace IFS.FTP
return;
}
+ if (AuthenticateUser(fileSpec) == null)
+ {
+ return;
+ }
+
List files = EnumerateFiles(fullPath);
// Send each list to the user, followed by the actual file data.
@@ -426,6 +436,25 @@ namespace IFS.FTP
return;
}
+ UserToken userToken = AuthenticateUser(fileSpec);
+
+ if (userToken == null)
+ {
+ return;
+ }
+
+ //
+ // Check the privileges of the user. If the user has write permissions
+ // then we are OK to store. Otherwise, we must be writing to the user's directory.
+ //
+ string fullFileName = Path.Combine(Configuration.FTPRoot, fullPath);
+ if (userToken.Privileges != IFSPrivileges.ReadWrite &&
+ !IsUserDirectory(userToken, fullFileName))
+ {
+ SendFTPNoResponse(NoCode.AccessDenied, "This account is not allowed to store files here.");
+ return;
+ }
+
if (newStore)
{
//
@@ -455,8 +484,7 @@ namespace IFS.FTP
//
// At this point the client should start sending data, so we should start receiving it.
- //
- string fullFileName = Path.Combine(Configuration.FTPRoot, fullPath);
+ //
bool success = true;
FTPCommand lastMark;
byte[] buffer;
@@ -525,7 +553,7 @@ namespace IFS.FTP
}
///
- /// Deletes the files matching the requested file specification and sends them to the client, one at a time.
+ /// Deletes the files matching the requested file specification, one at a time.
///
///
private void DeleteFiles(PropertyList fileSpec)
@@ -537,6 +565,25 @@ namespace IFS.FTP
return;
}
+ UserToken userToken = AuthenticateUser(fileSpec);
+
+ if (userToken == null)
+ {
+ return;
+ }
+
+ //
+ // Check the privileges of the user. If the user has write permissions
+ // then we are OK to delete. Otherwise, we must be deleteing files in the user's directory.
+ //
+ string fullFileName = Path.Combine(Configuration.FTPRoot, fullPath);
+ if (userToken.Privileges != IFSPrivileges.ReadWrite &&
+ !IsUserDirectory(userToken, fullFileName))
+ {
+ SendFTPNoResponse(NoCode.AccessDenied, "This account is not allowed to delete files here.");
+ return;
+ }
+
List files = EnumerateFiles(fullPath);
// Send each list to the user, followed by the actual file data.
@@ -749,6 +796,53 @@ namespace IFS.FTP
return relativePath;
}
+ private UserToken AuthenticateUser(PropertyList fileSpec)
+ {
+ //
+ // If no username is specified then we default to the guest account.
+ //
+ if (!fileSpec.ContainsPropertyValue(KnownPropertyNames.UserName))
+ {
+ return UserToken.Guest;
+ }
+
+ //
+ // Otherwise, we validate the username/password and return the associated token
+ // if successful.
+ // On failure, we send a "No" code back to the client.
+ //
+ string userName = fileSpec.GetPropertyValue(KnownPropertyNames.UserName);
+ string password = String.Empty;
+
+ if (fileSpec.ContainsPropertyValue(KnownPropertyNames.UserPassword))
+ {
+ password = fileSpec.GetPropertyValue(KnownPropertyNames.UserPassword);
+ }
+
+ UserToken user = Authentication.Authenticate(userName, password);
+
+ if (user == null)
+ {
+ SendFTPNoResponse(NoCode.AccessDenied, "Invalid username or password.");
+ }
+
+ return user;
+ }
+
+ ///
+ /// Checks that the path points to the user's directory.
+ /// For now, this is assumed to be rooted directly under the FTP root, so for user
+ /// "alan", and an FTP root of "c:\ifs\ftp" the path should start with "c:\ifs\ftp\alan".
+ ///
+ ///
+ ///
+ ///
+ private bool IsUserDirectory(UserToken userToken, string fullPath)
+ {
+ string userDirPath = Path.Combine(Configuration.FTPRoot, userToken.HomeDirectory);
+ return fullPath.StartsWith(userDirPath, StringComparison.OrdinalIgnoreCase);
+ }
+
private void SendFTPResponse(FTPCommand responseCommand, object data)
{
_channel.SendMark((byte)responseCommand, false);
diff --git a/PUP/IFS.csproj b/PUP/IFS.csproj
index 82818ea..78768c3 100644
--- a/PUP/IFS.csproj
+++ b/PUP/IFS.csproj
@@ -72,6 +72,7 @@
+
@@ -107,6 +108,9 @@
PreserveNewest
+
+ PreserveNewest
+
PreserveNewest