From 6cb6cf88de1cf73b10b07abc173c55638ca10bda Mon Sep 17 00:00:00 2001 From: Josh Dersch Date: Wed, 20 Apr 2016 16:13:16 -0700 Subject: [PATCH] Added basic auth and access control. --- PUP/Authentication.cs | 300 ++++++++++++++++++++++++++++++++++++++++++ PUP/Conf/accounts.txt | 10 ++ PUP/Entrypoint.cs | 6 + PUP/FTP/FTPServer.cs | 100 +++++++++++++- PUP/IFS.csproj | 4 + 5 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 PUP/Authentication.cs create mode 100644 PUP/Conf/accounts.txt 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