Last active
April 24, 2024 06:06
-
-
Save overing/5161a37ada481fbc1b17d29c472d46a2 to your computer and use it in GitHub Desktop.
basic auth file server
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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