Bildordner pro Benutzer in ASP.NET MVC 5

Der Ordner uploads des MVC-5-Beispiels mit den gemeinsamen Ordnern blog, products und team und einem Ordner users mit zwei Ordnern pro Benutzer

Im MVC-5-Beispiel teilen sich alle Benutzer des Editors einen Ordner uploads/, bis Sie einen Schalter in den appSettings umlegen. Ist er eingeschaltet, arbeiten die Actions für Galerie und Upload für den angemeldeten Benutzer in uploads/users/<id>/ und beantworten anonyme Requests mit einem JSON-401. Damit dieses 401 den Browser erreicht, braucht es eine Zeile mehr, als man erwarten würde, und [Authorize] ist dafür nicht das richtige Werkzeug. Diese Seite zeigt, warum.

Der Schalter und der Stammordner

XML · web.config
<!-- true: each signed-in user gets uploads/users/<id>/ (see RteFiles.RootFor) -->
<add key="rte:PerUserFolders" value="false" />

Alles in RteFiles beginnt mit einer Methode. Der Controller übergibt seinen HttpContext (ein HttpContextBase), und RootFor gibt den app-relativen Stammordner für diesen Request zurück:

C# · RteFiles.cs
// 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(HttpContextBase 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();
    }
}

Die Liste, das Anlegen von Ordnern und der Upload lösen ihre Pfade alle innerhalb dieses Stammordners auf, und die Bild-URLs werden daraus gebildet. Ist der Schalter eingeschaltet, ändert sich also sonst nichts. Der Ordnername wird aus User.Identity.Name abgeleitet (16 Hex-Zeichen seines SHA-256), nie so übernommen, wie er eingegeben wurde. Wenn Ihre Benutzer eine Datenbank-ID haben, geben Sie stattdessen diese zurück.

Warum nicht [Authorize]

[Authorize] an RteController wäre der naheliegende Weg, eine Anmeldung zu verlangen. Unter Forms-Authentifizierung erhält der Dialog damit aber kein 401. Wir haben [Authorize] an eine Wegwerf-Action gesetzt und sie anonym angefordert. Die Antwort war 302 auf /Account/SignIn?ReturnUrl=%2fProbe%2fSecret, mit oder ohne Header X-Requested-With: XMLHttpRequest. [Authorize] gibt ein 401 zurück, und FormsAuthenticationModule schreibt jedes 401 in eine Weiterleitung auf loginUrl um. Der Request des Dialogs folgt ihr, erhält das HTML der Anmeldeseite und kann es nicht parsen.

Deshalb wirft RootFor eine RteException(401, …), und die JSON-Hilfsmethode des Controllers behält den Status bei:

C# · Controllers/RteController.cs
// Json() refuses GET unless told otherwise (JsonRequestBehavior.DenyGet is the default)
// and caps output at 2,097,152 characters; both are lifted here. Property names are
// written as declared, so the anonymous objects in RteFiles produce camelCase JSON.
JsonResult JsonStatus(int status, object data)
{
    Response.StatusCode = status;
    // Without this, IIS replaces 4xx bodies with its own error page for remote users.
    Response.TrySkipIisCustomErrors = true;
    // Forms authentication would turn a 401 into a 302 to the login page; keep the 401.
    Response.SuppressFormsAuthenticationRedirect = true;
    Response.Cache.SetCacheability(HttpCacheability.NoCache);
    Response.Cache.SetNoStore();
    return new JsonResult
    {
        Data = data,
        JsonRequestBehavior = JsonRequestBehavior.AllowGet,
        MaxJsonLength = int.MaxValue
    };
}

Mit SuppressFormsAuthenticationRedirect = true antwortete ein anonymes GET /rte/gallery mit 401 {"error":"Sign in to use the image gallery."}. Uploads rufen RootFor auf, bevor sie die Datei lesen, sodass ein anonymer Upload dasselbe 401 erhält.

Anmelden

RootFor liest nur User, daher funktionieren ASP.NET Identity mit OWIN-Cookies, Forms-Authentifizierung und Windows-Authentifizierung gleichermaßen. Das Beispiel verwendet Forms-Authentifizierung:

XML · web.config
<!-- Any ASP.NET sign-in works (Forms, Windows, OWIN cookies): RootFor only reads
     HttpContext.User. AccountController.SignIn is a demo stand-in for your real login. -->
<authentication mode="Forms">
  <forms loginUrl="~/Account/SignIn" name=".RTEDEMO" />
</authentication>

AccountController.SignIn ist eine Demo, die jeden eingegebenen Namen ohne Passwort anmeldet. Ihr POST trägt [ValidateAntiForgeryToken], und der gesamte Controller antwortet mit 404, solange der Schalter nicht eingeschaltet ist. Ersetzen Sie ihn durch Ihr echtes Login. Wenn Sie stattdessen OWIN-Cookie-Authentifizierung verwenden, braucht deren eigene Weiterleitung bei 401 dieselbe Sorgfalt. Diese Kombination haben wir nicht getestet.

Was wir getestet haben

Bei eingeschaltetem Schalter hat ein Skript das Anmeldeformular abgerufen, es mit dem Antiforgery-Token für alice und für bob gesendet und deren .RTEDEMO-Cookies verwendet:

  • Alices Dateien landeten in /uploads/users/2bd806c97f0e00af/, Bobs in /uploads/users/81b637d8fcd2c6da/.
  • Alices Ordner trips erschien in ihrer Liste und nicht in Bobs.
  • folder=.. ergab ein 400. folder=users ergab ein 404, weil der Stammordner eines Benutzers sein eigener Ordner ist.
  • Die URL von Alices Bild gab auf einen Request ohne Cookie 200 zurück.

Das letzte Ergebnis ist zu erwarten. /uploads/ wird von IIS als statische Dateien ausgeliefert, also kann jeder mit einer URL das Bild laden, und genau das braucht ein veröffentlichter Artikel. Ordner pro Benutzer trennen die Benutzer im Dialog. Sie machen Dateien nicht privat.

Das Bild oben zeigt den Ordner uploads des Beispiels nach den Tests: die gemeinsamen Ordner aus einem Lauf mit ausgeschaltetem Schalter und users/ mit einem Ordner pro Benutzer.

Testen

Setzen Sie im Beispielprojekt zu dieser Anleitung rte:PerUserFolders auf true und starten Sie run.ps1 (Port 5402):

Shell
curl -i "http://localhost:5402/rte/gallery?folder="
HTTP/1.1 401 Unauthorized
{"error":"Sign in to use the image gallery."}

Öffnen Sie dann /Account/SignIn, melden Sie sich an und öffnen Sie Insert Gallery: Der Dialog beginnt in Ihrem eigenen, leeren Ordner. Steht der Schalter wieder auf false, besteht der Vertragstest 35 von 35 Prüfungen.

Dieselbe Anleitung für andere Plattformen

JavaScript · PHP · ASP.NET Core · Web Forms

Weiterlesen