In the previous articles, we first saw how our web server starts up, then how it begins to handle connections and generate responses. Now, it's time to add security features to our server! Indeed, we might want to restrict access to certain parts of the website to specific users only. (In the next article, we will see that authentication is a crucial component for other features!)
Previous articles:
- PandApache3, the Apache killer
- Deep Dive into PandApache3: Launch Code
- Deep Dive into PandApache3: Understanding Connection Management and Response Generation
Directory Configuration
As we saw earlier, our web server has a root directory, www
, which contains our website with, for example, our index.html
file.
Imagine that my server contains some documents or pages that I do not want to make public or accessible to everyone. How can we organize this?
The simplest and most common approach is to place all my documents in a special directory and then set a password to protect that directory. However, we can have multiple directories on a web server to organize our resources. Therefore, we cannot rely solely on the physical directories on our disk. We will thus configure some special directories for our web server!
Let’s start with our root directory. On PandApache, it is located at /etc/PandApache3/www/
and it should, by default, be accessible to everyone. Here is how this directory is defined in the configuration file:
<Directory /etc/PandApache3/www/>
Require all granted
</Directory>
For now, it is quite simple; we define that it is a directory, its location, and the associated permissions. Here, Require all granted
indicates that everyone can access our directory.
If we want to define another directory with different, secure permissions, we can modify our configuration as follows:
<Directory /etc/PandApache3/www/secure>
AuthType Basic
AuthName "Authentication"
AuthUserFile /etc/PandApache3/htpasswd.txt
Require valid-user
</Directory>
Here, we define a second directory, still within www
, but this time with an additional folder: "secure".
The Require all granted
directive has been replaced with Require valid-user
, which indicates that we want an authenticated user. This authentication will be of the Basic type and our authentication information will be in the htpasswd.txt
file.
When a user attempts to access the URL http://pandapache3/secure/file.html
, an authentication window will appear to allow them to enter a valid username and password to access the resource.
Between us
The attributes that define access conditions for directories are called directives; this is the term we will use from now on.
Loading Directory Parameters
"Talk is cheap. Show me the code," said Linus Torvalds. And it's true. We’ve seen the configuration file; now let's look at how it's interpreted in the code. We’ll start by examining how the configuration is loaded. Once we have a good understanding of the objects and their attributes, we’ll see how they are used later in the code.
First, what does a Directory
object look like? Well, unsurprisingly, it mirrors what is found in the configuration file:
public class DirectoryConfig
{
public string Path { get; set; }
public string AuthType { get; set; }
public string AuthName { get; set; }
public string AuthUserFile { get; set; }
public string Require { get; set; }
}
We’ve already seen how the configuration file is parsed to store different keys and values into our configuration instance. Here, we'll simplify the code by omitting this part and focusing only on the directives.
public void ReloadConfiguration()
{
List<string> currentSection = new List<string>();
DirectoryConfig currentDirectory = null;
foreach (var line in File.ReadLines(fullPath))
{
if (line.Trim().StartsWith("<") && line.Trim().EndsWith(">") && !line.Trim().StartsWith("</"))
{
Logger.LogDebug($"Starting to read new directive {line.Trim()}");
string sectionName = line.Trim().Substring(1, line.Trim().Length - 2);
Logger.LogInfo($"Reading section {sectionName}");
if ((sectionName.StartsWith("Directory") || sectionName.StartsWith("Admin")) && currentDirectory == null)
{
currentDirectory = new DirectoryConfig
{
Path = sectionName.Split(' ')[1]
};
Directories.Add(currentDirectory);
currentSection.Add("Directory");
}
continue;
}
if (currentSection.Count != 0)
{
if (line.Trim() == "</Directory>")
{
currentDirectory = null;
currentSection.Remove("Directory");
continue;
}
else if (currentDirectory != null && currentSection.Last().Equals("Directory"))
{
getKeyValue(line);
}
}
}
}
Although a bit lengthy, you will see that the method is quite simple and rather short in each iteration. The first thing of interest is detecting the first line indicating a directory: <Directory /etc/PandApache3/www/secure>
.
So it must start with a <
, but not with </
, and must end with a >
.
On this first line, we can retrieve our section title, which will also be our Path
. From now on, we know that we are in a section, so our second if
will be executed for the next iterations as long as we stay within the section.
To exit the section, it is necessary to encounter a line that starts with </
and ends with >
.
Anything found before will be a directive that we can read with our getKeyValue()
function, as seen in the first article.
Between us
You might recall the
MapConfiguration
function used ingetKeyValue
that assigns our configuration fields to variables. It is stillMapConfiguration
that is used for directory-related values, but since we can have multiple directories, we must store them in a list, and it is this last element of the list that will be updated inMapConfiguration
.public void MapConfiguration(string key, string value) { var actionMap = new Dictionary<string, Action<string>> { ["authtype"] = v => Directories.Last().AuthType = v, ["authname"] = v => Directories.Last().AuthName = v, ["authuserfile"] = v => Directories.Last().AuthUserFile = v, ["require"] = v => Directories.Last().Require = v }; if (actionMap.TryGetValue(key.ToLower(), out var action)) { action(value); } }
New Middlewares
Well, we’ve seen that our configuration can be programmatically loaded into PandApache3. It’s now time to use it, and this is the perfect moment to demonstrate how interesting and scalable middleware architecture is. Remember that we had three middlewares currently, and each of them was used to process our request.
So far, it was mostly the routing middleware doing the work. But now, we have a new action to perform on each request... actually, we have two!
The first middleware is, of course, for handling authentication, and here is the function executed for this middleware:
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Headers.ContainsKey("Authorization"))
{
string authHeader = context.Request.Headers["Authorization"];
string credentials = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader.Substring("Basic ".Length).Trim()));
string[] credentialParts = credentials.Split(':');
if (credentialParts.Length == 2)
{
string username = credentialParts[0];
string password = credentialParts[1];
string mainDirectory = ServerConfiguration.Instance.RootDirectory;
string filePath = Path.Combine(mainDirectory, Utils.GetFilePath(context.Request.Path));
DirectoryConfig directoryConfig = ServerConfiguration.Instance.GetDirectory(filePath);
if (IsValidUser(directoryConfig, username, password))
{
context.isAuth = true;
}
}
}
await _next(context);
}
This middleware should only execute if the header contains authentication information. If it does, we can decode this information from base64. We now have a username and a password. Given that we know which resource the user requested (it’s the Path
in our Request
object), we can easily determine the directory handling this resource. We now have all the information needed to verify a user’s authentication.
For this, let’s take a closer look at the IsValidUser
method:
private bool IsValidUser(DirectoryConfig directoryConfig, string username, string password)
{
if (directoryConfig == null)
return false;
string authUserFile = directoryConfig.AuthUserFile;
bool exist = FileManagerFactory.Instance().Exists(authUserFile);
if (string.IsNullOrEmpty(authUserFile) || !exist)
{
Logger.LogError($"Auth User File {authUserFile} doesn't exist");
return false;
}
Logger.LogInfo($"Reading from the auth user file {authUserFile}");
foreach (string line in File.ReadAllLines(authUserFile))
{
string[] parts = line.Split(':');
if (parts.Length == 2)
{
if (parts[0].ToLower().Equals(username.ToLower()) && parts[1].Equals(HashPassword(password)))
return true;
}
}
return false;
}
This method will check the AuthUserFile
parameter to read and compare the information within it with what was sent by the user. If it matches, the user is authenticated; otherwise, they are not.
The goal of this middleware is only to authenticate users; it is not responsible for denying access to a resource.
The authentication status is kept in the HttpContext
, which has been slightly modified for this purpose:
public class HttpContext
{
public Request Request { get; set; }
public HttpResponse Response { get; set; }
public bool isAuth { get; set; } = false;
public HttpContext(Request request, HttpResponse response)
{
Request = request;
Response = response;
}
}
Next, we have another middleware to allow or deny access to a resource. Welcome to DirectoryMiddleware
:
public async Task InvokeAsync(HttpContext context)
{
string mainDirectory = ServerConfiguration.Instance.RootDirectory;
string filePath = Path.Combine(mainDirectory, Utils.GetFilePath(context.Request.Path));
DirectoryConfig directoryConfig = ServerConfiguration.Instance.GetDirectory(filePath);
bool authNeeded = false;
if (directoryConfig != null && directoryConfig.Require.Equals("valid-user"))
{
Logger.LogDebug($"Authentication requested");
authNeeded = true;
}
if (authNeeded && !context.isAuth)
{
context.Response = new HttpResponse(401);
context.Response.Headers["WWW-Authenticate"] = "Basic realm=\"Authentication\"";
Logger.LogWarning($"User not authenticated");
return;
}
await _next(context);
}
We now know whether the user is authenticated or not. The next step is to determine if they are trying to access a resource that requires authentication.
If the resource belongs to a directory where the Require
property is set to valid-user
, then authentication is necessary. We have the authentication status from the previous middleware, so we can either allow the request to pass to the next middleware or stop it right now and generate a 401 error.
As seen in the very first article, our pipeline is defined when the server starts, so we need to add these new middlewares to the pipeline!
TerminalMiddleware terminalMiddleware = new TerminalMiddleware();
RoutingMiddleware routingMiddleware = new RoutingMiddleware(terminalMiddleware.InvokeAsync, fileManager);
DirectoryMiddleware directoryMiddleware = new DirectoryMiddleware(routingMiddleware.InvokeAsync);
AuthenticationMiddleware authenticationMiddleware = new AuthenticationMiddleware(directoryMiddleware.InvokeAsync);
LoggerMiddleware loggerMiddleware = new LoggerMiddleware(authenticationMiddleware.InvokeAsync);
Func<HttpContext, Task> pipeline = loggerMiddleware.InvokeAsync;
Between us
Why create two middlewares instead of one? It's always good to separate responsibilities. Currently, the middlewares are short because we only have one type of authentication to verify and access is based solely on this criterion. In the future, these methods might become more complex, so it's useful and important to separate them now.
Limiting HTTP Methods
We have already significantly improved security with authentication, but we can go even further quite easily.
If your website is purely static and meant for viewing, what type of requests do you expect to receive? GET requests, of course. Therefore, we could decide that any other type of request is illegitimate and should be denied.
Allowing only one type of request aligns with the principle of reducing the attack surface. For example, if POST HTTP methods contain a vulnerability that bypasses authentication, then if your server refuses all POST requests, you have nothing to worry about.
In addition to authentication information, our directories can have the following directive:
<LimitVerb>
GET
POST
</LimitVerb>
This directive specifies which HTTP methods are allowed for our directory. The implementation of this security is found in the DirectoryMiddleware
because it is already the one that authorizes or denies access to a resource. Here is the added code for checking this in the middleware:
bool verbAccess = true;
if (directoryConfig != null)
{
if (directoryConfig.AllowedMethods != null)
{
if (directoryConfig.AllowedMethods.Contains(context.Request.Verb) == false)
{
Logger.LogError(
$"Verb {context.Request.Verb} not allowed for the directory {directoryConfig.Path}"
);
context.Response = new HttpResponse(403);
verbAccess = false;
}
}
}
Because this article is already getting long, I’ll spare you the code related to loading the configuration, which is very similar to loading the directories. Just consider that each DirectoryConfig
object now has a list of allowed methods.
To proceed, the request must either use an HTTP method that is present in this list or target a resource that is not in a directory or a directory where no methods have been specified (so our AllowedMethods
list will be null).
If you use an HTTP method on a resource that is not allowed, you will simply receive a 403 status.
Between us
Why use a 403 and not a 401? These two error codes are slightly different. A 401 means
Unauthorized
. You can authenticate to gain access to the resource. A 403 meansForbidden
. The server refuses to execute your request, and authentication will not change that. This is the expected behavior of the server; only a configuration change by an administrator can alter this. That's why we have two different error codes here.
In this article, we delved into the security configuration of PandApache3, covering directives for managing directories and limiting HTTP methods. You have seen how these mechanisms effectively protect resources based on specific needs.
This first overview provides you with a solid foundation for understanding request processing in PandApache3. In our next article, we will explore the new features of the latest version of PandApache3, which simplify server administration in a PaaS environment. Stay tuned to discover these innovations and their impact on web infrastructure management.
Stay tuned!
Thank you so much for exploring the inner workings of PandApache3 with me! Your thoughts and support are crucial in advancing this project. 🚀
Feel free to share your ideas and impressions in the comments below. I look forward to hearing from you!
Follow my adventures on Twitter @pykpyky to stay updated on all the news.
You can also explore the full project on GitHub and join me for live coding sessions on Twitch for exciting and interactive sessions. See you soon behind the screen!