Skip to content

Instantly share code, notes, and snippets.

@overing
Last active April 24, 2024 06:06
Show Gist options
  • Save overing/5161a37ada481fbc1b17d29c472d46a2 to your computer and use it in GitHub Desktop.
Save overing/5161a37ada481fbc1b17d29c472d46a2 to your computer and use it in GitHub Desktop.
basic auth file server
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http.Headers;
using System.Reflection;
using System.Security.Authentication;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
var fileFolder = Path.Combine(Environment.CurrentDirectory, "files");
if (!Directory.Exists(fileFolder))
Directory.CreateDirectory(fileFolder);
const string FileListRequestPath = "/files";
const string UploadRequestPath = "/upload";
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery();
builder.Services.AddAuthentication(BasicAuthenticationScheme.DefaultScheme)
.AddScheme<BasicAuthenticationOption, BasicAuthenticationHandler>(BasicAuthenticationScheme.DefaultScheme, null);
builder.Services.Configure<BasicAuthenticationOption>(options =>
{
options.Realm = "File Server";
options.UserName = "overing";
options.UserPwd = "test123";
});
var app = builder.Build();
app.UseAntiforgery();
app.UseBasicAuthentication();
app.UseFileServer(new Func<FileServerOptions>(() =>
{
var options = new FileServerOptions
{
FileProvider = new PhysicalFileProvider(fileFolder),
RequestPath = new PathString(FileListRequestPath),
EnableDirectoryBrowsing = true
};
var encoder = app.Services.GetRequiredService<HtmlEncoder>();
var antiforgery = app.Services.GetRequiredService<IAntiforgery>();
options.DirectoryBrowserOptions.Formatter = new HtmlDirectoryFormatter(encoder, antiforgery, UploadRequestPath);
return options;
})());
app.MapGet("/", () => Results.Redirect(FileListRequestPath));
app.MapGet(HtmlDirectoryFormatter.CSS_URL, () => Results.Content(HtmlDirectoryFormatter.CSS_CONTENT, "text/css; charset=utf-8"));
app.MapGet(HtmlDirectoryFormatter.SCRIPT_URL, () => Results.Content(HtmlDirectoryFormatter.SCRIPT_CONTENT, "text/javascript"));
app.MapPost(UploadRequestPath, async (IFormFile file, HttpContext context, IAntiforgery antiforgery) =>
{
await antiforgery.ValidateRequestAsync(context);
if (file is not { Length: > 0 })
return Results.BadRequest("file is invalid");
var path = Path.Combine(fileFolder, file.FileName);
using (var stream = File.Create(path))
await file.CopyToAsync(stream);
return Results.Redirect(FileListRequestPath);
});
app.MapPost("/delete", async ([FromForm] string delName, HttpContext context, IAntiforgery antiforgery) =>
{
await antiforgery.ValidateRequestAsync(context);
if (delName is not { Length: > 0 })
return Results.BadRequest("name is invalid");
var path = Path.Combine(fileFolder, delName);
if (!File.Exists(path))
return Results.NotFound("file not found");
File.Delete(path);
return Results.Redirect(FileListRequestPath);
});
app.Run("http://localhost:8080");
public sealed class HtmlDirectoryFormatter(HtmlEncoder encoder, IAntiforgery antiforgery, string uploadRequestPath)
: Microsoft.AspNetCore.StaticFiles.IDirectoryFormatter
{
public const string CSS_URL = "/css/styles.css";
public const string CSS_CONTENT =
"""
body {
font-family: "Segoe UI", "Segoe WP", "Helvetica Neue", 'RobotoRegular', sans-serif;
font-size: 14px;}
header h1 {
font-family: "Segoe UI Light", "Helvetica Neue", 'RobotoLight', "Segoe UI", "Segoe WP", sans-serif;
font-size: 28px;
font-weight: 100;
margin-top: 5px;
margin-bottom: 0px;}
#index {
border-collapse: separate;
border-spacing: 0;
margin: 0 0 20px; }
#index th {
vertical-align: bottom;
padding: 10px 5px 5px 5px;
font-weight: 400;
color: #a0a0a0;
text-align: center; }
#index td { padding: 3px 10px; }
#index th, #index td {
border-right: 1px #ddd solid;
border-bottom: 1px #ddd solid;
border-left: 1px transparent solid;
border-top: 1px transparent solid;
box-sizing: border-box; }
#index th:last-child, #index td:last-child { border-right: 1px transparent solid; }
#index td.length, td.modified { text-align:right; }
a { color:#1ba1e2;text-decoration:none; }
a:hover { color:#13709e;text-decoration:underline; }
""";
public const string SCRIPT_URL = "/js/script.js";
public const string SCRIPT_CONTENT =
"""
let toggleDelFileForm = function(elementToken, show) {
let elementTD = document.querySelector('td#fileTD-' + elementToken);
let formControls = elementTD.querySelector('[name="delFileControls"]');
let openButton = elementTD.querySelector('[name="delFileOpen"]');
if (show) {
formControls.style.display = 'block';
openButton.style.display = 'none';
} else {
formControls.style.display = 'none';
openButton.style.display = 'block';
}
};
""";
const string TextHtmlUtf8 = "text/html; charset=utf-8";
readonly HtmlEncoder _htmlEncoder = encoder;
readonly IAntiforgery _antiforgery = antiforgery;
readonly string _uploadRequestPath = uploadRequestPath;
public Task GenerateContentAsync(HttpContext context, IEnumerable<IFileInfo> contents)
{
var token = _antiforgery.GetAndStoreTokens(context);
context.Response.ContentType = TextHtmlUtf8;
if (HttpMethods.IsHead(context.Request.Method))
return Task.CompletedTask;
PathString requestPath = context.Request.PathBase + context.Request.Path;
var builder = new StringBuilder();
builder.AppendFormat(
CultureInfo.InvariantCulture,
"""
<!DOCTYPE html>
<html lang="{0}">
<head>
<title>{1} {2}</title>
<link href="{3}" rel="stylesheet" media="screen" />
<script type="application/javascript" src="{4}"></script>
</head>
<body>
<section id="main">
<header><h1>Index Of <a href="/">/</a>
""",
CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
HtmlEncode("Index Of"),
HtmlEncode(requestPath.Value!),
CSS_URL,
SCRIPT_URL);
string cumulativePath = "/";
foreach (var segment in requestPath.Value!.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
cumulativePath = cumulativePath + segment + "/";
builder.AppendFormat(
CultureInfo.InvariantCulture,
"""<a href="{0}">{1}/</a>""",
HtmlEncode(cumulativePath),
HtmlEncode(segment));
}
var dirLastModified = string.Empty;
if (contents.GetType().GetField("_info", BindingFlags.NonPublic | BindingFlags.Instance) is FieldInfo infoField &&
infoField.GetValue(contents) is IFileInfo dirInfo)
dirLastModified = dirInfo.LastModified.ToString(CultureInfo.CurrentCulture);
else if (contents.GetType().GetField("_directory", BindingFlags.NonPublic | BindingFlags.Instance) is FieldInfo dirField &&
dirField.GetValue(contents) is string dirPath)
dirLastModified = ((DateTimeOffset)Directory.GetLastWriteTimeUtc(dirPath)).ToString(CultureInfo.CurrentCulture);
builder.AppendFormat(
CultureInfo.InvariantCulture,
"""
</h1></header>
<form method="post" action="{0}" enctype="multipart/form-data">
<input type="hidden" name="{1}" value="{2}" />
<input type="file" name="file" />
<input type="submit" name="submit" value="Upload" />
</form>
<table id="index" summary="Summary">
<thead>
<tr>
<th abbr="Name">Name</th>
<th abbr="Size">Size</th>
<th abbr="Modified">Modified</th>
<th />
</tr>
</thead>
<tbody>
""",
HtmlEncode(_uploadRequestPath),
token.FormFieldName,
token.RequestToken,
HtmlEncode(dirLastModified));
foreach (var subdir in contents.Where(info => info.IsDirectory))
{
try
{
AppendDirRow(builder, token, subdir);
}
catch
{
}
}
foreach (var file in contents.Where(info => !info.IsDirectory))
{
try
{
AppendFileRow(builder, token, file);
}
catch
{
}
}
builder.Append(@"
</tbody>
</table>
</section>
</body>
</html>");
string data = builder.ToString();
byte[] bytes = Encoding.UTF8.GetBytes(data);
context.Response.ContentLength = bytes.Length;
return context.Response.Body.WriteAsync(bytes, 0, bytes.Length);
}
void AppendDirRow(StringBuilder builder, AntiforgeryTokenSet token, IFileInfo dir)
{
builder.AppendFormat(
CultureInfo.InvariantCulture,
"""
<tr class="directory">
<td class="name"><a href="./{0}/">{0}/</a></td>
<td />
<td class="modified">{1}</td>
<td />
</tr>
""",
HtmlEncode(dir.Name),
HtmlEncode(dir.LastModified.ToString(CultureInfo.CurrentCulture)));
}
void AppendFileRow(StringBuilder builder, AntiforgeryTokenSet token, IFileInfo file)
{
builder.AppendFormat(
CultureInfo.InvariantCulture,
"""
<tr class="file">
<td class="name"><a href="./{0}">{0}</a></td>
<td class="length">{1}</td>
<td class="modified">{2}</td>
<td id="fileTD-{3}">
<button name="delFileOpen" onclick="toggleDelFileForm('{3}', true);">-</button>
<span name="delFileControls" style="display: none;">
<form id="delFileForm-{3}" method="post" action="/delete" enctype="multipart/form-data">
<input type="hidden" name="{4}" value="{5}" />
<input type="hidden" name="delName" value="{0}" />
</form>
<button name="cancelDelFile" onclick="toggleDelFileForm('{3}', false);">Cancel</button>
<input type="submit" name="submit" value="Del" form="delFileForm-{3}" />
</span>
</td>
</tr>
""",
HtmlEncode(file.Name),
HtmlEncode(file.Length.ToString("n0", CultureInfo.CurrentCulture)),
HtmlEncode(file.LastModified.ToString(CultureInfo.CurrentCulture)),
BitConverter.ToString(MD5.HashData(Encoding.UTF8.GetBytes(file.Name))),
token.FormFieldName,
token.RequestToken);
}
string HtmlEncode(string body) => _htmlEncoder.Encode(body);
}
public sealed class BasicAuthenticationOption : AuthenticationSchemeOptions
{
public string Realm { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string UserPwd { get; set; } = string.Empty;
}
public sealed class BasicAuthenticationHandler(
IOptionsMonitor<BasicAuthenticationOption> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<BasicAuthenticationOption>(options, logger, encoder)
{
readonly BasicAuthenticationOption _authOptions = options.CurrentValue;
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.ContainsKey("Authorization"))
return Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header"));
string username, password;
try
{
if (!AuthenticationHeaderValue.TryParse(Request.Headers["Authorization"], out var authHeader) || authHeader.Parameter is null)
throw new AuthenticationFailureException("header not found");
var credentialBytes = Convert.FromBase64String(authHeader.Parameter);
var credentials = Encoding.UTF8.GetString(credentialBytes).Split(':');
username = credentials[0];
password = credentials[1];
if (!IsAuthorized(username, password))
return Task.FromResult(AuthenticateResult.Fail("Invalid username or password"));
}
catch
{
return Task.FromResult(AuthenticateResult.Fail("Invalid authorization Header"));
}
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, username),
new Claim(ClaimTypes.Name, username),
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.Headers["WWW-Authenticate"] = $"""Basic realm="{Options.Realm}" """;
return base.HandleChallengeAsync(properties);
}
bool IsAuthorized(string username, string password)
{
if (!username.Equals(_authOptions.UserName, StringComparison.InvariantCultureIgnoreCase))
return false;
if (!password.Equals(_authOptions.UserPwd))
return false;
return true;
}
}
public sealed class BasicAuthenticationMiddleware(RequestDelegate next, ILogger<BasicAuthenticationMiddleware> logger)
{
readonly RequestDelegate _next = next;
readonly ILogger _logger = logger;
public async Task Invoke(HttpContext httpContext, IAuthenticationService authenticationService)
{
var authenticated = await authenticationService.AuthenticateAsync(httpContext, BasicAuthenticationScheme.DefaultScheme);
_logger.LogInformation("Access Status:" + authenticated.Succeeded);
if (!authenticated.Succeeded)
{
await authenticationService.ChallengeAsync(httpContext, BasicAuthenticationScheme.DefaultScheme, new AuthenticationProperties());
return;
}
await _next(httpContext);
}
}
public static class BasicAuthenticationScheme
{
public const string DefaultScheme = "Basic";
public static IApplicationBuilder UseBasicAuthentication(this IApplicationBuilder app)
=> app.UseMiddleware<BasicAuthenticationMiddleware>();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment