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:
300
PUP/Authentication.cs
Normal file
300
PUP/Authentication.cs
Normal 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
10
PUP/Conf/accounts.txt
Normal 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
|
||||
|
||||
@@ -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(">>>");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user