Per-user image folders in ASP.NET Web Forms

With one shared uploads/ folder, every editor user sees every other user's pictures in the gallery. The Web Forms sample changes that with one method, RteFiles.RootFor, and one appSettings switch. With the switch on, each signed-in user gets uploads/users/<id>/ and anonymous requests get 401. This page shows the change, the Forms authentication redirect that breaks the 401, and what per-user folders do not protect.
The switch
<appSettings>
<!-- true: each signed-in user gets uploads/users/<id>/ (see RteFiles.RootFor) -->
<add key="rte:PerUserFolders" value="false" />
</appSettings>It is off by default, and with it off the sample behaves exactly as in the gallery guide. It is read with WebConfigurationManager.AppSettings, like any other appSettings value.
RootFor
// The storage root for this request, as an app-relative virtual path. Files are written
// to Server.MapPath(root) and served from the same URL, so it stays inside ~/uploads,
// which web.config locks down to static files only.
//
// Default: one shared folder. With <add key="rte:PerUserFolders" value="true" /> in
// web.config, every signed-in user gets ~/uploads/users/<id>/ and anonymous requests get
// 401. /uploads/... is still a public URL: per-user roots keep users out of each other's
// folders in the dialog, they do not make the files private.
public static string RootFor(HttpContext context)
{
if (WebConfigurationManager.AppSettings["rte:PerUserFolders"] != "true")
return "~/uploads";
IPrincipal user = context.User;
if (user == null || !user.Identity.IsAuthenticated)
throw new RteException(401, "Sign in to use the image gallery.");
return "~/uploads/users/" + FolderIdFor(user.Identity.Name);
}
// A folder name for a user: the same user always gets the same folder, and nothing the
// user typed reaches the disk path (a name like "DOMAIN\bob" or "a@b.com" is not a folder name).
// If your users have a numeric id in your database, return that instead.
static string FolderIdFor(string userName)
{
using (SHA256 sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(userName.ToLowerInvariant()));
return BitConverter.ToString(hash, 0, 8).Replace("-", "").ToLowerInvariant();
}
}RootFor returns an app-relative virtual path. Everything else builds on it. Server.MapPath turns it into the disk root that every folder is checked against, and VirtualPathUtility.ToAbsolute turns it into the URL prefix of every image. So the listing, folder creation and upload all switch together. The folder is created when the user first opens the gallery.
The folder name comes from HttpContext.User, but never as typed. A sign-in name such as DOMAIN\bob or a@b.com is not a usable folder name, and it is not something to put in a path. The sample uses the first 16 hex characters of the SHA-256 of the lowercased name. If your users have a numeric id in your database, return that instead.
RootFor only reads context.User, so any ASP.NET sign-in works: Forms authentication, Windows authentication or an OWIN cookie. The sample uses Forms authentication with a demo page, SignIn.aspx, that signs in any typed name with no password. That page answers 404 unless the switch is on. Do not deploy it.
<!-- Any ASP.NET sign-in works (Forms, Windows, OWIN cookies): RootFor only reads
HttpContext.User. SignIn.aspx is a demo stand-in for your real login page. -->
<authentication mode="Forms">
<forms loginUrl="~/SignIn.aspx" name=".RTEDEMO" />
</authentication>The 401 that became a 302
The first test with the switch on did not return 401. An anonymous GET /rte/gallery came back as 302 to /SignIn.aspx?ReturnUrl=%2frte%2fgallery…. FormsAuthenticationModule turns every 401 into a redirect to loginUrl. The dialog's XMLHttpRequest follows the redirect silently, receives the sign-in page's HTML, and reports an error. The fix is one line in the JSON writer, after which the same request answered 401 {"error":"Sign in to use the image gallery."}:
public static void WriteJson(HttpContext context, int status, object value)
{
HttpResponse response = context.Response;
response.Clear();
response.StatusCode = status;
// Without this, IIS replaces 4xx/5xx bodies with its own error page when
// <httpErrors errorMode="Custom"> is in effect (the default for remote users).
response.TrySkipIisCustomErrors = true;
// FormsAuthenticationModule turns every 401 into a 302 to the login page, which an
// XMLHttpRequest follows silently and then fails to parse. Keep the 401.
response.SuppressFormsAuthenticationRedirect = true;
response.ContentType = "application/json";
response.Cache.SetCacheability(HttpCacheability.NoCache);
response.Cache.SetNoStore();
// JavaScriptSerializer ships with .NET Framework (System.Web.Extensions) and writes
// property names exactly as declared, so the anonymous objects above are camelCase.
// Its default MaxJsonLength (2,097,152 chars) would fail a huge folder; lift it.
JavaScriptSerializer serializer = new JavaScriptSerializer();
serializer.MaxJsonLength = int.MaxValue;
response.Write(serializer.Serialize(value));
}Uploads ask RootFor first too, so an anonymous upload is a 401 rather than "No file was uploaded."
What we tested
With the switch on, a script signed in as alice and bob through SignIn.aspx, kept each .RTEDEMO cookie, and found:
- alice's uploads went to
/uploads/users/2bd806c97f0e00af/…and bob's to/uploads/users/81b637d8fcd2c6da/…. - alice created
trips. It was in her listing and not in bob's. - For alice,
folder=..gave 400 andfolder=usersgave 404. Her root is her own folder, so she cannot browse up intousers/. - alice's image URL loaded with no cookie at all, with status 200.
The last point is by design. Files under uploads/ are static files that IIS serves to anyone who has the URL, and an image in a published page has to load for every reader. Per-user roots keep users out of each other's folders in the dialog. They do not make the files private. For private files, serve them through a handler that checks the user, and keep them outside the site folder.
The picture above shows uploads after both runs. The shared folders come from the switch-off run, and users/ holds one folder per signed-in user.
Test it
In the sample project for this guide, set rte:PerUserFolders to true and start run.ps1. Then:
curl -i "http://localhost:5401/rte/gallery?folder="
HTTP/1.1 401 Unauthorized
{"error":"Sign in to use the image gallery."}Open /SignIn.aspx and sign in with any name. The demo page says who you are, and the gallery opens your own empty folder under uploads/users/. Set the switch back to false and the contract test passes again (35 of 35).
The same guide for other platforms
JavaScript · PHP · ASP.NET Core · MVC 5