1
0
mirror of https://github.com/livingcomputermuseum/IFS.git synced 2026-03-04 10:44:32 +00:00

Added basic auth and access control.

This commit is contained in:
Josh Dersch
2016-04-20 16:13:16 -07:00
parent 217388ebda
commit 6cb6cf88de
5 changed files with 417 additions and 3 deletions

300
PUP/Authentication.cs Normal file
View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
public static class Authentication
{
static Authentication()
{
ReadAccountDatabase();
}
public static List<UserToken> EnumerateUsers()
{
return _accounts.Values.ToList<UserToken>();
}
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;
}
}
/// <summary>
/// Changes the user's password. This is intended to be executed from the IFS console or by
/// an authenticated administrator.
/// </summary>
/// <param name="userName"></param>
/// <param name="currentPassword"></param>
/// <param name="newPassword"></param>
/// <returns></returns>
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("# <username>:<password hash>:[Admin|User]:<Full Name>:<home directory>");
accountStream.WriteLine("#");
foreach(UserToken account in _accounts.Values)
{
accountStream.WriteLine(account.ToString());
}
}
}
private static void ReadAccountDatabase()
{
_accounts = new Dictionary<string, UserToken>();
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:
// <username>:<password hash>:[Admin|User]:<Full Name>:<home directory>
//
// 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<string, UserToken> _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;
}
}

10
PUP/Conf/accounts.txt Normal file
View File

@@ -0,0 +1,10 @@
# accounts.txt:
#
#
# The format for a user account is:
# <username>:<password hash>:[Admin|User]:<Full Name>:<home directory>
#
jdersch::Admin:Josh Dersch:jdersch
luser::User:Joe Luser:luser

View File

@@ -97,6 +97,12 @@ namespace IFS
private static void RunCommandPrompt()
{
List<UserToken> users = Authentication.EnumerateUsers();
Authentication.SetPassword(users[0].UserName, "hamdinger");
UserToken user = Authentication.Authenticate(users[0].UserName, "hamdinger");
while (true)
{
Console.Write(">>>");

View File

@@ -310,6 +310,11 @@ namespace IFS.FTP
return;
}
if (AuthenticateUser(fileSpec) == null)
{
return;
}
List<PropertyList> files = EnumerateFiles(fullPath);
@@ -355,6 +360,11 @@ namespace IFS.FTP
return;
}
if (AuthenticateUser(fileSpec) == null)
{
return;
}
List<PropertyList> 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
}
/// <summary>
/// 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.
/// </summary>
/// <param name="fileSpec"></param>
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<PropertyList> 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;
}
/// <summary>
/// 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".
/// </summary>
/// <param name="userToken"></param>
/// <param name="fullPath"></param>
/// <returns></returns>
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);

View File

@@ -72,6 +72,7 @@
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="Authentication.cs" />
<Compile Include="BCPLString.cs" />
<Compile Include="Boot\BootServer.cs" />
<Compile Include="Boot\BreathOfLife.cs" />
@@ -107,6 +108,9 @@
<None Include="Conf\ifs.cfg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<Content Include="Conf\accounts.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Conf\hosts.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>