Pastas de imagens por usuário no ASP.NET MVC 5

A pasta uploads do exemplo em MVC 5 com as pastas compartilhadas blog, products e team e uma pasta users com duas pastas por usuário

No exemplo MVC 5, todos os usuários do editor compartilham uma única pasta uploads/ até você ativar uma chave em appSettings. Com ela ativada, as actions de galeria e de upload trabalham em uploads/users/<id>/ para o usuário autenticado e respondem a requisições anônimas com um JSON 401. Fazer esse 401 chegar ao navegador exige uma linha a mais do que você esperaria, e [Authorize] não é a ferramenta certa para isso. Esta página mostra por quê.

A chave e a raiz

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

Tudo em RteFiles parte de um único método. O controller passa seu HttpContext (um HttpContextBase), e RootFor retorna a raiz relativa ao aplicativo para aquela requisição:

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();
    }
}

A listagem, a criação de pastas e o upload resolvem seus caminhos dentro dessa raiz, e as URLs das imagens são montadas a partir dela. Por isso, nada mais muda quando a chave está ativada. O nome da pasta é derivado de User.Identity.Name (16 caracteres hexadecimais do seu SHA-256), nunca usado do jeito que foi digitado. Se os seus usuários têm um id no banco de dados, retorne esse id.

Por que não [Authorize]

[Authorize] no RteController seria a forma óbvia de exigir autenticação. Com Forms authentication, ele não entrega um 401 à caixa de diálogo. Colocamos [Authorize] em uma action descartável e fizemos uma requisição anônima a ela. A resposta foi 302 para /Account/SignIn?ReturnUrl=%2fProbe%2fSecret, com ou sem o cabeçalho X-Requested-With: XMLHttpRequest. [Authorize] retorna um 401, e o FormsAuthenticationModule reescreve todo 401 como um redirecionamento para loginUrl. A requisição da caixa de diálogo segue o redirecionamento, recebe o HTML da página de login e não consegue interpretá-lo.

Por isso, RootFor lança RteException(401, …), e o helper de JSON do controller mantém o código de status:

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
    };
}

Com SuppressFormsAuthenticationRedirect = true, um GET /rte/gallery anônimo respondeu 401 {"error":"Sign in to use the image gallery."}. Os uploads chamam RootFor antes de ler o arquivo, então um upload anônimo recebe o mesmo 401.

Autenticação

RootFor lê apenas User, então ASP.NET Identity com cookies OWIN, Forms authentication e Windows authentication funcionam. O exemplo usa Forms authentication:

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 é uma demonstração que autentica qualquer nome digitado, sem senha. Seu POST tem [ValidateAntiForgeryToken], e o controller inteiro responde 404 a menos que a chave esteja ativada. Substitua-o pelo seu login real. Se você usar autenticação por cookie OWIN, o redirecionamento que ela faz para os 401 exige o mesmo cuidado. Não testamos essa combinação.

O que testamos

Com a chave ativada, um script buscou o formulário de login, enviou-o com o token antiforgery para alice e para bob e usou os cookies .RTEDEMO de cada um:

  • Os arquivos de alice foram para /uploads/users/2bd806c97f0e00af/ e os de bob para /uploads/users/81b637d8fcd2c6da/.
  • A pasta trips de alice apareceu na listagem dela, e não na de bob.
  • folder=.. gerou 400. folder=users gerou 404, porque a raiz de um usuário é a própria pasta dele.
  • A URL da imagem de alice retornou 200 para uma requisição sem cookie.

Esse último resultado é esperado. /uploads/ é servido pelo IIS como arquivos estáticos, então qualquer pessoa com a URL pode carregar a imagem, que é o que um artigo publicado precisa. As pastas por usuário separam os usuários na caixa de diálogo. Elas não tornam os arquivos privados.

A imagem no topo é a pasta uploads do exemplo depois dos testes: as pastas compartilhadas de uma execução com a chave desativada e users/ com uma pasta por usuário.

Teste

No projeto de exemplo deste guia, defina rte:PerUserFolders como true e inicie o run.ps1 (porta 5402):

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

Depois abra /Account/SignIn, faça login e abra Insert Gallery: ela começa na sua própria pasta vazia. Com a chave de volta em false, o teste de contrato passa em 35 de 35 verificações.

O mesmo guia para outras plataformas

JavaScript · PHP · ASP.NET Core · Web Forms

Leia a seguir